Files
scrabble-game/ui/src/lib/netstate.test.ts
T
Ilia Denisov 2d1fadb50c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
feat(offline): implicit net-state model, two-tier version gate, unified lobby
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00

251 lines
11 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 });
});
});