Files
qwen-code/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.test.ts
yiliang114 9cc48f12da feat(vscode-ide-companion): 改进消息排序和显示逻辑
- 添加时间戳支持,确保消息按时间顺序排列
- 更新工具调用处理逻辑,自动添加和保留时间戳
- 修改消息渲染逻辑,将所有类型的消息合并排序后统一渲染
- 优化完成的工具调用显示,修复显示顺序问题
- 调整进行中的工具调用显示,统一到消息流中展示
- 移除重复的计划展示逻辑,避免最新块重复出现
- 重构消息处理和渲染代码,提高可维护性
2025-11-28 09:55:06 +08:00

213 lines
6.1 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook, act } from '@testing-library/react';
import { useCompletionTrigger } from './useCompletionTrigger';
// Mock CompletionItem type
interface CompletionItem {
id: string;
label: string;
description?: string;
icon?: React.ReactNode;
type: 'file' | 'symbol' | 'command' | 'variable';
value?: unknown;
}
describe('useCompletionTrigger', () => {
let mockInputRef: React.RefObject<HTMLDivElement>;
let mockGetCompletionItems: (
trigger: '@' | '/',
query: string,
) => Promise<CompletionItem[]>;
beforeEach(() => {
mockInputRef = {
current: document.createElement('div'),
};
mockGetCompletionItems = jest.fn();
});
afterEach(() => {
jest.clearAllTimers();
});
it('should trigger completion when @ is typed at word boundary', async () => {
mockGetCompletionItems.mockResolvedValue([
{ id: '1', label: 'test.txt', type: 'file' },
]);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
// Mock window.getSelection to return a valid range
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
// Wait for async operations
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current.isOpen).toBe(true);
expect(result.current.triggerChar).toBe('@');
expect(mockGetCompletionItems).toHaveBeenCalledWith('@', '');
});
it('should show loading state initially', async () => {
// Simulate slow file loading
mockGetCompletionItems.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
100,
),
),
);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
// Wait for async operations but not for the slow promise
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Should show loading state immediately
expect(result.current.isOpen).toBe(true);
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].id).toBe('loading');
});
it('should timeout if loading takes too long', async () => {
// Simulate very slow file loading
mockGetCompletionItems.mockImplementation(
() =>
new Promise(
(resolve) =>
setTimeout(
() => resolve([{ id: '1', label: 'test.txt', type: 'file' }]),
10000,
), // 10 seconds
),
);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
// Wait for async operations
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Should show loading state initially
expect(result.current.isOpen).toBe(true);
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].id).toBe('loading');
// Wait for timeout (5 seconds)
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5100)); // 5.1 seconds
});
// Should show timeout message
expect(result.current.items).toHaveLength(1);
expect(result.current.items[0].id).toBe('timeout');
expect(result.current.items[0].label).toBe('Timeout');
});
it('should close completion when cursor moves away from trigger', async () => {
mockGetCompletionItems.mockResolvedValue([
{ id: '1', label: 'test.txt', type: 'file' },
]);
const { result } = renderHook(() =>
useCompletionTrigger(mockInputRef, mockGetCompletionItems),
);
// Simulate typing @ at the beginning
mockInputRef.current.textContent = '@';
const mockRange = {
getBoundingClientRect: () => ({ top: 100, left: 50 }),
};
window.getSelection = jest.fn().mockReturnValue({
rangeCount: 1,
getRangeAt: () => mockRange,
} as unknown as Selection);
// Trigger input event to open completion
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current.isOpen).toBe(true);
// Simulate moving cursor away (typing space after @)
mockInputRef.current.textContent = '@ ';
// Trigger input event to close completion
await act(async () => {
const event = new Event('input', { bubbles: true });
mockInputRef.current.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Should close completion when query contains space
expect(result.current.isOpen).toBe(false);
});
});