|
| 1 | +/** |
| 2 | + * __tests__/stallClassLabels.test.ts — Issue #4802: stall label + state helpers. |
| 3 | + * |
| 4 | + * Path 2 defensive: tests cover both typed payload (post-F-9) and missing |
| 5 | + * fields (pre-F-9) scenarios. The renderer must degrade gracefully when the |
| 6 | + * typed payload isn't yet wired to the SSE bus. |
| 7 | + */ |
| 8 | + |
| 9 | +import { describe, it, expect } from 'vitest'; |
| 10 | +import { |
| 11 | + STALL_CLASS_LABELS, |
| 12 | + STALL_GENERIC_LABEL, |
| 13 | + formatStallClassLabel, |
| 14 | + formatStallSubLabel, |
| 15 | + isRecoveryExhausted, |
| 16 | + isRecoveryDisabled, |
| 17 | + formatStallTooltip, |
| 18 | +} from '../utils/stallClassLabels'; |
| 19 | + |
| 20 | +describe('Issue #4802: stall label utilities', () => { |
| 21 | + describe('formatStallClassLabel', () => { |
| 22 | + it('maps each ErrorClass to a display label', () => { |
| 23 | + for (const [key, expected] of Object.entries(STALL_CLASS_LABELS)) { |
| 24 | + expect(formatStallClassLabel(key as never)).toBe(expected); |
| 25 | + } |
| 26 | + }); |
| 27 | + |
| 28 | + it('returns generic label for missing errorClass (Path 2 default)', () => { |
| 29 | + expect(formatStallClassLabel(undefined)).toBe(STALL_GENERIC_LABEL); |
| 30 | + expect(formatStallClassLabel(null)).toBe(STALL_GENERIC_LABEL); |
| 31 | + }); |
| 32 | + |
| 33 | + it('returns generic label for unknown string', () => { |
| 34 | + // Runtime guard at server side rejects these; renderer is defensive. |
| 35 | + expect(formatStallClassLabel('5xx_529' as never)).toBe(STALL_GENERIC_LABEL); |
| 36 | + }); |
| 37 | + }); |
| 38 | + |
| 39 | + describe('isRecoveryExhausted', () => { |
| 40 | + it('returns true when attempt >= max (both > 0)', () => { |
| 41 | + expect(isRecoveryExhausted({ recoveryAttemptCount: 5, recoveryMaxAttempts: 5 })).toBe(true); |
| 42 | + expect(isRecoveryExhausted({ recoveryAttemptCount: 6, recoveryMaxAttempts: 5 })).toBe(true); |
| 43 | + }); |
| 44 | + |
| 45 | + it('returns false when attempt < max', () => { |
| 46 | + expect(isRecoveryExhausted({ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 })).toBe(false); |
| 47 | + }); |
| 48 | + |
| 49 | + it('returns false when attempt < max', () => { |
| 50 | + expect(isRecoveryExhausted({ recoveryAttemptCount: 1, recoveryMaxAttempts: 5 })).toBe(false); |
| 51 | + }); |
| 52 | + |
| 53 | + it('returns false when max is 0 (Path 2 default — unknown)', () => { |
| 54 | + expect(isRecoveryExhausted({ recoveryAttemptCount: 0, recoveryMaxAttempts: 0 })).toBe(false); |
| 55 | + expect(isRecoveryExhausted({})).toBe(false); |
| 56 | + }); |
| 57 | + |
| 58 | + it('returns false when only max is set, attempt is missing', () => { |
| 59 | + expect(isRecoveryExhausted({ recoveryMaxAttempts: 5 })).toBe(false); |
| 60 | + }); |
| 61 | + }); |
| 62 | + |
| 63 | + describe('formatStallSubLabel', () => { |
| 64 | + it('returns "X/Y (auto-recovering…)" when not exhausted', () => { |
| 65 | + expect(formatStallSubLabel({ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 })).toBe( |
| 66 | + '3/5 (auto-recovering…)', |
| 67 | + ); |
| 68 | + }); |
| 69 | + |
| 70 | + it('returns "X/Y — intervention required" when exhausted', () => { |
| 71 | + expect(formatStallSubLabel({ recoveryAttemptCount: 5, recoveryMaxAttempts: 5 })).toBe( |
| 72 | + '5/5 — intervention required', |
| 73 | + ); |
| 74 | + }); |
| 75 | + |
| 76 | + it('returns null when max is 0 (Path 2 default — sub-label hidden)', () => { |
| 77 | + expect(formatStallSubLabel({ recoveryAttemptCount: 0, recoveryMaxAttempts: 0 })).toBeNull(); |
| 78 | + expect(formatStallSubLabel({})).toBeNull(); |
| 79 | + }); |
| 80 | + }); |
| 81 | + |
| 82 | + describe('isRecoveryDisabled', () => { |
| 83 | + it('returns true when recoveryDisabled === true', () => { |
| 84 | + expect(isRecoveryDisabled({ recoveryDisabled: true })).toBe(true); |
| 85 | + }); |
| 86 | + |
| 87 | + it('returns false when recoveryDisabled is false or missing (Path 2 default)', () => { |
| 88 | + expect(isRecoveryDisabled({ recoveryDisabled: false })).toBe(false); |
| 89 | + expect(isRecoveryDisabled({})).toBe(false); |
| 90 | + }); |
| 91 | + }); |
| 92 | + |
| 93 | + describe('formatStallTooltip', () => { |
| 94 | + it('composes metadata-only tooltip (no transcript text)', () => { |
| 95 | + const tooltip = formatStallTooltip({ |
| 96 | + errorClass: 'transient_5xx', |
| 97 | + statusCode: 529, |
| 98 | + lastErrorAt: '2026-06-22T12:00:00.000Z', |
| 99 | + stallDurationMs: 600000, // 10 minutes |
| 100 | + recoveryAttemptCount: 3, |
| 101 | + recoveryMaxAttempts: 5, |
| 102 | + }); |
| 103 | + expect(tooltip).toContain('Transient 5xx'); |
| 104 | + expect(tooltip).toContain('529'); |
| 105 | + expect(tooltip).toContain('2026-06-22T12:00:00.000Z'); |
| 106 | + expect(tooltip).toContain('10m'); |
| 107 | + expect(tooltip).toContain('3/5'); |
| 108 | + expect(tooltip).not.toContain('detail'); // F-6 redaction discipline |
| 109 | + }); |
| 110 | + |
| 111 | + it('omits statusCode for non-transient_5xx classes', () => { |
| 112 | + const tooltip = formatStallTooltip({ |
| 113 | + errorClass: 'jsonl_stall', |
| 114 | + statusCode: 529, // would be invalid in real payload, but defensive |
| 115 | + recoveryAttemptCount: 1, |
| 116 | + recoveryMaxAttempts: 5, |
| 117 | + }); |
| 118 | + expect(tooltip).toContain('JSONL Stall'); |
| 119 | + expect(tooltip).not.toContain('529'); |
| 120 | + }); |
| 121 | + |
| 122 | + it('handles missing fields gracefully (Path 2 default)', () => { |
| 123 | + const tooltip = formatStallTooltip({}); |
| 124 | + expect(tooltip).toBe('Stalled'); // just the generic label |
| 125 | + }); |
| 126 | + }); |
| 127 | +}); |
0 commit comments