3429dbec5f
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.
279 lines
12 KiB
TypeScript
279 lines
12 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 });
|
||
});
|
||
});
|
||
|
||
// 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' }]);
|
||
});
|
||
});
|