mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-14 18:39:54 +08:00
3520 lines
131 KiB
TypeScript
3520 lines
131 KiB
TypeScript
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();
|
||
},
|
||
);
|
||
});
|