Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/entrypoints/sidepanel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ function ChatLayout() {
onSelectModel: handleSelectModel,
reasoningEffort,
onReasoningEffortChange: handleReasoningEffortChange,
tokenUsage,
contextWindowTokens: agentSettings.contextWindowTokens,
};

return (
Expand Down
75 changes: 75 additions & 0 deletions src/entrypoints/sidepanel/__tests__/ChatContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ vi.mock('@/conversation', () => ({
ConversationManager: vi.fn(() => mockConversationManager),
}));

// ── useAgent mock:捕获 ChatProvider 注册的回调(onTokenUsage 等),供回归测试驱动 ──

const mockAgent = vi.hoisted(() => {
const callbacks: {
onMessage?: (msg: unknown) => void;
onConfirm?: (req: unknown) => void;
onTokenUsage?: (usage: { prompt: number; completion: number }) => void;
onConversationTitle?: (conversationId: string, title: string) => void;
} = {};
return {
status: 'idle',
error: null,
runningConversationId: null,
callbacks,
setCallbacks: (cb: typeof callbacks) => {
Object.assign(callbacks, cb);
},
run: vi.fn(),
abort: vi.fn(),
resolveConfirm: vi.fn(),
};
});

vi.mock('../hooks/useAgent', () => ({
useAgent: () => mockAgent,
}));

import { ChatProvider, useChat } from '../ChatContext';

function ContextInspector() {
Expand Down Expand Up @@ -320,4 +347,52 @@ describe('ChatContext message loading', () => {
expect(screen.getByTestId('messages-count').textContent).toBe('1');
expect(screen.getByTestId('active-id').textContent).toBe('conv-B');
});

it('切换 activeId 后 tokenUsage 重置为 {0,0}(会话切换无残留旧值)', async () => {
const now = Date.now();
mockConversationManager.list.mockResolvedValue([
{ id: 'conv-A', title: 'Conv A', titleGenerated: true, updatedAt: now, createdAt: now, messages: [], sensitiveDataGranted: false },
{ id: 'conv-B', title: 'Conv B', titleGenerated: true, updatedAt: now, createdAt: now, messages: [], sensitiveDataGranted: false },
]);
mockConversationManager.get.mockResolvedValue({
id: 'conv-A', title: 'Conv A', titleGenerated: true, createdAt: now, updatedAt: now, messages: [], sensitiveDataGranted: false,
});

function TokenUsageInspector() {
const chat = useChat();
return (
<div>
<div data-testid="token-usage-prompt">{chat.tokenUsage.prompt}</div>
<div data-testid="active-id">{chat.conversations.activeId ?? '(none)'}</div>
<button
type="button"
data-testid="fire-usage"
onClick={() => mockAgent.callbacks.onTokenUsage?.({ prompt: 5000, completion: 100 })}
>
Fire usage
</button>
<button type="button" data-testid="select-conv-b" onClick={() => chat.conversations.select('conv-B')}>
Select B
</button>
</div>
);
}

render(
<ChatProvider>
<TokenUsageInspector />
</ChatProvider>,
);

await waitFor(() => expect(screen.getByTestId('active-id').textContent).toBe('conv-A'));

// 模拟一次请求返回 usage → tokenUsage 变为非零
await userEvent.click(screen.getByTestId('fire-usage'));
await waitFor(() => expect(screen.getByTestId('token-usage-prompt').textContent).toBe('5000'));

// 切换会话 → 重置为 {0,0}
await userEvent.click(screen.getByTestId('select-conv-b'));
await waitFor(() => expect(screen.getByTestId('active-id').textContent).toBe('conv-B'));
expect(screen.getByTestId('token-usage-prompt').textContent).toBe('0');
});
});
94 changes: 94 additions & 0 deletions src/entrypoints/sidepanel/__tests__/ContextUsageRing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react';
import { render } from '@testing-library/react';
import { describe, it, expect, beforeEach } from 'vitest';
import { ContextUsageRing, contextUsagePercent } from '../components/ContextUsageRing';
import { I18nProvider } from '../i18n/I18nProvider';
import { mockBrowserStorage } from './test-utils';

// 与组件实现同一几何公式的独立期望值(CIRC = 2π·((20-2.5)/2))
const SIZE = 20;
const STROKE = 2.5;
const CIRCUMFERENCE = 2 * Math.PI * ((SIZE - STROKE) / 2);

beforeEach(() => {
mockBrowserStorage();
});

