Files
MyGoNavi/frontend/src/store.test.ts
2026-06-23 18:26:20 +08:00

1621 lines
49 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
class MemoryStorage implements Storage {
private data = new Map<string, string>();
get length(): number {
return this.data.size;
}
clear(): void {
this.data.clear();
}
getItem(key: string): string | null {
return this.data.has(key) ? this.data.get(key)! : null;
}
key(index: number): string | null {
return Array.from(this.data.keys())[index] ?? null;
}
removeItem(key: string): void {
this.data.delete(key);
}
setItem(key: string, value: string): void {
this.data.set(key, String(value));
}
}
const importStore = async () => {
const store = await import('./store');
await store.useStore.persist.rehydrate();
return store;
};
describe('store appearance persistence', () => {
let storage: MemoryStorage;
beforeEach(() => {
storage = new MemoryStorage();
vi.stubGlobal('localStorage', storage);
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it('fills missing DataGrid appearance settings with defaults during hydration', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
appearance: {
enabled: false,
opacity: 0.75,
blur: 6,
useNativeMacWindowControls: true,
},
},
version: 7,
}));
const { useStore } = await importStore();
const appearance = useStore.getState().appearance;
expect(appearance.uiVersion).toBe('legacy');
expect(appearance.enabled).toBe(false);
expect(appearance.opacity).toBe(0.75);
expect(appearance.blur).toBe(6);
expect(appearance.useNativeMacWindowControls).toBe(true);
expect(appearance.v2SidebarSearchMode).toBe('command');
expect(appearance.v2CommandSearchPersistentFilterEnabled).toBe(false);
expect(appearance.v2SidebarPersistedFilter).toBe('');
expect(appearance.showDataTableVerticalBorders).toBe(false);
expect(appearance.dataTableDensity).toBe('comfortable');
expect(appearance.dataTableFontSize).toBeNull();
expect(appearance.dataTableFontSizeFollowGlobal).toBe(true);
expect(appearance.sidebarTreeFontSize).toBeNull();
expect(appearance.sidebarTreeFontSizeFollowGlobal).toBe(true);
expect(appearance.customUIFontFamily).toBeNull();
expect(appearance.customMonoFontFamily).toBeNull();
expect(appearance.tabDisplay).toEqual({
layout: 'single',
primaryElements: ['connection', 'kind', 'object'],
secondaryElements: [],
});
});
it('persists DataGrid appearance settings and restores them after reload', async () => {
const { useStore } = await importStore();
useStore.getState().setAppearance({
showDataTableVerticalBorders: true,
dataTableDensity: 'compact',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.showDataTableVerticalBorders).toBe(true);
expect(persisted.state.appearance.dataTableDensity).toBe('compact');
vi.resetModules();
const reloaded = await importStore();
const appearance = reloaded.useStore.getState().appearance;
expect(appearance.showDataTableVerticalBorders).toBe(true);
expect(appearance.dataTableDensity).toBe('compact');
});
it('persists language preference and sanitizes unsupported persisted values', async () => {
const { useStore } = await importStore();
expect(useStore.getState().languagePreference).toBe('system');
useStore.getState().setLanguagePreference('ja-JP');
expect(useStore.getState().languagePreference).toBe('ja-JP');
let persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.languagePreference).toBe('ja-JP');
vi.resetModules();
let reloaded = await importStore();
expect(reloaded.useStore.getState().languagePreference).toBe('ja-JP');
reloaded.useStore.getState().setLanguagePreference('system');
expect(reloaded.useStore.getState().languagePreference).toBe('system');
storage.setItem('lite-db-storage', JSON.stringify({
state: {
languagePreference: 'fr-FR',
},
version: 10,
}));
vi.resetModules();
reloaded = await importStore();
expect(reloaded.useStore.getState().languagePreference).toBe('system');
storage.setItem('lite-db-storage', JSON.stringify({
state: {
languagePreference: 'zh-CN',
},
version: 10,
}));
vi.resetModules();
reloaded = await importStore();
expect(reloaded.useStore.getState().languagePreference).toBe('zh-CN');
});
it('persists custom font families and sanitizes blank values', async () => {
const { useStore } = await importStore();
useStore.getState().setAppearance({
customUIFontFamily: ' IBM Plex Sans, PingFang SC ',
customMonoFontFamily: ' ',
});
let appearance = useStore.getState().appearance;
expect(appearance.customUIFontFamily).toBe('IBM Plex Sans, PingFang SC');
expect(appearance.customMonoFontFamily).toBeNull();
vi.resetModules();
const reloaded = await importStore();
appearance = reloaded.useStore.getState().appearance;
expect(appearance.customUIFontFamily).toBe('IBM Plex Sans, PingFang SC');
expect(appearance.customMonoFontFamily).toBeNull();
});
it('persists v2 sidebar search preferences and sanitizes filter text', async () => {
const { useStore } = await importStore();
useStore.getState().setAppearance({
v2SidebarSearchMode: 'filter',
v2CommandSearchPersistentFilterEnabled: true,
v2SidebarPersistedFilter: ` ${'orders'.repeat(40)} `,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.v2SidebarSearchMode).toBe('filter');
expect(persisted.state.appearance.v2CommandSearchPersistentFilterEnabled).toBe(true);
expect(persisted.state.appearance.v2SidebarPersistedFilter).toHaveLength(120);
expect(persisted.state.appearance.v2SidebarPersistedFilter.startsWith('orders')).toBe(true);
vi.resetModules();
const reloaded = await importStore();
const appearance = reloaded.useStore.getState().appearance;
expect(appearance.v2SidebarSearchMode).toBe('filter');
expect(appearance.v2CommandSearchPersistentFilterEnabled).toBe(true);
expect(appearance.v2SidebarPersistedFilter).toHaveLength(120);
});
it('persists tab display appearance settings and sanitizes invalid elements', async () => {
const { useStore } = await importStore();
useStore.getState().setAppearance({
tabDisplay: {
layout: 'double',
primaryElements: ['kind', 'object', 'invalid' as never, 'object'],
secondaryElements: ['connection', 'host', 'schema', 'kind'],
},
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.tabDisplay).toEqual({
layout: 'double',
primaryElements: ['kind', 'object'],
secondaryElements: ['connection', 'host', 'schema'],
});
vi.resetModules();
const reloaded = await importStore();
const appearance = reloaded.useStore.getState().appearance;
expect(appearance.tabDisplay).toEqual({
layout: 'double',
primaryElements: ['kind', 'object'],
secondaryElements: ['connection', 'host', 'schema'],
});
});
it('persists independent single-line and double-line tab display snapshots', async () => {
const { useStore } = await importStore();
useStore.getState().setAppearance({
tabDisplay: {
layout: 'double',
primaryElements: ['kind', 'object'],
secondaryElements: ['connection', 'database'],
single: {
primaryElements: ['object', 'host'],
secondaryElements: [],
},
double: {
primaryElements: ['kind', 'object'],
secondaryElements: ['connection', 'database'],
},
},
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.appearance.tabDisplay).toEqual({
layout: 'double',
primaryElements: ['kind', 'object'],
secondaryElements: ['connection', 'database'],
single: {
primaryElements: ['object', 'host'],
secondaryElements: [],
},
double: {
primaryElements: ['kind', 'object'],
secondaryElements: ['connection', 'database'],
},
});
vi.resetModules();
const reloaded = await importStore();
const appearance = reloaded.useStore.getState().appearance;
expect(appearance.tabDisplay.single).toEqual({
primaryElements: ['object', 'host'],
secondaryElements: [],
});
expect(appearance.tabDisplay.double).toEqual({
primaryElements: ['kind', 'object'],
secondaryElements: ['connection', 'database'],
});
});
it('does not clear persisted legacy connections during hydration migration', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().connections).toHaveLength(1);
expect(useStore.getState().connections[0]?.config.password).toBe('secret');
});
it('does not fail hydration when persisted OceanBase connection uses unsupported native protocol', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
connections: [
{
id: 'oceanbase-native',
name: 'OceanBase Native',
config: {
id: 'oceanbase-native',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
oceanBaseProtocol: 'mysql',
connectionParams: 'protocol=native',
},
},
],
},
version: 9,
}));
const { useStore } = await importStore();
const config = useStore.getState().connections[0]?.config;
expect(useStore.getState().connections).toHaveLength(1);
expect(config?.connectionParams).toBe('protocol=native');
expect(config?.oceanBaseProtocol).toBe('mysql');
});
it('preserves JVM Arthas diagnostic config when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'jvm-1',
name: 'Orders JVM',
config: {
id: 'jvm-1',
type: 'jvm',
host: '127.0.0.1',
port: 9010,
user: '',
jvm: {
allowedModes: ['jmx'],
preferredMode: 'jmx',
diagnostic: {
enabled: true,
transport: 'arthas-tunnel',
baseUrl: 'http://127.0.0.1:7777',
targetId: 'gonavi-local-test',
apiKey: 'diag-token',
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 20,
},
},
},
},
]);
expect(useStore.getState().connections[0]?.config.jvm?.diagnostic).toEqual({
enabled: true,
transport: 'arthas-tunnel',
baseUrl: 'http://127.0.0.1:7777',
targetId: 'gonavi-local-test',
apiKey: 'diag-token',
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 20,
});
});
it('preserves connection icon metadata when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'visual-1',
name: 'Visual Orders',
iconType: 'postgres',
iconColor: '#2f855a',
config: {
id: 'visual-1',
type: 'mysql',
host: 'db.local',
port: 3306,
user: 'root',
},
},
]);
expect(useStore.getState().connections[0]?.iconType).toBe('postgres');
expect(useStore.getState().connections[0]?.iconColor).toBe('#2f855a');
});
it('normalizes ClickHouse protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'clickhouse-http',
name: 'ClickHouse HTTP',
config: {
id: 'clickhouse-http',
type: 'clickhouse',
host: 'clickhouse.local',
port: 8125,
user: 'default',
clickHouseProtocol: 'https' as any,
},
},
]);
expect(useStore.getState().connections[0]?.config.clickHouseProtocol).toBe(
'http',
);
});
it('normalizes keepalive settings when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'postgres-keepalive',
name: 'Postgres KeepAlive',
config: {
id: 'postgres-keepalive',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
keepAliveEnabled: true,
keepAliveIntervalMinutes: 0,
},
},
]);
const config = useStore.getState().connections[0]?.config;
expect(config?.keepAliveEnabled).toBe(true);
expect(config?.keepAliveIntervalMinutes).toBe(240);
});
it('keeps StarRocks saved connections as independent datasource type', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'starrocks-fe',
name: 'StarRocks FE',
config: {
id: 'starrocks-fe',
type: 'starrocks',
host: 'starrocks.local',
port: 9030,
user: 'root',
},
},
]);
const config = useStore.getState().connections[0]?.config;
expect(config?.type).toBe('starrocks');
expect(config?.port).toBe(9030);
});
it('preserves Redis database indexes above the default 16 databases', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'redis-32',
name: 'Redis 32 DBs',
includeRedisDatabases: [0, 15, 16, 31, -1, 31],
config: {
id: 'redis-32',
type: 'redis',
host: 'redis.local',
port: 6379,
user: '',
redisDB: 31,
},
},
]);
const saved = useStore.getState().connections[0];
expect(saved?.config.redisDB).toBe(31);
expect(saved?.includeRedisDatabases).toEqual([0, 15, 16, 31]);
});
it('keeps InterSystems IRIS saved connections as independent datasource type', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'iris-user',
name: 'IRIS USER',
config: {
id: 'iris-user',
type: 'iris',
host: 'iris.local',
port: 1972,
user: '_SYSTEM',
database: 'USER',
},
},
{
id: 'iris-alias',
name: 'IRIS Alias',
config: {
id: 'iris-alias',
type: 'InterSystemsIRIS',
host: 'iris-alias.local',
port: 1972,
user: '_SYSTEM',
database: 'USER',
},
},
]);
const connections = useStore.getState().connections;
expect(connections[0]?.config.type).toBe('iris');
expect(connections[0]?.config.port).toBe(1972);
expect(connections[1]?.config.type).toBe('iris');
});
it('normalizes saved connection type aliases without falling back to mysql', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{ id: 'pg', name: 'Postgres', config: { id: 'pg', type: 'PostgreSQL', host: 'pg.local', port: 5432, user: 'postgres' } },
{ id: 'mssql', name: 'MSSQL', config: { id: 'mssql', type: 'mssql', host: 'sql.local', port: 1433, user: 'sa' } },
{ id: 'kingbase', name: 'Kingbase', config: { id: 'kingbase', type: 'kingbase8', host: 'kingbase.local', port: 54321, user: 'system' } },
{ id: 'dm', name: 'Dameng', config: { id: 'dm', type: 'dm8', host: 'dm.local', port: 5236, user: 'SYSDBA' } },
{ id: 'sqlite', name: 'SQLite', config: { id: 'sqlite', type: 'sqlite3', host: 'D:/db/app.sqlite', port: 0, user: '' } },
]);
expect(useStore.getState().connections.map((conn) => conn.config.type)).toEqual([
'postgres',
'sqlserver',
'kingbase',
'dameng',
'sqlite',
]);
});
it('preserves SSL certificate paths for SSL-capable saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'postgres-ssl',
name: 'Postgres SSL',
config: {
id: 'postgres-ssl',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
useSSL: true,
sslMode: 'required',
sslCAPath: 'C:/certs/ca.pem',
sslCertPath: 'C:/certs/client-cert.pem',
sslKeyPath: 'C:/certs/client-key.pem',
},
},
]);
const config = useStore.getState().connections[0]?.config;
expect(config?.sslCAPath).toBe('C:/certs/ca.pem');
expect(config?.sslCertPath).toBe('C:/certs/client-cert.pem');
expect(config?.sslKeyPath).toBe('C:/certs/client-key.pem');
});
it('normalizes OceanBase protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-oracle',
name: 'OceanBase Oracle',
config: {
id: 'oceanbase-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
oceanBaseProtocol: 'oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('restores OceanBase protocol from saved URI or connection params', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-uri-oracle',
name: 'OceanBase URI Oracle',
config: {
id: 'oceanbase-uri-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
uri: 'oceanbase://sys%40oracle001:pass@ob.local:2881/OBORCL?protocol=oracle',
},
},
{
id: 'oceanbase-param-oracle',
name: 'OceanBase Param Oracle',
config: {
id: 'oceanbase-param-oracle',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'tenantMode=oracle&PREFETCH_ROWS=5000',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
expect(useStore.getState().connections[1]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('prefers OceanBase protocol query key over legacy aliases when restoring saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-conflict',
name: 'OceanBase Conflict',
config: {
id: 'oceanbase-conflict',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql&tenantMode=oracle',
},
},
]);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'mysql',
);
});
it('keeps saved OceanBase native protocol loadable for connect-time rejection', async () => {
const { useStore } = await importStore();
expect(() => useStore.getState().replaceConnections([
{
id: 'oceanbase-native',
name: 'OceanBase Native',
config: {
id: 'oceanbase-native',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
oceanBaseProtocol: 'mysql',
connectionParams: 'protocol=native',
},
},
])).not.toThrow();
expect(useStore.getState().connections[0]?.config.connectionParams).toBe(
'protocol=native',
);
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'mysql',
);
});
it('normalizes OceanBase protocol when updating a saved connection', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'root@test',
connectionParams: 'protocol=mysql',
},
},
]);
useStore.getState().updateConnection({
id: 'oceanbase-existing',
name: 'OceanBase Existing',
config: {
id: 'oceanbase-existing',
type: 'oceanbase',
host: 'ob.local',
port: 2881,
user: 'sys@oracle001',
connectionParams: 'protocol=oracle',
},
});
expect(useStore.getState().connections[0]?.config.oceanBaseProtocol).toBe(
'oracle',
);
});
it('reorders connections inside tags and ungrouped roots independently', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'conn-a',
name: 'A',
config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' },
},
{
id: 'conn-b',
name: 'B',
config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' },
},
{
id: 'conn-c',
name: 'C',
config: { id: 'conn-c', type: 'mysql', host: 'c.local', port: 3306, user: 'root' },
},
{
id: 'conn-d',
name: 'D',
config: { id: 'conn-d', type: 'mysql', host: 'd.local', port: 3306, user: 'root' },
},
]);
useStore.getState().addConnectionTag({
id: 'tag-dev',
name: '开发',
connectionIds: ['conn-b', 'conn-d'],
});
useStore.getState().reorderConnections('conn-d', 'conn-b', 'tag-dev', true);
expect(useStore.getState().connectionTags[0]?.connectionIds).toEqual(['conn-d', 'conn-b']);
useStore.getState().reorderConnections('conn-c', 'conn-a', null, true);
expect(useStore.getState().connections.map((conn) => conn.id)).toEqual([
'conn-c',
'conn-a',
'conn-b',
'conn-d',
]);
});
it('reorders sidebar root items across tags and ungrouped hosts', async () => {
const {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
resolveSidebarRootOrderTokens,
useStore,
} = await importStore();
useStore.getState().replaceConnections([
{
id: 'conn-a',
name: 'A',
config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' },
},
{
id: 'conn-b',
name: 'B',
config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' },
},
{
id: 'conn-c',
name: 'C',
config: { id: 'conn-c', type: 'mysql', host: 'c.local', port: 3306, user: 'root' },
},
]);
useStore.getState().addConnectionTag({
id: 'tag-dev',
name: '开发',
connectionIds: ['conn-b'],
});
const initialOrder = resolveSidebarRootOrderTokens(
useStore.getState().sidebarRootOrder,
useStore.getState().connectionTags,
useStore.getState().connections,
);
expect(initialOrder).toEqual([
buildSidebarRootTagToken('tag-dev'),
buildSidebarRootConnectionToken('conn-a'),
buildSidebarRootConnectionToken('conn-c'),
]);
useStore.getState().reorderSidebarRoot(
buildSidebarRootTagToken('tag-dev'),
buildSidebarRootConnectionToken('conn-c'),
false,
);
expect(resolveSidebarRootOrderTokens(
useStore.getState().sidebarRootOrder,
useStore.getState().connectionTags,
useStore.getState().connections,
)).toEqual([
buildSidebarRootConnectionToken('conn-a'),
buildSidebarRootConnectionToken('conn-c'),
buildSidebarRootTagToken('tag-dev'),
]);
});
it('restores ungrouped host root order after moving a host out of a tag', async () => {
const {
buildSidebarRootConnectionToken,
buildSidebarRootTagToken,
resolveSidebarRootOrderTokens,
useStore,
} = await importStore();
useStore.getState().replaceConnections([
{
id: 'conn-a',
name: 'A',
config: { id: 'conn-a', type: 'mysql', host: 'a.local', port: 3306, user: 'root' },
},
{
id: 'conn-b',
name: 'B',
config: { id: 'conn-b', type: 'mysql', host: 'b.local', port: 3306, user: 'root' },
},
]);
useStore.getState().addConnectionTag({
id: 'tag-dev',
name: '开发',
connectionIds: ['conn-b'],
});
useStore.getState().moveConnectionToTag('conn-a', 'tag-dev');
useStore.getState().moveConnectionToTag('conn-a', null);
expect(resolveSidebarRootOrderTokens(
useStore.getState().sidebarRootOrder,
useStore.getState().connectionTags,
useStore.getState().connections,
)).toEqual([
buildSidebarRootTagToken('tag-dev'),
buildSidebarRootConnectionToken('conn-a'),
]);
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
},
version: 7,
}));
const { useStore } = await importStore();
expect(useStore.getState().globalProxy.password).toBe('proxy-secret');
expect(useStore.getState().globalProxy.hasPassword).toBe(true);
});
it('persists external SQL directories and restores valid items after reload', async () => {
const { useStore } = await importStore();
useStore.getState().saveExternalSQLDirectory({
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
createdAt: 1,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.externalSQLDirectories).toEqual([
{
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
createdAt: 1,
},
]);
storage.setItem('lite-db-storage', JSON.stringify({
state: {
externalSQLDirectories: [
persisted.state.externalSQLDirectories[0],
{
id: 'legacy-ext-1',
name: 'legacy duplicate',
path: 'D:\\sql\\scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 2,
},
{ path: '', name: 'broken' },
],
},
version: 7,
}));
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().externalSQLDirectories).toEqual([
{
id: 'ext-1',
name: 'scripts',
path: 'D:/sql/scripts',
createdAt: 1,
},
]);
});
it('uses localized external SQL directory fallback names without overriding explicit names or path segments', async () => {
const i18n = await import('./i18n');
i18n.setCurrentLanguage('de-DE');
const { useStore } = await importStore();
useStore.getState().saveExternalSQLDirectory({
id: 'ext-fallback',
name: ' ',
path: '/',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
});
useStore.getState().saveExternalSQLDirectory({
id: 'ext-segment',
name: '',
path: 'D:/sql/reports',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 2,
});
useStore.getState().saveExternalSQLDirectory({
id: 'ext-explicit',
name: 'Handwritten scripts',
path: 'D:/sql/handwritten',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 3,
});
expect(useStore.getState().externalSQLDirectories.map((directory) => directory.name)).toEqual([
i18n.t('sidebar.sql_directory.default_name'),
'reports',
'Handwritten scripts',
]);
storage.setItem('lite-db-storage', JSON.stringify({
state: {
externalSQLDirectories: [
{
id: 'ext-reloaded-fallback',
name: '',
path: '/',
connectionId: 'conn-2',
dbName: 'demo2',
createdAt: 4,
},
{
id: 'ext-reloaded-segment',
name: ' ',
path: 'D:/sql/migrations',
connectionId: 'conn-2',
dbName: 'demo2',
createdAt: 5,
},
],
},
version: 10,
}));
vi.resetModules();
const reloadedI18n = await import('./i18n');
reloadedI18n.setCurrentLanguage('ja-JP');
const reloaded = await importStore();
expect(reloaded.useStore.getState().externalSQLDirectories.map((directory) => directory.name)).toEqual([
reloadedI18n.t('sidebar.sql_directory.default_name'),
'migrations',
]);
});
it('uses localized store fallback names when restoring persisted records', async () => {
const i18n = await import('./i18n');
i18n.setCurrentLanguage('en-US');
storage.setItem('lite-db-storage', JSON.stringify({
state: {
connectionTags: [
{
id: 'tag-empty-name',
name: ' ',
connectionIds: [],
},
],
sqlSnippets: [
{
id: 'snippet-empty-name',
prefix: 'demo',
name: ' ',
body: 'select 1;',
isBuiltin: false,
createdAt: 1,
},
],
tabs: [
{
id: 'query-empty-title',
title: ' ',
type: 'query',
query: 'select 1;',
},
],
tableExportHistories: {
'conn-1::main::users': [
{
jobId: 'job-1',
targetName: ' ',
startedAt: 1,
finishedAt: 0,
format: 'csv',
scope: 'table',
scopeLabel: 'Table',
strategyLabel: 'Export',
status: 'running',
stage: '',
current: 0,
total: 0,
totalRowsKnown: false,
filePath: '',
message: '',
},
],
},
activeTabId: 'query-empty-title',
},
version: 11,
}));
vi.resetModules();
const reloadedI18n = await import('./i18n');
reloadedI18n.setCurrentLanguage('en-US');
const reloaded = await importStore();
expect(reloaded.useStore.getState().connectionTags[0]?.name).toBe(
reloadedI18n.t('store.fallback.connection_tag_name', { index: 1 }),
);
expect(reloaded.useStore.getState().sqlSnippets[0]?.name).toBe(
reloadedI18n.t('store.fallback.sql_snippet_name', { index: 1 }),
);
expect(reloaded.useStore.getState().tabs[0]?.title).toBe(
reloadedI18n.t('sidebar.tab.new_query'),
);
expect(
reloaded.useStore.getState().tableExportHistories['conn-1::main::users']?.[0]?.targetName,
).toBe(
reloadedI18n.t('data_export.progress.value.target_fallback'),
);
});
it('uses localized AI session fallback titles for non-user first messages', async () => {
vi.useFakeTimers();
try {
const i18n = await import('./i18n');
i18n.setCurrentLanguage('ja-JP');
const { useStore } = await importStore();
useStore.getState().addAIChatMessage('assistant-first', {
id: 'message-1',
role: 'assistant',
content: '',
timestamp: 1,
});
expect(useStore.getState().aiChatSessions[0]?.title).toBe(
i18n.t('ai_chat.panel.session.default_title'),
);
} finally {
vi.useRealTimers();
}
});
it('keeps store fallback titles out of production source literals', async () => {
const { readFileSync } = await import('node:fs');
const source = readFileSync(new URL('./store.ts', import.meta.url), 'utf8');
expect(source).not.toContain('`连接-${index + 1}`');
expect(source).not.toContain('`标签-${index + 1}`');
expect(source).not.toContain('`片段-${index + 1}`');
expect(source).not.toContain('"未命名对象"');
expect(source).not.toContain('"新建查询"');
expect(source).not.toContain('"新的对话"');
});
it('persists open query tab drafts and restores them after reload', async () => {
const { useStore } = await importStore();
useStore.getState().addTab({
id: 'query-tab-1',
title: '临时 SQL',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select * from users where id = 1;',
});
useStore.getState().updateQueryTabDraft('query-tab-1', {
query: 'select * from orders where status = "paid";',
connectionId: 'conn-2',
dbName: 'reporting',
formatRestoreSnapshot: {
query: 'select * from orders where status="paid";',
createdAt: 123,
},
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.tabs).toEqual([
expect.objectContaining({
id: 'query-tab-1',
title: '临时 SQL',
type: 'query',
connectionId: 'conn-2',
dbName: 'reporting',
query: 'select * from orders where status = "paid";',
formatRestoreSnapshot: {
query: 'select * from orders where status="paid";',
createdAt: 123,
},
}),
]);
expect(persisted.state.activeTabId).toBe('query-tab-1');
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().tabs).toEqual([
expect.objectContaining({
id: 'query-tab-1',
type: 'query',
connectionId: 'conn-2',
dbName: 'reporting',
query: 'select * from orders where status = "paid";',
formatRestoreSnapshot: {
query: 'select * from orders where status="paid";',
createdAt: 123,
},
}),
]);
expect(reloaded.useStore.getState().activeTabId).toBe('query-tab-1');
reloaded.useStore.getState().updateQueryTabDraft('query-tab-1', {
formatRestoreSnapshot: undefined,
});
expect(reloaded.useStore.getState().tabs[0].formatRestoreSnapshot).toBeUndefined();
});
it('updates activeContext when switching between tabs with different host or database', async () => {
const { useStore } = await importStore();
useStore.getState().addTab({
id: 'table-main',
title: 'users',
type: 'table',
connectionId: 'conn-1',
dbName: 'sys',
tableName: 'users',
});
expect(useStore.getState().activeContext).toEqual({
connectionId: 'conn-1',
dbName: 'sys',
});
useStore.getState().addTab({
id: 'query-bot',
title: '新建查询',
type: 'query',
connectionId: 'conn-2',
dbName: 'missav_bot',
query: 'select 1;',
});
expect(useStore.getState().activeContext).toEqual({
connectionId: 'conn-2',
dbName: 'missav_bot',
});
useStore.getState().setActiveTab('table-main');
expect(useStore.getState().activeTabId).toBe('table-main');
expect(useStore.getState().activeContext).toEqual({
connectionId: 'conn-1',
dbName: 'sys',
});
});
it('falls back activeContext to the new active tab after closing the current tab', async () => {
const { useStore } = await importStore();
useStore.getState().addTab({
id: 'query-sys',
title: '新建查询',
type: 'query',
connectionId: 'conn-1',
dbName: 'sys',
query: 'select 1;',
});
useStore.getState().addTab({
id: 'query-bot',
title: '新建查询',
type: 'query',
connectionId: 'conn-2',
dbName: 'missav_bot',
query: 'select 2;',
});
expect(useStore.getState().activeTabId).toBe('query-bot');
expect(useStore.getState().activeContext).toEqual({
connectionId: 'conn-2',
dbName: 'missav_bot',
});
useStore.getState().closeTab('query-bot');
expect(useStore.getState().activeTabId).toBe('query-sys');
expect(useStore.getState().activeContext).toEqual({
connectionId: 'conn-1',
dbName: 'sys',
});
});
it('reuses the same table-export tab for the same connection and table identity', async () => {
const { useStore } = await importStore();
useStore.getState().addTab({
id: 'table-export-conn-1-main-users',
title: '导出 users',
type: 'table-export',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
initialTab: 'config',
});
useStore.getState().addTab({
id: 'another-id-that-should-collapse',
title: '导出 users',
type: 'table-export',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
initialTab: 'progress',
});
expect(useStore.getState().tabs).toHaveLength(1);
expect(useStore.getState().tabs[0]).toEqual(expect.objectContaining({
id: 'table-export-conn-1-main-users',
type: 'table-export',
initialTab: 'progress',
}));
expect(useStore.getState().activeTabId).toBe('table-export-conn-1-main-users');
});
it('persists table export history across store reloads', async () => {
const { useStore } = await importStore();
useStore.getState().upsertTableExportHistory('conn-1::main::users', {
jobId: 'job-1',
targetName: 'users',
startedAt: 1_000,
finishedAt: 61_000,
format: 'XLSX',
scope: 'all',
scopeLabel: '全表数据',
strategyLabel: '整表导出链路',
status: 'done',
stage: '导出完成',
current: 500_000,
total: 500_000,
totalRowsKnown: true,
filePath: '/tmp/users.xlsx',
message: '',
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.tableExportHistories['conn-1::main::users']).toEqual([
expect.objectContaining({
jobId: 'job-1',
status: 'done',
filePath: '/tmp/users.xlsx',
}),
]);
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().tableExportHistories['conn-1::main::users']).toEqual([
expect.objectContaining({
jobId: 'job-1',
current: 500_000,
total: 500_000,
status: 'done',
}),
]);
});
it('only restores persisted query tabs with useful SQL state', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
tabs: [
{
id: 'query-1',
title: '有效 SQL',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
},
{
id: 'table-1',
title: 'users',
type: 'table',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
},
{
id: 'empty-query',
title: '空查询',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: ' ',
},
],
activeTabId: 'table-1',
},
version: 9,
}));
const { useStore } = await importStore();
expect(useStore.getState().tabs).toEqual([
expect.objectContaining({
id: 'query-1',
type: 'query',
query: 'select 1;',
}),
]);
expect(useStore.getState().activeTabId).toBe('query-1');
});
it('keeps only the most recent runtime SQL logs and trims oversized entries', async () => {
const { useStore } = await importStore();
const longSql = `select '${'x'.repeat(20 * 1024)}'`;
for (let i = 0; i < 140; i += 1) {
useStore.getState().addSqlLog({
id: `log-${i}`,
timestamp: 100 + i,
sql: longSql,
status: 'success',
duration: 12 + i,
dbName: 'main',
});
}
expect(useStore.getState().sqlLogs).toHaveLength(120);
expect(useStore.getState().sqlLogs[0]).toEqual(expect.objectContaining({
id: 'log-139',
dbName: 'main',
}));
expect(useStore.getState().sqlLogs[119]).toEqual(expect.objectContaining({
id: 'log-20',
}));
expect(useStore.getState().sqlLogs[0]?.sql.length).toBe(12 * 1024);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.sqlLogs).toHaveLength(120);
expect(persisted.state.sqlLogs[0].sql.length).toBe(12 * 1024);
expect(persisted.state.sqlLogs[0].dbName).toBe('main');
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().sqlLogs[0]).toEqual(expect.objectContaining({
id: 'log-139',
status: 'success',
duration: 151,
dbName: 'main',
}));
expect(reloaded.useStore.getState().sqlLogs).toHaveLength(120);
expect(reloaded.useStore.getState().sqlLogs[119]).toEqual(expect.objectContaining({
id: 'log-20',
}));
expect(reloaded.useStore.getState().sqlLogs[0]?.sql.length).toBe(12 * 1024);
});
it('shrinks oversized SQL logs from older persisted snapshots during hydration', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
sqlLogs: Array.from({ length: 200 }, (_, index) => ({
id: `legacy-log-${index}`,
timestamp: 500 + index,
sql: `select '${'x'.repeat(18 * 1024)}'`,
status: index % 2 === 0 ? 'success' : 'error',
duration: index,
dbName: 'legacy',
message: 'm'.repeat(3 * 1024),
})),
},
version: 12,
}));
const { useStore } = await importStore();
const sqlLogs = useStore.getState().sqlLogs;
expect(sqlLogs).toHaveLength(120);
expect(sqlLogs[0]).toEqual(expect.objectContaining({
id: 'legacy-log-0',
dbName: 'legacy',
}));
expect(sqlLogs[119]).toEqual(expect.objectContaining({
id: 'legacy-log-119',
}));
expect(sqlLogs[0]?.sql.length).toBe(12 * 1024);
expect(sqlLogs[0]?.message?.length).toBe(1024);
});
it('defaults AI chat send shortcut to Enter in shared shortcut options', async () => {
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
});
});
it('persists recorded AI chat send shortcut and restores it after reload', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
}, 'mac');
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
mac: { combo: 'Meta+Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
});
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
mac: { combo: 'Meta+Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
});
});
it('persists startup fullscreen immediately so next launch does not miss maximize preference', async () => {
const { useStore } = await importStore();
useStore.getState().setStartupFullscreen(true);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.startupFullscreen).toBe(true);
vi.resetModules();
const reloaded = await importStore();
expect(reloaded.useStore.getState().startupFullscreen).toBe(true);
});
it('falls back to Enter when persisted AI chat send shortcut is invalid', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
sendAIChatMessage: {
combo: 'A',
enabled: true,
},
},
},
version: 8,
}));
const { useStore } = await importStore();
expect(useStore.getState().shortcutOptions.sendAIChatMessage).toEqual({
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
});
});
it('does not overwrite recorded AI chat send shortcut during startup config refresh', async () => {
const { useStore } = await importStore();
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Ctrl+Enter',
enabled: true,
}, 'windows');
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Ctrl+Enter', enabled: true },
});
});
it('keeps persisted AI chat send shortcut when startup refresh runs before shortcut hydration catches up', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
storage.setItem('lite-db-storage', JSON.stringify({
state: {
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
mac: { combo: 'Meta+Enter', enabled: true },
windows: { combo: 'Ctrl+Enter', enabled: true },
},
},
},
version: 8,
}));
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
},
},
});
useStore.getState().replaceConnections([]);
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
mac: { combo: 'Meta+Enter', enabled: true },
windows: { combo: 'Ctrl+Enter', enabled: true },
});
});
it('does not let a stale default shortcut state overwrite an explicitly recorded AI chat shortcut', async () => {
const { useStore } = await importStore();
const shortcutOptions = useStore.getState().shortcutOptions;
useStore.getState().updateShortcut('sendAIChatMessage', {
combo: 'Meta+Enter',
enabled: true,
}, 'mac');
useStore.setState({
shortcutOptions: {
...shortcutOptions,
sendAIChatMessage: {
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
},
},
});
useStore.getState().replaceGlobalProxy({});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
expect(persisted.state.shortcutOptions.sendAIChatMessage).toEqual({
mac: { combo: 'Meta+Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },
});
});
it('updates an existing custom SQL snippet by id and persists editable syntax help', async () => {
const { useStore } = await importStore();
const original = {
id: 'custom-merge',
prefix: 'mrg',
name: 'MERGE INTO',
description: 'Oracle merge 模板',
syntaxHelp: '旧说明',
body: 'MERGE INTO t USING s ON (t.id = s.id)$0',
isBuiltin: false,
createdAt: 1710000000000,
};
useStore.getState().saveSqlSnippet(original);
useStore.getState().saveSqlSnippet({
...original,
name: 'MERGE INTO 更新',
syntaxHelp: '新说明:目标表、数据源、关联字段均可修改',
body: 'MERGE INTO ${1:目标表} t USING ${2:源表} s ON (${3:关联条件})$0',
});
const snippets = useStore.getState().sqlSnippets.filter((s) => s.id === original.id);
expect(snippets).toHaveLength(1);
expect(snippets[0]).toMatchObject({
prefix: 'mrg',
name: 'MERGE INTO 更新',
syntaxHelp: '新说明:目标表、数据源、关联字段均可修改',
body: 'MERGE INTO ${1:目标表} t USING ${2:源表} s ON (${3:关联条件})$0',
isBuiltin: false,
});
const persisted = JSON.parse(storage.getItem('lite-db-storage') || '{}');
const persistedSnippets = persisted.state.sqlSnippets.filter((s: { id: string }) => s.id === original.id);
expect(persistedSnippets).toHaveLength(1);
expect(persistedSnippets[0].syntaxHelp).toBe('新说明:目标表、数据源、关联字段均可修改');
});
});