qwen-code/packages/web-shell/client/components/dialogs/TokenHeatmap.test.tsx
Shaojin Wen 350191e101
feat(web-shell): add token-usage analytics dashboard to Daemon Status (#6388)
* feat(web-shell): add token-usage analytics dashboard to Daemon Status

Add a "统计 / Usage" tab to the Daemon Status page: a Today/7D/30D period toggle over the selected range's token totals and input/output/cache-read breakdown, a 12-month token heatmap (per-day tokens + cache-read tooltip, localized month labels), per-model token share, skill-call counts, and daily token/session charts.

Backend: a new read-only GET /usage/dashboard daemon API backed by a core usage-dashboard service that aggregates the durable local usage history (cross-project ~/.qwen), reusing loadUsageHistory + aggregateUsage. Skill counts are threaded through the shared usage pipeline. No new instrumentation — every metric is read from data qwen-code already persists.

* fix(web-shell): address usage-dashboard review feedback

- cap `aggregateUsage` topSkills at 25 like topTools, so the aggregate and dashboard payload stay bounded
- fix a DST drift in the heatmap grid: advance the day/month cursor by calendar day (setDate) instead of a fixed `i * MS_PER_DAY` offset
- cache the loaded history once (range-independent) so toggling Today/7D/30D re-aggregates from a single disk read; split a pure `buildUsageDashboard(records, opts)` out of `loadUsageDashboard`
- drop the unused per-day streak computation and the dead `daemon.usage.streak` i18n key
- add debug logging to the dashboard builder and a direct `aggregateUsage`-skills unit test

* fix(usage-dashboard): make the dashboard load read-only + fix cache coalescing

- Make the daemon dashboard side-effect free: `loadUsageHistory` gains a `persistRebuild` option, and the route passes `persistRebuild: false`, so serving a GET never writes to `~/.qwen`. The transcript-rebuild fallback previously persisted rebuilt records (including an in-progress session), violating the read-only contract.
- Fix cache coalescing on the slow path: a pending history load is now reused regardless of age (the TTL starts at settlement), so a request arriving after the TTL while the load is still pending no longer kicks off a second full load.
- Tests: read-only rebuild writes nothing, `metricsToUsageRecord` copies `SessionMetrics.skills`, and a pending load is shared past the TTL.
2026-07-06 13:43:41 +00:00

84 lines
2.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
import { TokenHeatmap } from './TokenHeatmap';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function todayKey(): string {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function render(node: ReactNode, language: 'en' | 'zh-CN' = 'en') {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root!.render(<I18nProvider language={language}>{node}</I18nProvider>);
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});
describe('TokenHeatmap', () => {
it('renders a ~12-month grid of day cells', () => {
render(
<TokenHeatmap
heatmap={{ [todayKey()]: { tokens: 1_000, cacheReadRate: 0 } }}
days={365}
/>,
);
const cells = container!.querySelectorAll('[data-date]');
// 365 days aligned to whole weeks ≈ 53 columns × 7 rows.
expect(cells.length).toBeGreaterThan(300);
});
it('shows a custom tooltip (ISO date + tokens + cache) on hover', () => {
const key = todayKey();
render(
<TokenHeatmap
heatmap={{ [key]: { tokens: 1_395_800_000, cacheReadRate: 0.96 } }}
days={365}
/>,
);
// No tooltip until hover.
expect(container!.textContent).not.toContain('Tokens:');
const cell = container!.querySelector(`[data-date="${key}"]`)!;
act(() => {
cell.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
const text = container!.textContent ?? '';
expect(text).toContain(`${key} · Tokens: 1395.8M · Cache: 96%`);
});
it('localizes the month labels to the app language (zh-CN)', () => {
render(
<TokenHeatmap
heatmap={{ [todayKey()]: { tokens: 1_000, cacheReadRate: 0 } }}
days={365}
/>,
'zh-CN',
);
// Chinese short months render as "N月" rather than English abbreviations.
expect(container!.textContent ?? '').toContain('月');
expect(container!.textContent ?? '').not.toContain('Jan');
});
});