feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
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, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin 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 (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
This commit is contained in:
@@ -44,10 +44,10 @@ beforeEach(() => clearLobby());
|
||||
|
||||
describe('patchLobbyGame', () => {
|
||||
it('replaces the matching game by id and leaves the others untouched', () => {
|
||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
|
||||
// The player's own move flipped game "a" to the opponent's turn.
|
||||
patchLobbyGame(gameView('a', 'active', 1));
|
||||
const snap = getLobby(false);
|
||||
const snap = getLobby();
|
||||
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
|
||||
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
|
||||
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
|
||||
@@ -55,27 +55,27 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
it('preserves invitations and incoming when patching a game', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming, offline: false });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming });
|
||||
patchLobbyGame(gameView('a', 'finished'));
|
||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||
expect(getLobby(false)?.games[0].status).toBe('finished');
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games[0].status).toBe('finished');
|
||||
});
|
||||
|
||||
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
|
||||
patchLobbyGame(gameView('z', 'active'));
|
||||
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
||||
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
it('is a no-op when there is no cached lobby yet', () => {
|
||||
patchLobbyGame(gameView('a'));
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate the previous snapshot array', () => {
|
||||
const games = [gameView('a', 'active', 0)];
|
||||
setLobby({ games, invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games, invitations: [], incoming: [] });
|
||||
patchLobbyGame(gameView('a', 'active', 1));
|
||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||
});
|
||||
@@ -83,59 +83,52 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
describe('patchLobbyInvitation', () => {
|
||||
it('adds a new pending invitation to the cached lobby', () => {
|
||||
setLobby({ games: [], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||
});
|
||||
|
||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||
patchLobbyInvitation(updated);
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
});
|
||||
|
||||
it('removes an invitation that reached a terminal status', () => {
|
||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', terminal));
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op for a terminal invitation that is not in the list', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
});
|
||||
|
||||
it('is a no-op when there is no cached lobby yet', () => {
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby()).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves games and incoming when patching an invitation', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming, offline: false });
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => {
|
||||
it('returns the snapshot only for the mode it was stored under', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
|
||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
|
||||
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
||||
expect(getLobby(true)).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
|
||||
describe('the unified snapshot (one lobby cache — offline reuses its server games, greyed)', () => {
|
||||
it('replaces the whole snapshot on each store, so the latest merged lists win', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [] });
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['online1']);
|
||||
setLobby({ games: [gameView('local1'), gameView('online2')], invitations: [], incoming: [] });
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['local1', 'online2']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user