b56f4e2757
The launcher needs design work later for showing how many peers are currently feeding an active download. Surface that data now on the existing progress payload so UI state can consume it without a separate event stream or rendering change. The peer download tracker now treats each live chunk receive as peer activity and reports the number of unique peers with in-flight streams. This is a live transfer count, not the number of peers that advertised the game or received a plan. Multiple chunks from one peer count once, and the count falls as chunk streams finish. Tauri already forwards DownloadGameFilesProgress, so no bridge event was added. The TypeScript model accepts active_peer_count under download_progress and preserves it with the same reducer path that keeps bytes and speed while the backend says the game is still downloading. Test Plan: - just fmt - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just test - just frontend-test - RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= just clippy - git diff --check - git diff --cached --check Refs: none
212 lines
6.1 KiB
TypeScript
212 lines
6.1 KiB
TypeScript
import {
|
|
actionLabel,
|
|
activeStatusById,
|
|
applyFilterAndSort,
|
|
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');
|
|
});
|