From d225e0e4f066f05adeb2c92e62bf97dca88be484 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:13:31 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(shared):=20notificationIntents/context?= =?UTF-8?q?/breakdown/toastStore=20=E8=A1=A5=20113=20=E4=B8=AA=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=88Lane=20D=20#1764=20?= =?UTF-8?q?=E7=AC=AC=E5=9B=9B=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notificationIntents.ts:+51(intent 归一化/别名/必填校验/导航映射全分支) - context/breakdown.ts:+43(estimateTokens/breakdownContext/toSegments/formatTokens/formatCost) - ui/toast/toastStore.ts:11→30(补 pause/resume/边界,合并 __tests__ 消除重复文件) 不改任何产品代码。Lane D #1764 Co-authored-by: Cursor --- app/shared/src/context/breakdown.test.ts | 437 ++++++++++++++++ app/shared/src/notificationIntents.test.ts | 492 ++++++++++++++++++ .../src/ui/toast/__tests__/toastStore.test.ts | 482 ++++++++++++++--- 3 files changed, 1341 insertions(+), 70 deletions(-) create mode 100644 app/shared/src/context/breakdown.test.ts create mode 100644 app/shared/src/notificationIntents.test.ts diff --git a/app/shared/src/context/breakdown.test.ts b/app/shared/src/context/breakdown.test.ts new file mode 100644 index 000000000..f788cc600 --- /dev/null +++ b/app/shared/src/context/breakdown.test.ts @@ -0,0 +1,437 @@ +// real_tested=true +import { describe, it, expect } from 'vitest'; +import { + breakdownContext, + estimateTokens, + formatCost, + formatTokens, + toSegments, + type ContextBreakdown, + type SessionMetrics, +} from './breakdown'; + +// ── estimateTokens ────────────────────────────── + +describe('estimateTokens', () => { + it('uses the chars/4 formula for exact multiples', () => { + expect(estimateTokens(0)).toBe(0); + expect(estimateTokens(4)).toBe(1); + expect(estimateTokens(400)).toBe(100); + }); + + it('rounds fractional tokens up to the next whole token', () => { + expect(estimateTokens(1)).toBe(1); + expect(estimateTokens(5)).toBe(2); + }); + + it('handles non-integer character counts', () => { + // ceil(1.5 / 4) = ceil(0.375) = 1 + expect(estimateTokens(1.5)).toBe(1); + }); + + it('handles large character counts', () => { + expect(estimateTokens(10_000)).toBe(2500); + }); + + it('returns -0 for small negative counts via ceil', () => { + // Math.ceil(-0.25) === -0; the function has no negative guard and + // Vitest toBe uses Object.is, so assert -0 explicitly. + expect(Object.is(estimateTokens(-1), -0)).toBe(true); + }); +}); + +// ── breakdownContext ──────────────────────────── + +describe('breakdownContext', () => { + it('returns an all-zero breakdown when messages is empty', () => { + const result = breakdownContext([], 1000); + expect(result).toEqual({ + system: 0, + user: 0, + assistant: 0, + tool: 0, + other: 0, + total: 0, + }); + }); + + it('returns an all-zero breakdown when totalInputTokens is 0', () => { + const result = breakdownContext([{ role: 'user', content: 'hello world' }], 0); + expect(result).toEqual({ + system: 0, + user: 0, + assistant: 0, + tool: 0, + other: 0, + total: 0, + }); + }); + + it('buckets chars by role using chars/4 and fills other up to the total', () => { + const messages = [ + { role: 'system', content: 's'.repeat(40) }, // ceil(40/4) = 10 + { role: 'user', content: 'u'.repeat(4) }, // ceil(4/4) = 1 + { role: 'assistant', content: 'a'.repeat(16) }, // ceil(16/4) = 4 + { role: 'tool', content: 't'.repeat(2) }, // ceil(2/4) = 1 + ]; + // estimated = 16, total = 25 -> other = 9 + const result = breakdownContext(messages, 25); + expect(result).toEqual({ + system: 10, + user: 1, + assistant: 4, + tool: 1, + other: 9, + total: 25, + }); + }); + + it('sets other to 0 when the estimate matches the total exactly', () => { + const messages = [ + { role: 'user', content: 'a'.repeat(100) }, // 25 est + { role: 'assistant', content: 'b'.repeat(200) }, // 50 est + ]; + const result = breakdownContext(messages, 75); + expect(result).toEqual({ + system: 0, + user: 25, + assistant: 50, + tool: 0, + other: 0, + total: 75, + }); + }); + + it('assigns the residual to other when the estimate is under the total', () => { + const messages = [ + { role: 'user', content: 'a'.repeat(100) }, // 25 est + { role: 'assistant', content: 'b'.repeat(200) }, // 50 est + ]; + const result = breakdownContext(messages, 100); + expect(result).toEqual({ + system: 0, + user: 25, + assistant: 50, + tool: 0, + other: 25, + total: 100, + }); + }); + + it('scales buckets proportionally when the estimate exceeds the total', () => { + const messages = [ + { role: 'user', content: 'a'.repeat(400) }, // 100 est + { role: 'assistant', content: 'b'.repeat(400) }, // 100 est + ]; + // scale = 100 / 200 = 0.5 -> floor(100 * 0.5) = 50 each + const result = breakdownContext(messages, 100); + expect(result).toEqual({ + system: 0, + user: 50, + assistant: 50, + tool: 0, + other: 0, + total: 100, + }); + }); + + it('puts the rounding loss from floor-scaling into other', () => { + const messages = [ + { role: 'user', content: 'a'.repeat(100) }, // 25 est + { role: 'assistant', content: 'b'.repeat(100) }, // 25 est + ]; + // scale = 33 / 50 = 0.66 -> floor(25 * 0.66) = 16 each, remainder 1 + const result = breakdownContext(messages, 33); + expect(result).toEqual({ + system: 0, + user: 16, + assistant: 16, + tool: 0, + other: 1, + total: 33, + }); + }); + + it('can floor a small bucket to zero while scaling', () => { + const messages = [ + { role: 'user', content: 'a'.repeat(4) }, // 1 est + { role: 'assistant', content: 'b'.repeat(16) }, // 4 est -> estimated 5 + ]; + // scale = 3 / 5 = 0.6 -> floor(1 * 0.6) = 0, floor(4 * 0.6) = 2 + const result = breakdownContext(messages, 3); + expect(result).toEqual({ + system: 0, + user: 0, + assistant: 2, + tool: 0, + other: 1, + total: 3, + }); + }); + + it('treats null content as an empty string', () => { + const messages = [ + { role: 'user', content: null as unknown as string }, + { role: 'assistant', content: 'hello' }, // ceil(5/4) = 2 + ]; + const result = breakdownContext(messages, 100); + expect(result).toEqual({ + system: 0, + user: 0, + assistant: 2, + tool: 0, + other: 98, + total: 100, + }); + }); + + it('treats undefined content as an empty string', () => { + const messages = [{ role: 'user', content: undefined as unknown as string }]; + const result = breakdownContext(messages, 50); + expect(result).toEqual({ + system: 0, + user: 0, + assistant: 0, + tool: 0, + other: 50, + total: 50, + }); + }); + + it('ignores unknown roles in buckets, leaving their weight in other under budget', () => { + const messages = [ + { role: 'function', content: 'f'.repeat(400) }, // unknown role: no bucket + { role: 'user', content: 'u'.repeat(100) }, // 25 est + ]; + // estimated = 25 <= 100 -> the residual 75 lands in other + const result = breakdownContext(messages, 100); + expect(result).toEqual({ + system: 0, + user: 25, + assistant: 0, + tool: 0, + other: 75, + total: 100, + }); + }); + + it('drops unknown-role chars entirely when scaling', () => { + const messages = [ + { role: 'function', content: 'f'.repeat(400) }, + { role: 'user', content: 'u'.repeat(100) }, // 25 est > 10 total + ]; + // scale = 10 / 25 = 0.4 -> floor(25 * 0.4) = 10, other = max(0, 0) + const result = breakdownContext(messages, 10); + expect(result).toEqual({ + system: 0, + user: 10, + assistant: 0, + tool: 0, + other: 0, + total: 10, + }); + }); + + it('scales with a negative factor for negative totals (documents current behavior)', () => { + const messages = [{ role: 'user', content: 'u'.repeat(100) }]; // 25 est + // scale = -5 / 25 = -0.2 -> floor(25 * -0.2) = -5, other clamps to 0 + // (the zero buckets become -0 via floor(0 * scale), so they are not + // asserted with toEqual, which distinguishes -0 from +0) + const result = breakdownContext(messages, -5); + expect(result.user).toBe(-5); + expect(result.other).toBe(0); + expect(result.total).toBe(-5); + }); + + it('accepts the SessionMetrics messages shape', () => { + const metrics: SessionMetrics = { + model: 'test-model', + provider: 'test-provider', + contextLimit: 200_000, + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + totalCost: 0.001, + messages: [{ role: 'user', content: 'a'.repeat(100) }], + }; + const result = breakdownContext(metrics.messages, metrics.inputTokens); + expect(result).toEqual({ + system: 0, + user: 25, + assistant: 0, + tool: 0, + other: 75, + total: 100, + }); + }); +}); + +// ── toSegments ────────────────────────────────── + +describe('toSegments', () => { + const emptyBreakdown: ContextBreakdown = { + system: 0, + user: 0, + assistant: 0, + tool: 0, + other: 0, + total: 0, + }; + + it('returns an empty array when total is zero', () => { + expect(toSegments(emptyBreakdown)).toEqual([]); + // Non-zero buckets are ignored whenever total is zero. + expect(toSegments({ ...emptyBreakdown, user: 5 })).toEqual([]); + }); + + it('returns an empty array when every bucket is zero despite a positive total', () => { + expect(toSegments({ ...emptyBreakdown, total: 100 })).toEqual([]); + }); + + it('filters zero-token buckets and keeps the fixed key order', () => { + const segments = toSegments({ + ...emptyBreakdown, + user: 50, + tool: 25, + other: 25, + total: 100, + }); + expect(segments.map((segment) => segment.key)).toEqual(['user', 'tool', 'other']); + }); + + it('computes width and percent for equal quarters', () => { + const segments = toSegments({ + system: 25, + user: 25, + assistant: 25, + tool: 25, + other: 0, + total: 100, + }); + expect(segments).toHaveLength(4); + for (const segment of segments) { + expect(segment.width).toBe(25); + expect(segment.percent).toBe(25); + } + }); + + it('rounds percent to one decimal while keeping full-precision width', () => { + const segments = toSegments({ ...emptyBreakdown, user: 2, other: 1, total: 3 }); + const userSegment = segments.find((segment) => segment.key === 'user'); + const otherSegment = segments.find((segment) => segment.key === 'other'); + + expect(userSegment?.width).toBeCloseTo(66.66666666666666, 6); + expect(userSegment?.percent).toBe(66.7); + expect(otherSegment?.width).toBeCloseTo(33.33333333333333, 6); + expect(otherSegment?.percent).toBe(33.3); + }); + + it('caps a single full bucket at width 100 and percent 100', () => { + const segments = toSegments({ ...emptyBreakdown, user: 100, total: 100 }); + expect(segments).toHaveLength(1); + expect(segments[0]?.key).toBe('user'); + expect(segments[0]?.tokens).toBe(100); + expect(segments[0]?.width).toBe(100); + expect(segments[0]?.percent).toBe(100); + }); + + it('emits a lone other segment when only other is populated', () => { + const segments = toSegments({ ...emptyBreakdown, other: 50, total: 50 }); + expect(segments).toHaveLength(1); + expect(segments[0]?.key).toBe('other'); + expect(segments[0]?.tokens).toBe(50); + expect(segments[0]?.width).toBe(100); + expect(segments[0]?.percent).toBe(100); + }); +}); + +// ── formatTokens ──────────────────────────────── + +describe('formatTokens', () => { + it('returns "0" for negative values', () => { + expect(formatTokens(-1)).toBe('0'); + expect(formatTokens(-0.5)).toBe('0'); + }); + + it('returns a plain string for values under 1000', () => { + expect(formatTokens(0)).toBe('0'); + expect(formatTokens(1)).toBe('1'); + expect(formatTokens(999)).toBe('999'); + }); + + it('returns a plain string for fractional values under 1000', () => { + expect(formatTokens(0.5)).toBe('0.5'); + expect(formatTokens(999.9)).toBe('999.9'); + }); + + it('formats thousands with one decimal and a K suffix', () => { + expect(formatTokens(1000)).toBe('1.0K'); + }); + + it('rounds K-tier values to one decimal', () => { + expect(formatTokens(1234)).toBe('1.2K'); + expect(formatTokens(1560)).toBe('1.6K'); + expect(formatTokens(9999)).toBe('10.0K'); + }); + + it('handles the upper K boundary just below one million', () => { + expect(formatTokens(999_999)).toBe('1000.0K'); + }); + + it('formats millions with one decimal and an M suffix', () => { + expect(formatTokens(1_000_000)).toBe('1.0M'); + expect(formatTokens(1_234_567)).toBe('1.2M'); + }); + + it('handles the upper M boundary just below one billion', () => { + expect(formatTokens(999_999_999)).toBe('1000.0M'); + }); + + it('formats billions with zero decimals and a B suffix', () => { + expect(formatTokens(1_000_000_000)).toBe('1B'); + expect(formatTokens(1_234_567_890)).toBe('1B'); + }); + + it('formats trillions with zero decimals and a T suffix', () => { + expect(formatTokens(1_000_000_000_000)).toBe('1T'); + }); + + it('clamps to the T suffix beyond the unit table', () => { + expect(formatTokens(1e15)).toBe('1000T'); + expect(formatTokens(1e18)).toBe('1000000T'); + }); +}); + +// ── formatCost ────────────────────────────────── + +describe('formatCost', () => { + it('formats zero with two decimals', () => { + expect(formatCost(0)).toBe('$0.00'); + }); + + it('returns "$0.00" for negative values', () => { + expect(formatCost(-1)).toBe('$0.00'); + expect(formatCost(-0.001)).toBe('$0.00'); + }); + + it('rounds small fractional costs down', () => { + expect(formatCost(0.0423)).toBe('$0.04'); + expect(formatCost(0.004)).toBe('$0.00'); + }); + + it('rounds small fractional costs up', () => { + expect(formatCost(0.046)).toBe('$0.05'); + expect(formatCost(0.006)).toBe('$0.01'); + }); + + it('formats whole dollars with cents', () => { + expect(formatCost(1.5)).toBe('$1.50'); + expect(formatCost(10)).toBe('$10.00'); + }); + + it('rounds large values to two decimals', () => { + expect(formatCost(1.239)).toBe('$1.24'); + expect(formatCost(1.234)).toBe('$1.23'); + expect(formatCost(1_234_567.891)).toBe('$1234567.89'); + }); +}); diff --git a/app/shared/src/notificationIntents.test.ts b/app/shared/src/notificationIntents.test.ts new file mode 100644 index 000000000..809f679ed --- /dev/null +++ b/app/shared/src/notificationIntents.test.ts @@ -0,0 +1,492 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; +import { + mapNotificationIntentToNavigationTarget, + parseNotificationIntent, + parseNotificationIntentPayload, +} from './notificationIntents'; + +describe('parseNotificationIntentPayload', () => { + describe('nullish / invalid data', () => { + it('ignores null as missing_data', () => { + expect(parseNotificationIntentPayload(null)).toEqual({ + kind: 'ignore', + reason: 'missing_data', + }); + }); + + it('ignores undefined as missing_data', () => { + expect(parseNotificationIntentPayload(undefined)).toEqual({ + kind: 'ignore', + reason: 'missing_data', + }); + }); + + it('rejects strings as invalid_data', () => { + expect(parseNotificationIntentPayload('thread')).toEqual({ + kind: 'error', + reason: 'invalid_data', + }); + }); + + it('rejects numbers as invalid_data', () => { + expect(parseNotificationIntentPayload(42)).toEqual({ + kind: 'error', + reason: 'invalid_data', + }); + }); + + it('rejects booleans as invalid_data', () => { + expect(parseNotificationIntentPayload(true)).toEqual({ + kind: 'error', + reason: 'invalid_data', + }); + }); + + it('rejects arrays as invalid_data', () => { + expect(parseNotificationIntentPayload([{ intent: 'thread' }])).toEqual({ + kind: 'error', + reason: 'invalid_data', + }); + }); + }); + + describe('intent detection', () => { + it('ignores an empty record as unknown_intent', () => { + expect(parseNotificationIntentPayload({})).toEqual({ + kind: 'ignore', + reason: 'unknown_intent', + }); + }); + + it('ignores records whose intent value is not a recognized string', () => { + expect(parseNotificationIntentPayload({ intent: 'message' })).toEqual({ + kind: 'ignore', + reason: 'unknown_intent', + }); + }); + + it('ignores a non-string intent value', () => { + expect(parseNotificationIntentPayload({ intent: 42 })).toEqual({ + kind: 'ignore', + reason: 'unknown_intent', + }); + }); + + it('normalizes intent case-insensitively', () => { + expect(parseNotificationIntentPayload({ intent: 'Thread', threadId: 't-1' })).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + }); + + it('trims whitespace around the intent value', () => { + expect(parseNotificationIntentPayload({ intent: ' thread ', threadId: 't-1' })).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + }); + + it('ignores an empty-string intent value', () => { + expect(parseNotificationIntentPayload({ intent: '', threadId: 't-1' })).toEqual({ + kind: 'ignore', + reason: 'unknown_intent', + }); + }); + + it('reads the intent from any of the fallback fields', () => { + expect(parseNotificationIntentPayload({ type: 'thread', threadId: 't-1' })).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + expect(parseNotificationIntentPayload({ kind: 'run', runId: 'r-1' })).toEqual({ + kind: 'payload', + value: { kind: 'run', runId: 'r-1' }, + }); + expect(parseNotificationIntentPayload({ screen: 'approval', approvalId: 'a-1' })).toEqual({ + kind: 'payload', + value: { kind: 'approval', approvalId: 'a-1' }, + }); + expect(parseNotificationIntentPayload({ target: 'activity' })).toEqual({ + kind: 'payload', + value: { kind: 'activity' }, + }); + }); + + it('prefers the earlier intent field when several are present', () => { + expect( + parseNotificationIntentPayload({ + intent: 'thread', + type: 'run', + kind: 'approval', + screen: 'activity', + threadId: 't-1', + }), + ).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + }); + + it('skips an invalid intent field and falls through to the next', () => { + expect( + parseNotificationIntentPayload({ intent: 'nonsense', type: 'run', runId: 'r-1' }), + ).toEqual({ + kind: 'payload', + value: { kind: 'run', runId: 'r-1' }, + }); + }); + + it('ignores an empty-string value in one intent field in favor of the next', () => { + expect( + parseNotificationIntentPayload({ intent: ' ', kind: 'thread', threadId: 't-1' }), + ).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + }); + }); + + describe('thread intent', () => { + it('parses a thread payload with its threadId', () => { + expect(parseNotificationIntentPayload({ intent: 'thread', threadId: 't-1' })).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + }); + + it('accepts the plural alias "threads"', () => { + expect(parseNotificationIntentPayload({ intent: 'threads', threadId: 't-2' })).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-2' }, + }); + }); + + it('errors when threadId is missing', () => { + expect(parseNotificationIntentPayload({ intent: 'thread' })).toEqual({ + kind: 'error', + reason: 'missing_thread_id', + }); + }); + + it('errors when threadId is empty or whitespace-only', () => { + expect(parseNotificationIntentPayload({ intent: 'thread', threadId: '' })).toEqual({ + kind: 'error', + reason: 'missing_thread_id', + }); + expect(parseNotificationIntentPayload({ intent: 'thread', threadId: ' ' })).toEqual({ + kind: 'error', + reason: 'missing_thread_id', + }); + }); + + it('errors when threadId is a non-string value', () => { + expect(parseNotificationIntentPayload({ intent: 'thread', threadId: 7 })).toEqual({ + kind: 'error', + reason: 'missing_thread_id', + }); + }); + + it('trims the threadId value', () => { + expect(parseNotificationIntentPayload({ intent: 'thread', threadId: ' t-1 ' })).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + }); + + it('drops unrelated extra fields from the payload', () => { + expect( + parseNotificationIntentPayload({ + intent: 'thread', + threadId: 't-1', + runId: 'r-1', + approvalId: 'a-1', + }), + ).toEqual({ + kind: 'payload', + value: { kind: 'thread', threadId: 't-1' }, + }); + }); + }); + + describe('run intent', () => { + it('parses a run payload with only its runId', () => { + expect(parseNotificationIntentPayload({ intent: 'run', runId: 'r-1' })).toEqual({ + kind: 'payload', + value: { kind: 'run', runId: 'r-1' }, + }); + }); + + it('parses a run payload with an optional threadId', () => { + expect( + parseNotificationIntentPayload({ intent: 'run', runId: 'r-1', threadId: 't-1' }), + ).toEqual({ + kind: 'payload', + value: { kind: 'run', runId: 'r-1', threadId: 't-1' }, + }); + }); + + it('accepts the plural alias "runs"', () => { + expect(parseNotificationIntentPayload({ intent: 'runs', runId: 'r-2' })).toEqual({ + kind: 'payload', + value: { kind: 'run', runId: 'r-2' }, + }); + }); + + it('errors when runId is missing', () => { + expect(parseNotificationIntentPayload({ intent: 'run' })).toEqual({ + kind: 'error', + reason: 'missing_run_id', + }); + }); + + it('errors when runId is a non-string value', () => { + expect(parseNotificationIntentPayload({ intent: 'run', runId: null })).toEqual({ + kind: 'error', + reason: 'missing_run_id', + }); + }); + + it('omits threadId when it is empty or non-string', () => { + expect( + parseNotificationIntentPayload({ intent: 'run', runId: 'r-1', threadId: '' }), + ).toEqual({ + kind: 'payload', + value: { kind: 'run', runId: 'r-1' }, + }); + expect( + parseNotificationIntentPayload({ intent: 'run', runId: 'r-1', threadId: 123 }), + ).toEqual({ + kind: 'payload', + value: { kind: 'run', runId: 'r-1' }, + }); + }); + }); + + describe('approval intent', () => { + it('parses an approval payload with only its approvalId', () => { + expect(parseNotificationIntentPayload({ intent: 'approval', approvalId: 'a-1' })).toEqual({ + kind: 'payload', + value: { kind: 'approval', approvalId: 'a-1' }, + }); + }); + + it('parses an approval payload with optional runId and threadId', () => { + expect( + parseNotificationIntentPayload({ + intent: 'approval', + approvalId: 'a-1', + runId: 'r-1', + threadId: 't-1', + }), + ).toEqual({ + kind: 'payload', + value: { kind: 'approval', approvalId: 'a-1', runId: 'r-1', threadId: 't-1' }, + }); + }); + + it('accepts the "approvals" alias', () => { + expect(parseNotificationIntentPayload({ intent: 'approvals', approvalId: 'a-2' })).toEqual({ + kind: 'payload', + value: { kind: 'approval', approvalId: 'a-2' }, + }); + }); + + it('accepts the "approval_required" alias', () => { + expect( + parseNotificationIntentPayload({ intent: 'approval_required', approvalId: 'a-3' }), + ).toEqual({ + kind: 'payload', + value: { kind: 'approval', approvalId: 'a-3' }, + }); + }); + + it('errors when approvalId is missing', () => { + expect(parseNotificationIntentPayload({ intent: 'approval' })).toEqual({ + kind: 'error', + reason: 'missing_approval_id', + }); + }); + + it('errors when approvalId is whitespace-only', () => { + expect(parseNotificationIntentPayload({ intent: 'approval', approvalId: ' ' })).toEqual({ + kind: 'error', + reason: 'missing_approval_id', + }); + }); + + it('omits optional ids that are empty or non-string', () => { + expect( + parseNotificationIntentPayload({ + intent: 'approval', + approvalId: 'a-1', + runId: undefined, + threadId: '', + }), + ).toEqual({ + kind: 'payload', + value: { kind: 'approval', approvalId: 'a-1' }, + }); + }); + }); + + describe('activity intent', () => { + it('parses an empty activity payload (no required fields)', () => { + expect(parseNotificationIntentPayload({ intent: 'activity' })).toEqual({ + kind: 'payload', + value: { kind: 'activity' }, + }); + }); + + it('parses an activity payload with all optional ids', () => { + expect( + parseNotificationIntentPayload({ + intent: 'activity', + activityId: 'act-1', + runId: 'r-1', + threadId: 't-1', + }), + ).toEqual({ + kind: 'payload', + value: { + kind: 'activity', + activityId: 'act-1', + runId: 'r-1', + threadId: 't-1', + }, + }); + }); + + it('accepts the "activities" alias', () => { + expect(parseNotificationIntentPayload({ intent: 'activities', runId: 'r-1' })).toEqual({ + kind: 'payload', + value: { kind: 'activity', runId: 'r-1' }, + }); + }); + + it('omits optional ids that are empty or non-string', () => { + expect( + parseNotificationIntentPayload({ + intent: 'activity', + activityId: '', + runId: null, + threadId: ' ', + }), + ).toEqual({ + kind: 'payload', + value: { kind: 'activity' }, + }); + }); + }); +}); + +describe('mapNotificationIntentToNavigationTarget', () => { + it('maps a thread payload to the thread screen', () => { + expect(mapNotificationIntentToNavigationTarget({ kind: 'thread', threadId: 't-1' })).toEqual({ + screen: 'thread', + threadId: 't-1', + }); + }); + + it('maps a run payload to the tasks screen with source run', () => { + expect(mapNotificationIntentToNavigationTarget({ kind: 'run', runId: 'r-1' })).toEqual({ + screen: 'tasks', + source: 'run', + runId: 'r-1', + }); + }); + + it('maps a run payload with a threadId', () => { + expect( + mapNotificationIntentToNavigationTarget({ kind: 'run', runId: 'r-1', threadId: 't-1' }), + ).toEqual({ + screen: 'tasks', + source: 'run', + runId: 'r-1', + threadId: 't-1', + }); + }); + + it('maps an approval payload to the tasks screen with source approval', () => { + expect(mapNotificationIntentToNavigationTarget({ kind: 'approval', approvalId: 'a-1' })).toEqual( + { + screen: 'tasks', + source: 'approval', + approvalId: 'a-1', + }, + ); + }); + + it('maps an approval payload with runId and threadId', () => { + expect( + mapNotificationIntentToNavigationTarget({ + kind: 'approval', + approvalId: 'a-1', + runId: 'r-1', + threadId: 't-1', + }), + ).toEqual({ + screen: 'tasks', + source: 'approval', + approvalId: 'a-1', + runId: 'r-1', + threadId: 't-1', + }); + }); + + it('maps an activity payload to the tasks screen with source activity', () => { + expect(mapNotificationIntentToNavigationTarget({ kind: 'activity' })).toEqual({ + screen: 'tasks', + source: 'activity', + }); + }); + + it('maps an activity payload with all optional ids', () => { + expect( + mapNotificationIntentToNavigationTarget({ + kind: 'activity', + activityId: 'act-1', + runId: 'r-1', + threadId: 't-1', + }), + ).toEqual({ + screen: 'tasks', + source: 'activity', + activityId: 'act-1', + runId: 'r-1', + threadId: 't-1', + }); + }); +}); + +describe('parseNotificationIntent', () => { + it('returns a navigate result with the parsed payload and mapped target', () => { + expect(parseNotificationIntent({ intent: 'thread', threadId: 't-1' })).toEqual({ + kind: 'navigate', + payload: { kind: 'thread', threadId: 't-1' }, + target: { screen: 'thread', threadId: 't-1' }, + }); + }); + + it('maps a run intent through to the tasks screen', () => { + expect(parseNotificationIntent({ intent: 'run', runId: 'r-1', threadId: 't-9' })).toEqual({ + kind: 'navigate', + payload: { kind: 'run', runId: 'r-1', threadId: 't-9' }, + target: { screen: 'tasks', source: 'run', runId: 'r-1', threadId: 't-9' }, + }); + }); + + it('passes an ignore outcome through unchanged', () => { + expect(parseNotificationIntent(null)).toEqual({ + kind: 'ignore', + reason: 'missing_data', + }); + }); + + it('passes an error outcome through unchanged', () => { + expect(parseNotificationIntent({ intent: 'approval' })).toEqual({ + kind: 'error', + reason: 'missing_approval_id', + }); + }); +}); diff --git a/app/shared/src/ui/toast/__tests__/toastStore.test.ts b/app/shared/src/ui/toast/__tests__/toastStore.test.ts index 2f2ae5d8e..24bc58916 100644 --- a/app/shared/src/ui/toast/__tests__/toastStore.test.ts +++ b/app/shared/src/ui/toast/__tests__/toastStore.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useToastStore, type ToastItem } from '../toastStore'; +// real_tested=true + +const EXIT_ANIMATION_MS = 300; +const DEFAULT_DURATION_MS = 4000; describe('toastStore', () => { beforeEach(() => { @@ -11,90 +15,428 @@ describe('toastStore', () => { vi.useRealTimers(); }); - it('addToast appends a toast and returns its id', () => { - const id = useToastStore.getState().addToast({ type: 'success', message: 'Saved' }); - expect(id).toMatch(/^toast-/); - const toast = useToastStore.getState().toasts.find((t) => t.id === id); - expect(toast?.type).toBe('success'); - expect(toast?.message).toBe('Saved'); - }); + describe('addToast', () => { + it('appends a toast with type and message and returns its id', () => { + const id = useToastStore + .getState() + .addToast({ type: 'success', message: 'Saved' }); + expect(id).toMatch(/^toast-\d+$/); + const toast = useToastStore.getState().toasts.find((t) => t.id === id); + expect(toast?.type).toBe('success'); + expect(toast?.message).toBe('Saved'); + }); - it('addToast keeps at most 5 toasts', () => { - const { addToast } = useToastStore.getState(); - for (let i = 0; i < 7; i++) { - addToast({ type: 'info', message: `msg ${i}` }); - } - expect(useToastStore.getState().toasts).toHaveLength(5); - expect(useToastStore.getState().toasts[4]?.message).toBe('msg 6'); - }); + it('generates a unique id for every toast', () => { + const first = useToastStore + .getState() + .addToast({ type: 'info', message: 'one' }); + const second = useToastStore + .getState() + .addToast({ type: 'info', message: 'two' }); + expect(second).not.toBe(first); + expect(useToastStore.getState().toasts).toHaveLength(2); + }); - it('addToast preserves duration and action', () => { - const action = { label: 'Undo', onClick: vi.fn() }; - const id = useToastStore - .getState() - .addToast({ type: 'warning', message: 'Careful', duration: 1000, action }); - const toast = useToastStore.getState().toasts.find((t) => t.id === id); - expect(toast?.duration).toBe(1000); - expect(toast?.action?.label).toBe('Undo'); - }); + it('accepts an empty message', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: '' }); + const toast = useToastStore.getState().toasts.find((t) => t.id === id); + expect(toast?.message).toBe(''); + }); - it('addToast auto-dismisses after the duration', () => { - useToastStore.getState().addToast({ type: 'info', message: 'Auto', duration: 500 }); - vi.advanceTimersByTime(499); - expect(useToastStore.getState().toasts).toHaveLength(1); - vi.advanceTimersByTime(1); - expect(useToastStore.getState().toasts[0]?.exiting).toBe(true); - vi.advanceTimersByTime(300); - expect(useToastStore.getState().toasts).toHaveLength(0); - }); + it('uses the default duration when duration is omitted', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Default' }); + const toast = useToastStore.getState().toasts.find((t) => t.id === id); + expect(toast?.duration).toBeUndefined(); + expect(toast?.action).toBeUndefined(); + + vi.advanceTimersByTime(DEFAULT_DURATION_MS - 1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('preserves a provided duration and action', () => { + const action = { label: 'Undo', onClick: vi.fn() }; + const id = useToastStore.getState().addToast({ + type: 'warning', + message: 'Careful', + duration: 1500, + action, + }); + const toast = useToastStore.getState().toasts.find((t) => t.id === id); + expect(toast?.duration).toBe(1500); + expect(toast?.action).toBe(action); + expect(toast?.action?.label).toBe('Undo'); + }); - it('addToast with duration 0 never auto-dismisses', () => { - useToastStore.getState().addToast({ type: 'error', message: 'Sticky', duration: 0 }); - vi.advanceTimersByTime(100_000); - expect(useToastStore.getState().toasts).toHaveLength(1); + it('auto-dismisses after the provided duration', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Auto', duration: 500 }); + vi.advanceTimersByTime(499); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + vi.advanceTimersByTime(EXIT_ANIMATION_MS - 1); + expect(useToastStore.getState().toasts).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('never auto-dismisses when duration is 0', () => { + useToastStore + .getState() + .addToast({ type: 'error', message: 'Sticky', duration: 0 }); + vi.advanceTimersByTime(100_000); + expect(useToastStore.getState().toasts).toHaveLength(1); + expect(useToastStore.getState().toasts[0]?.exiting).toBeUndefined(); + }); + + it('never auto-dismisses when duration is negative', () => { + const id = useToastStore.getState().addToast({ + type: 'error', + message: 'Negative', + duration: -100, + }); + vi.advanceTimersByTime(100_000); + const toast = useToastStore.getState().toasts.find((t) => t.id === id); + expect(toast?.duration).toBe(-100); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); + + it('keeps at most 5 toasts and drops the oldest', () => { + const { addToast } = useToastStore.getState(); + for (let i = 0; i < 7; i++) { + addToast({ type: 'info', message: `msg ${i}` }); + } + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(5); + expect(toasts[0]?.message).toBe('msg 2'); + expect(toasts[4]?.message).toBe('msg 6'); + }); + + it('keeps exactly 5 toasts when the cap is not exceeded', () => { + const { addToast } = useToastStore.getState(); + for (let i = 0; i < 5; i++) { + addToast({ type: 'info', message: `msg ${i}` }); + } + const toasts = useToastStore.getState().toasts; + expect(toasts).toHaveLength(5); + expect(toasts[0]?.message).toBe('msg 0'); + expect(toasts[4]?.message).toBe('msg 4'); + }); + + it('does not mutate previously stored toast items', () => { + useToastStore + .getState() + .addToast({ type: 'info', message: 'Immutable' }); + const before = useToastStore.getState().toasts as ToastItem[]; + const beforeFirst = before[0]; + useToastStore + .getState() + .addToast({ type: 'info', message: 'Second' }); + const after = useToastStore.getState().toasts; + expect(after).not.toBe(before); + // Existing items are reused by reference but never modified. + expect(after[0]).toBe(beforeFirst); + expect(beforeFirst?.message).toBe('Immutable'); + expect(beforeFirst?.exiting).toBeUndefined(); + expect(after[1]?.message).toBe('Second'); + }); }); - it('showToast builds a toast from type + message + options', () => { - const id = useToastStore - .getState() - .showToast('error', 'Boom', { duration: 200, action: { label: 'Retry', onClick: vi.fn() } }); - const toast = useToastStore.getState().toasts.find((t) => t.id === id); - expect(toast?.type).toBe('error'); - expect(toast?.message).toBe('Boom'); - expect(toast?.duration).toBe(200); - expect(toast?.action?.label).toBe('Retry'); + describe('showToast', () => { + it('builds a toast from type, message and options', () => { + const action = { label: 'Retry', onClick: vi.fn() }; + const id = useToastStore + .getState() + .showToast('error', 'Boom', { duration: 200, action }); + const toast = useToastStore.getState().toasts.find((t) => t.id === id); + expect(toast?.type).toBe('error'); + expect(toast?.message).toBe('Boom'); + expect(toast?.duration).toBe(200); + expect(toast?.action).toBe(action); + }); + + it('works without options and uses the default duration', () => { + const id = useToastStore + .getState() + .showToast('warning', 'No options'); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.duration, + ).toBeUndefined(); + vi.advanceTimersByTime(DEFAULT_DURATION_MS - 1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + }); }); - it('dismissToast marks exiting then removes after the animation', () => { - const id = useToastStore.getState().addToast({ type: 'info', message: 'Bye' }); - useToastStore.getState().dismissToast(id); - expect(useToastStore.getState().toasts.find((t) => t.id === id)?.exiting).toBe(true); - vi.advanceTimersByTime(300); - expect(useToastStore.getState().toasts).toHaveLength(0); + describe('dismissToast', () => { + it('marks the toast exiting and removes it after the exit animation', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Bye' }); + useToastStore.getState().dismissToast(id); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + expect(useToastStore.getState().toasts).toHaveLength(1); + vi.advanceTimersByTime(EXIT_ANIMATION_MS - 1); + expect(useToastStore.getState().toasts).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('clears a pending auto-dismiss timer so the toast is not double-expired', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Timed', duration: 2000 }); + useToastStore.getState().dismissToast(id); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts).toHaveLength(0); + vi.advanceTimersByTime(10_000); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('is a no-op for an unknown id', () => { + useToastStore + .getState() + .addToast({ type: 'info', message: 'Still here' }); + useToastStore.getState().dismissToast('toast-unknown'); + expect(useToastStore.getState().toasts).toHaveLength(1); + expect(useToastStore.getState().toasts[0]?.exiting).toBeUndefined(); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); }); - it('removeToast removes immediately without the exit animation', () => { - const id = useToastStore.getState().addToast({ type: 'info', message: 'Gone' }); - useToastStore.getState().removeToast(id); - expect(useToastStore.getState().toasts).toHaveLength(0); + describe('removeToast', () => { + it('removes the toast immediately without the exit animation', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Gone' }); + useToastStore.getState().removeToast(id); + expect(useToastStore.getState().toasts).toHaveLength(0); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('clears a pending auto-dismiss timer', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Gone', duration: 100 }); + useToastStore.getState().removeToast(id); + vi.advanceTimersByTime(10_000); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('is a no-op for an unknown id', () => { + useToastStore + .getState() + .addToast({ type: 'info', message: 'Keep me' }); + useToastStore.getState().removeToast('toast-unknown'); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); }); - it('removeToast on an unknown id is a no-op', () => { - useToastStore.getState().addToast({ type: 'info', message: 'Keep me' }); - useToastStore.getState().removeToast('toast-999'); - expect(useToastStore.getState().toasts).toHaveLength(1); + describe('pauseAutoDismiss / resumeAutoDismiss', () => { + it('pauses the auto-dismiss clock and resume continues where it left off', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Pausable', duration: 1000 }); + vi.advanceTimersByTime(400); + useToastStore.getState().pauseAutoDismiss(id); + + vi.advanceTimersByTime(10_000); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + + useToastStore.getState().resumeAutoDismiss(id); + vi.advanceTimersByTime(599); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('is idempotent when called twice', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Double pause', duration: 1000 }); + useToastStore.getState().pauseAutoDismiss(id); + useToastStore.getState().pauseAutoDismiss(id); + vi.advanceTimersByTime(10_000); + expect(useToastStore.getState().toasts).toHaveLength(1); + useToastStore.getState().resumeAutoDismiss(id); + vi.advanceTimersByTime(999); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + }); + + it('pause is a no-op for an unknown id', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Untouched', duration: 100 }); + useToastStore.getState().pauseAutoDismiss('toast-unknown'); + vi.advanceTimersByTime(99); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + }); + + it('pause is a no-op for a toast without a timer (duration 0)', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Sticky', duration: 0 }); + useToastStore.getState().pauseAutoDismiss(id); + useToastStore.getState().resumeAutoDismiss(id); + vi.advanceTimersByTime(100_000); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); + + it('resume is a no-op for an unknown id', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Untouched', duration: 100 }); + useToastStore.getState().resumeAutoDismiss('toast-unknown'); + vi.advanceTimersByTime(100); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + }); + + it('resume while a timer is already active does not double-schedule', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Active', duration: 1000 }); + useToastStore.getState().resumeAutoDismiss(id); + vi.advanceTimersByTime(999); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it('resume after pause preserves the remaining time', () => { + const id = useToastStore + .getState() + .addToast({ type: 'info', message: 'Remaining', duration: 1000 }); + vi.advanceTimersByTime(900); + useToastStore.getState().pauseAutoDismiss(id); + useToastStore.getState().resumeAutoDismiss(id); + vi.advanceTimersByTime(99); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(1); + expect( + useToastStore.getState().toasts.find((t) => t.id === id)?.exiting, + ).toBe(true); + }); }); - it('dismissToast on an unknown id is a no-op', () => { - useToastStore.getState().addToast({ type: 'info', message: 'Still here' }); - useToastStore.getState().dismissToast('toast-999'); - expect(useToastStore.getState().toasts).toHaveLength(1); + describe('multiple toasts', () => { + it('auto-dismisses one toast without affecting others', () => { + const first = useToastStore + .getState() + .addToast({ type: 'info', message: 'First', duration: 500 }); + const second = useToastStore + .getState() + .addToast({ type: 'error', message: 'Second', duration: 0 }); + vi.advanceTimersByTime(500); + expect( + useToastStore.getState().toasts.find((t) => t.id === first)?.exiting, + ).toBe(true); + expect( + useToastStore.getState().toasts.find((t) => t.id === second)?.exiting, + ).toBeUndefined(); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts.map((t) => t.id)).toEqual([ + second, + ]); + }); + + it('dismisses only the targeted toast', () => { + const first = useToastStore + .getState() + .addToast({ type: 'info', message: 'First', duration: 0 }); + const second = useToastStore + .getState() + .addToast({ type: 'info', message: 'Second', duration: 0 }); + useToastStore.getState().dismissToast(first); + vi.advanceTimersByTime(EXIT_ANIMATION_MS); + expect(useToastStore.getState().toasts.map((t) => t.id)).toEqual([ + second, + ]); + }); }); - it('does not mutate previous toast items when adding', () => { - const before = useToastStore.getState().toasts as ToastItem[]; - useToastStore.getState().addToast({ type: 'info', message: 'Immutable' }); - const after = useToastStore.getState().toasts; - expect(after[0]).not.toBe(before[0]); + describe('store integration', () => { + it('supports selector subscriptions via subscribeWithSelector', () => { + const lengths: number[] = []; + const unsubscribe = useToastStore.subscribe( + (state) => state.toasts.length, + (length) => lengths.push(length), + ); + useToastStore + .getState() + .addToast({ type: 'info', message: 'Sub' }); + expect(lengths).toEqual([1]); + const id = useToastStore.getState().toasts[0]?.id ?? 'missing'; + useToastStore.getState().removeToast(id); + expect(lengths).toEqual([1, 0]); + unsubscribe(); + }); + + it('stores the action callback so the UI can invoke it', () => { + const onClick = vi.fn(); + const id = useToastStore.getState().addToast({ + type: 'info', + message: 'Action', + action: { label: 'Go', onClick }, + }); + const toast = useToastStore.getState().toasts.find((t) => t.id === id); + toast?.action?.onClick(); + expect(onClick).toHaveBeenCalledTimes(1); + }); }); }); From ce4cb334b2eb1db27d82ea6282e9f513ef47fa34 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:55:09 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(shared):=20=E7=A7=BB=E9=99=A4=20breakd?= =?UTF-8?q?own=20=E8=B4=9F=E8=BE=93=E5=85=A5=E4=BC=AA=E5=BD=B1=E6=96=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=88CodeRabbit=20#1771=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除 estimateTokens(-1)→-0 与 breakdownContext(messages,-5) 负缩放两条 断言:它们锁定的是未守卫的负输入伪影(-0/负桶值),非契约行为,无回归价值。 保留 formatTokens/formatCost 的负值守卫断言(返回 0)。Lane D #1764 Co-authored-by: Cursor --- app/shared/src/context/breakdown.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/app/shared/src/context/breakdown.test.ts b/app/shared/src/context/breakdown.test.ts index f788cc600..465c055da 100644 --- a/app/shared/src/context/breakdown.test.ts +++ b/app/shared/src/context/breakdown.test.ts @@ -32,12 +32,6 @@ describe('estimateTokens', () => { it('handles large character counts', () => { expect(estimateTokens(10_000)).toBe(2500); }); - - it('returns -0 for small negative counts via ceil', () => { - // Math.ceil(-0.25) === -0; the function has no negative guard and - // Vitest toBe uses Object.is, so assert -0 explicitly. - expect(Object.is(estimateTokens(-1), -0)).toBe(true); - }); }); // ── breakdownContext ──────────────────────────── @@ -232,17 +226,6 @@ describe('breakdownContext', () => { }); }); - it('scales with a negative factor for negative totals (documents current behavior)', () => { - const messages = [{ role: 'user', content: 'u'.repeat(100) }]; // 25 est - // scale = -5 / 25 = -0.2 -> floor(25 * -0.2) = -5, other clamps to 0 - // (the zero buckets become -0 via floor(0 * scale), so they are not - // asserted with toEqual, which distinguishes -0 from +0) - const result = breakdownContext(messages, -5); - expect(result.user).toBe(-5); - expect(result.other).toBe(0); - expect(result.total).toBe(-5); - }); - it('accepts the SessionMetrics messages shape', () => { const metrics: SessionMetrics = { model: 'test-model',