mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-13 01:54:20 +08:00
595 lines
18 KiB
TypeScript
595 lines
18 KiB
TypeScript
import React from 'react';
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import TableOverview from './TableOverview';
|
|
|
|
const storeSubscribers = vi.hoisted(() => new Set<() => void>());
|
|
|
|
const storeState = vi.hoisted(() => ({
|
|
theme: 'light',
|
|
appearance: {
|
|
uiVersion: 'legacy',
|
|
tableDoubleClickAction: 'open-data',
|
|
} as {
|
|
uiVersion: 'legacy' | 'v2';
|
|
tableDoubleClickAction: 'open-data' | 'open-design';
|
|
},
|
|
connections: [
|
|
{
|
|
id: 'conn-1',
|
|
config: {
|
|
type: 'tdengine',
|
|
host: '127.0.0.1',
|
|
port: 6041,
|
|
user: 'root',
|
|
password: 'taosdata',
|
|
database: 'metrics',
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
],
|
|
addTab: vi.fn(),
|
|
setActiveContext: vi.fn(),
|
|
setAIPanelVisible: vi.fn(),
|
|
addAIContext: vi.fn(),
|
|
pinnedSidebarTables: [] as string[],
|
|
setSidebarTablePinned: vi.fn(),
|
|
queryOptions: {
|
|
tableOverviewViewMode: undefined,
|
|
} as { tableOverviewViewMode?: 'card' | 'list' | 'table' },
|
|
setQueryOptions: vi.fn(),
|
|
}));
|
|
|
|
const backendApp = vi.hoisted(() => ({
|
|
DBGetTables: vi.fn(),
|
|
DBQuery: vi.fn(),
|
|
DBShowCreateTable: vi.fn(),
|
|
ExportTable: vi.fn(),
|
|
DropTable: vi.fn(),
|
|
RenameTable: vi.fn(),
|
|
}));
|
|
|
|
const messageApi = vi.hoisted(() => ({
|
|
error: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../store', async () => {
|
|
const react = await vi.importActual<typeof import('react')>('react');
|
|
return {
|
|
useStore: <T,>(selector: (state: typeof storeState) => T) => react.useSyncExternalStore(
|
|
(listener) => {
|
|
storeSubscribers.add(listener);
|
|
return () => storeSubscribers.delete(listener);
|
|
},
|
|
() => selector(storeState),
|
|
() => selector(storeState),
|
|
),
|
|
buildSidebarTablePinKey: (connectionId: string, dbName: string, tableName: string, schemaName: string) =>
|
|
`${connectionId}:${dbName}:${schemaName}:${tableName}`,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../wailsjs/go/app/App', () => backendApp);
|
|
vi.mock('../utils/autoFetchVisibility', () => ({
|
|
useAutoFetchVisibility: () => true,
|
|
}));
|
|
vi.mock('../utils/connectionRpcConfig', () => ({
|
|
buildRpcConnectionConfig: (config: unknown) => config,
|
|
}));
|
|
vi.mock('./ExportProgressModal', () => ({
|
|
useExportProgressDialog: () => ({
|
|
exportProgressModal: null,
|
|
runExportWithProgress: vi.fn(),
|
|
}),
|
|
}));
|
|
vi.mock('./V2TableContextMenu', () => ({
|
|
V2TableContextMenuView: () => null,
|
|
}));
|
|
|
|
vi.mock('@ant-design/icons', () => {
|
|
const Icon = () => <span />;
|
|
return {
|
|
TableOutlined: Icon,
|
|
SearchOutlined: Icon,
|
|
ReloadOutlined: Icon,
|
|
SortAscendingOutlined: Icon,
|
|
DatabaseOutlined: Icon,
|
|
ConsoleSqlOutlined: Icon,
|
|
EditOutlined: Icon,
|
|
CopyOutlined: Icon,
|
|
SaveOutlined: Icon,
|
|
DeleteOutlined: Icon,
|
|
ExportOutlined: Icon,
|
|
AppstoreOutlined: Icon,
|
|
UnorderedListOutlined: Icon,
|
|
WarningOutlined: Icon,
|
|
CaretUpFilled: Icon,
|
|
CaretDownFilled: Icon,
|
|
};
|
|
});
|
|
|
|
vi.mock('antd', () => {
|
|
const Button = ({ children, onClick, ...rest }: any) => <button type="button" onClick={onClick} {...rest}>{children}</button>;
|
|
const Input: any = ({ value, onChange, ...rest }: any) => <input value={value} onChange={onChange} {...rest} />;
|
|
Input.Search = ({ value, onChange, ...rest }: any) => <input value={value} onChange={onChange} {...rest} />;
|
|
const Spin = ({ children }: any) => <div>{children}</div>;
|
|
const Empty = ({ description }: any) => <div>{description}</div>;
|
|
const Dropdown = ({ children }: any) => <div>{children}</div>;
|
|
const Tooltip = ({ children }: any) => <div>{children}</div>;
|
|
const Modal: any = ({ children }: any) => <div>{children}</div>;
|
|
Modal.confirm = vi.fn();
|
|
const Text = ({ children }: any) => <span>{children}</span>;
|
|
const Paragraph = ({ children }: any) => <p>{children}</p>;
|
|
return {
|
|
Button,
|
|
Dropdown,
|
|
Empty,
|
|
Input,
|
|
Modal,
|
|
Spin,
|
|
Tooltip,
|
|
Typography: { Text, Paragraph },
|
|
message: messageApi,
|
|
};
|
|
});
|
|
|
|
const flushPromises = async () => {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
};
|
|
|
|
const collectText = (node: any): string => {
|
|
if (!node) {
|
|
return '';
|
|
}
|
|
if (typeof node === 'string') {
|
|
return node;
|
|
}
|
|
if (Array.isArray(node)) {
|
|
return node.map((item) => collectText(item)).join('');
|
|
}
|
|
return collectText(node.children || []);
|
|
};
|
|
|
|
describe('TableOverview metadata compatibility', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
storeSubscribers.clear();
|
|
storeState.appearance = { uiVersion: 'legacy', tableDoubleClickAction: 'open-data' };
|
|
storeState.queryOptions = { tableOverviewViewMode: undefined };
|
|
storeState.setQueryOptions.mockImplementation((options: { tableOverviewViewMode?: 'card' | 'list' | 'table' }) => {
|
|
storeState.queryOptions = { ...storeState.queryOptions, ...options };
|
|
storeSubscribers.forEach((listener) => listener());
|
|
});
|
|
storeState.connections = [
|
|
{
|
|
id: 'conn-1',
|
|
config: {
|
|
type: 'tdengine',
|
|
host: '127.0.0.1',
|
|
port: 6041,
|
|
user: 'root',
|
|
password: 'taosdata',
|
|
database: 'metrics',
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
];
|
|
backendApp.DBGetTables.mockResolvedValue({
|
|
success: true,
|
|
data: [
|
|
{ Table: 'd001' },
|
|
{ Table: 'meters' },
|
|
],
|
|
});
|
|
backendApp.DBQuery.mockResolvedValue({
|
|
success: false,
|
|
message: '[0x2600] syntax error near',
|
|
});
|
|
});
|
|
|
|
it('loads tdengine overview rows through DBGetTables instead of direct metadata SQL', async () => {
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: '表概览 - metrics',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName: 'metrics',
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'metrics');
|
|
expect(backendApp.DBQuery).not.toHaveBeenCalled();
|
|
expect(messageApi.error).not.toHaveBeenCalled();
|
|
const renderedText = collectText(renderer!.toJSON());
|
|
expect(renderedText).toContain('meters');
|
|
expect(renderedText).toContain('d001');
|
|
});
|
|
|
|
it('loads sqlite overview rows through DBGetTables instead of information_schema SQL', async () => {
|
|
storeState.connections = [
|
|
{
|
|
id: 'conn-1',
|
|
config: {
|
|
type: 'sqlite',
|
|
host: '',
|
|
port: 0,
|
|
user: '',
|
|
password: '',
|
|
database: 'E:\\data\\app.db',
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
];
|
|
backendApp.DBGetTables.mockResolvedValue({
|
|
success: true,
|
|
data: [
|
|
{ Table: 'users', Rows: '12', Data_length: '4096', Index_length: '8192' },
|
|
{ Table: 'orders', Rows: '34', Data_length: '2048', Index_length: '0' },
|
|
],
|
|
});
|
|
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: '表概览 - main',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName: 'main',
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'main');
|
|
expect(backendApp.DBQuery).not.toHaveBeenCalled();
|
|
expect(messageApi.error).not.toHaveBeenCalled();
|
|
const renderedText = collectText(renderer!.toJSON());
|
|
expect(renderedText).toContain('users');
|
|
expect(renderedText).toContain('12');
|
|
expect(renderedText).toContain('orders');
|
|
expect(renderedText).toContain('34');
|
|
expect(renderedText).toContain('4.0 KB');
|
|
expect(renderedText).toContain('8.0 KB');
|
|
expect(renderedText).toContain('2.0 KB');
|
|
expect(renderedText).toContain('0 B');
|
|
expect(renderedText).toContain('100%');
|
|
});
|
|
|
|
it('loads milvus collections through DBGetTables instead of information_schema SQL', async () => {
|
|
storeState.connections = [
|
|
{
|
|
id: 'conn-1',
|
|
config: {
|
|
type: 'milvus',
|
|
host: '192.168.3.230',
|
|
port: 19530,
|
|
user: '',
|
|
password: '',
|
|
database: 'default',
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
];
|
|
backendApp.DBGetTables.mockResolvedValue({
|
|
success: true,
|
|
data: [
|
|
{ Table: 'documents' },
|
|
{ Table: 'embeddings' },
|
|
],
|
|
});
|
|
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: '表概览 - default',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName: 'default',
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'default');
|
|
expect(backendApp.DBQuery).not.toHaveBeenCalled();
|
|
expect(messageApi.error).not.toHaveBeenCalled();
|
|
const renderedText = collectText(renderer!.toJSON());
|
|
expect(renderedText).toContain('documents');
|
|
expect(renderedText).toContain('embeddings');
|
|
});
|
|
|
|
it.each([
|
|
{ type: 'oracle', dbName: 'APP' },
|
|
{ type: 'trino', dbName: 'catalog' },
|
|
])('keeps unsupported $type storage metrics unknown', async ({ type, dbName }) => {
|
|
storeState.connections = [
|
|
{
|
|
id: 'conn-1',
|
|
config: {
|
|
type,
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
user: 'tester',
|
|
password: 'secret',
|
|
database: dbName,
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
];
|
|
backendApp.DBQuery.mockResolvedValue({
|
|
success: true,
|
|
data: [
|
|
{
|
|
table_name: 'orders',
|
|
table_rows: '8',
|
|
data_length: null,
|
|
index_length: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: `表概览 - ${dbName}`,
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName,
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(backendApp.DBGetTables).not.toHaveBeenCalled();
|
|
expect(backendApp.DBQuery).toHaveBeenCalledOnce();
|
|
const metadataSQL = String(backendApp.DBQuery.mock.calls[0]?.[2] || '');
|
|
expect(metadataSQL).toMatch(/NULL AS data_length/i);
|
|
expect(metadataSQL).toMatch(/NULL AS index_length/i);
|
|
|
|
const renderedText = collectText(renderer!.toJSON());
|
|
expect(renderedText).toContain('orders');
|
|
expect(renderedText).toContain('—');
|
|
expect(renderedText).not.toContain('0 B');
|
|
});
|
|
|
|
it('uses the table default open behavior for v2 card double-clicks', async () => {
|
|
storeState.appearance = { uiVersion: 'v2', tableDoubleClickAction: 'open-design' };
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: '表概览 - metrics',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName: 'metrics',
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
const card = renderer!.root.findAll((node) => node.props.className === 'gn-v2-table-card')[0];
|
|
expect(card).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
card.props.onDoubleClick();
|
|
});
|
|
|
|
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'table',
|
|
tableName: 'd001',
|
|
initialViewMode: 'fields',
|
|
initialViewModeRequestId: expect.any(String),
|
|
}));
|
|
expect(storeState.addTab).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'design',
|
|
tableName: 'd001',
|
|
}));
|
|
});
|
|
|
|
it('uses the table default open behavior for list double-clicks', async () => {
|
|
storeState.appearance = { uiVersion: 'v2', tableDoubleClickAction: 'open-design' };
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: '表概览 - metrics',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName: 'metrics',
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
await act(async () => {
|
|
renderer!.root.findByProps({ 'data-table-overview-view-mode': 'list' }).props.onClick();
|
|
});
|
|
|
|
const listRow = renderer!.root.findAll((node) => node.props.className === 'gn-v2-table-row')[0];
|
|
expect(listRow).toBeTruthy();
|
|
|
|
await act(async () => {
|
|
listRow.props.onDoubleClick();
|
|
});
|
|
|
|
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'table',
|
|
tableName: 'd001',
|
|
initialViewMode: 'fields',
|
|
initialViewModeRequestId: expect.any(String),
|
|
}));
|
|
expect(storeState.addTab).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'design',
|
|
tableName: 'd001',
|
|
}));
|
|
});
|
|
|
|
it('persists compact view across hosts and sorts numeric headers with unknown values last', async () => {
|
|
storeState.appearance = { uiVersion: 'v2', tableDoubleClickAction: 'open-data' };
|
|
storeState.connections = [
|
|
{
|
|
id: 'conn-1',
|
|
config: {
|
|
type: 'mysql',
|
|
host: '127.0.0.1',
|
|
port: 3306,
|
|
user: 'root',
|
|
password: 'secret',
|
|
database: 'app_db',
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
{
|
|
id: 'conn-2',
|
|
config: {
|
|
type: 'mysql',
|
|
host: '192.0.2.20',
|
|
port: 3306,
|
|
user: 'root',
|
|
password: 'secret',
|
|
database: 'other_db',
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
];
|
|
backendApp.DBQuery.mockResolvedValue({
|
|
success: true,
|
|
data: [
|
|
{ TABLE_NAME: 'unknown_stats', TABLE_COMMENT: '', TABLE_ROWS: null, DATA_LENGTH: null, INDEX_LENGTH: null },
|
|
{ TABLE_NAME: 'large_table', TABLE_COMMENT: 'large', TABLE_ROWS: 50, DATA_LENGTH: 500, INDEX_LENGTH: 20, ENGINE: 'InnoDB' },
|
|
{ TABLE_NAME: 'small_table', TABLE_COMMENT: 'small', TABLE_ROWS: 5, DATA_LENGTH: 50, INDEX_LENGTH: 2, ENGINE: 'InnoDB' },
|
|
],
|
|
});
|
|
|
|
let renderer: ReactTestRenderer;
|
|
let otherHostRenderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: '表概览 - app_db',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName: 'app_db',
|
|
} as any} />);
|
|
otherHostRenderer = create(<TableOverview tab={{
|
|
id: 'tab-2',
|
|
title: '表概览 - other_db',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-2',
|
|
dbName: 'other_db',
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
await act(async () => {
|
|
renderer!.root.findByProps({ 'data-table-overview-view-mode': 'table' }).props.onClick();
|
|
});
|
|
|
|
expect(storeState.setQueryOptions).toHaveBeenCalledWith({ tableOverviewViewMode: 'table' });
|
|
|
|
expect(renderer!.root.findByProps({ 'data-table-overview-view-mode': 'card' }).props).toMatchObject({
|
|
type: 'button',
|
|
'aria-pressed': false,
|
|
});
|
|
expect(renderer!.root.findByProps({ 'data-table-overview-view-mode': 'table' }).props).toMatchObject({
|
|
type: 'button',
|
|
'aria-pressed': true,
|
|
});
|
|
expect(otherHostRenderer!.root.findByProps({ 'data-table-overview-view-mode': 'table' }).props['aria-pressed']).toBe(true);
|
|
expect(otherHostRenderer!.root.findByProps({ role: 'table' })).toBeTruthy();
|
|
|
|
const rowNames = () => renderer!.root
|
|
.findAll((node) => typeof node.props?.['data-table-overview-row'] === 'string')
|
|
.map((node) => node.props['data-table-overview-row']);
|
|
|
|
expect(rowNames()).toEqual(['large_table', 'small_table', 'unknown_stats']);
|
|
expect(renderer!.root.findByProps({ role: 'table' })).toBeTruthy();
|
|
expect(renderer!.root.findAllByProps({ role: 'columnheader' })).toHaveLength(8);
|
|
|
|
await act(async () => {
|
|
renderer!.root.findByProps({ 'data-table-overview-sort': 'rows' }).props.onClick();
|
|
});
|
|
expect(rowNames()).toEqual(['large_table', 'small_table', 'unknown_stats']);
|
|
|
|
await act(async () => {
|
|
renderer!.root.findByProps({ 'data-table-overview-sort': 'rows' }).props.onClick();
|
|
});
|
|
expect(rowNames()).toEqual(['small_table', 'large_table', 'unknown_stats']);
|
|
|
|
storeState.addTab.mockClear();
|
|
await act(async () => {
|
|
renderer!.root.findByProps({ 'data-table-overview-row': 'small_table' }).props.onDoubleClick();
|
|
});
|
|
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'table',
|
|
tableName: 'small_table',
|
|
}));
|
|
|
|
});
|
|
|
|
it('renders comment and temporal metadata in the legacy table list when available', async () => {
|
|
storeState.connections = [
|
|
{
|
|
id: 'conn-1',
|
|
config: {
|
|
type: 'mysql',
|
|
host: '127.0.0.1',
|
|
port: 3306,
|
|
user: 'root',
|
|
password: 'secret',
|
|
database: 'app_db',
|
|
useSSH: false,
|
|
ssh: { host: '', port: 22, user: '', password: '', keyPath: '' },
|
|
},
|
|
},
|
|
];
|
|
backendApp.DBQuery.mockResolvedValue({
|
|
success: true,
|
|
data: [
|
|
{
|
|
TABLE_NAME: 'orders',
|
|
TABLE_COMMENT: '订单表',
|
|
TABLE_ROWS: 128,
|
|
DATA_LENGTH: 2048,
|
|
INDEX_LENGTH: 1024,
|
|
ENGINE: 'InnoDB',
|
|
CREATE_TIME: '2026-05-01 09:00:00',
|
|
UPDATE_TIME: '2026-06-02 10:30:00',
|
|
},
|
|
],
|
|
});
|
|
|
|
let renderer: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(<TableOverview tab={{
|
|
id: 'tab-1',
|
|
title: '表概览 - app_db',
|
|
type: 'table-overview',
|
|
connectionId: 'conn-1',
|
|
dbName: 'app_db',
|
|
} as any} />);
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(backendApp.DBQuery).toHaveBeenCalled();
|
|
const renderedText = collectText(renderer!.toJSON());
|
|
expect(renderedText).toContain('orders');
|
|
expect(renderedText).toContain('订单表');
|
|
expect(renderedText).toContain('2026-06-02 10:30:00');
|
|
expect(renderedText).toContain('2026-05-01 09:00:00');
|
|
});
|
|
});
|