From 810fe7b6681e6f2d58843cd6eb1062f4a7de01aa Mon Sep 17 00:00:00 2001 From: Kunghim Date: Sat, 1 Aug 2026 00:56:55 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test(query-editor):=20AI=20?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E6=8E=A5=E5=8F=97=E5=BF=AB=E6=8D=B7=E9=94=AE?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E6=B5=8B=E8=AF=95(=E9=BB=98=E8=AE=A4=20Right?= =?UTF-8?q?/=E5=8F=AF=E9=85=8D=E7=BD=AE=20Tab/=E4=B8=8D=E6=8B=A6=E6=88=AA?= =?UTF-8?q?=E5=9C=BA=E6=99=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../QueryEditor.external-sql-save.test.tsx | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 31ef92c3..9db6103a 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -1952,6 +1952,413 @@ describe('QueryEditor external SQL save', () => { } }); + it('accepts the AI inline ghost with the default Right shortcut and consumes the keydown', async () => { + vi.useFakeTimers(); + try { + const inlineAiService = { + AIGetProviders: vi.fn(async () => [{ + id: 'openai-main', + type: 'openai', + name: 'OpenAI', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + maxTokens: 2048, + temperature: 0.2, + }]), + AIGetActiveProvider: vi.fn(async () => 'openai-main'), + AIGetUserPromptSettings: vi.fn(async () => ({ + global: '', + database: '', + jvm: '', + jvmDiagnostic: '', + })), + AIChatSend: vi.fn(async () => ({ success: true, content: 'videos' })), + }; + backendApp.DBGetTables.mockResolvedValueOnce({ + success: true, + data: [ + { TABLE_NAME: 'videos' }, + { TABLE_NAME: 'visits' }, + ], + }); + + const windowListeners: Record 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(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + go: { + aiservice: { + Service: inlineAiService, + }, + }, + }); + + await act(async () => { + create(); + }); + + editorState.value = 'SELECT'; + editorState.position = { lineNumber: 1, column: 'SELECT'.length + 1 }; + editorState.editor.executeEdits.mockClear(); + editorState.editor.trigger.mockClear(); + editorState.domNode.appendChild.mockClear(); + + await act(async () => { + editorState.latestOnChange?.('SELECT'); + editorState.modelContentListeners.forEach((listener) => listener({ + changes: [{ text: 'T' }], + })); + vi.advanceTimersByTime(220); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + }); + + const shortcutEvent = { + type: 'keydown', + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + which: 39, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const monacoShortcutEvent = { + browserEvent: shortcutEvent, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + editorState.keyDownListeners.forEach((listener) => listener(monacoShortcutEvent)); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + }); + + expect(editorState.editor.executeEdits).toHaveBeenCalledWith( + 'gonavi-ai-inline-sql-completion', + [expect.objectContaining({ text: expect.any(String) })], + ); + expect(monacoShortcutEvent.preventDefault).toHaveBeenCalled(); + expect(monacoShortcutEvent.stopPropagation).toHaveBeenCalled(); + expect(shortcutEvent.preventDefault).toHaveBeenCalled(); + expect(shortcutEvent.stopPropagation).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not consume Right when no AI inline ghost is visible', async () => { + vi.useFakeTimers(); + try { + const windowListeners: Record 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(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + }); + + await act(async () => { + create(); + }); + + editorState.editor.executeEdits.mockClear(); + + const shortcutEvent = { + type: 'keydown', + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + which: 39, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const monacoShortcutEvent = { + browserEvent: shortcutEvent, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + editorState.keyDownListeners.forEach((listener) => listener(monacoShortcutEvent)); + }); + + expect(monacoShortcutEvent.preventDefault).not.toHaveBeenCalled(); + expect(monacoShortcutEvent.stopPropagation).not.toHaveBeenCalled(); + expect(shortcutEvent.preventDefault).not.toHaveBeenCalled(); + expect(shortcutEvent.stopPropagation).not.toHaveBeenCalled(); + expect(editorState.editor.executeEdits).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not consume Right when the AI inline ghost is stale (cursor moved)', async () => { + vi.useFakeTimers(); + try { + const inlineAiService = { + AIGetProviders: vi.fn(async () => [{ + id: 'openai-main', + type: 'openai', + name: 'OpenAI', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + maxTokens: 2048, + temperature: 0.2, + }]), + AIGetActiveProvider: vi.fn(async () => 'openai-main'), + AIGetUserPromptSettings: vi.fn(async () => ({ + global: '', + database: '', + jvm: '', + jvmDiagnostic: '', + })), + AIChatSend: vi.fn(async () => ({ success: true, content: 'videos' })), + }; + backendApp.DBGetTables.mockResolvedValueOnce({ + success: true, + data: [ + { TABLE_NAME: 'videos' }, + { TABLE_NAME: 'visits' }, + ], + }); + + const windowListeners: Record 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(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + go: { + aiservice: { + Service: inlineAiService, + }, + }, + }); + + await act(async () => { + create(); + }); + + editorState.value = 'SELECT'; + editorState.position = { lineNumber: 1, column: 'SELECT'.length + 1 }; + editorState.editor.executeEdits.mockClear(); + editorState.editor.trigger.mockClear(); + editorState.domNode.appendChild.mockClear(); + + await act(async () => { + editorState.latestOnChange?.('SELECT'); + editorState.modelContentListeners.forEach((listener) => listener({ + changes: [{ text: 'T' }], + })); + vi.advanceTimersByTime(220); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + }); + + // 光标已移走,幽灵与当前位置不匹配 + editorState.position = { lineNumber: 1, column: 1 }; + + const shortcutEvent = { + type: 'keydown', + key: 'ArrowRight', + code: 'ArrowRight', + keyCode: 39, + which: 39, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const monacoShortcutEvent = { + browserEvent: shortcutEvent, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + editorState.keyDownListeners.forEach((listener) => listener(monacoShortcutEvent)); + }); + + expect(monacoShortcutEvent.preventDefault).not.toHaveBeenCalled(); + expect(monacoShortcutEvent.stopPropagation).not.toHaveBeenCalled(); + expect(shortcutEvent.preventDefault).not.toHaveBeenCalled(); + expect(shortcutEvent.stopPropagation).not.toHaveBeenCalled(); + expect(editorState.editor.executeEdits).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('accepts the AI inline ghost with a rebound Tab shortcut', async () => { + vi.useFakeTimers(); + try { + storeState.shortcutOptions.acceptSqlAiCompletion = { + mac: { enabled: true, combo: 'Tab' }, + windows: { enabled: true, combo: 'Tab' }, + }; + + const inlineAiService = { + AIGetProviders: vi.fn(async () => [{ + id: 'openai-main', + type: 'openai', + name: 'OpenAI', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5-mini', + maxTokens: 2048, + temperature: 0.2, + }]), + AIGetActiveProvider: vi.fn(async () => 'openai-main'), + AIGetUserPromptSettings: vi.fn(async () => ({ + global: '', + database: '', + jvm: '', + jvmDiagnostic: '', + })), + AIChatSend: vi.fn(async () => ({ success: true, content: 'videos' })), + }; + backendApp.DBGetTables.mockResolvedValueOnce({ + success: true, + data: [ + { TABLE_NAME: 'videos' }, + { TABLE_NAME: 'visits' }, + ], + }); + + const windowListeners: Record 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(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + go: { + aiservice: { + Service: inlineAiService, + }, + }, + }); + + await act(async () => { + create(); + }); + + editorState.value = 'SELECT'; + editorState.position = { lineNumber: 1, column: 'SELECT'.length + 1 }; + editorState.editor.executeEdits.mockClear(); + editorState.editor.trigger.mockClear(); + editorState.domNode.appendChild.mockClear(); + + await act(async () => { + editorState.latestOnChange?.('SELECT'); + editorState.modelContentListeners.forEach((listener) => listener({ + changes: [{ text: 'T' }], + })); + vi.advanceTimersByTime(220); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + }); + + const shortcutEvent = { + type: 'keydown', + key: 'Tab', + code: 'Tab', + keyCode: 9, + which: 9, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const monacoShortcutEvent = { + browserEvent: shortcutEvent, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + editorState.keyDownListeners.forEach((listener) => listener(monacoShortcutEvent)); + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } + }); + + expect(editorState.editor.executeEdits).toHaveBeenCalledWith( + 'gonavi-ai-inline-sql-completion', + [expect.objectContaining({ text: expect.any(String) })], + ); + } finally { + vi.useRealTimers(); + } + }); + it('continues accepted inline SQL ghost with grounded table AI completion', async () => { vi.useFakeTimers(); try {