From 5df82aa4f38f20419cdaf2aed2432b7662d5cc1d Mon Sep 17 00:00:00 2001 From: ddidderr Date: Tue, 19 May 2026 23:48:34 +0200 Subject: [PATCH] fix(ui): derive operation status from snapshots The launcher was mixing lifecycle event handlers with the games-list snapshot when deciding the card status. That left multiple writers for the same install_status field and made event ordering visible in React. Make games-list-updated active_operations the authoritative source for busy status. Lifecycle events no longer mutate the card status; they only keep their non-status side effects such as rescans and error messages. The only remaining optimistic status is CheckingPeers before the backend emits its next snapshot. Add a frontend reducer test that proves an install stays in Installing while an active install snapshot exists, then settles to Installed only after the active operation clears with installed local state. Test Plan: - git diff --check - just fmt - just frontend-test - just build Refs: local install/download status snapshot cleanup --- CLAUDE.md | 3 +- .../src/hooks/useGameActions.ts | 10 +- .../src/hooks/useGames.ts | 99 +------------------ .../src/lib/gameState.ts | 38 ++----- .../tests/gameState.test.ts | 83 ++++++++++++++++ justfile | 3 + 6 files changed, 105 insertions(+), 131 deletions(-) create mode 100644 crates/lanspread-tauri-deno-ts/tests/gameState.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 14d547d..87bd422 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,11 +26,12 @@ Never use normal cargo ... commands, use the just ... commands instead. - `just fmt` — format the workspace. - `just clippy` — lint the workspace. - `just test` — run the workspace unit tests. +- `just frontend-test` — run frontend reducer/unit tests. - `just fix` — auto-apply cargo/clippy fixes, then format. - `just clean` — wipe the build cache. - `just peer-cli-build` — build the scripted peer harness. - `just peer-cli-image` — build the peer harness Docker image. -- `just peer-cli-run NAME` — run one named harness container with persistent state under `target/peer-cli/NAME/`. +- `just peer-cli-run NAME` — run one named harness container with persistent state under `.lanspread-peer-cli/NAME/`. ## Protocol policy diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts index 1151a26..18b106c 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGameActions.ts @@ -13,9 +13,9 @@ export interface GameActions { /** * Thin wrappers over the backend `run_game` / `install_game` / `update_game` - * / `uninstall_game` / `remove_downloaded_game` commands. We mark peer-backed - * downloads as "checking peers" and already-downloaded installs as "installing" - * up-front so the UI doesn't have to wait for the first backend event. + * / `uninstall_game` / `remove_downloaded_game` commands. Peer-backed downloads + * are marked as "checking peers" until the backend emits an authoritative + * operation snapshot. */ export const useGameActions = (games: UseGamesResult): GameActions => { const play = useCallback(async (id: string) => { @@ -32,9 +32,7 @@ export const useGameActions = (games: UseGamesResult): GameActions => { if (!success) return; const game = games.games.find(item => item.id === id); - if (game?.downloaded && !game.installed) { - games.markInstalling(id); - } else { + if (!game?.downloaded) { games.markChecking(id); } } catch (err) { diff --git a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts index de6fcbb..936e54d 100644 --- a/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts +++ b/crates/lanspread-tauri-deno-ts/src/hooks/useGames.ts @@ -6,7 +6,6 @@ import { Game, GamesListPayload, InstallStatus, - StatusLevel, } from '../lib/types'; import { activeStatusById, @@ -17,36 +16,23 @@ import { interface PendingPatch { install_status?: InstallStatus; - downloaded?: boolean; - installed?: boolean; - local_version?: string | null; - status_message?: string; - status_level?: StatusLevel | undefined; clearStatus?: boolean; } const applyPatch = (game: Game, patch: PendingPatch): Game => { let next: Game = { ...game }; if (patch.install_status !== undefined) next.install_status = patch.install_status; - if (patch.downloaded !== undefined) next.downloaded = patch.downloaded; - if (patch.installed !== undefined) next.installed = patch.installed; - if (patch.local_version !== undefined) next.local_version = patch.local_version ?? undefined; if (patch.clearStatus) { next.status_message = undefined; next.status_level = undefined; } - if (patch.status_message !== undefined) { - next.status_message = patch.status_message; - next.status_level = patch.status_level; - } return next; }; /** - * Owns the games list and reflects every backend event (download/install/ - * uninstall/remove lifecycle, peer count) into local React state. Returns a - * fire-and-forget `markChecking` helper so action calls can immediately show a - * "Checking peers…" state until the backend emits the authoritative outcome. + * Owns the games list and derives card status from backend snapshots. Returns + * a fire-and-forget `markChecking` helper so action calls can immediately show + * a "Checking peers…" state until the next backend snapshot arrives. */ export interface UseGamesResult { games: Game[]; @@ -54,7 +40,6 @@ export interface UseGamesResult { totalPeerCount: number; requestGames: () => Promise; markChecking: (id: string) => void; - markInstalling: (id: string) => void; } export const useGames = (rescanGameDir: () => void): UseGamesResult => { @@ -71,14 +56,6 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { )); }, []); - const markInstalling = useCallback((id: string) => { - setGames(prev => prev.map(item => - item.id === id - ? applyPatch(item, { install_status: InstallStatus.Installing, clearStatus: true }) - : item, - )); - }, []); - const requestGames = useCallback(async () => { try { await invoke('request_games'); @@ -91,10 +68,6 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { const unlisteners: UnlistenFn[] = []; let cancelled = false; - const updateById = (id: string, patch: PendingPatch) => { - setGames(prev => prev.map(item => item.id === id ? applyPatch(item, patch) : item)); - }; - const handleErrorEvent = ( id: string, message: string, @@ -120,37 +93,16 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { event.payload as GamesListPayload | Game[], ); const activeStatuses = activeStatusById(payload.active_operations); - const hasAuthoritative = payload.active_operations !== undefined; setGames(prev => { const previousById = new Map(prev.map(item => [item.id, item])); return payload.games.map(game => mergeGameUpdate( game, previousById.get(game.id), activeStatuses.get(game.id), - hasAuthoritative, )); }); })); - // 'game-download-pre' confirms peer metadata was found. The backend may still - // reject the download during majority validation (which now emits a terminal fail event), - // so keep showing CheckingPeers until 'game-download-begin' reports that transfer started. - unlisteners.push(await listen('game-download-pre', (e) => { - const id = e.payload as string; - updateById(id, { install_status: InstallStatus.CheckingPeers, clearStatus: true }); - })); - - // 'game-download-begin' signals consensus size validation has completed and file transfer has started. - unlisteners.push(await listen('game-download-begin', (e) => { - const id = e.payload as string; - updateById(id, { install_status: InstallStatus.Downloading, clearStatus: true }); - })); - - unlisteners.push(await listen('game-download-finished', (e) => { - const id = e.payload as string; - updateById(id, { install_status: InstallStatus.Installing, clearStatus: true }); - })); - unlisteners.push(await listen('game-download-failed', (e) => { handleErrorEvent(e.payload as string, 'Download failed. Please try again.', { triggerRescan: true, @@ -167,18 +119,7 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { handleErrorEvent(e.payload as string, 'No peers currently have this game.'); })); - unlisteners.push(await listen('game-install-begin', (e) => { - const id = e.payload as string; - updateById(id, { install_status: InstallStatus.Installing, clearStatus: true }); - })); - - unlisteners.push(await listen('game-install-finished', (e) => { - const id = e.payload as string; - updateById(id, { - install_status: InstallStatus.Installed, - installed: true, - clearStatus: true, - }); + unlisteners.push(await listen('game-install-finished', () => { rescanRef.current(); })); @@ -186,40 +127,11 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { handleErrorEvent(e.payload as string, 'Install failed. Please try again.'); })); - unlisteners.push(await listen('game-uninstall-begin', (e) => { - updateById(e.payload as string, { - install_status: InstallStatus.Uninstalling, - clearStatus: true, - }); - })); - - unlisteners.push(await listen('game-uninstall-finished', (e) => { - updateById(e.payload as string, { - install_status: InstallStatus.NotInstalled, - installed: false, - clearStatus: true, - }); - })); - unlisteners.push(await listen('game-uninstall-failed', (e) => { handleErrorEvent(e.payload as string, 'Uninstall failed. Please try again.'); })); - unlisteners.push(await listen('game-remove-download-begin', (e) => { - updateById(e.payload as string, { - install_status: InstallStatus.Removing, - clearStatus: true, - }); - })); - - unlisteners.push(await listen('game-remove-download-finished', (e) => { - updateById(e.payload as string, { - install_status: InstallStatus.NotInstalled, - downloaded: false, - installed: false, - local_version: null, - clearStatus: true, - }); + unlisteners.push(await listen('game-remove-download-finished', () => { rescanRef.current(); })); @@ -257,6 +169,5 @@ export const useGames = (rescanGameDir: () => void): UseGamesResult => { totalPeerCount, requestGames, markChecking, - markInstalling, }; }; diff --git a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts index 46329ab..8665b3a 100644 --- a/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts +++ b/crates/lanspread-tauri-deno-ts/src/lib/gameState.ts @@ -17,19 +17,9 @@ const IN_PROGRESS_INSTALL_STATUSES = new Set([ InstallStatus.Removing, ]); -const RECONCILED_OPERATION_STATUSES = new Set([ - InstallStatus.Downloading, - InstallStatus.Installing, - InstallStatus.Uninstalling, - InstallStatus.Removing, -]); - export const isInProgress = (status: InstallStatus): boolean => IN_PROGRESS_INSTALL_STATUSES.has(status); -const isReconciledOperationStatus = (status: InstallStatus): boolean => - RECONCILED_OPERATION_STATUSES.has(status); - export const installStatusFromActiveOperation = (op: ActiveOperationKind): InstallStatus => { switch (op) { case ActiveOperationKind.Downloading: @@ -52,35 +42,23 @@ export const normalizeGamesListPayload = ( ): GamesListPayload => Array.isArray(payload) ? { games: payload } : payload; /** - * Reconcile a freshly received backend snapshot of a game with our prior - * locally-tracked install status. Keeps in-progress operations visible across - * snapshots that don't yet reflect the running operation. + * Reconcile a freshly received backend snapshot. Core operation status is + * derived only from the backend active-operation snapshot plus installed state. */ export const mergeGameUpdate = ( incoming: Game, previous?: Game, activeStatus?: InstallStatus, - hasAuthoritativeSnapshot = false, ): Game => { - let installStatus = InstallStatus.NotInstalled; - if (activeStatus !== undefined) { - installStatus = activeStatus; - } else if (incoming.installed) { - installStatus = InstallStatus.Installed; - } else if ( - previous - && isInProgress(previous.install_status) - && (!hasAuthoritativeSnapshot || previous.install_status === InstallStatus.CheckingPeers) - ) { - installStatus = previous.install_status; - } + const installStatus = activeStatus + ?? (incoming.installed ? InstallStatus.Installed : InstallStatus.NotInstalled); const localStateChanged = previous !== undefined && (previous.installed !== incoming.installed || previous.downloaded !== incoming.downloaded); - const activeStateReconciled = hasAuthoritativeSnapshot - && (activeStatus !== undefined - || (previous !== undefined && isReconciledOperationStatus(previous.install_status))); - const clearStatus = localStateChanged || activeStateReconciled; + const statusChanged = previous !== undefined + && previous.install_status !== installStatus; + const clearStatus = localStateChanged + || (statusChanged && (activeStatus !== undefined || isInProgress(previous.install_status))); return { ...incoming, diff --git a/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts new file mode 100644 index 0000000..c7eecb6 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/tests/gameState.test.ts @@ -0,0 +1,83 @@ +import { + activeStatusById, + mergeGameUpdate, +} from '../src/lib/gameState.ts'; +import { + ActiveOperationKind, + GameAvailability, + InstallStatus, + type Game, +} from '../src/lib/types.ts'; + +const assertEquals = (actual: T, expected: T, message: string) => { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +}; + +const game = (overrides: Partial = {}): Game => ({ + id: 'game', + name: 'Game', + description: '', + size: 0, + downloaded: false, + installed: false, + availability: GameAvailability.LocalOnly, + install_status: InstallStatus.NotInstalled, + peer_count: 1, + ...overrides, +}); + +Deno.test('snapshot keeps installing visible until installed state settles', () => { + const fromDownloading = game({ + install_status: InstallStatus.Downloading, + }); + const installing = mergeGameUpdate( + game({ downloaded: true }), + fromDownloading, + InstallStatus.Installing, + ); + const installedWhileActive = mergeGameUpdate( + game({ downloaded: true, installed: true }), + installing, + InstallStatus.Installing, + ); + const settled = mergeGameUpdate( + game({ downloaded: true, installed: true }), + installedWhileActive, + ); + + assertEquals( + installing.install_status, + InstallStatus.Installing, + 'active install snapshot should render Installing', + ); + assertEquals( + installedWhileActive.install_status, + InstallStatus.Installing, + 'installed local state should not override an active install snapshot', + ); + assertEquals( + settled.install_status, + InstallStatus.Installed, + 'cleared active snapshot with installed local state should render Installed', + ); +}); + +Deno.test('active operation snapshot is the source of busy status', () => { + const statuses = activeStatusById([ + { id: 'game', operation: ActiveOperationKind.Downloading }, + { id: 'other', operation: ActiveOperationKind.Updating }, + ]); + + assertEquals( + statuses.get('game'), + InstallStatus.Downloading, + 'download operation should render Downloading', + ); + assertEquals( + statuses.get('other'), + InstallStatus.Installing, + 'update operation should render Installing', + ); +}); diff --git a/justfile b/justfile index 8fac915..36e7a84 100644 --- a/justfile +++ b/justfile @@ -27,6 +27,9 @@ clippy: test: cargo test --workspace +frontend-test: + cd crates/lanspread-tauri-deno-ts && deno test --unstable-sloppy-imports tests + clean: cargo clean