Files
scrabble-game/ui/src/lib/netstate.test.ts
T
Ilia Denisov 3429dbec5f fix(boot): native offline-first launch is silent and fast (Android 10)
Two native offline-first boot glitches (seen on Android 10):
- bootOffline forced offlineNoNetwork, so its first successful reconcile healed to online with a
  spurious "back online" toast on a first launch that actually had connectivity. The boot-assumed
  offline is now PROVISIONAL: the first probe confirms it — a success heals silently (nothing to
  come back from), a failure turns it into a real offline whose later recovery does toast.
- A native launch with a cached guest session (no email) skipped the offline short-circuit and hung
  ~25 s on adoptSession's retrying profile fetch in airplane mode. The native channel is offline-first,
  so it now takes the same reachability-gated fallback (~3 s bound) as an installed email PWA.
2026-07-15 02:17:13 +02:00

279 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {}): 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<NetSnapshot>({ state: 'connecting', fails: 1, connectingSince: 5 });
expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow();
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
});
});
// A boot-assumed (provisional) offline — bootOffline sets this — heals SILENTLY on its first
// successful probe (a first launch that actually had connectivity must not flash "back online"),
// while a failed probe confirms it as a real offline whose later recovery does toast.
describe('provisional (boot-assumed) offline', () => {
const bootOffline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };
it('heals to online with NO toast when the first probe succeeds', () => {
const r = reduce(bootOffline, e('probeOk'), cfg);
expect(r.next.state).toBe('online');
expect(r.effects).toEqual([]); // no "back online" toast — it was never really offline
});
it('clears provisional on a failed probe, then toasts on the eventual recovery', () => {
const failed = reduce(bootOffline, e('probeFailed'), cfg);
expect(failed.next.state).toBe('offlineNoNetwork');
expect(failed.next.provisional).toBe(false);
expect(failed.effects).toEqual([]);
const healed = reduce(failed.next, e('probeOk'), cfg);
expect(healed.next.state).toBe('online');
expect(healed.effects).toEqual([{ kind: 'toast', toast: 'online' }]); // now a real recovery
});
it('a non-provisional (observed) offline still toasts on recovery', () => {
const r = reduce(offline, e('probeOk'), cfg);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
});