40697a73e5
NEXT_STEPS item 1 called out that streamed install was still CLI-only because the Tauri app started the peer with no stream provider. Users can now choose an explicit "Low disk install" action from the game detail modal for remote-only games instead of taking the default archive-preserving download path. The GUI command queues a normal peer detail fetch first so the peer database has the file metadata needed for source validation. A small pending handoff in Tauri routes the resulting GotGameFiles event into StreamInstallGame instead of DownloadGameFiles, and clears that pending state on no-peer or download failure events. This keeps the existing download continuation untouched for the default action. The external unrar stream provider moved from the CLI harness into lanspread-peer so CLI and Tauri use the same implementation. Tauri resolves the bundled unrar sidecar path and injects that provider at peer startup; falling back to the noop provider keeps peer startup alive if the sidecar cannot be resolved, while the streamed install operation still fails safely. Test Plan: - just fmt - just test - just frontend-test - just clippy - just build - git diff --check Refs: NEXT_STEPS.md item 1
246 lines
7.2 KiB
TypeScript
246 lines
7.2 KiB
TypeScript
import {
|
|
actionLabel,
|
|
activeStatusById,
|
|
applyFilterAndSort,
|
|
canStreamInstall,
|
|
countByFilter,
|
|
deriveState,
|
|
downloadProgressPercent,
|
|
formatDownloadBytes,
|
|
formatBytesPerSecond,
|
|
formatDownloadEta,
|
|
formatDownloadSpeed,
|
|
formatDownloadSpeedShort,
|
|
mergeGameUpdate,
|
|
} from '../src/lib/gameState.ts';
|
|
import {
|
|
ActiveOperationKind,
|
|
GameAvailability,
|
|
InstallStatus,
|
|
type Game,
|
|
} from '../src/lib/types.ts';
|
|
|
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message}: expected ${expected}, got ${actual}`);
|
|
}
|
|
};
|
|
|
|
const game = (overrides: Partial<Game> = {}): 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',
|
|
);
|
|
});
|
|
|
|
Deno.test('download progress is preserved only while actively downloading', () => {
|
|
const downloading = game({
|
|
install_status: InstallStatus.Downloading,
|
|
download_progress: {
|
|
downloaded_bytes: 50,
|
|
total_bytes: 100,
|
|
bytes_per_second: 12_500_000,
|
|
active_peer_count: 2,
|
|
},
|
|
});
|
|
|
|
const stillDownloading = mergeGameUpdate(
|
|
game(),
|
|
downloading,
|
|
InstallStatus.Downloading,
|
|
);
|
|
const settled = mergeGameUpdate(game({ downloaded: true }), stillDownloading);
|
|
|
|
assertEquals(
|
|
stillDownloading.download_progress?.downloaded_bytes,
|
|
50,
|
|
'active download snapshot should keep progress',
|
|
);
|
|
assertEquals(
|
|
stillDownloading.download_progress?.active_peer_count,
|
|
2,
|
|
'active download snapshot should keep live peer count',
|
|
);
|
|
assertEquals(
|
|
settled.download_progress,
|
|
undefined,
|
|
'settled snapshot should clear progress',
|
|
);
|
|
});
|
|
|
|
Deno.test('downloading action label includes current speed', () => {
|
|
const downloading = game({
|
|
install_status: InstallStatus.Downloading,
|
|
download_progress: {
|
|
downloaded_bytes: 50,
|
|
total_bytes: 100,
|
|
bytes_per_second: 12_500_000,
|
|
active_peer_count: 2,
|
|
},
|
|
});
|
|
|
|
assertEquals(
|
|
formatBytesPerSecond(12_500_000),
|
|
'12.5 MB/s',
|
|
'speed formatter should use compact decimal units',
|
|
);
|
|
assertEquals(
|
|
actionLabel(downloading),
|
|
'Downloading… 12.5 MB/s',
|
|
'download label should include speed',
|
|
);
|
|
});
|
|
|
|
Deno.test('downloading state is distinct and stays on the local filter', () => {
|
|
const downloading = game({
|
|
id: 'downloading',
|
|
name: 'Downloading',
|
|
install_status: InstallStatus.Downloading,
|
|
});
|
|
const local = game({
|
|
id: 'local',
|
|
name: 'Local',
|
|
downloaded: true,
|
|
});
|
|
const remote = game({
|
|
id: 'remote',
|
|
name: 'Remote',
|
|
peer_count: 1,
|
|
});
|
|
|
|
assertEquals(
|
|
deriveState(downloading),
|
|
'downloading',
|
|
'download operation should render the dedicated downloading state',
|
|
);
|
|
assertEquals(
|
|
countByFilter([downloading, local, remote]).local,
|
|
2,
|
|
'local filter count should include in-flight downloads',
|
|
);
|
|
assertEquals(
|
|
applyFilterAndSort([downloading, local, remote], 'local', 'status', '').length,
|
|
2,
|
|
'local filter should include in-flight downloads',
|
|
);
|
|
});
|
|
|
|
Deno.test('download progress formatting matches the progress-bar layouts', () => {
|
|
const downloading = game({
|
|
install_status: InstallStatus.Downloading,
|
|
download_progress: {
|
|
downloaded_bytes: 12 * 1024 * 1024 * 1024,
|
|
total_bytes: 35 * 1024 * 1024 * 1024,
|
|
bytes_per_second: 49_400_000,
|
|
active_peer_count: 3,
|
|
},
|
|
});
|
|
|
|
assertEquals(
|
|
Math.round(downloadProgressPercent(downloading)),
|
|
34,
|
|
'progress percent should come from backend byte counters',
|
|
);
|
|
assertEquals(formatDownloadSpeed(49_400_000), '49.4 MB/s', 'large bar speed format');
|
|
assertEquals(formatDownloadSpeedShort(49_400_000), '49 MB/s', 'card speed format');
|
|
assertEquals(
|
|
formatDownloadBytes(12 * 1024 * 1024 * 1024),
|
|
'12 GB',
|
|
'downloaded byte format should avoid noisy trailing decimals',
|
|
);
|
|
assertEquals(formatDownloadEta(485), '8 min', 'eta format should stay compact');
|
|
});
|
|
|
|
Deno.test('stream install is available only for idle remote games', () => {
|
|
assertEquals(
|
|
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
|
|
true,
|
|
'remote-only idle games should allow streamed install',
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
|
|
false,
|
|
'downloaded games should install from local archives',
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
|
|
false,
|
|
'installed games should not expose streamed install',
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
|
|
false,
|
|
'games without peers should not expose streamed install',
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(game({
|
|
downloaded: false,
|
|
installed: false,
|
|
peer_count: 1,
|
|
install_status: InstallStatus.CheckingPeers,
|
|
})),
|
|
false,
|
|
'busy games should not expose streamed install',
|
|
);
|
|
});
|