Add a durable, acknowledged Local network sharing switch with fail-closed hydration, serialized mutation, and redacted ephemeral-identity diagnostics. Keep local Call-to-Play state available while gating every network action on the effective sharing generation. Render revisioned verification, invalid-source retry, and sticky source exhaustion states. Preserve opaque attempt IDs through progress delivery so out-of-order webview events cannot attach stale bytes to a successor transfer, and keep terminal exhaustion visible after the last source departs. Own listeners, native invokes, persistence, dialogs, and companion-window creation through webview close. Late creation is settled and cleaned before the parent realm is destroyed. Test Plan: - `just frontend-test` -- passed (91/91) - `just build` -- passed with TypeScript, Vite, and release Tauri compilation - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `git diff --cached --check` -- passed
419 lines
11 KiB
TypeScript
419 lines
11 KiB
TypeScript
import {
|
|
actionLabel,
|
|
activeStatusById,
|
|
applyFilterAndSort,
|
|
applyGameTransferStatusSnapshot,
|
|
canStreamInstall,
|
|
countByFilter,
|
|
deriveState,
|
|
downloadProgressPercent,
|
|
formatBytesPerSecond,
|
|
formatDownloadBytes,
|
|
formatDownloadEta,
|
|
formatDownloadSpeed,
|
|
formatDownloadSpeedShort,
|
|
gameStatusLabel,
|
|
mergeGameUpdate,
|
|
stateChipLabel,
|
|
} from "../src/lib/gameState.ts";
|
|
import {
|
|
ActiveOperationKind,
|
|
type Game,
|
|
GameAvailability,
|
|
GameTransferStatus,
|
|
InstallStatus,
|
|
} 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: {
|
|
attemptId: "1",
|
|
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: {
|
|
attemptId: "1",
|
|
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("sticky transfer exhaustion keeps a departed remote game visible until cleared", () => {
|
|
const remoteOnly = game({
|
|
id: "remote-only",
|
|
name: "Remote only",
|
|
peer_count: 1,
|
|
});
|
|
assertEquals(
|
|
applyFilterAndSort([remoteOnly], "all", "az", "").length,
|
|
1,
|
|
"a remote-only game should initially be visible",
|
|
);
|
|
|
|
const peerDeparted = mergeGameUpdate(
|
|
game({ id: "remote-only", name: "Remote only", peer_count: 0 }),
|
|
remoteOnly,
|
|
);
|
|
assertEquals(
|
|
peerDeparted.install_status,
|
|
InstallStatus.NotInstalled,
|
|
"an empty active-operation snapshot should leave the game idle",
|
|
);
|
|
assertEquals(
|
|
peerDeparted.peer_count,
|
|
0,
|
|
"the sole remote source should be absent from the next catalog snapshot",
|
|
);
|
|
assertEquals(
|
|
applyFilterAndSort([peerDeparted], "all", "az", "").length,
|
|
0,
|
|
"an idle remote-only game should be invisible before exhaustion arrives",
|
|
);
|
|
|
|
const [exhausted] = applyGameTransferStatusSnapshot([peerDeparted], {
|
|
revision: 1,
|
|
statuses: { "remote-only": GameTransferStatus.Exhausted },
|
|
openAttempts: {},
|
|
});
|
|
assertEquals(
|
|
exhausted.transfer_status,
|
|
GameTransferStatus.Exhausted,
|
|
"the sticky snapshot should preserve the exact terminal status",
|
|
);
|
|
assertEquals(
|
|
applyFilterAndSort([exhausted], "all", "az", "").length,
|
|
1,
|
|
"terminal exhaustion should keep the departed remote game visible",
|
|
);
|
|
assertEquals(
|
|
countByFilter([exhausted]).all,
|
|
1,
|
|
"the All-filter count should match terminal exhaustion visibility",
|
|
);
|
|
|
|
const [cleared] = applyGameTransferStatusSnapshot([exhausted], {
|
|
revision: 2,
|
|
statuses: {},
|
|
openAttempts: {},
|
|
});
|
|
assertEquals(
|
|
cleared.transfer_status,
|
|
undefined,
|
|
"a full replacement that omits the game should clear exhaustion",
|
|
);
|
|
assertEquals(
|
|
applyFilterAndSort([cleared], "all", "az", "").length,
|
|
0,
|
|
"clearing exhaustion should remove the otherwise-invisible game",
|
|
);
|
|
});
|
|
|
|
Deno.test("transient transfer activity does not resurrect an invisible game", () => {
|
|
const departed = game({ peer_count: 0 });
|
|
for (
|
|
const transferStatus of [
|
|
GameTransferStatus.Verifying,
|
|
GameTransferStatus.Retrying,
|
|
]
|
|
) {
|
|
assertEquals(
|
|
applyFilterAndSort(
|
|
[{ ...departed, transfer_status: transferStatus }],
|
|
"all",
|
|
"az",
|
|
"",
|
|
).length,
|
|
0,
|
|
`${transferStatus} should not make an otherwise-invisible game visible`,
|
|
);
|
|
}
|
|
});
|
|
|
|
Deno.test("download progress formatting matches the progress-bar layouts", () => {
|
|
const downloading = game({
|
|
install_status: InstallStatus.Downloading,
|
|
download_progress: {
|
|
attemptId: "1",
|
|
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,
|
|
),
|
|
true,
|
|
"catalog-supported remote-only idle games should allow streamed install",
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(
|
|
game({ downloaded: false, installed: false, peer_count: 1 }),
|
|
false,
|
|
),
|
|
false,
|
|
"catalog-unsupported games should not expose streamed install",
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(
|
|
game({ downloaded: true, installed: false, peer_count: 1 }),
|
|
true,
|
|
),
|
|
false,
|
|
"downloaded games should install from local archives",
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(
|
|
game({ downloaded: false, installed: true, peer_count: 1 }),
|
|
true,
|
|
),
|
|
false,
|
|
"installed games should not expose streamed install",
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(
|
|
game({ downloaded: false, installed: false, peer_count: 0 }),
|
|
true,
|
|
),
|
|
false,
|
|
"games without peers should not expose streamed install",
|
|
);
|
|
assertEquals(
|
|
canStreamInstall(
|
|
game({
|
|
downloaded: false,
|
|
installed: false,
|
|
peer_count: 1,
|
|
install_status: InstallStatus.Installing,
|
|
}),
|
|
true,
|
|
),
|
|
false,
|
|
"busy games should not expose streamed install",
|
|
);
|
|
});
|
|
|
|
Deno.test("streamed local installs are labeled installed but not shareable", () => {
|
|
const streamed = game({
|
|
downloaded: false,
|
|
installed: true,
|
|
install_status: InstallStatus.Installed,
|
|
});
|
|
const downloadedInstall = game({
|
|
downloaded: true,
|
|
installed: true,
|
|
install_status: InstallStatus.Installed,
|
|
});
|
|
|
|
assertEquals(
|
|
deriveState(streamed),
|
|
"installed",
|
|
"streamed local installs should keep installed visual state",
|
|
);
|
|
assertEquals(
|
|
stateChipLabel(streamed),
|
|
"Not shareable",
|
|
"card chip should make the non-shareable state visible",
|
|
);
|
|
assertEquals(
|
|
gameStatusLabel(streamed),
|
|
"Installed, not shareable",
|
|
"detail status should spell out installed plus non-shareable",
|
|
);
|
|
assertEquals(
|
|
stateChipLabel(downloadedInstall),
|
|
"Installed",
|
|
"normal downloaded installs should keep the installed chip label",
|
|
);
|
|
assertEquals(
|
|
gameStatusLabel(downloadedInstall),
|
|
"Installed",
|
|
"normal downloaded installs should keep the installed detail label",
|
|
);
|
|
});
|