Files
MyGoNavi/frontend/src/components/QueryEditor.external-sql-save.test.tsx
Syngnat ace6e18da8 🐛 fix(sql-editor): 移除对象信息未命中误报提示
- 取消对象信息未命中时的提示弹窗
- 保留已识别对象的 hover 与超链接行为
- 补充未命中静默回归测试
2026-06-07 12:12:02 +08:00

3520 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { readFileSync } from 'node:fs';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
import QueryEditor, {
collectQueryEditorObjectDecorationCandidates,
resolveQueryEditorNavigationTarget,
} from './QueryEditor';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
addTab: vi.fn(),
setActiveContext: vi.fn(),
updateQueryTabDraft: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
appearance: { uiVersion: 'legacy' as 'legacy' | 'v2' },
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
setQueryOptions: vi.fn(),
shortcutOptions: {
runQuery: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
selectCurrentStatement: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
saveQuery: {
mac: { enabled: true, combo: 'Meta+S' },
windows: { enabled: true, combo: 'Ctrl+S' },
},
},
activeTabId: 'tab-1',
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQuery: vi.fn(),
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
ExportSQLFile: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
const tabsState = vi.hoisted(() => ({
activeKey: undefined as string | undefined,
}));
const autoFetchState = vi.hoisted(() => ({
visible: false,
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
domNode: { style: { cursor: '' }, addEventListener: vi.fn(), removeEventListener: vi.fn() },
position: { lineNumber: 1, column: 1 },
selection: null as any,
providers: [] as any[],
hoverProviders: [] as any[],
cursorPositionListeners: [] as Array<(event: any) => void>,
modelContentListeners: [] as Array<(event: any) => void>,
mouseMoveListeners: [] as Array<(event: any) => void>,
mouseDownListeners: [] as Array<(event: any) => void>,
mouseLeaveListeners: [] as Array<() => void>,
hasTextFocus: true,
decorationIds: [] as string[],
contentHoverCalls: [] as any[],
latestOnChange: null as null | ((value?: string) => void),
};
const offsetAt = (position: { lineNumber: number; column: number }) => {
const text = state.value;
let offset = 0;
for (let lineNumber = 1; lineNumber < Math.max(1, position.lineNumber); lineNumber++) {
const nextLineBreak = text.indexOf('\n', offset);
if (nextLineBreak === -1) {
return text.length;
}
offset = nextLineBreak + 1;
}
return Math.min(text.length, offset + Math.max(0, position.column - 1));
};
const positionAt = (offset: number) => {
const text = state.value.replace(/\r\n/g, '\n');
const safeOffset = Math.max(0, Math.min(text.length, Number(offset) || 0));
const prefix = text.slice(0, safeOffset);
const lines = prefix.split('\n');
return { lineNumber: lines.length, column: (lines[lines.length - 1]?.length || 0) + 1 };
};
const valueInRange = (range: any) => {
if (!range) return '';
const start = offsetAt({ lineNumber: range.startLineNumber, column: range.startColumn });
const end = offsetAt({ lineNumber: range.endLineNumber, column: range.endColumn });
return state.value.slice(Math.min(start, end), Math.max(start, end));
};
const model = {
getValue: vi.fn(() => state.value),
getValueLength: vi.fn(() => state.value.length),
setValue: (value: string) => {
state.value = value;
},
getValueInRange: valueInRange,
getLineContent: (lineNumber: number) => state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '',
getLineCount: () => state.value.replace(/\r\n/g, '\n').split('\n').length,
getLineMaxColumn: (lineNumber: number) => (state.value.replace(/\r\n/g, '\n').split('\n')[lineNumber - 1] || '').length + 1,
getWordUntilPosition: (position: { lineNumber: number; column: number }) => {
const lineContent = model.getLineContent(position.lineNumber);
const beforeCursor = lineContent.slice(0, Math.max(0, position.column - 1));
const word = beforeCursor.match(/[A-Za-z0-9_$]*$/)?.[0] || '';
return {
startColumn: position.column - word.length,
endColumn: position.column,
word,
};
},
getOffsetAt: offsetAt,
getPositionAt: positionAt,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => model),
getPosition: vi.fn(() => state.position),
setPosition: vi.fn((position: any) => {
state.position = position;
}),
getSelection: vi.fn(() => state.selection),
getDomNode: vi.fn(() => state.domNode),
getContribution: vi.fn((id: string) => {
if (id === 'editor.contrib.contentHover') {
return {
showContentHover: vi.fn((range: any, mode: any, source: any, focus: any) => {
state.contentHoverCalls.push({ range, mode, source, focus });
}),
};
}
return null;
}),
setSelection: vi.fn((selection: any) => {
state.selection = selection;
}),
executeEdits: vi.fn((_source: string, edits: any[]) => {
edits.forEach((edit) => {
const start = offsetAt({ lineNumber: edit.range.startLineNumber, column: edit.range.startColumn });
const end = offsetAt({ lineNumber: edit.range.endLineNumber, column: edit.range.endColumn });
state.value = state.value.slice(0, start) + edit.text + state.value.slice(end);
});
}),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn((listener: (event: any) => void) => {
state.modelContentListeners.push(listener);
return { dispose: vi.fn() };
}),
onDidChangeCursorPosition: vi.fn((listener: (event: any) => void) => {
state.cursorPositionListeners.push(listener);
return { dispose: vi.fn() };
}),
onMouseMove: vi.fn((listener: (event: any) => void) => {
state.mouseMoveListeners.push(listener);
return { dispose: vi.fn() };
}),
onMouseDown: vi.fn((listener: (event: any) => void) => {
state.mouseDownListeners.push(listener);
return { dispose: vi.fn() };
}),
onMouseLeave: vi.fn((listener: () => void) => {
state.mouseLeaveListeners.push(listener);
return { dispose: vi.fn() };
}),
deltaDecorations: vi.fn((oldDecorations: string[], newDecorations: any[]) => {
state.decorationIds = newDecorations.map((_: any, index: number) => `decoration-${index + 1}`);
return state.decorationIds;
}),
updateOptions: vi.fn(),
pushUndoStop: vi.fn(),
onDidDispose: vi.fn(),
hasTextFocus: vi.fn(() => state.hasTextFocus),
revealLineInCenterIfOutsideViewport: vi.fn(),
revealRangeInCenterIfOutsideViewport: vi.fn(),
layout: vi.fn(),
focus: vi.fn(),
trigger: vi.fn(),
};
return state;
});
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => autoFetchState.visible,
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onChange, onMount }: any) => {
React.useEffect(() => {
editorState.value = String(defaultValue || '');
editorState.latestOnChange = onChange;
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
KeyMod: { CtrlCmd: 2048, WinCtrl: 256 },
KeyCode: { KeyQ: 81, KeyS: 83 },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
registerCompletionItemProvider: vi.fn((_language: string, provider: any) => {
editorState.providers.push(provider);
return { dispose: vi.fn() };
}),
registerHoverProvider: vi.fn((_language: string, provider: any) => {
editorState.hoverProviders.push(provider);
return { dispose: vi.fn() };
}),
},
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
MarkdownString: class {
value: string;
constructor(value: string) {
this.value = value;
}
},
Position: class {
lineNumber: number;
column: number;
constructor(lineNumber: number, column: number) {
this.lineNumber = lineNumber;
this.column = column;
}
},
});
}, []);
return <textarea data-editor value={editorState.value} readOnly />;
},
}));
vi.mock('./DataGrid', () => ({
default: (props: any) => {
dataGridState.latestProps = props;
return <div data-grid="true" />;
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
RobotOutlined: Icon,
DatabaseOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, onMouseDown, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} onMouseDown={onMouseDown} {...rest}>
{children}
</button>
);
Button.Group = ({ children }: any) => <div>{children}</div>;
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
return {
Button,
message: messageApi,
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
<section>
{children}
<button type="button" onClick={onOk}>{okText}</button>
</section>
) : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Form,
Dropdown: ({ children, menu }: any) => (
<>
{children}
{menu?.items?.map((item: any) => (
item?.type === 'divider'
? null
: <button key={item.key} type="button" disabled={item.disabled} onClick={item.onClick}>{item.label}</button>
))}
</>
),
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: ({ activeKey, items, onChange }: any) => {
const resolvedActiveKey = tabsState.activeKey ?? activeKey ?? items?.[0]?.key;
const activeItem = items?.find((item: any) => item.key === resolvedActiveKey) || items?.[0];
return (
<div>
<div>{items?.map((item: any) => (
<button
key={item.key}
type="button"
data-tab-key={item.key}
onClick={() => {
tabsState.activeKey = item.key;
onChange?.(item.key);
}}
>
{item.label}
</button>
))}</div>
<div>{activeItem?.children}</div>
</div>
);
},
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const findExactButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node) === text)[0];
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'query.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
...overrides,
});
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
const completionState = (globalThis as any).__gonaviSqlCompletionState;
if (completionState) {
completionState.registered = false;
completionState.disposables = [];
}
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
vi.stubGlobal('document', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
storeState.addTab.mockReset();
storeState.setActiveContext.mockReset();
storeState.saveQuery.mockReset();
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.warning.mockReset();
backendApp.DBQuery.mockResolvedValue({ success: true, data: [] });
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
backendApp.ExportSQLFile.mockResolvedValue({ success: true });
backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetDatabases.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] });
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
storeState.appearance.uiVersion = 'legacy';
autoFetchState.visible = false;
dataGridState.latestProps = null;
tabsState.activeKey = undefined;
editorState.value = '';
editorState.position = { lineNumber: 1, column: 1 };
editorState.selection = null;
editorState.domNode.style.cursor = '';
editorState.providers = [];
editorState.hoverProviders = [];
editorState.cursorPositionListeners = [];
editorState.modelContentListeners = [];
editorState.mouseMoveListeners = [];
editorState.mouseDownListeners = [];
editorState.mouseLeaveListeners = [];
editorState.hasTextFocus = true;
editorState.decorationIds = [];
editorState.contentHoverCalls = [];
editorState.latestOnChange = null;
editorState.editor.getValue.mockClear();
editorState.editor.getModel().getValue.mockClear();
editorState.editor.getModel().getValueLength.mockClear();
editorState.editor.setValue.mockClear();
editorState.editor.executeEdits.mockClear();
editorState.editor.deltaDecorations.mockClear();
editorState.editor.updateOptions.mockClear();
editorState.editor.pushUndoStop.mockClear();
editorState.editor.layout.mockClear();
storeState.updateQueryTabDraft.mockReset();
clearQueryTabDraft('tab-1');
clearSQLFileTabDraft('tab-1');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('shows the default SQL template for a fresh blank query tab', async () => {
await act(async () => {
create(<QueryEditor tab={createTab({ query: '' })} />);
});
expect(editorState.value).toBe('SELECT * FROM ');
});
it('keeps table name completion available after typing in a fresh query tab', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.database = '';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'information_schema' }, { Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'organization' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: '' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
expect(sqlProvider).toBeTruthy();
expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', expect.objectContaining({
dbName: 'main',
}));
editorState.value = 'SELECT * FROM org';
editorState.latestOnChange?.(editorState.value);
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
expect(result.suggestions.map((item: any) => item.label)).toContain('organization');
await act(async () => {
renderer.unmount();
});
});
it('fuzzy matches table names in FROM completion before column candidates', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.database = '';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'information_schema' }, { Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'fs_org_auth_application' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'fs_org_auth_application', name: 'orgi', type: 'varchar(32)' }],
});
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: '' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
expect(sqlProvider).toBeTruthy();
editorState.value = 'SELECT * FROM org';
editorState.latestOnChange?.(editorState.value);
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
const labels = result.suggestions.map((item: any) => item.label);
expect(labels).toContain('fs_org_auth_application');
expect(labels).not.toContain('orgi');
await act(async () => {
renderer.unmount();
});
});
it('lazy loads current database tables for FROM completion when metadata is not preloaded', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = false;
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [{ Table: 'fs_org_auth_application' }],
});
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: '', dbName: 'front_end_sys' })} />);
});
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
expect(sqlProvider).toBeTruthy();
editorState.value = 'SELECT * FROM or';
editorState.latestOnChange?.(editorState.value);
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
const labels = result.suggestions.map((item: any) => item.label);
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'front_end_sys');
expect(labels).toContain('fs_org_auth_application');
await act(async () => {
renderer.unmount();
});
});
it('resolves database and table targets for ctrl/cmd navigation', () => {
const tables = [
{ dbName: 'main', tableName: 'users' },
{ dbName: 'main', tableName: 'dbo.orders' },
{ dbName: 'analytics', tableName: 'events' },
];
const views = [
{ dbName: 'main', viewName: 'reporting.active_users', schemaName: 'reporting' },
];
const materializedViews = [
{ dbName: 'analytics', viewName: 'mv_daily_stats', schemaName: undefined },
];
const triggers = [
{ dbName: 'main', triggerName: 'audit.users_bi', tableName: 'audit.users', schemaName: 'audit' },
];
const routines = [
{ dbName: 'main', routineName: 'reporting.refresh_stats', routineType: 'PROCEDURE', schemaName: 'reporting' },
];
expect(resolveQueryEditorNavigationTarget('select * from analytics.events', 31, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'table',
dbName: 'analytics',
tableName: 'events',
schemaName: undefined,
});
expect(resolveQueryEditorNavigationTarget('select * from dbo.orders', 21, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'table',
dbName: 'main',
tableName: 'dbo.orders',
schemaName: 'dbo',
});
expect(resolveQueryEditorNavigationTarget('use analytics', 6, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'database',
dbName: 'analytics',
});
expect(resolveQueryEditorNavigationTarget('select * from users', 18, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'table',
dbName: 'main',
tableName: 'users',
schemaName: undefined,
});
expect(resolveQueryEditorNavigationTarget('select * from reporting.active_users', 31, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'view',
dbName: 'main',
viewName: 'reporting.active_users',
schemaName: 'reporting',
});
expect(resolveQueryEditorNavigationTarget('select * from analytics.mv_daily_stats', 37, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'materialized-view',
dbName: 'analytics',
viewName: 'mv_daily_stats',
schemaName: undefined,
});
expect(resolveQueryEditorNavigationTarget('call audit.users_bi()', 18, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'trigger',
dbName: 'main',
triggerName: 'audit.users_bi',
tableName: 'audit.users',
schemaName: 'audit',
});
expect(resolveQueryEditorNavigationTarget('call reporting.refresh_stats()', 21, 'main', ['main', 'analytics'], tables, views, materializedViews, triggers, routines)).toEqual({
type: 'routine',
dbName: 'main',
routineName: 'reporting.refresh_stats',
routineType: 'PROCEDURE',
schemaName: 'reporting',
});
});
it('opens a table tab on ctrl left click inside the editor', async () => {
editorState.value = 'select * from analytics.events where id = 1';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 27 } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault,
stopPropagation,
},
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'analytics' });
expect(storeState.addTab).toHaveBeenCalledWith({
id: 'conn-1-analytics-table-events',
title: 'events',
type: 'table',
connectionId: 'conn-1',
dbName: 'analytics',
tableName: 'events',
objectType: 'table',
});
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
}));
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
});
it('opens a table tab on macOS cmd click when Monaco omits leftButton', async () => {
editorState.value = 'select * from fs_mkefu_regist_record;';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'mkefu_location_dev_local' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_mkefu_location_dev_local: 'fs_mkefu_regist_record' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'mkefu_location_dev_local' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 'select * from fs_mkefu_regist_record'.length } },
event: {
browserEvent: { button: 0, buttons: 1 },
ctrlKey: false,
metaKey: true,
preventDefault,
stopPropagation,
},
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'mkefu_location_dev_local' });
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
dbName: 'mkefu_location_dev_local',
tableName: 'fs_mkefu_regist_record',
objectType: 'table',
}));
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
});
it('does not read the full editor model when ctrl/cmd clicking objects in large SQL', async () => {
editorState.value = [
...Array.from({ length: 4000 }, (_, index) => `-- filler ${index + 1}`),
'select * from analytics.events where id = 1',
].join('\n');
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
editorState.editor.getModel().getValue.mockClear();
editorState.editor.getModel().getValueLength.mockClear();
const lineNumber = editorState.value.split('\n').length;
const preventDefault = vi.fn();
const stopPropagation = vi.fn();
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber, column: 27 } },
event: {
browserEvent: { button: 0, buttons: 1 },
ctrlKey: true,
metaKey: false,
preventDefault,
stopPropagation,
},
});
});
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
dbName: 'analytics',
tableName: 'events',
}));
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
});
it('shows link-style hover feedback when ctrl/cmd is pressed over a navigable identifier', async () => {
editorState.value = 'select * from analytics.events where id = 1';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [{ tableName: 'events', name: 'id', type: 'bigint', comment: '事件ID' }] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
editorState.mouseMoveListeners[0]?.({
target: { position: { lineNumber: 1, column: 27 } },
event: {
ctrlKey: true,
metaKey: false,
},
});
});
expect(editorState.editor.deltaDecorations).toHaveBeenCalled();
expect(editorState.domNode.style.cursor).toBe('pointer');
const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1);
expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint');
expect(lastDecorationCall?.[1]?.[0]?.options?.hoverMessage).toBeUndefined();
const hover = editorState.hoverProviders[0]?.provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: 27 },
);
const hoverText = String(hover?.contents?.[0]?.value || '');
expect(hoverText.match(/\*\*表\*\*/g)).toHaveLength(1);
expect(hoverText).toContain('**表** `events`');
await act(async () => {
editorState.mouseLeaveListeners[0]?.();
});
expect(editorState.domNode.style.cursor).toBe('');
expect(editorState.editor.updateOptions).toHaveBeenLastCalledWith({ mouseStyle: 'text' });
});
it('formats SQL through Monaco edits so beautify can be undone', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: 'select * from users where id=1' })} />);
});
const formatButton = findButton(renderer, '美化');
await act(async () => {
await formatButton.props.onClick();
});
expect(editorState.editor.pushUndoStop).toHaveBeenCalledTimes(2);
expect(editorState.editor.executeEdits).toHaveBeenCalledWith(
'gonavi-format-sql',
expect.arrayContaining([
expect.objectContaining({
text: expect.stringContaining('SELECT'),
}),
]),
);
});
it('shows object info via editor ctrl+q action', async () => {
editorState.value = 'select users.id from users';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }],
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const showObjectInfoAction = editorState.editor.addAction.mock.calls
.map((call: any[]) => call[0])
.find((action: any) => action?.id === 'gonavi.queryEditor.showObjectInfo');
expect(showObjectInfoAction).toBeTruthy();
editorState.position = { lineNumber: 1, column: 13 };
await act(async () => {
showObjectInfoAction.run();
});
expect(editorState.contentHoverCalls).toHaveLength(1);
expect(editorState.contentHoverCalls[0]).toEqual(expect.objectContaining({
mode: 1,
source: 2,
focus: false,
}));
});
it('renders SQL metadata hover as a fixed overflow widget below first-line tokens', async () => {
editorState.value = 'select users.id from users';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }],
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
const initialOptions = editorState.editor.updateOptions.mock.calls[0]?.[0];
expect(initialOptions).toMatchObject({
fixedOverflowWidgets: true,
hover: {
enabled: true,
delay: 1000,
above: false,
},
});
});
it('prefers the hovered identifier position for ctrl+q object info', async () => {
editorState.value = 'select * from user_actions';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'user_actions' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const showObjectInfoAction = editorState.editor.addAction.mock.calls
.map((call: any[]) => call[0])
.find((action: any) => action?.id === 'gonavi.queryEditor.showObjectInfo');
expect(showObjectInfoAction).toBeTruthy();
editorState.position = { lineNumber: 1, column: 2 };
await act(async () => {
windowListeners.keydown?.forEach((listener) => listener({ ctrlKey: true, metaKey: false, key: 'Control' }));
editorState.mouseMoveListeners[0]?.({
target: { position: { lineNumber: 1, column: 17 } },
event: {
ctrlKey: true,
metaKey: false,
},
});
showObjectInfoAction.run();
});
expect(editorState.contentHoverCalls).toHaveLength(1);
expect(messageApi.info).not.toHaveBeenCalledWith(expect.objectContaining({
key: 'gonavi-query-editor-object-info-miss',
}));
});
it('keeps ctrl+q object info silent when no object is recognized', async () => {
editorState.value = 'select 1';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const showObjectInfoAction = editorState.editor.addAction.mock.calls
.map((call: any[]) => call[0])
.find((action: any) => action?.id === 'gonavi.queryEditor.showObjectInfo');
expect(showObjectInfoAction).toBeTruthy();
editorState.position = { lineNumber: 1, column: 1 };
await act(async () => {
showObjectInfoAction.run();
});
expect(editorState.contentHoverCalls).toHaveLength(0);
expect(messageApi.info).not.toHaveBeenCalled();
});
it('adds separate object and column color decorations', async () => {
editorState.value = 'select users.id from users';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }],
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const allDecorationEntries = editorState.editor.deltaDecorations.mock.calls.flatMap((call: any[]) => call[1] || []);
expect(allDecorationEntries.some((item: any) => item?.options?.inlineClassName === 'gonavi-query-editor-object-token')).toBe(true);
expect(allDecorationEntries.some((item: any) => item?.options?.inlineClassName === 'gonavi-query-editor-column-token')).toBe(true);
});
it('provides hover markdown for recognized table columns', async () => {
editorState.value = 'select users.id from users';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }],
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const hoverProvider = editorState.hoverProviders[0];
expect(hoverProvider).toBeTruthy();
const hover = hoverProvider.provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: 13 },
);
expect(hover?.contents?.[0]?.value).toContain('**字段** `id`');
expect(hover?.contents?.[0]?.value).toContain('类型:`bigint`');
expect(hover?.contents?.[0]?.value).toContain('表:`users`');
});
it('registers SQL metadata hover provider only once across query editor instances', async () => {
editorState.value = 'select * from H2.S_BUSI';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValue({ success: true, data: [{ Database: 'H2' }] });
backendApp.DBGetTables.mockResolvedValue({ success: true, data: [{ Tables_in_H2: 'H2.S_BUSI' }] });
backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] });
let firstRenderer: ReactTestRenderer;
let secondRenderer: ReactTestRenderer;
await act(async () => {
firstRenderer = create(<QueryEditor tab={createTab({ id: 'tab-1', query: editorState.value, dbName: 'H2' })} isActive={false} />);
});
await act(async () => {
secondRenderer = create(<QueryEditor tab={createTab({ id: 'tab-2', query: editorState.value, dbName: 'H2' })} isActive />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(editorState.hoverProviders).toHaveLength(1);
const hover = editorState.hoverProviders[0].provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: 18 },
);
const hoverText = String(hover?.contents?.[0]?.value || '');
expect(hoverText.match(/\*\*表\*\*/g)).toHaveLength(1);
expect(hoverText).toContain('`H2.S_BUSI`');
firstRenderer!.unmount();
secondRenderer!.unmount();
});
it('keeps hover underline active when ctrl/cmd is pressed repeatedly without moving the mouse', async () => {
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
editorState.value = 'select * from analytics.events where id = 1';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
editorState.mouseMoveListeners[0]?.({
target: { position: { lineNumber: 1, column: 27 } },
event: {
ctrlKey: true,
metaKey: false,
},
});
});
const firstDecorationCallCount = editorState.editor.deltaDecorations.mock.calls.length;
expect(firstDecorationCallCount).toBeGreaterThan(0);
expect(editorState.domNode.style.cursor).toBe('pointer');
await act(async () => {
const repeatedCtrlEvent = {
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
key: 'Control',
code: 'ControlLeft',
repeat: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: null,
};
windowListeners.keydown?.forEach((listener) => listener(repeatedCtrlEvent));
windowListeners.keydown?.forEach((listener) => listener(repeatedCtrlEvent));
});
expect(editorState.editor.deltaDecorations.mock.calls.length).toBeGreaterThan(firstDecorationCallCount);
expect(editorState.domNode.style.cursor).toBe('pointer');
const lastDecorationCall = editorState.editor.deltaDecorations.mock.calls.at(-1);
expect(lastDecorationCall?.[1]?.[0]?.options?.inlineClassName).toBe('gonavi-query-editor-link-hint');
});
it('keeps query editor hyperlink decorations blue with a solid underline', () => {
const css = readFileSync(new URL('../App.css', import.meta.url), 'utf8');
expect(css).toMatch(/\.gonavi-query-editor-link-hint\s*\{[^}]*color:\s*#1677ff\s*!important;[^}]*text-decoration:\s*underline;[^}]*text-decoration-style:\s*solid;[^}]*text-decoration-color:\s*currentColor;/s);
expect(css).toMatch(/body\[data-theme='dark'\]\s+\.gonavi-query-editor-link-hint\s*\{[^}]*color:\s*#69b1ff\s*!important;/s);
});
it('opens a view tab on ctrl left click inside the editor', async () => {
editorState.value = 'select * from reporting.active_users';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => {
if (sql.includes('information_schema.views') || sql.includes('pg_catalog.pg_views') || sql.includes('USER_VIEWS') || sql.includes('ALL_VIEWS')) {
return { success: true, data: [{ view_name: 'active_users', schema_name: 'reporting' }] };
}
return { success: true, data: [] };
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
});
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 31 } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' });
expect(storeState.addTab).toHaveBeenCalledWith({
id: 'view-def-conn-1-main-active_users',
title: '视图: active_users',
type: 'view-def',
connectionId: 'conn-1',
dbName: 'main',
viewName: 'active_users',
viewKind: 'view',
schemaName: 'reporting',
sidebarLocateKey: 'conn-1-main-view-active_users',
});
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'conn-1-main-view-active_users',
schemaName: 'reporting',
objectGroup: 'views',
}),
}));
});
it('opens trigger and routine tabs on ctrl left click inside the editor', async () => {
editorState.value = 'call audit.users_bi(); call reporting.refresh_stats();';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => {
if (sql.includes('information_schema.triggers') || sql.includes('SHOW TRIGGERS') || sql.includes('USER_TRIGGERS') || sql.includes('ALL_TRIGGERS')) {
return { success: true, data: [{ trigger_name: 'users_bi', table_name: 'users', schema_name: 'audit' }] };
}
if (sql.includes('information_schema.routines') || sql.includes('SHOW FUNCTION STATUS') || sql.includes('SHOW PROCEDURE STATUS') || sql.includes('USER_OBJECTS') || sql.includes('ALL_OBJECTS')) {
return { success: true, data: [{ routine_name: 'refresh_stats', routine_type: 'PROCEDURE', schema_name: 'reporting' }] };
}
return { success: true, data: [] };
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
for (let i = 0; i < 10; i += 1) {
await Promise.resolve();
}
});
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 12 } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 39 } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
expect(storeState.addTab).toHaveBeenCalledWith({
id: 'trigger-conn-1-main-audit.users_bi',
title: '触发器: audit.users_bi',
type: 'trigger',
connectionId: 'conn-1',
dbName: 'main',
triggerName: 'audit.users_bi',
triggerTableName: 'audit.users',
schemaName: 'audit',
sidebarLocateKey: 'conn-1-main-trigger-audit.users_bi-audit.users',
});
expect(storeState.addTab).toHaveBeenCalledWith({
id: 'routine-def-conn-1-main-reporting.refresh_stats',
title: '存储过程: reporting.refresh_stats',
type: 'routine-def',
connectionId: 'conn-1',
dbName: 'main',
routineName: 'reporting.refresh_stats',
routineType: 'PROCEDURE',
schemaName: 'reporting',
sidebarLocateKey: 'conn-1-main-routine-reporting.refresh_stats',
});
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'conn-1-main-trigger-audit.users_bi-audit.users',
triggerName: 'audit.users_bi',
schemaName: 'audit',
objectGroup: 'triggers',
}),
}));
expect((window as any).dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'gonavi:locate-sidebar-object',
detail: expect.objectContaining({
tabId: 'conn-1-main-routine-reporting.refresh_stats',
routineName: 'reporting.refresh_stats',
schemaName: 'reporting',
objectGroup: 'routines',
}),
}));
});
it('switches current database on cmd left click for database identifiers', async () => {
editorState.value = 'use analytics';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_analytics: 'events' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 6 } },
event: {
leftButton: true,
ctrlKey: false,
metaKey: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'analytics' });
expect(storeState.addTab).not.toHaveBeenCalled();
expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', expect.objectContaining({
dbName: 'analytics',
}));
});
it('skips heavy autocomplete metadata fetch for object edit query tabs', async () => {
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'analytics' }] });
await act(async () => {
create(<QueryEditor tab={createTab({
query: 'CREATE OR REPLACE VIEW reporting.active_users AS SELECT * FROM users;',
dbName: 'main',
queryMode: 'object-edit',
})} />);
});
await act(async () => {
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
}
});
expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1);
expect(backendApp.DBGetTables).not.toHaveBeenCalled();
expect(backendApp.DBGetAllColumns).not.toHaveBeenCalled();
expect(backendApp.DBQuery).not.toHaveBeenCalled();
expect(editorState.editor.deltaDecorations).toHaveBeenCalledWith([], []);
});
it('keeps the editor empty when a tab draft is externally synced to an empty query', async () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: 'SELECT * FROM ' })} />);
});
await act(async () => {
renderer.update(<QueryEditor tab={createTab({ query: '' })} />);
});
expect(editorState.value).toBe('');
expect(editorState.editor.setValue).toHaveBeenCalledWith('');
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 2;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
filePath,
query: 'select 2;',
savedQueryId: undefined,
}));
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
});
it('keeps external SQL file typing out of persisted tab drafts to avoid input freezes', async () => {
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
create(<QueryEditor tab={createTab({ filePath })} />);
});
storeState.updateQueryTabDraft.mockClear();
editorState.editor.deltaDecorations.mockClear();
editorState.editor.getModel().getValue.mockClear();
editorState.editor.getModel().getValueLength.mockClear();
await act(async () => {
editorState.value = 'select 1;\n1';
editorState.latestOnChange?.(editorState.value);
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: '1' }],
}));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(storeState.updateQueryTabDraft).not.toHaveBeenCalledWith('tab-1', expect.objectContaining({
query: 'select 1;\n1',
}));
expect(getSQLFileTabDraft('tab-1')).toBe('select 1;\n1');
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
});
it('keeps large regular query typing out of persisted tab drafts to avoid input freezes', async () => {
const largeSql = `select * from users;\n${'x'.repeat(60_000)}`;
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
});
storeState.updateQueryTabDraft.mockClear();
editorState.editor.deltaDecorations.mockClear();
editorState.editor.getModel().getValue.mockClear();
editorState.editor.getModel().getValueLength.mockClear();
await act(async () => {
editorState.value = largeSql;
editorState.latestOnChange?.(largeSql);
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: largeSql }],
}));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(storeState.updateQueryTabDraft).not.toHaveBeenCalledWith('tab-1', expect.objectContaining({
query: largeSql,
}));
expect(getQueryTabDraft('tab-1')).toBe(largeSql);
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
});
it('keeps short regular query typing on the Monaco fast path without rerender side effects', async () => {
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
});
storeState.updateQueryTabDraft.mockClear();
editorState.editor.deltaDecorations.mockClear();
editorState.editor.getModel().getValue.mockClear();
editorState.editor.getModel().getValueLength.mockClear();
await act(async () => {
editorState.value = 'SELECT * FROM fs_org_auth_application;\n\nSELECT * FROM fs_bcp_auth_info; ';
editorState.latestOnChange?.(editorState.value);
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: ' ' }],
}));
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(getQueryTabDraft('tab-1')).toBe('SELECT * FROM fs_org_auth_application;\n\nSELECT * FROM fs_bcp_auth_info; ');
expect(storeState.updateQueryTabDraft).not.toHaveBeenCalledWith('tab-1', expect.objectContaining({
query: expect.any(String),
}));
expect(editorState.editor.deltaDecorations).not.toHaveBeenCalled();
expect(editorState.editor.getModel().getValue).not.toHaveBeenCalled();
expect(editorState.editor.getModel().getValueLength).not.toHaveBeenCalled();
});
it('skips SQL literals when collecting object decoration candidates for insert scripts', () => {
const insertValues = Array.from({ length: 120 }, (_, index) => {
const suffix = String(index + 1).padStart(3, '0');
return `('legacy-seed-L${suffix}', '旧版企业-L${suffix}', '深圳市南山区 ${suffix} 号', 'legacy${suffix}@demo.test')`;
}).join(',\n');
const sql = [
'-- 字符串里的 fs_org_auth_file 不应参与对象装饰扫描',
'INSERT INTO mkefu_location_dev_local.uk_corp (id, corp_name, address, email) VALUES',
`${insertValues};`,
'SELECT uk_corp.id FROM uk_corp;',
].join('\n');
const candidates = collectQueryEditorObjectDecorationCandidates(sql, 1000);
const candidateTexts = candidates.map((candidate) => candidate.lineContent.slice(candidate.positionColumn - 1, candidate.positionColumn + 30));
expect(candidateTexts.some((text) => text.includes('legacy-seed'))).toBe(false);
expect(candidateTexts.some((text) => text.includes('旧版企业'))).toBe(false);
expect(candidateTexts.some((text) => text.includes('demo.test'))).toBe(false);
expect(candidateTexts.some((text) => text.includes('mkefu_location_dev_local'))).toBe(true);
expect(candidateTexts.some((text) => text.includes('uk_corp'))).toBe(true);
});
it('does not provide metadata hover inside SQL string literals', async () => {
editorState.value = "insert into users(name) values ('users.id should stay plain');";
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'users', name: 'id', type: 'bigint', comment: '主键ID' }],
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const hoverProvider = editorState.hoverProviders[0];
expect(hoverProvider).toBeTruthy();
const literalColumn = editorState.value.indexOf('users.id should') + 3;
const hover = hoverProvider.provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: literalColumn },
);
expect(hover).toBeNull();
});
it('registers Ctrl/Cmd+S to quick-save the active query', async () => {
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
await act(async () => {
create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
const saveAction = editorState.editor.addAction.mock.calls
.map((call: any[]) => call[0])
.find((action: any) => action?.id === 'gonavi.saveQuery');
expect(saveAction).toMatchObject({
label: 'GoNavi: 保存查询',
});
expect(saveAction?.keybindings?.[0]).toBeGreaterThan(0);
editorState.value = 'select 5;';
const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`);
const event = {
ctrlKey: !isMacRuntime,
metaKey: isMacRuntime,
altKey: false,
shiftKey: false,
key: 's',
target: null,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
};
await act(async () => {
windowListeners.keydown?.forEach((listener) => listener(event));
});
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '常用查询',
sql: 'select 5;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
expect(messageApi.success).toHaveBeenCalledWith('查询已保存!');
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer!: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 4;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).not.toHaveBeenCalled();
expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
});
it('keeps saved query quick-save behavior for non-file tabs', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 3;';
await act(async () => {
findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '常用查询',
sql: 'select 3;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
});
it('renames saved queries without creating a new saved query id', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 9;';
await act(async () => {
findButton(renderer!, '重命名查询').props.onClick();
});
await act(async () => {
await findExactButton(renderer!, '重命名').props.onClick();
});
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '查询',
sql: 'select 9;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
title: '查询',
savedQueryId: 'saved-1',
}));
expect(messageApi.success).toHaveBeenCalledWith('查询已重命名!');
});
it('exports the current editor SQL without changing saved query state', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 10;';
await act(async () => {
await findButton(renderer!, '导出 SQL 文件').props.onClick();
});
expect(backendApp.ExportSQLFile).toHaveBeenCalledWith('常用查询', 'select 10;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).not.toHaveBeenCalledWith(expect.objectContaining({
query: 'select 10;',
}));
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已导出!');
});
it('shows Chinese semantic meaning for SQL execution errors', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: false,
message: 'pq: syntax error at or near "from"',
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: 'SELECT * from' })} />);
});
await act(async () => {
await findButton(renderer, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const pageText = textContent(renderer!.root);
expect(pageText).toContain('执行失败');
expect(pageText).toContain('中文语义SQL 语法错误');
expect(pageText).toContain('处理建议:');
expect(pageText).toContain('原始错误pq: syntax error at or near "from"');
});
it('automatically appends hidden primary key locator columns for editable query results', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_ID'], rows: [{ NAME: 'old-name', __gonavi_locator_1_ID: 7 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('MYCIMLED.EDC_LOG');
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(dataGridState.latestProps?.resultSql).toBe('SELECT NAME FROM MYCIMLED.EDC_LOG');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"ID" AS "__gonavi_locator_1_ID"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('normalizes unquoted lowercase Oracle identifiers before committing query result edits', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_ID'], rows: [{ NAME: 'old-name', __gonavi_locator_1_ID: 7 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'anonymous', query: 'select name from mycimled.edc_log' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'MYCIMLED', 'EDC_LOG');
expect(dataGridState.latestProps?.tableName).toBe('MYCIMLED.EDC_LOG');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
writableColumns: {
name: 'NAME',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses a unique index locator for query results without primary keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: true,
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['__gonavi_locator_1_EMAIL'],
hiddenColumns: ['__gonavi_locator_1_EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"EMAIL" AS "__gonavi_locator_1_EMAIL"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses snake_case unique index metadata for query result row locators', async () => {
storeState.connections[0].config.type = 'kingbase';
storeState.connections[0].config.database = 'KINGBASE';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ column_name: 'EMAIL' }, { column_name: 'NAME' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: true,
data: [{ index_name: 'users_email_key', column_name: 'EMAIL', is_unique: 't', seq_in_index: '1', index_type: 'btree' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'KINGBASE', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['__gonavi_locator_1_EMAIL'],
hiddenColumns: ['__gonavi_locator_1_EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses hidden Oracle ROWID for query results without primary or unique keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`);
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('rewrites Oracle SELECT * queries before injecting hidden ROWID locator columns', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['WAFER_ID', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ WAFER_ID: 'R015Z10F08', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'WAFER_ID', key: '' }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT * FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG');
expect(executedSql).toContain('FROM MYCIMLED.EDC_LOG gonavi_query_source');
expect(executedSql).not.toContain('__gonavi_query_source__');
expect(executedSql).not.toContain('SELECT *, ROWID AS');
expect(executedSql).toMatch(/SELECT\s+gonavi_query_source\.\*\s*,\s+gonavi_query_source\.ROWID\s+AS\s+"__gonavi_oracle_rowid__"/i);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer?.unmount();
});
it('keeps Oracle anonymous PL/SQL blocks intact when running from the editor', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }],
});
const plsql = [
'BEGIN',
" INSERT INTO tmp_disable_trigger (table_name) VALUES ('t_memcard_reg');",
" UPDATE t_memcard_reg SET CARDLEVEL = 1 WHERE MEMCARDNO = '8032277312';",
" DELETE FROM tmp_disable_trigger WHERE table_name = 't_memcard_reg';",
'END;',
].join('\n');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: plsql })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'ORCLPDB1', plsql, 'query-1');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: plsql,
status: 'success',
}));
renderer?.unmount();
});
it('renders result grid for sqlserver exec statements that return rows', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['SPID', 'STATUS'], rows: [{ SPID: 52, STATUS: 'RUNNABLE' }] }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_who2' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).not.toContain('影响行数:');
expect(dataGridState.latestProps?.columnNames).toEqual(['SPID', 'STATUS']);
expect(Array.isArray(dataGridState.latestProps?.data)).toBe(true);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ SPID: 52, STATUS: 'RUNNABLE' });
});
it('renders standalone message result for sqlserver statistics statements', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: [],
rows: [],
messages: ["Table 'users'. Scan count 1, logical reads 3."],
}],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'SET STATISTICS IO ON;' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 1');
expect(textContent(renderer!.toJSON())).toContain("Table 'users'. Scan count 1, logical reads 3.");
expect(dataGridState.latestProps?.columnNames).not.toEqual([]);
});
it('keeps multiple result sets from a single sqlserver statement', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_helpdb' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(dataGridState.latestProps?.columnNames).toEqual(['name']);
});
it('keeps both tabs when rerunning the same single sqlserver statement with multiple result sets', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] },
],
})
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'tempdb' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_helpdb' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const tabLabels = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
});
expect(tabLabels).toHaveLength(2);
expect(dataGridState.latestProps?.columnNames).toEqual(['name']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ name: 'tempdb' });
});
it('reloads the active secondary result set for a single sqlserver statement', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'sa' }] },
],
})
.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['name'], rows: [{ name: 'master' }] },
{ statementIndex: 1, columns: ['owner'], rows: [{ owner: 'dbo' }] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'master', query: 'EXEC sp_helpdb' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const resultTabButtons = renderer!.root.findAll((node) => node.type === 'button' && node.props['data-tab-key']);
expect(resultTabButtons).toHaveLength(2);
await act(async () => {
resultTabButtons[1].props.onClick();
});
expect(dataGridState.latestProps?.columnNames).toEqual(['owner']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'sa' });
await act(async () => {
await dataGridState.latestProps?.onReload?.();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(dataGridState.latestProps?.columnNames).toEqual(['owner']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ owner: 'dbo' });
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'master' })]));
});
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读main.users 未检测到主键或可用唯一索引,无法安全提交修改。');
});
it('keeps MySQL information_schema routine results read-only without a locator warning', async () => {
const sql = [
'SELECT ROUTINE_SCHEMA, ROUTINE_NAME, DEFINER, SECURITY_TYPE',
'FROM information_schema.ROUTINES',
"WHERE ROUTINE_SCHEMA = 'mkefu_location_dev_local'",
" AND ROUTINE_NAME = 'init_orgi'",
].join('\n');
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['ROUTINE_SCHEMA', 'ROUTINE_NAME', 'DEFINER', 'SECURITY_TYPE'],
rows: [{
ROUTINE_SCHEMA: 'mkefu_location_dev_local',
ROUTINE_NAME: 'init_orgi',
DEFINER: 'root@%',
SECURITY_TYPE: 'DEFINER',
}],
}],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'mkefu_location_dev_local', query: sql })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('ROUTINES');
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('runs the SQL statement at the cursor instead of the whole editor when nothing is selected', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 2, column: 8 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.();
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: expect.stringContaining('select 2 as two'),
}));
});
it('keeps cursor statement execution available in v2 UI', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as two;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 2, column: 8 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.();
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as two'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('uses the last editor cursor position when the run button takes focus', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } });
});
editorState.hasTextFocus = false;
editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
});
it('prefers the last editor cursor event even if Monaco still reports text focus', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['two'], rows: [{ two: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 2, column: 'select 2 as b;'.length + 1 } });
});
editorState.hasTextFocus = true;
editorState.position = { lineNumber: 3, column: 'select 3 as c;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
});
it('uses Monaco active selection position when run button focus drifts onto a blank line', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;',
})} />);
});
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
editorState.position = { lineNumber: 3, column: 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
});
it('keeps cursor statement execution when CRLF line endings put the cursor after a semicolon', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\r\nselect 2 as b;\r\n\r\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as b'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3 as c');
expect(messageApi.info).not.toHaveBeenCalledWith('没有可执行的 SQL。');
});
it('does not execute SQL when the cursor is on a blank line', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\n\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
backendApp.DBQueryMulti.mockClear();
messageApi.info.mockClear();
editorState.position = { lineNumber: 3, column: 1 };
editorState.selection = {
startLineNumber: 3,
startColumn: 1,
endLineNumber: 3,
endColumn: 1,
positionLineNumber: 3,
positionColumn: 1,
};
editorState.cursorPositionListeners.forEach((listener) => {
listener({ position: { lineNumber: 3, column: 1 } });
});
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
expect(messageApi.info).toHaveBeenCalledWith('没有可执行的 SQL。');
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
});
it('runs only appended SQL and keeps existing results after a full editor execution', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
editorState.value = 'select 1 as a;\nselect 2 as b;';
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a');
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-count') && textContent(node) === '1';
})).toHaveLength(2);
});
it('replaces existing result tabs when rerunning the same formatted SQL', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['id'], rows: [{ id: 1 }, { id: 2 }, { id: 3 }] },
{ columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 1 })) },
],
})
.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['id'], rows: [{ id: 11 }, { id: 12 }, { id: 13 }] },
{ columns: ['id'], rows: Array.from({ length: 10 }, (_, index) => ({ id: index + 11 })) },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'SELECT * FROM fs_org_auth_application;\nSELECT * FROM fs_bcp_auth_info;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'SELECT * FROM fs_org_auth_application;'.length + 1 };
editorState.selection = null;
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
editorState.value = [
'SELECT',
' *',
'FROM',
' fs_org_auth_application;',
'',
'SELECT',
' *',
'FROM',
' fs_bcp_auth_info;',
].join('\n');
editorState.position = { lineNumber: 4, column: ' fs_org_auth_application;'.length + 1 };
editorState.selection = null;
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(textContent(renderer!.toJSON())).not.toContain('结果 3');
expect(textContent(renderer!.toJSON())).not.toContain('结果 4');
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(2);
});
it('provides context menu actions for query result tabs', async () => {
backendApp.DBQueryMulti.mockResolvedValue({
success: true,
data: [
{ columns: ['a'], rows: [{ a: 1 }] },
{ columns: ['b'], rows: [{ b: 2 }] },
{ columns: ['c'], rows: [{ c: 3 }] },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(3);
await act(async () => {
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭右侧')[1].props.onClick();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(2);
expect(textContent(renderer!.toJSON())).not.toContain('结果 3');
await act(async () => {
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭左侧')[1].props.onClick();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(1);
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })]));
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ c: 3 })]));
await act(async () => {
renderer!.root.findAll((node) => node.type === 'button' && textContent(node) === '关闭所有')[0].props.onClick();
});
expect(renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
})).toHaveLength(0);
});
it('replaces the current result when rerunning the same cursor SQL', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 10 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const tabLabels = renderer!.root.findAll((node) => textContent(node).includes('结果 '));
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).not.toContain('结果 2');
expect(tabLabels.length).toBeGreaterThan(0);
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ a: 10 })]));
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 2 as b');
});
it('appends a result when running a different cursor SQL after an existing result', async () => {
backendApp.DBQueryMulti
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['a'], rows: [{ a: 1 }] }],
})
.mockResolvedValueOnce({
success: true,
data: [{ columns: ['b'], rows: [{ b: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;\nselect 3 as c;',
})} />);
});
editorState.position = { lineNumber: 1, column: 'select 1 as a;'.length + 1 };
editorState.selection = {
startLineNumber: 1,
startColumn: 'select 1 as a;'.length + 1,
endLineNumber: 1,
endColumn: 'select 1 as a;'.length + 1,
positionLineNumber: 1,
positionColumn: 'select 1 as a;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
editorState.position = { lineNumber: 2, column: 'select 2 as b;'.length + 1 };
editorState.selection = {
startLineNumber: 2,
startColumn: 'select 2 as b;'.length + 1,
endLineNumber: 2,
endColumn: 'select 2 as b;'.length + 1,
positionLineNumber: 2,
positionColumn: 'select 2 as b;'.length + 1,
};
await act(async () => {
const runButton = findButton(renderer!, '运行');
runButton.props.onMouseDown?.({ preventDefault: vi.fn() });
await runButton.props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).toContain('select 2 as b');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 1 as a');
expect(String(backendApp.DBQueryMulti.mock.calls[1][2])).not.toContain('select 3 as c');
expect(textContent(renderer!.toJSON())).toContain('结果 1');
expect(textContent(renderer!.toJSON())).toContain('结果 2');
expect(dataGridState.latestProps?.data).toEqual(expect.arrayContaining([expect.objectContaining({ b: 2 })]));
expect(dataGridState.latestProps?.data).not.toEqual(expect.arrayContaining([expect.objectContaining({ a: 1 })]));
});
it('renders compact result tab labels with row counts outside the title text', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ columns: ['a'], rows: [{ a: 1 }, { a: 2 }] },
{ columns: ['b'], rows: [{ b: 3 }] },
],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1 as a;\nselect 2 as b;',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const tabLabels = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-label');
});
const counts = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-count');
});
const titles = renderer!.root.findAll((node) => {
const className = String(node.props?.className || '');
return className.includes('query-result-tab-text');
});
expect(tabLabels).toHaveLength(2);
expect(titles.map((node) => textContent(node))).toEqual(['结果 1', '结果 2']);
expect(counts.map((node) => textContent(node))).toEqual(['2', '1']);
expect(textContent(renderer!.toJSON())).not.toContain('结果 1 (2)');
});
it('keeps query result tabs compact, centered, and readable in v2 UI', () => {
const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(source).toContain('.query-result-tabs .ant-tabs-tab {');
expect(source).toContain('width: auto !important;');
expect(source).toContain('max-width: 148px !important;');
expect(source).toContain('height: 30px !important;');
expect(source).toContain('align-items: center !important;');
expect(source).toContain('font-size: 14px !important;');
expect(source).toContain('.query-result-tab-text {');
expect(source).toContain('user-select: none;');
expect(source).toContain('font-weight: 700;');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab {');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tabs > .ant-tabs-nav .ant-tabs-tab-btn {');
expect(css).toContain('user-select: none;');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {');
});
it('keeps the v2 query editor toolbar grouped and compact', () => {
const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(source).toContain('gn-v2-query-toolbar-selects');
expect(source).toContain('gn-v2-query-toolbar-actions');
expect(source).toContain('gn-v2-query-toolbar-connection-select');
expect(source).toContain('gn-v2-query-toolbar-database-select');
expect(source).toContain('gn-v2-query-toolbar-max-rows-select');
expect(source).toContain('gn-v2-query-toolbar-action-group');
expect(source).toContain('style={isV2Ui ? undefined : { width: 150 }}');
expect(source).toContain('style={isV2Ui ? undefined : { width: 200 }}');
expect(source).toContain('style={isV2Ui ? undefined : { width: 170 }}');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-selects');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-actions');
expect(css).toContain('flex: 0 1 auto !important;');
expect(css).toContain('justify-content: flex-start;');
expect(css).toContain('height: 32px !important;');
expect(css).toContain('line-height: 30px !important;');
expect(css).toContain('display: inline-flex !important;');
expect(css).toContain('gap: 6px;');
expect(css).toContain('margin-left: 0 !important;');
expect(css).toContain('max-width: 520px;');
expect(css).toContain('width: 140px !important;');
expect(css).toContain('width: 166px !important;');
expect(css).toContain('width: 132px !important;');
expect(css).toContain('width: 34px !important;');
expect(css).toContain('@media (max-width: 900px)');
const queryToolbarCss = css.slice(css.indexOf('body[data-ui-version="v2"] .gn-v2-query-toolbar {'), css.indexOf('body[data-ui-version="v2"] .gn-v2-query-monaco-shell {'));
expect(queryToolbarCss).not.toContain('margin-left: auto;');
expect(queryToolbarCss).not.toContain('justify-content: flex-end;');
});
it('coalesces editor result splitter dragging through requestAnimationFrame', async () => {
const moveListeners: Array<(event: MouseEvent) => void> = [];
const upListeners: Array<() => void> = [];
const frameCallbacks: FrameRequestCallback[] = [];
vi.mocked(document.addEventListener).mockImplementation((type: string, listener: any) => {
if (type === 'mousemove') moveListeners.push(listener);
if (type === 'mouseup') upListeners.push(listener);
});
vi.mocked(window.requestAnimationFrame).mockImplementation((callback: FrameRequestCallback) => {
frameCallbacks.push(callback);
return frameCallbacks.length;
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab()} />);
});
const resizer = renderer.root.find((node) => node.props?.title === '拖动调整高度');
await act(async () => {
resizer.props.onMouseDown({ clientY: 300, preventDefault: vi.fn() });
moveListeners.forEach((listener) => listener({ clientY: 340 } as MouseEvent));
moveListeners.forEach((listener) => listener({ clientY: 380 } as MouseEvent));
});
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
expect(editorState.editor.layout).not.toHaveBeenCalled();
await act(async () => {
frameCallbacks.splice(0).forEach((callback) => callback(16));
});
expect(editorState.editor.layout).toHaveBeenCalledTimes(1);
await act(async () => {
upListeners.forEach((listener) => listener());
});
expect(editorState.editor.layout).toHaveBeenCalledTimes(2);
expect(document.removeEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function));
expect(document.removeEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function));
});
it('inserts sidebar object text when dropped into the SQL editor', async () => {
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode = {
style: { cursor: '' },
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
} as any;
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'select * from ' })} />);
});
editorState.position = { lineNumber: 1, column: 'select * from '.length + 1 };
await act(async () => {
domListeners.drop?.forEach((listener) => listener({
clientX: 10,
clientY: 10,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: ['application/x-gonavi-sql-object', 'text/plain'],
getData: (type: string) => {
if (type === 'application/x-gonavi-sql-object') {
return JSON.stringify({ text: 'reporting.active_users' });
}
if (type === 'text/plain') {
return 'reporting.active_users';
}
return '';
},
},
}));
});
expect(editorState.editor.executeEdits).toHaveBeenCalledWith(
'gonavi-sidebar-drop',
[expect.objectContaining({ text: 'reporting.active_users' })],
);
expect(editorState.value).toContain('reporting.active_users');
});
it('prevents Monaco native drag marker and keeps metadata hover after sidebar object drops', async () => {
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode = {
style: { cursor: '' },
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
} as any;
editorState.editor.getTargetAtClientPoint = vi.fn(() => ({
position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 },
}));
editorState.value = 'SELECT * FROM ';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'front_end_sys' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'front_end_sys' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const dragOverEvent = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: ['application/x-gonavi-sql-object', 'text/plain'],
dropEffect: 'none',
getData: vi.fn(() => ''),
},
};
await act(async () => {
domListeners.dragover?.forEach((listener) => listener(dragOverEvent));
});
expect(dragOverEvent.preventDefault).toHaveBeenCalled();
expect(dragOverEvent.stopPropagation).toHaveBeenCalled();
expect(dragOverEvent.dataTransfer.dropEffect).toBe('copy');
expect(dragOverEvent.dataTransfer.getData).not.toHaveBeenCalled();
await act(async () => {
domListeners.drop?.forEach((listener) => listener({
clientX: 10,
clientY: 10,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: ['application/x-gonavi-sql-object', 'text/plain'],
getData: (type: string) => {
if (type === 'application/x-gonavi-sql-object') {
return JSON.stringify({ text: 'fs_mkefu_regist_record' });
}
if (type === 'text/plain') {
return 'fs_mkefu_regist_record';
}
return '';
},
},
}));
});
const hover = editorState.hoverProviders[0]?.provideHover(
editorState.editor.getModel(),
{ lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length },
);
expect(editorState.value).toContain('fs_mkefu_regist_record');
expect(hover?.contents?.[0]?.value).toContain('**表** `fs_mkefu_regist_record`');
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 'SELECT * FROM fs_mkefu_regist_record'.length } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' });
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
dbName: 'front_end_sys',
tableName: 'fs_mkefu_regist_record',
objectType: 'table',
}));
});
it('keeps sidebar object navigation tied to the dragged database after drop', async () => {
const domListeners: Record<string, ((event?: any) => void)[]> = {};
editorState.domNode = {
style: { cursor: '' },
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
domListeners[type] ||= [];
domListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
} as any;
editorState.editor.getTargetAtClientPoint = vi.fn(() => ({
position: { lineNumber: 1, column: 'SELECT * FROM '.length + 1 },
}));
editorState.value = 'SELECT * FROM ';
autoFetchState.visible = true;
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }, { Database: 'front_end_sys' }] });
backendApp.DBGetTables
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'users' }] })
.mockResolvedValueOnce({ success: true, data: [{ Tables_in_front_end_sys: 'fs_mkefu_regist_record' }] });
backendApp.DBGetAllColumns
.mockResolvedValueOnce({ success: true, data: [] })
.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
domListeners.drop?.forEach((listener) => listener({
clientX: 10,
clientY: 10,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
types: ['application/x-gonavi-sql-object', 'text/plain'],
getData: (type: string) => {
if (type === 'application/x-gonavi-sql-object') {
return JSON.stringify({
text: 'fs_mkefu_regist_record',
nodeType: 'table',
connectionId: 'conn-1',
dbName: 'front_end_sys',
});
}
if (type === 'text/plain') {
return 'fs_mkefu_regist_record';
}
return '';
},
},
}));
});
expect(editorState.value).toContain('front_end_sys.fs_mkefu_regist_record');
await act(async () => {
editorState.mouseDownListeners[0]?.({
target: { position: { lineNumber: 1, column: 'SELECT * FROM front_end_sys.fs_mkefu_regist_record'.length } },
event: {
leftButton: true,
ctrlKey: true,
metaKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
},
});
});
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'front_end_sys' });
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
type: 'table',
connectionId: 'conn-1',
dbName: 'front_end_sys',
tableName: 'fs_mkefu_regist_record',
objectType: 'table',
}));
});
it('runs selected SQL before cursor SQL', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['selected'], rows: [{ selected: 2 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect 2 as selected;\nselect 3;',
})} />);
});
editorState.position = { lineNumber: 1, column: 4 };
editorState.selection = {
startLineNumber: 2,
startColumn: 1,
endLineNumber: 2,
endColumn: 'select 2 as selected'.length + 1,
};
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'main', expect.stringContaining('select 2 as selected'), 'query-1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('allows editable table columns while leaving expression columns out of commits', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['DISPLAY_NAME', 'NAME_UPPER', '__gonavi_locator_1_ID'],
rows: [{ DISPLAY_NAME: 'old-name', NAME_UPPER: 'OLD-NAME', __gonavi_locator_1_ID: 7 }],
}],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'SELECT NAME AS DISPLAY_NAME, UPPER(NAME) AS NAME_UPPER FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
writableColumns: {
DISPLAY_NAME: 'NAME',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('`ID` AS `__gonavi_locator_1_ID`');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('keeps DuckDB qualified table query results writable when primary key metadata arrives', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_id'], rows: [{ NAME: 'launch', __gonavi_locator_1_id: 7 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'id', key: 'PRI' }, { name: 'name', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM main.events' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'main', 'main.events');
expect(dataGridState.latestProps?.tableName).toBe('main.events');
expect(dataGridState.latestProps?.pkColumns).toEqual(['id']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['id'],
valueColumns: ['__gonavi_locator_1_id'],
hiddenColumns: ['__gonavi_locator_1_id'],
writableColumns: {
NAME: 'name',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"id" AS "__gonavi_locator_1_id"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses hidden DuckDB rowid when query results have no primary or unique key', async () => {
storeState.connections[0].config.type = 'duckdb';
storeState.connections[0].config.database = 'main';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_duckdb_rowid__'], rows: [{ NAME: 'launch', __gonavi_duckdb_rowid__: 17 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'name', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: true,
data: [],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM main.events' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('main.events');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'duckdb-rowid',
columns: ['rowid'],
valueColumns: ['__gonavi_duckdb_rowid__'],
hiddenColumns: ['__gonavi_duckdb_rowid__'],
writableColumns: {
NAME: 'name',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('rowid AS "__gonavi_duckdb_rowid__"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it.each([
'mysql',
'mariadb',
'oceanbase',
'diros',
'sphinx',
'postgres',
'kingbase',
'highgo',
'vastbase',
'opengauss',
'sqlserver',
'sqlite',
'duckdb',
'oracle',
'dameng',
'tdengine',
'clickhouse',
])(
'keeps aggregate query results silently read-only for %s',
async (dbType) => {
storeState.connections[0].config.type = dbType;
storeState.connections[0].config.database = dbType === 'oracle' || dbType === 'dameng' ? 'APP' : 'main';
const forceReadOnlyQueryResult = dbType === 'tdengine' || dbType === 'clickhouse';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['COUNT'], rows: [{ COUNT: 1 }] }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({
dbName: storeState.connections[0].config.database,
query: 'SELECT count(1) FROM users',
})} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const expectedTableName = dbType === 'oracle' || dbType === 'dameng' ? 'USERS' : 'users';
expect(dataGridState.latestProps?.tableName).toBe(forceReadOnlyQueryResult ? undefined : expectedTableName);
expect(dataGridState.latestProps?.editLocator).toBeUndefined();
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(messageApi.warning).not.toHaveBeenCalled();
},
);
});