返回 DeepSeek-Reasonix
context-panel-breakdown.test.ts
根目录 / desktop / frontend / src / __tests__ / context-panel-breakdown.test.ts
1 // Run: tsx src/__tests__/context-panel-breakdown.test.ts
2
3 import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens, liveTurnUsageBreakdown } from "../components/ContextPanel";
4 import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
5 import type { WireUsage } from "../lib/types";
6
7 let passed = 0;
8 let failed = 0;
9
10 function eq(a: unknown, b: unknown, label: string) {
11 if (JSON.stringify(a) === JSON.stringify(b)) {
12 process.stdout.write(` PASS ${label}\n`);
13 passed += 1;
14 } else {
15 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
16 failed += 1;
17 }
18 }
19
20 function ok(condition: boolean, label: string) {
21 if (condition) {
22 process.stdout.write(` PASS ${label}\n`);
23 passed += 1;
24 } else {
25 process.stdout.write(` FAIL ${label}\n`);
26 failed += 1;
27 }
28 }
29
30 console.log("\ncontext panel breakdown");
31
32 const mock = contextBreakdown(42_124, 128_000, 22_134, 12_345, 7_521);
33 eq(
34 {
35 promptTokens: mock.promptTokens,
36 completionTokens: mock.completionTokens,
37 reasoningTokens: mock.reasoningTokens,
38 otherTokens: mock.otherTokens,
39 },
40 {
41 promptTokens: 22_134,
42 completionTokens: 4_824,
43 reasoningTokens: 7_521,
44 otherTokens: 7_645,
45 },
46 "reasoning is split out of completion rather than double-counted",
47 );
48 eq(
49 mock.promptTokens + mock.completionTokens + mock.reasoningTokens + mock.otherTokens,
50 42_124,
51 "legend values sum to used context tokens",
52 );
53 eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
54
55 const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
56 eq(
57 {
58 promptTokens: issue5283.promptTokens,
59 completionTokens: issue5283.completionTokens,
60 reasoningTokens: issue5283.reasoningTokens,
61 otherTokens: issue5283.otherTokens,
62 },
63 {
64 promptTokens: 6840,
65 completionTokens: 0,
66 reasoningTokens: 48,
67 otherTokens: 0,
68 },
69 "prompt tokens are not scaled down when used context includes completion tokens",
70 );
71
72 const oversized = contextBreakdown(61_000, 1_000_000, 1_622_277, 12_049, 3_217);
73 eq(
74 oversized.promptTokens + oversized.completionTokens + oversized.reasoningTokens + oversized.otherTokens,
75 61_000,
76 "oversized provider breakdown is normalized to used context tokens",
77 );
78 eq(Math.round(oversized.otherPct * 10) / 10, 6.1, "oversized provider breakdown does not fill the ring");
79
80 // Multi-attempt stream recovery: billable aggregates vs latest Context* shape.
81 // Ring uses used=30002; panel breakdown must not show 60000/5 from aggregates.
82 const multiAttemptUsage = {
83 promptTokens: 60_000,
84 completionTokens: 5,
85 totalTokens: 60_005,
86 cacheHitTokens: 0,
87 cacheMissTokens: 60_000,
88 reasoningTokens: 3,
89 sessionCacheHitTokens: 0,
90 sessionCacheMissTokens: 0,
91 contextPromptTokens: 30_000,
92 contextCompletionTokens: 2,
93 contextReasoningTokens: 1,
94 } as WireUsage;
95 const multiAttemptTurn = liveTurnUsageBreakdown(multiAttemptUsage, {
96 promptTokens: 30_000,
97 completionTokens: 2,
98 reasoningTokens: 1,
99 });
100 eq(
101 multiAttemptTurn,
102 { promptTokens: 30_000, completionTokens: 2, reasoningTokens: 1 },
103 "live turn breakdown prefers Context* over billable aggregates",
104 );
105 const multiAttemptRing = contextBreakdown(
106 30_002,
107 200_000,
108 multiAttemptTurn.promptTokens,
109 multiAttemptTurn.completionTokens,
110 multiAttemptTurn.reasoningTokens,
111 );
112 eq(
113 {
114 promptTokens: multiAttemptRing.promptTokens,
115 completionTokens: multiAttemptRing.completionTokens,
116 reasoningTokens: multiAttemptRing.reasoningTokens,
117 },
118 { promptTokens: 30_000, completionTokens: 1, reasoningTokens: 1 },
119 "2×30K recovery: panel segments match latest attempt, not 60000/5",
120 );
121 const legacyLive = liveTurnUsageBreakdown(
122 {
123 promptTokens: 100,
124 completionTokens: 20,
125 totalTokens: 120,
126 cacheHitTokens: 0,
127 cacheMissTokens: 100,
128 reasoningTokens: 8,
129 sessionCacheHitTokens: 0,
130 sessionCacheMissTokens: 0,
131 } as WireUsage,
132 null,
133 );
134 eq(
135 legacyLive,
136 { promptTokens: 100, completionTokens: 20, reasoningTokens: 8 },
137 "legacy usage without Context* falls back to billable fields",
138 );
139 const rebindFallback = liveTurnUsageBreakdown(null, {
140 promptTokens: 30_000,
141 completionTokens: 2,
142 reasoningTokens: 1,
143 });
144 eq(
145 rebindFallback,
146 { promptTokens: 30_000, completionTokens: 2, reasoningTokens: 1 },
147 "without live usage, panel uses backend rebind snapshot",
148 );
149
150 const unknownWindow = contextBreakdown(42_124, 0, 22_134, 12_345, 7_521);
151 eq(
152 {
153 promptPct: unknownWindow.promptPct,
154 completionPct: unknownWindow.completionPct,
155 reasoningPct: unknownWindow.reasoningPct,
156 otherPct: unknownWindow.otherPct,
157 },
158 {
159 promptPct: 0,
160 completionPct: 0,
161 reasoningPct: 0,
162 otherPct: 0,
163 },
164 "unknown context window keeps usage segments empty",
165 );
166
167 console.log("\ncontext window status");
168
169 eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
170 eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
171 eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
172 eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
173
174 console.log("\ncontext panel cost");
175
176 const infoCost = contextCostDisplay({
177 info: { sessionCost: 0.1759, sessionCurrency: "$", sessionCostUsd: 0.1759 },
178 sessionCost: 0,
179 sessionCurrency: "¥",
180 usage: { cost: 0, costUsd: 0, currency: "¥" },
181 });
182 eq(infoCost, { amount: 0.1759, currency: "$" }, "panel cost keeps the panel currency instead of state default");
183 const singleRequestOnly = contextCostDisplay({
184 info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
185 sessionCost: 0,
186 sessionCurrency: "¥",
187 usage: { cost: 0.42, costUsd: 0.42, currency: "¥" },
188 });
189 eq(
190 singleRequestOnly,
191 { amount: 0, currency: "¥" },
192 "a single request's cost never renders under the session-cost label",
193 );
194 const localAccumulated = contextCostDisplay({
195 info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
196 sessionCost: 1.5,
197 sessionCurrency: "¥",
198 usage: { cost: 0.42, costUsd: 0.42, currency: "$" },
199 });
200 eq(localAccumulated, { amount: 1.5, currency: "¥" }, "locally accumulated session cost still renders");
201
202 console.log("\ncontext panel session cache scope");
203
204 eq(
205 contextSessionCache(
206 { sessionCacheHitTokens: 900, sessionCacheMissTokens: 100 },
207 { cacheHitTokens: 800, cacheMissTokens: 200 },
208 { sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
209 ),
210 { hit: 800, miss: 200 },
211 "live shared ContextInfo beats a stale all-sources panel snapshot",
212 );
213 eq(
214 contextSessionCache(
215 { sessionCacheHitTokens: 900, sessionCacheMissTokens: 100 },
216 { cacheHitTokens: 0, cacheMissTokens: 0 },
217 { sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
218 ),
219 { hit: 900, miss: 100 },
220 "panel telemetry remains the all-sources fallback without live ContextInfo",
221 );
222 eq(
223 contextSessionCache(
224 { sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
225 { cacheHitTokens: 0, cacheMissTokens: 0 },
226 { sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
227 ),
228 { hit: 700, miss: 300 },
229 "executor-only wire counters only bridge the pre-refresh gap",
230 );
231 eq(
232 contextSessionCache(null, undefined, undefined),
233 { hit: 0, miss: 0 },
234 "no data renders as empty, not NaN",
235 );
236 eq(formatMoney(infoCost.amount, infoCost.currency, "dash"), "$0.1759", "USD panel cost renders with dollar sign");
237 eq(currencySymbol("楼"), "¥", "unexpected currency text does not leak into money values");
238 eq(currencySymbol("aud"), "AUD ", "unknown ISO currency codes stay readable");
239 eq(currencySymbol("A$"), "A$", "compact multi-character currency symbols are preserved");
240 const usdLocalized = formatMoneyLocalized(0.1759, "USD", { locale: "en" });
241 ok(/\$|USD|US\$/.test(usdLocalized) && usdLocalized.includes("0.1759"), "ISO USD cost renders with locale-aware currency formatting");
242 const cnyLocalized = formatMoneyLocalized(12.3, "CNY", { locale: "zh" });
243 ok(/¥|CNY|CN¥/.test(cnyLocalized) && cnyLocalized.includes("12.30"), "ISO CNY cost renders with locale-aware currency formatting");
244 eq(formatMoneyLocalized(0.1759, "A$", { locale: "en" }), "A$0.1759", "symbol currency remains symbol-based");
245 eq(formatMoneyLocalized(0, "USD", { locale: "en", empty: "dash" }), "-", "localized money preserves dash empty state");
246
247 console.log("\ncontext panel cache rate");
248
249 eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
250 eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
251 eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
252 eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
253 eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
254 eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
255 eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
256
257 console.log("\ncontext panel usage refresh key");
258
259 eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
260 ok(
261 contextUsageRefreshKey({
262 totalTokens: 10,
263 promptTokens: 10,
264 completionTokens: 0,
265 reasoningTokens: 0,
266 sessionCacheHitTokens: 0,
267 sessionCacheMissTokens: 0,
268 }) !== contextUsageRefreshKey({
269 totalTokens: 11,
270 promptTokens: 10,
271 completionTokens: 1,
272 reasoningTokens: 0,
273 sessionCacheHitTokens: 0,
274 sessionCacheMissTokens: 0,
275 }),
276 "general token changes refresh even when cache counters stay unchanged",
277 );
278
279 console.log("\ncontext panel source rows");
280
281 const sourceRows = contextSourceRows({
282 usedTokens: 0,
283 windowTokens: 0,
284 promptTokens: 0,
285 completionTokens: 0,
286 totalTokens: 0,
287 reasoningTokens: 0,
288 cacheHitTokens: 0,
289 cacheMissTokens: 0,
290 sessionCacheHitTokens: 0,
291 sessionCacheMissTokens: 0,
292 sessionCompletionTokens: 0,
293 readFiles: [],
294 changedFiles: [],
295 sources: {
296 planner: {
297 promptTokens: 200,
298 completionTokens: 20,
299 totalTokens: 220,
300 reasoningTokens: 0,
301 cacheHitTokens: 0,
302 cacheMissTokens: 0,
303 requestCount: 1,
304 },
305 executor: {
306 promptTokens: 1000,
307 completionTokens: 120,
308 totalTokens: 1120,
309 reasoningTokens: 0,
310 cacheHitTokens: 700,
311 cacheMissTokens: 300,
312 requestCount: 2,
313 sessionCost: 0.42,
314 sessionCurrency: "¥",
315 estimated: true,
316 },
317 },
318 }, "¥");
319
320 eq(sourceRows.map((row) => row.source), ["executor", "planner"], "source rows keep executor before planner");
321 eq(
322 {
323 input: sourceRows[0].promptTokens,
324 output: sourceRows[0].completionTokens,
325 hit: sourceRows[0].cacheHitTokens,
326 miss: sourceRows[0].cacheMissTokens,
327 requests: sourceRows[0].requests,
328 },
329 { input: 1000, output: 120, hit: 700, miss: 300, requests: 2 },
330 "executor source row exposes input, output, cache hit, cache miss, and request count",
331 );
332 eq(sourceRows[1].requests, 1, "planner source row remains visible without cache metadata");
333 eq(sourceRows[0].estimated, true, "executor source row preserves estimated usage metadata");
334 eq(sourceRows[1].estimated, false, "missing estimated metadata remains exact for backward compatibility");
335 eq(sourceRows[1].cacheHitTokens + sourceRows[1].cacheMissTokens, 0, "planner source preserves absent cache metadata as empty");
336
337 const executorOnlyRows = contextSourceRows({
338 usedTokens: 0,
339 windowTokens: 0,
340 promptTokens: 0,
341 completionTokens: 0,
342 totalTokens: 0,
343 reasoningTokens: 0,
344 cacheHitTokens: 0,
345 cacheMissTokens: 0,
346 sessionCacheHitTokens: 0,
347 sessionCacheMissTokens: 0,
348 sessionCompletionTokens: 0,
349 readFiles: [],
350 changedFiles: [],
351 sources: {
352 planner: {
353 promptTokens: 0,
354 completionTokens: 0,
355 totalTokens: 0,
356 reasoningTokens: 0,
357 cacheHitTokens: 0,
358 cacheMissTokens: 0,
359 requestCount: 0,
360 },
361 executor: {
362 promptTokens: 4000,
363 completionTokens: 800,
364 totalTokens: 4800,
365 reasoningTokens: 0,
366 cacheHitTokens: 2500,
367 cacheMissTokens: 500,
368 requestCount: 3,
369 },
370 subagent: {
371 promptTokens: 0,
372 completionTokens: 0,
373 totalTokens: 0,
374 reasoningTokens: 0,
375 cacheHitTokens: 0,
376 cacheMissTokens: 0,
377 requestCount: 0,
378 },
379 },
380 }, "¥");
381 eq(executorOnlyRows.map((row) => row.source), ["executor"], "source rows omit unused planner and subagent entries");
382
383 console.log("\ncontext panel metric token labels");
384
385 const exactMetric = formatMetricTokens(999_999, "en");
386 eq(exactMetric.display, "999,999", "sub-million metric tokens keep exact comma formatting");
387 eq(exactMetric.exact, "999,999", "sub-million exact metric title matches the display");
388
389 const largeMetric = formatMetricTokens(123_456_789, "en");
390 eq(largeMetric.display, "123,456,789", "large metric tokens keep exact comma formatting");
391 eq(largeMetric.exact, "123,456,789", "large metric exact title matches the display");
392
393 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
394 if (failed > 0) process.exit(1);
395
395 lines TYPESCRIPT