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