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