function wrappedRender(ui: React.ReactElement) {
const utils = render(<I18nProvider>{ui}</I18nProvider>);
return {
...utils,
// rerender 会替换整个渲染树,必须重新包裹 I18nProvider
rerender: (nextUi: React.ReactElement) => utils.rerender(<I18nProvider>{nextUi}</I18nProvider>),
};
}

describe('contextUsagePercent', () => {
it('返回 0-100 整数(含恰好 100% 边界)', () => {
expect(contextUsagePercent(45200, 128000)).toBe(35);
expect(contextUsagePercent(128000, 128000)).toBe(100);
});

it('clamp 上限:占用超过上限时返回 100', () => {
expect(contextUsagePercent(150000, 128000)).toBe(100);
});

it('clamp 下限与 limit<=0 防御:返回 0 且不抛错', () => {
expect(contextUsagePercent(0, 128000)).toBe(0);
expect(contextUsagePercent(-1, 128000)).toBe(0);
expect(contextUsagePercent(100, 0)).toBe(0);
});
});

describe('ContextUsageRing', () => {
it('used<=0 或 limit<=0 时隐藏,有效值时显示', () => {
const { queryByTestId, rerender } = wrappedRender(<ContextUsageRing used={0} limit={128000} />);
expect(queryByTestId('context-usage-ring')).toBeNull();

rerender(<ContextUsageRing used={45200} limit={0} />);
expect(queryByTestId('context-usage-ring')).toBeNull();

rerender(<ContextUsageRing used={45200} limit={128000} />);
expect(queryByTestId('context-usage-ring')).not.toBeNull();
});

it('占用率 ≥80% 时 wrapper data-state=warning 且进度弧 text-warning,<80% 为 normal', () => {
const { getByTestId, rerender } = wrappedRender(<ContextUsageRing used={102400} limit={128000} />);
expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('warning');
expect(getByTestId('context-usage-ring-progress').getAttribute('class')).toContain('text-warning');

rerender(<ContextUsageRing used={101120} limit={128000} />);
expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('normal');
expect(getByTestId('context-usage-ring-progress').getAttribute('class')).toContain('text-primary');

// 100% 仍为 warning
rerender(<ContextUsageRing used={128000} limit={128000} />);
expect(getByTestId('context-usage-ring').getAttribute('data-state')).toBe('warning');
});

it('hover title/aria-label 显示千位分隔的上下文占用文案', () => {
const { getByTestId, rerender } = wrappedRender(<ContextUsageRing used={45200} limit={128000} />);
let wrapper = getByTestId('context-usage-ring');
expect(wrapper.getAttribute('title')).toBe('上下文占用:45,200 / 128,000');
expect(wrapper.getAttribute('aria-label')).toBe('上下文占用:45,200 / 128,000');

rerender(<ContextUsageRing used={1000} limit={128000} />);
wrapper = getByTestId('context-usage-ring');
expect(wrapper.getAttribute('title')).toContain('1,000');
expect(wrapper.getAttribute('title')).toContain('128,000');
});

it('进度弧 stroke-dashoffset 与 percent 几何闭环', () => {
const { getByTestId, rerender } = wrappedRender(<ContextUsageRing used={32000} limit={128000} />);
const offset = () => parseFloat(getByTestId('context-usage-ring-progress').getAttribute('stroke-dashoffset') ?? 'NaN');

// percent=25 → 0.75·CIRC
expect(Math.abs(offset() - 0.75 * CIRCUMFERENCE)).toBeLessThan(0.5);
// percent=100 → ≈0
rerender(<ContextUsageRing used={128000} limit={128000} />);
expect(Math.abs(offset() - 0)).toBeLessThan(0.5);
// percent=0 → ≈CIRC
rerender(<ContextUsageRing used={1} limit={128000} />);
expect(Math.abs(offset() - CIRCUMFERENCE)).toBeLessThan(0.5);
});
});
143 changes: 142 additions & 1 deletion src/entrypoints/sidepanel/__tests__/MessageInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,44 @@ function getLatestMockReturn(): UseVoiceInputReturn & { __onTranscribed: (text:

const EMPTY_PROVIDERS: ProviderConfig[] = [];

/** 模型未配置 limit.context → 上限回退 contextWindowTokens / 默认 128000 */
const PROVIDER_NO_CONTEXT_LIMIT: ProviderConfig = {
id: 'local',
name: 'Local Gateway',
apiKey: '',
isLocalTrusted: true,
models: {
chat: { id: 'chat', name: 'Chat' },
},
defaultModelId: 'chat',
};

/** 模型配置 limit.context=32768 且支持思考等级 → reasoning-select 渲染 */
const PROVIDER_CONTEXT_32768: ProviderConfig = {
id: 'local',
name: 'Local Gateway',
apiKey: '',
isLocalTrusted: true,
models: {
qwen: {
id: 'qwen',
name: 'Qwen',
limit: { context: 32768, output: 8192 },
reasoning: true,
reasoningEfforts: ['low', 'high'],
defaultReasoningEffort: 'high',
},
},
defaultModelId: 'qwen',
};

function wrappedRender(ui: React.ReactElement) {
return render(<I18nProvider>{ui}</I18nProvider>);
const utils = render(<I18nProvider>{ui}</I18nProvider>);
return {
...utils,
// rerender 会替换整个渲染树,必须重新包裹 I18nProvider
rerender: (nextUi: React.ReactElement) => utils.rerender(<I18nProvider>{nextUi}</I18nProvider>),
};
}

describe('MessageInput', () => {
Expand Down Expand Up @@ -341,4 +377,109 @@ describe('MessageInput', () => {

expect(screen.getByTestId('reasoning-unsupported')).toHaveTextContent('Think: Unsupported');
});

// ── 上下文占用进度环集成 ──

it('传入 tokenUsage 时显示上下文占用进度环(模型无 limit.context 时回退默认上限 128000)', () => {
wrappedRender(
<MessageInput
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
isRunning={false}
providers={[PROVIDER_NO_CONTEXT_LIMIT]}
selectedProviderId="local"
onSelectProvider={vi.fn()}
selectedModelId="chat"
onSelectModel={vi.fn()}
tokenUsage={{ prompt: 45200, completion: 0 }}
/>,
);

const ring = screen.getByTestId('context-usage-ring');
expect(ring.getAttribute('aria-label')).toContain('45,200 / 128,000');
});

it('tokenUsage 未传或 prompt=0 时不渲染进度环', () => {
const { rerender } = wrappedRender(
<MessageInput
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
isRunning={false}
providers={[PROVIDER_NO_CONTEXT_LIMIT]}
selectedProviderId="local"
onSelectProvider={vi.fn()}
selectedModelId="chat"
onSelectModel={vi.fn()}
/>,
);
expect(screen.queryByTestId('context-usage-ring')).toBeNull();

rerender(
<MessageInput
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
isRunning={false}
providers={[PROVIDER_NO_CONTEXT_LIMIT]}
selectedProviderId="local"
onSelectProvider={vi.fn()}
selectedModelId="chat"
onSelectModel={vi.fn()}
tokenUsage={{ prompt: 0, completion: 0 }}
/>,
);
expect(screen.queryByTestId('context-usage-ring')).toBeNull();
});

it('activeModel.limit.context 优先于 contextWindowTokens 作为上限', () => {
wrappedRender(
<MessageInput
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
isRunning={false}
providers={[PROVIDER_CONTEXT_32768]}
selectedProviderId="local"
onSelectProvider={vi.fn()}
selectedModelId="qwen"
onSelectModel={vi.fn()}
contextWindowTokens={50000}
tokenUsage={{ prompt: 45200, completion: 0 }}
/>,
);

const ring = screen.getByTestId('context-usage-ring');
expect(ring.getAttribute('aria-label')).toContain('45,200');
expect(ring.getAttribute('aria-label')).toContain('32,768');
expect(ring.getAttribute('aria-label')).not.toContain('50,000');
});

it('DOM 顺序为 reasoning 下拉 → 进度环 → 麦克风按钮', () => {
wrappedRender(
<MessageInput
onSend={vi.fn()}
onAbort={vi.fn()}
disabled={false}
isRunning={false}
providers={[PROVIDER_CONTEXT_32768]}
selectedProviderId="local"
onSelectProvider={vi.fn()}
selectedModelId="qwen"
onSelectModel={vi.fn()}
tokenUsage={{ prompt: 45200, completion: 0 }}
/>,
);

const reasoning = screen.getByRole('combobox', { name: '思考' });
const ring = screen.getByTestId('context-usage-ring');
const mic = screen.getByTestId('mic-button');

const follows = (a: Element, b: Element) =>
(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;

expect(follows(reasoning, ring)).toBe(true);
expect(follows(ring, mic)).toBe(true);
});
});
Loading
Loading