import { describe, it, expect } from 'vitest'; import { reduce, INITIAL, type NetConfig, type NetEvent, type NetInput, type NetSnapshot, } from './netstate'; // A deterministic config for the assertions: k = 3 (a single blip rides out; three consecutive // failures trip offline) and a 1 s debounce window (the wall-clock anti-flap branch). const cfg: NetConfig = { k: 3, debounceMs: 1000 }; // Event and snapshot builders keep the cases terse; `at` defaults to 0 where the debounce branch is // irrelevant. const e = (type: NetEvent, at = 0): NetInput => ({ type, at }); const online: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 }; const offline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 }; const locked: NetSnapshot = { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }; const connecting = (over: Partial = {}): NetSnapshot => ({ state: 'connecting', fails: 0, connectingSince: 0, ...over, }); // fold applies a sequence of inputs, threading the snapshot and collecting every emitted effect — // the shape most edge cases assert against. function fold(start: NetSnapshot, inputs: NetInput[], config: NetConfig) { let snap = start; const effects = []; for (const ev of inputs) { const r = reduce(snap, ev, config); snap = r.next; effects.push(...r.effects); } return { final: snap, effects }; } describe('reduce — transition table', () => { it('online + callFailed → connecting (starts a probe, counts the failure)', () => { const r = reduce(online, e('callFailed', 100), cfg); expect(r.next).toEqual({ state: 'connecting', fails: 1, connectingSince: 100 }); expect(r.effects).toEqual([{ kind: 'startProbe' }]); }); it('online + osOffline → connecting (starts a probe, no failure counted — a hint)', () => { const r = reduce(online, e('osOffline', 100), cfg); expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 }); expect(r.effects).toEqual([{ kind: 'startProbe' }]); }); it('connecting + probeOk → online (silent; a blip must not thrash the chrome)', () => { const r = reduce(connecting({ fails: 2, connectingSince: 100 }), e('probeOk', 200), cfg); expect(r.next).toEqual(online); expect(r.effects).toEqual([]); }); it('connecting + callOk → online (silent)', () => { const r = reduce(connecting({ fails: 1, connectingSince: 100 }), e('callOk', 200), cfg); expect(r.next).toEqual(online); expect(r.effects).toEqual([]); }); it('connecting + a failure reaching K → offlineNoNetwork (toast "offline")', () => { const r = reduce(connecting({ fails: cfg.k - 1, connectingSince: 0 }), e('probeFailed', 10), cfg); expect(r.next).toEqual({ state: 'offlineNoNetwork', fails: 0, connectingSince: 0 }); expect(r.effects).toEqual([{ kind: 'toast', toast: 'offline' }]); }); it('offlineNoNetwork + osOnline → stays offline, triggers a probe (a hint never flips by itself)', () => { const r = reduce(offline, e('osOnline', 100), cfg); expect(r.next).toEqual(offline); expect(r.effects).toEqual([{ kind: 'startProbe' }]); }); it('offlineNoNetwork + probeOk → online (toast "back online")', () => { const r = reduce(offline, e('probeOk', 100), cfg); expect(r.next).toEqual(online); expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]); }); it('offlineNoNetwork + callOk → online (toast "back online")', () => { const r = reduce(offline, e('callOk', 100), cfg); expect(r.next).toEqual(online); expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]); }); it('any state + versionRejected → offlineVersionLocked (shows the notice once)', () => { for (const start of [online, connecting({ fails: 1, connectingSince: 5 }), offline]) { const r = reduce(start, e('versionRejected', 100), cfg); expect(r.next).toEqual({ state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }); expect(r.effects).toEqual([{ kind: 'showVersionNotice' }]); } }); it('offlineVersionLocked + versionRejected (probe still rejected) → stays locked, no repeat notice', () => { const r = reduce(locked, e('versionRejected', 100), cfg); expect(r.next).toEqual(locked); expect(r.effects).toEqual([]); }); it('online + versionRecommended → online + a nudge', () => { const r = reduce(online, e('versionRecommended', 100), cfg); expect(r.next).toEqual(online); expect(r.effects).toEqual([{ kind: 'setNudge' }]); }); it('boot → connecting + startProbe from any state (resets the sticky lock)', () => { for (const start of [online, offline, locked, connecting({ fails: 2, connectingSince: 5 })]) { const r = reduce(start, e('boot', 100), cfg); expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 }); expect(r.effects).toEqual([{ kind: 'startProbe' }]); } }); }); describe('reduce — hysteresis (blip vs sustained)', () => { it('a single fail then a success rides out inside connecting (no offline toast)', () => { const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 200)], cfg); expect(final).toEqual(online); expect(effects).toEqual([{ kind: 'startProbe' }]); }); it('K consecutive failures trip offlineNoNetwork', () => { const { final, effects } = fold(online, [e('callFailed', 0), e('probeFailed', 10), e('probeFailed', 20)], cfg); expect(final.state).toBe('offlineNoNetwork'); expect(effects).toContainEqual({ kind: 'toast', toast: 'offline' }); }); it('the debounce window trips offlineNoNetwork even below K', () => { const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs + 1)], cfg); expect(final.state).toBe('offlineNoNetwork'); }); it('a failure inside the debounce window and below K stays connecting', () => { const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs - 1)], cfg); expect(final.state).toBe('connecting'); expect(final.fails).toBe(1); }); }); describe('reduce — version gate (two tiers)', () => { it('versionRecommended sets the nudge only from online', () => { expect(reduce(online, e('versionRecommended', 0), cfg).effects).toEqual([{ kind: 'setNudge' }]); for (const start of [connecting({ connectingSince: 0 }), offline, locked]) { expect(reduce(start, e('versionRecommended', 0), cfg).effects).toEqual([]); } }); it('versionRejected from any state locks (the hard tier), superseding a pending soft nudge', () => { const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg); expect(final.state).toBe('offlineVersionLocked'); expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]); }); }); describe('reduce — edge cases (#1–#12)', () => { it('#1 rapid flap (fail→ok inside the window) never leaves connecting for offline', () => { const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 50)], cfg); expect(final).toEqual(online); expect(effects).not.toContainEqual({ kind: 'toast', toast: 'offline' }); }); it('#2 cold boot, no network → offlineNoNetwork', () => { const { final } = fold( INITIAL, [e('boot', 0), e('osOffline', 1), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)], cfg, ); expect(final.state).toBe('offlineNoNetwork'); }); it('#3 cold boot, gateway up but version < min → offlineVersionLocked + notice', () => { const { final, effects } = fold(INITIAL, [e('boot', 0), e('versionRejected', 20)], cfg); expect(final.state).toBe('offlineVersionLocked'); expect(effects).toContainEqual({ kind: 'showVersionNotice' }); }); it('#4 cold boot, session-less guest: the reconcile IS the probe — ok→online, fail→offline', () => { expect(fold(INITIAL, [e('boot', 0), e('callOk', 20)], cfg).final).toEqual(online); const failed = fold( INITIAL, [e('boot', 0), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)], cfg, ); expect(failed.final.state).toBe('offlineNoNetwork'); }); it('#5 mid-session min-version bump → next call versionRejected → locked mid-play', () => { const r = reduce(online, e('versionRejected', 100), cfg); expect(r.next.state).toBe('offlineVersionLocked'); expect(r.effects).toContainEqual({ kind: 'showVersionNotice' }); }); it('#6 captive portal: osOnline fires but the gateway is down → probe fails → stays offline', () => { const { final, effects } = fold(offline, [e('osOnline', 0), e('probeFailed', 10)], cfg); expect(final.state).toBe('offlineNoNetwork'); expect(effects).toEqual([{ kind: 'startProbe' }]); }); it('#7 recovery race: probeOk then a queued callOk → one idempotent transition to online', () => { const { final, effects } = fold(offline, [e('probeOk', 0), e('callOk', 1)], cfg); expect(final).toEqual(online); expect(effects.filter((x) => x.kind === 'toast')).toEqual([{ kind: 'toast', toast: 'online' }]); }); it('#8 soft nudge then a hard reject → escalates to locked (notice after the nudge)', () => { const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg); expect(final.state).toBe('offlineVersionLocked'); expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]); }); it('#9 offlineVersionLocked is sticky — no connectivity event exits it; only boot does', () => { const stuck: NetEvent[] = ['callOk', 'probeOk', 'osOnline', 'osOffline', 'callFailed', 'probeFailed', 'versionRecommended']; for (const ev of stuck) { expect(reduce(locked, e(ev, 100), cfg).next).toEqual(locked); } expect(reduce(locked, e('boot', 100), cfg).next.state).toBe('connecting'); }); it('#10 offline → back online transitions cleanly (local-game persistence is O5, not the machine)', () => { expect(fold(offline, [e('probeOk', 0)], cfg).final).toEqual(online); }); it('#11 nothing forces the machine offline without a real failure/version signal (a stale pref cannot stick)', () => { expect(reduce(INITIAL, e('boot', 0), cfg).next.state).toBe('connecting'); const { final } = fold( INITIAL, [e('boot', 0), e('callOk', 10), e('osOnline', 20), e('versionRecommended', 30)], cfg, ); expect(final.state).toBe('online'); }); it('#12 TG/VK inertness: with only success/hint events the machine never leaves online', () => { const { final, effects } = fold(online, [e('callOk', 0), e('probeOk', 10), e('osOnline', 20), e('callOk', 30)], cfg); expect(final).toEqual(online); expect(effects).toEqual([]); }); }); describe('reduce — purity', () => { it('does not mutate the previous snapshot', () => { const prev = Object.freeze({ state: 'connecting', fails: 1, connectingSince: 5 }); expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow(); expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 }); }); });