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