返回 DeepSeek-Reasonix
usage-stats-panel.test.tsx
根目录 / desktop / frontend / src / __tests__ / usage-stats-panel.test.tsx
1 // Run: tsx src/__tests__/usage-stats-panel.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import { registerHooks } from "node:module";
5 import React from "react";
6 import { act } from "react";
7 import { createRoot } from "react-dom/client";
8 import type { UsageStatsRange, UsageStatsRequest } from "../lib/types";
9 import { installDesktopHostStub } from "./desktopHostStub";
10
11 registerHooks({
12 resolve(specifier, context, nextResolve) {
13 if (specifier.endsWith(".css")) {
14 return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
15 }
16 return nextResolve(specifier, context);
17 },
18 });
19
20 let failed = 0;
21 function ok(value: boolean, label: string) {
22 if (value) process.stdout.write(` PASS ${label}\n`);
23 else {
24 process.stdout.write(` FAIL ${label}\n`);
25 failed += 1;
26 }
27 }
28
29 function emptyStats(overrides: Partial<UsageStatsRange> = {}): UsageStatsRange {
30 return {
31 from: "", to: "", tokens: 1, requests: 1, turns: 1,
32 cacheHit: 0, cacheMiss: 1, activeDays: 1,
33 topModel: "deepseek/model", topProvider: "deepseek",
34 daily: [], models: [], providers: [], ...overrides,
35 };
36 }
37
38 function day(offset: number): string {
39 const d = new Date();
40 d.setDate(d.getDate() + offset);
41 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
42 }
43
44 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
45 pretendToBeVisual: true,
46 url: "http://localhost/",
47 });
48 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
49 globalThis.window = dom.window as unknown as Window & typeof globalThis;
50 globalThis.document = dom.window.document;
51 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
52 globalThis.Node = dom.window.Node;
53 globalThis.HTMLElement = dom.window.HTMLElement;
54 Object.defineProperty(globalThis.HTMLElement.prototype, "clientWidth", { configurable: true, get: () => 720 });
55 globalThis.Event = dom.window.Event;
56 globalThis.MouseEvent = dom.window.MouseEvent;
57 globalThis.localStorage = dom.window.localStorage;
58 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
59 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
60
61 class TestResizeObserver {
62 observe() {}
63 unobserve() {}
64 disconnect() {}
65 }
66 globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
67
68 let rejectCLIHeat: ((error: Error) => void) | undefined;
69 let rejectCLIMain: ((error: Error) => void) | undefined;
70 let resolveDesktopMain: ((stats: UsageStatsRange) => void) | undefined;
71 const usageStats = (req: UsageStatsRequest): Promise<UsageStatsRange> => {
72 if (req.range !== "custom") {
73 if (req.source === "cli") {
74 return new Promise((_, reject) => { rejectCLIMain = reject; });
75 }
76 if (req.source === "desktop") {
77 return new Promise((resolve) => { resolveDesktopMain = resolve; });
78 }
79 return Promise.resolve(emptyStats({
80 tokens: 21,
81 daily: Array.from({ length: 400 }, (_, index) => ({
82 day: day(index - 399),
83 total: 21,
84 byModel: Object.fromEntries(Array.from({ length: 21 }, (_, modelIndex) => [`deepseek/model-${modelIndex + 1}`, 1])),
85 byProvider: { deepseek: 21 },
86 requests: 1,
87 turns: 1,
88 cacheHit: 0,
89 cacheMiss: 1,
90 })),
91 models: Array.from({ length: 21 }, (_, index) => ({
92 model: `deepseek/model-${index + 1}`,
93 tokens: 1,
94 percent: 100 / 21,
95 })),
96 }));
97 }
98 if (req.source === "cli") {
99 return new Promise((_, reject) => { rejectCLIHeat = reject; });
100 }
101 return Promise.resolve(emptyStats({
102 from: day(-279),
103 to: day(0),
104 daily: [{ day: day(0), total: 100, byModel: { "deepseek/model": 100 }, byProvider: { deepseek: 100 }, requests: 1, turns: 1, cacheHit: 10, cacheMiss: 10 }],
105 }));
106 };
107 installDesktopHostStub(({ main: { App: { UsageStats: usageStats } } }).main.App);
108
109 const [{ LocaleProvider }, { UsageStatsPanel }] = await Promise.all([
110 import("../lib/i18n"),
111 import("../components/UsageStatsPanel"),
112 ]);
113
114 const rootEl = document.getElementById("root");
115 if (!rootEl) throw new Error("missing root");
116 const root = createRoot(rootEl);
117 await act(async () => {
118 root.render(<LocaleProvider><UsageStatsPanel /></LocaleProvider>);
119 await new Promise((resolve) => setTimeout(resolve, 0));
120 });
121
122 for (let i = 0; i < 20 && rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 0; i += 1) {
123 await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
124 }
125 ok(rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 1, "initial source heat data is rendered");
126 ok(rootEl.textContent?.includes("Completed turns") === true && !rootEl.textContent?.includes("Sessions"), "completed turns are not mislabeled as sessions");
127 ok(rootEl.querySelectorAll("rect.usage-stats__bar-hit").length === 180, "long custom trends render at most 180 interactive columns");
128 ok(rootEl.textContent?.includes("Showing the latest 180 days") === true, "bounded trends disclose the visible window");
129 const modelSegments = rootEl.querySelectorAll("circle.usage-stats__donut-seg");
130 ok(modelSegments.length === 6, "21 models collapse into five steps plus Other");
131 // Model colours map to the fixed --chart-1..5 series (rank order) plus the
132 // gray --chart-other step; each series colour is mixed toward --bg-elev
133 // like the heatmap levels (light/dark hex variants live in styles.css), so
134 // the assertions only check the rank-to-variable mapping.
135 const seriesVar = (color: string) => color.match(/var\(--chart-\d+\)/)?.[0] ?? "";
136 const primaryColour = modelSegments[0]?.getAttribute("stroke") ?? "";
137 ok(seriesVar(primaryColour) === "var(--chart-1)" && primaryColour.includes("72%") && primaryColour.includes("var(--bg-elev)"), "most-used model keeps the first series colour softened toward the card background");
138 const lastTopColour = modelSegments[4]?.getAttribute("stroke") ?? "";
139 ok(seriesVar(lastTopColour) === "var(--chart-5)", "fifth model gets the fifth series colour");
140 const otherColour = modelSegments[modelSegments.length - 1]?.getAttribute("stroke") ?? "";
141 ok(otherColour === "var(--chart-other)", "models beyond the top five collapse into the gray Other step");
142 const series = [...modelSegments].slice(0, 5).map((seg) => seriesVar(seg.getAttribute("stroke") ?? ""));
143 ok(new Set(series).size === 5, "the five model steps each get a distinct series colour");
144
145 const otherToggle = rootEl.querySelector<HTMLButtonElement>("button.usage-stats__model-row--expandable");
146 ok(otherToggle instanceof dom.window.HTMLButtonElement, "Other uses one native button for the whole expandable row");
147 ok(otherToggle?.querySelector("button") === null, "Other does not nest an interactive button inside another button");
148 ok(otherToggle?.getAttribute("aria-expanded") === "false", "Other exposes its collapsed state on the row button");
149 await act(async () => {
150 otherToggle?.click();
151 await new Promise((resolve) => setTimeout(resolve, 0));
152 });
153 ok(otherToggle?.getAttribute("aria-expanded") === "true", "clicking the Other row exposes its expanded state");
154 ok(rootEl.querySelector(".usage-stats__model-other-wrap--open") !== null, "clicking the Other row opens the detail list");
155
156 await act(async () => {
157 modelSegments[modelSegments.length - 1]?.dispatchEvent(new dom.window.MouseEvent("mouseover", { bubbles: true, clientX: 120, clientY: 120 }));
158 await new Promise((resolve) => setTimeout(resolve, 0));
159 });
160 ok(rootEl.querySelectorAll(".usage-stats__tip-breakdown .usage-stats__tip-row--other").length === 5, "donut tooltip caps the visible Other breakdown");
161 ok(rootEl.querySelector(".usage-stats__tip-breakdown .usage-stats__tip-more") !== null, "donut tooltip discloses omitted Other models");
162 await act(async () => {
163 modelSegments[modelSegments.length - 1]?.dispatchEvent(new dom.window.MouseEvent("mouseout", { bubbles: true }));
164 rootEl.querySelector("rect.usage-stats__bar-hit")?.dispatchEvent(new dom.window.MouseEvent("mouseover", { bubbles: true, clientX: 120, clientY: 120 }));
165 await new Promise((resolve) => setTimeout(resolve, 0));
166 });
167 const trendTip = rootEl.querySelector(".usage-stats__tip--screen");
168 ok(trendTip?.querySelectorAll(".usage-stats__tip-row--other").length === 5, "daily tooltip caps the visible Other breakdown");
169 ok(trendTip?.querySelector(".usage-stats__tip-more") !== null, "daily tooltip discloses omitted Other models");
170
171 const cliButton = [...rootEl.querySelectorAll("button")].find((button) => button.textContent === "CLI");
172 if (!cliButton) throw new Error("missing CLI source button");
173 await act(async () => {
174 cliButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
175 await new Promise((resolve) => setTimeout(resolve, 0));
176 });
177 ok(rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 0, "source switch clears previous heat data while loading");
178
179 await act(async () => {
180 rejectCLIHeat?.(new Error("expected test failure"));
181 rejectCLIMain?.(new Error("expected main test failure"));
182 await new Promise((resolve) => setTimeout(resolve, 0));
183 });
184 ok(rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 0, "failed current-source fetch cannot revive previous heat data");
185 ok(rootEl.querySelectorAll("circle.usage-stats__donut-seg").length === 0, "failed current-source fetch cannot retain previous main stats");
186
187 const desktopButton = [...rootEl.querySelectorAll("button")].find((button) => button.textContent === "Desktop");
188 if (!desktopButton) throw new Error("missing Desktop source button");
189 await act(async () => {
190 desktopButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
191 await new Promise((resolve) => setTimeout(resolve, 0));
192 });
193 const customButton = [...rootEl.querySelectorAll("button")].find((button) => button.textContent === "Custom");
194 if (!customButton) throw new Error("missing Custom range button");
195 await act(async () => {
196 customButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
197 await new Promise((resolve) => setTimeout(resolve, 0));
198 });
199 await act(async () => {
200 resolveDesktopMain?.(emptyStats({ tokens: 777 }));
201 await new Promise((resolve) => setTimeout(resolve, 0));
202 });
203 ok(!rootEl.textContent?.includes("777"), "incomplete custom range invalidates an older main response");
204
205 await act(async () => root.unmount());
206 dom.window.close();
207 if (failed > 0) process.exit(1);
208
208 lines Plain Text