返回 DeepSeek-Reasonix
context-window-ring.test.tsx
根目录 / desktop / frontend / src / __tests__ / context-window-ring.test.tsx
1 // Run: tsx src/__tests__/context-window-ring.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { ContextWindowRing } from "../components/ContextWindowRing";
8 import { LocaleProvider } from "../lib/i18n";
9 import type { ContextPanelInfo } from "../lib/types";
10 import { installDesktopHostStub } from "./desktopHostStub";
11
12 let passed = 0;
13 let failed = 0;
14
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 function eq(actual: unknown, expected: unknown, label: string) {
26 if (actual === expected) ok(true, label);
27 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
28 }
29
30 function wait(ms = 0): Promise<void> {
31 return new Promise((resolve) => setTimeout(resolve, ms));
32 }
33
34 class TestResizeObserver {
35 observe() {}
36 unobserve() {}
37 disconnect() {}
38 }
39
40 function installDom() {
41 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
42 pretendToBeVisual: true,
43 url: "http://localhost/",
44 });
45 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
46 globalThis.window = dom.window as unknown as Window & typeof globalThis;
47 globalThis.document = dom.window.document;
48 globalThis.Node = dom.window.Node;
49 globalThis.HTMLElement = dom.window.HTMLElement;
50 globalThis.Event = dom.window.Event;
51 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
52 globalThis.MouseEvent = dom.window.MouseEvent;
53 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
54 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
55 globalThis.ResizeObserver = TestResizeObserver;
56 Object.defineProperty(window, "matchMedia", {
57 configurable: true,
58 value: () => ({
59 matches: true,
60 media: "(prefers-reduced-motion: reduce)",
61 onchange: null,
62 addEventListener() {},
63 removeEventListener() {},
64 addListener() {},
65 removeListener() {},
66 dispatchEvent: () => false,
67 }),
68 });
69 return dom;
70 }
71
72 function contextPanelInfo(requestCount: number): ContextPanelInfo {
73 return {
74 usedTokens: 0,
75 windowTokens: 0,
76 promptTokens: 0,
77 completionTokens: 0,
78 totalTokens: 0,
79 reasoningTokens: 0,
80 cacheHitTokens: 0,
81 cacheMissTokens: 0,
82 sessionCacheHitTokens: 0,
83 sessionCacheMissTokens: 0,
84 sessionCompletionTokens: 0,
85 requestCount,
86 elapsedMs: 0,
87 sessionCost: 0,
88 sessionCurrency: "",
89 readFiles: [],
90 changedFiles: [],
91 };
92 }
93
94 function installContextPanelMock(fn: (tabId: string) => Promise<ContextPanelInfo>) {
95 installDesktopHostStub(({
96 main: {
97 App: {
98 ContextPanel: fn,
99 },
100 },
101 }).main.App);
102 }
103
104 async function checkSeparateDurations() {
105 const dom = installDom();
106 installContextPanelMock(async () => ({ ...contextPanelInfo(12), elapsedMs: 120_000 }));
107 const { root } = await renderRing({ turnMetrics: { elapsed: "20s", tokens: "104 tokens", tps: "12 tokens/s" } });
108 await act(async () => {
109 (document.querySelector(".context-ring") as HTMLButtonElement).click();
110 await wait();
111 });
112 const rows = [...document.querySelectorAll(".context-ring-popover__row")];
113 const value = (label: string) => rows.find(row => row.querySelector(".context-ring-popover__label")?.textContent === label)
114 ?.querySelector(".context-ring-popover__value")?.textContent;
115 eq(value("Turn time"), "20s", "turn duration has its own stable label");
116 ok(Boolean(value("Session time")) && value("Session time") !== "20s", "session duration stays separate from turn duration");
117 await act(async () => { root.unmount(); });
118 dom.window.close();
119 }
120
121 async function renderRing(props: Partial<Parameters<typeof ContextWindowRing>[0]> = {}) {
122 const rootEl = document.getElementById("root");
123 if (!rootEl) throw new Error("missing root");
124 const root = createRoot(rootEl);
125 let currentProps: Parameters<typeof ContextWindowRing>[0] = {
126 enabled: true,
127 tabId: "tab-a",
128 context: { used: 10, window: 100, compactRatio: 0.8 },
129 ...props,
130 };
131 const paint = async (nextProps: Partial<Parameters<typeof ContextWindowRing>[0]> = {}) => {
132 currentProps = { ...currentProps, ...nextProps };
133 await act(async () => {
134 root.render(
135 <LocaleProvider>
136 <ContextWindowRing {...currentProps} />
137 </LocaleProvider>,
138 );
139 await wait();
140 });
141 };
142 await paint();
143 return { root, rerender: paint };
144 }
145
146 console.log("\ncontext window ring");
147
148 {
149 const dom = installDom();
150 installContextPanelMock(async () => contextPanelInfo(2));
151 const { root } = await renderRing();
152 const trigger = document.querySelector<HTMLButtonElement>(".context-ring")!;
153 eq(trigger.textContent, "10%", "ring exposes its percentage outside Creation layout");
154 await act(async () => { trigger.focus(); trigger.click(); await wait(); });
155 eq(trigger.getAttribute("aria-expanded"), "true", "keyboard activation opens usage details");
156 await act(async () => {
157 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
158 await wait(230);
159 });
160 eq(trigger.getAttribute("aria-expanded"), "false", "Escape cancels pending hover timers without reopening");
161 await act(async () => { root.unmount(); });
162 dom.window.close();
163 }
164
165 {
166 const dom = installDom();
167 const calls: string[] = [];
168 installContextPanelMock(async (tabId) => {
169 calls.push(tabId);
170 return contextPanelInfo(1);
171 });
172
173 const { root } = await renderRing({ enabled: false });
174
175 eq(document.querySelector(".context-ring"), null, "disabled ring renders nothing");
176 eq(calls.length, 0, "disabled ring does not request context panel data");
177
178 await act(async () => {
179 root.unmount();
180 });
181 dom.window.close();
182 }
183
184 {
185 const dom = installDom();
186 installContextPanelMock(async () => contextPanelInfo(0));
187
188 const { root } = await renderRing({ turnCost: 0.125, currency: "$" });
189 const button = document.querySelector(".context-ring") as HTMLButtonElement | null;
190 if (!button) throw new Error("missing context ring button");
191 await act(async () => {
192 button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
193 await wait(220);
194 });
195 const turnCostRow = [...document.querySelectorAll(".context-ring-popover__row")]
196 .find((row) => row.querySelector(".context-ring-popover__label")?.textContent === "turn cost");
197 eq(
198 turnCostRow?.querySelector(".context-ring-popover__value")?.textContent,
199 "$0.1250",
200 "turn cost uses the session currency before panel info is available",
201 );
202
203 await act(async () => {
204 root.unmount();
205 });
206 dom.window.close();
207 }
208
209 {
210 const dom = installDom();
211 installContextPanelMock(async () => contextPanelInfo(0));
212
213 const { root } = await renderRing({ context: { used: 1_001, window: 1_000, compactRatio: 0.8 } });
214 const button = document.querySelector(".context-ring") as HTMLButtonElement | null;
215 if (!button) throw new Error("missing over-limit context ring button");
216 await act(async () => {
217 button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
218 await wait(220);
219 });
220
221 const popover = document.querySelector(".context-ring-popover");
222 const fill = popover?.querySelector(".context-ring-popover__fill") as HTMLElement | null;
223 eq(popover?.querySelector(".context-ring-popover__pct")?.textContent, "101%", "ring popover keeps a just-over-limit ratio visibly above 100 percent");
224 eq(fill?.style.width, "100%", "ring popover fill is capped at the physical track width");
225 eq(popover?.querySelectorAll(".context-ring-popover__seg").length, 0, "ring popover does not mix token composition into its capacity fill");
226
227 await act(async () => {
228 root.unmount();
229 });
230 dom.window.close();
231 }
232
233 {
234 const dom = installDom();
235 const calls: string[] = [];
236 const resolvers = new Map<string, (value: ContextPanelInfo) => void>();
237 installContextPanelMock((tabId) => {
238 calls.push(tabId);
239 return new Promise<ContextPanelInfo>((resolve) => {
240 resolvers.set(tabId, resolve);
241 });
242 });
243
244 const { root, rerender } = await renderRing({ tabId: "old-tab" });
245 const button = document.querySelector(".context-ring") as HTMLButtonElement | null;
246 if (!button) throw new Error("missing context ring button");
247 await act(async () => {
248 button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
249 await wait();
250 });
251
252 await rerender({ tabId: "new-tab" });
253 const nextButton = document.querySelector(".context-ring") as HTMLButtonElement | null;
254 if (!nextButton) throw new Error("missing context ring button after tab switch");
255 await act(async () => {
256 nextButton.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
257 await wait();
258 });
259
260 await act(async () => {
261 resolvers.get("new-tab")?.(contextPanelInfo(2));
262 await wait();
263 });
264 await act(async () => {
265 resolvers.get("old-tab")?.(contextPanelInfo(1));
266 await wait();
267 });
268 await act(async () => {
269 nextButton.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
270 await wait(220);
271 });
272
273 eq(calls[0], "old-tab", "old tab request starts first");
274 eq(calls[1], "new-tab", "new tab request starts after tab switch");
275 const requestRow = [...document.querySelectorAll(".context-ring-popover__row")]
276 .find((row) => row.querySelector(".context-ring-popover__label")?.textContent === "Requests");
277 eq(
278 requestRow?.querySelector(".context-ring-popover__value")?.textContent,
279 "2",
280 "stale old-tab response cannot overwrite the new tab info",
281 );
282
283 await act(async () => {
284 root.unmount();
285 });
286 dom.window.close();
287 }
288
289 await checkSeparateDurations();
290 console.log(`\n${passed} passed, ${failed} failed`);
291 if (failed > 0) process.exit(1);
292
292 lines Plain Text