import { describe, expect, it } from 'vitest' import { initialSeanceState, seanceReducer } from '../state/seance' import type { AnomalyEntry, SeanceState, Utterance } from '../state/seance' import type { ServerFrame } from '../lib/types' import { anomalyDensity, anomalyString, collectDataStrings, pushRing, utteranceWord, DATA_CAP } from './MatrixView' import { adaptiveMax, anomalyColor, pushHistory, HISTORY_CAP } from './GraphView' const AT = 1_700_000_000_000 function frame(state: SeanceState, f: ServerFrame, at = AT): SeanceState { return seanceReducer(state, { type: 'frame', frame: f, at }) } function lastRow(state: SeanceState) { return state.transcript[state.transcript.length - 1] } describe('seanceReducer — server anomaly frames', () => { it('appends a server anomaly to both the anomalies list and the transcript', () => { const state = frame(initialSeanceState, { type: 'anomaly', source: 'wire', frequency: 432.8, magnitude: -3.1, }) expect(state.anomalies).toHaveLength(1) expect(state.anomalies[0]).toMatchObject({ source: 'wire', frequency: 432.8, magnitude: -3.1, at: AT, }) expect(lastRow(state)).toMatchObject({ kind: 'anomaly', source: 'wire', frequency: 432.8, magnitude: -3.1, at: AT, }) // Same entry id shared between the list and the transcript row. const row = lastRow(state) if (row.kind === 'anomaly') expect(row.id).toBe(state.anomalies[0].id) // The server-side count only moves when anomaly_ack arrives. expect(state.anomalyCount).toBe(0) expect(initialSeanceState.anomalies).toEqual([]) // reducer is pure }) it('caps server anomalies at 200, dropping the oldest', () => { let state = initialSeanceState for (let i = 1; i <= 205; i++) { state = frame( state, { type: 'anomaly', source: 'radio', frequency: 100 + i, magnitude: 5 }, AT + i, ) } expect(state.anomalies).toHaveLength(200) expect(state.anomalies[0].frequency).toBe(106) expect(state.anomalies[199].frequency).toBe(305) }) }) describe('MatrixView helpers', () => { it('pushRing appends and caps, dropping the oldest', () => { let ring: number[] = [] for (let i = 1; i <= 5; i++) ring = pushRing(ring, i, 3) expect(ring).toEqual([3, 4, 5]) }) it('anomalyString formats frequency and signed magnitude', () => { expect(anomalyString({ frequency: 101.44, magnitude: 8.23 })).toBe('101.4MHZ +8.2DB') expect(anomalyString({ frequency: 88, magnitude: -3.06 })).toBe('88.0MHZ -3.1DB') }) it('utteranceWord picks a single uppercase word and rejects noise', () => { expect(utteranceWord('the veil is thin')).toBe('VEIL') // 'IS' filtered (<3), middle of the rest expect(utteranceWord('mirela')).toBe('MIRELA') expect(utteranceWord('... --- ...')).toBeNull() }) it('collectDataStrings gathers words, anomalies, telemetry and the entity name', () => { const anomalies: AnomalyEntry[] = [ { id: 1, source: 'wire', frequency: 55.2, magnitude: 1.2, at: AT }, ] const utterances: Utterance[] = [ { id: 'u1', kind: 'fragment', text: 'we are below', entity: null, audioUrl: null, at: AT }, ] const ring = collectDataStrings({ anomalies, utterances, telemetry: { jitter_bytes_per_s: 23.68, latency_variance_ms: 1, latency_mean_ms: 5.94, dns_ms: 7 }, entityName: 'Mirela', }) expect(ring).toContain('BELOW') expect(ring).toContain('55.2MHZ +1.2DB') expect(ring).toContain('JIT 23.7KB/S') expect(ring).toContain('RTT 5.9MS') expect(ring).toContain('MIRELA') expect(ring.length).toBeLessThanOrEqual(DATA_CAP) }) it('collectDataStrings degrades to an empty ring with no live data', () => { expect( collectDataStrings({ anomalies: [], utterances: [], telemetry: null, entityName: null }), ).toEqual([]) }) it('anomalyDensity scales 0→1 with the recent anomaly rate', () => { const now = AT expect(anomalyDensity([], now)).toBe(0) const recent = Array.from({ length: 10 }, (_, i) => ({ at: now - i * 500 })) expect(anomalyDensity(recent, now, 10_000)).toBe(1) const stale = [{ at: now - 60_000 }] expect(anomalyDensity(stale, now, 10_000)).toBe(0) }) }) describe('GraphView helpers', () => { it('pushHistory appends and caps at HISTORY_CAP, dropping the oldest', () => { let history = pushHistory([], { at: AT, jitter: 1, variance: 1, dns: 1 }) expect(history).toHaveLength(1) for (let i = 1; i <= HISTORY_CAP + 5; i++) { history = pushHistory(history, { at: AT + i, jitter: i, variance: i, dns: i }) } expect(history).toHaveLength(HISTORY_CAP) expect(history[history.length - 1].jitter).toBe(HISTORY_CAP + 5) expect(history[0].jitter).toBe(6) }) it('adaptiveMax respects the floor and adds headroom', () => { expect(adaptiveMax([], 10)).toBeCloseTo(11.5) expect(adaptiveMax([2, 4, 100, 7], 10)).toBeCloseTo(115) expect(adaptiveMax([1, 2], 50)).toBeCloseTo(57.5) }) it('anomalyColor maps sources to their token colors', () => { expect(anomalyColor('wire')).toBe('#7cffb2') expect(anomalyColor('evp')).toBe('#b26bff') expect(anomalyColor('radio')).toBe('#ff9d4a') }) })