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
267 lines
7.4 KiB
TypeScript
267 lines
7.4 KiB
TypeScript
import {
|
|
applyDownloadProgress,
|
|
applyGameTransferStatusSnapshot,
|
|
downloadProgressAriaLabel,
|
|
downloadProgressTransferLabel,
|
|
gameTransferStatusPresentation,
|
|
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
|
newestGameTransferStatusSnapshot,
|
|
} from "../src/lib/gameState.ts";
|
|
import {
|
|
type DownloadProgressPayload,
|
|
type Game,
|
|
GameAvailability,
|
|
GameTransferStatus,
|
|
type GameTransferStatusSnapshot,
|
|
InstallStatus,
|
|
} from "../src/lib/types.ts";
|
|
|
|
const assertEquals = <T>(actual: T, expected: T, message: string) => {
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new Error(
|
|
`${message}: expected ${JSON.stringify(expected)}, got ${
|
|
JSON.stringify(actual)
|
|
}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const game = (id: string): Game => ({
|
|
id,
|
|
name: id,
|
|
description: "",
|
|
size: 0,
|
|
downloaded: false,
|
|
installed: false,
|
|
availability: GameAvailability.LocalOnly,
|
|
install_status: InstallStatus.Downloading,
|
|
peer_count: 1,
|
|
download_progress: {
|
|
attemptId: `${id}-attempt`,
|
|
downloaded_bytes: 10,
|
|
total_bytes: 100,
|
|
bytes_per_second: 5,
|
|
active_peer_count: 1,
|
|
},
|
|
});
|
|
|
|
const snapshot = (
|
|
revision: number,
|
|
statuses: GameTransferStatusSnapshot["statuses"],
|
|
openAttempts: GameTransferStatusSnapshot["openAttempts"] = {},
|
|
): GameTransferStatusSnapshot => ({ revision, statuses, openAttempts });
|
|
|
|
const progress = (
|
|
id: string,
|
|
attemptId: string,
|
|
downloadedBytes: number,
|
|
): DownloadProgressPayload => ({
|
|
id,
|
|
attemptId,
|
|
downloaded_bytes: downloadedBytes,
|
|
total_bytes: 100,
|
|
bytes_per_second: 5,
|
|
active_peer_count: 1,
|
|
});
|
|
|
|
Deno.test("newer transfer event beats stale and equal GamesList snapshots", () => {
|
|
const event = snapshot(2, { alpha: GameTransferStatus.Retrying });
|
|
assertEquals(
|
|
newestGameTransferStatusSnapshot(event, snapshot(1, {})),
|
|
event,
|
|
"a delayed older GamesList snapshot must not replace the listener event",
|
|
);
|
|
assertEquals(
|
|
newestGameTransferStatusSnapshot(
|
|
event,
|
|
snapshot(2, { alpha: GameTransferStatus.Exhausted }),
|
|
),
|
|
event,
|
|
"an equal revision replay must not change the accepted full snapshot",
|
|
);
|
|
});
|
|
|
|
Deno.test("higher revision full replacement clears omitted game statuses", () => {
|
|
const games = applyGameTransferStatusSnapshot(
|
|
[game("alpha"), game("bravo")],
|
|
snapshot(4, {
|
|
alpha: GameTransferStatus.Verifying,
|
|
bravo: GameTransferStatus.Exhausted,
|
|
}, { alpha: "alpha-attempt", bravo: "bravo-attempt" }),
|
|
);
|
|
const replacement = applyGameTransferStatusSnapshot(
|
|
games,
|
|
snapshot(
|
|
5,
|
|
{ bravo: GameTransferStatus.Retrying },
|
|
{ alpha: "alpha-attempt", bravo: "bravo-attempt" },
|
|
),
|
|
);
|
|
|
|
assertEquals(
|
|
replacement.map((item) => item.transfer_status),
|
|
[undefined, GameTransferStatus.Retrying],
|
|
"omission must mean None while the listed game retains its exact status",
|
|
);
|
|
assertEquals(
|
|
replacement.map((item) => item.download_progress?.downloaded_bytes),
|
|
[10, 10],
|
|
"typed status replacement must not mutate direct byte progress",
|
|
);
|
|
});
|
|
|
|
Deno.test("progress is fenced by the newest exact open attempt", () => {
|
|
const attemptA = "9007199254740992";
|
|
const attemptB = "9007199254740993";
|
|
const withoutProgress = [{ ...game("alpha"), download_progress: undefined }];
|
|
const openA = snapshot(1, {}, { alpha: attemptA });
|
|
const openB = snapshot(2, {}, { alpha: attemptB });
|
|
const terminalB = snapshot(3, {}, {});
|
|
|
|
const progressA = applyDownloadProgress(
|
|
withoutProgress,
|
|
openA,
|
|
progress("alpha", attemptA, 10),
|
|
);
|
|
assertEquals(
|
|
progressA[0].download_progress?.attemptId,
|
|
attemptA,
|
|
"A progress should be accepted while A is the open attempt",
|
|
);
|
|
|
|
const beganB = applyGameTransferStatusSnapshot(progressA, openB);
|
|
assertEquals(
|
|
beganB[0].download_progress,
|
|
undefined,
|
|
"a newer B Begin snapshot must clear already-rendered A progress",
|
|
);
|
|
const delayedA = applyDownloadProgress(
|
|
beganB,
|
|
openB,
|
|
progress("alpha", attemptA, 20),
|
|
);
|
|
assertEquals(
|
|
delayedA[0].download_progress,
|
|
undefined,
|
|
"delayed A progress must be rejected after B becomes authoritative",
|
|
);
|
|
|
|
const progressB = applyDownloadProgress(
|
|
delayedA,
|
|
openB,
|
|
progress("alpha", attemptB, 30),
|
|
);
|
|
assertEquals(
|
|
progressB[0].download_progress?.downloaded_bytes,
|
|
30,
|
|
"progress for the exact current B attempt must be accepted",
|
|
);
|
|
|
|
const finishedB = applyGameTransferStatusSnapshot(progressB, terminalB);
|
|
assertEquals(
|
|
finishedB[0].download_progress,
|
|
undefined,
|
|
"terminal replacement must clear B progress with the open attempt",
|
|
);
|
|
const lateB = applyDownloadProgress(
|
|
finishedB,
|
|
terminalB,
|
|
progress("alpha", attemptB, 40),
|
|
);
|
|
assertEquals(
|
|
lateB[0].download_progress,
|
|
undefined,
|
|
"late B progress must remain rejected after terminal settlement",
|
|
);
|
|
});
|
|
|
|
Deno.test("transfer status lookup uses only own game-id properties", () => {
|
|
const [projected] = applyGameTransferStatusSnapshot(
|
|
[game("toString")],
|
|
snapshot(3, {}),
|
|
);
|
|
assertEquals(
|
|
projected.transfer_status,
|
|
undefined,
|
|
"prototype properties must not become catalog game statuses",
|
|
);
|
|
assertEquals(
|
|
newestGameTransferStatusSnapshot(
|
|
INITIAL_GAME_TRANSFER_STATUS_SNAPSHOT,
|
|
snapshot(1, {}),
|
|
).revision,
|
|
1,
|
|
"the backend revision-one bootstrap must replace the frontend initial state",
|
|
);
|
|
});
|
|
|
|
Deno.test("transfer notices use the exact copy and severity contract", () => {
|
|
assertEquals(
|
|
gameTransferStatusPresentation(GameTransferStatus.Verifying),
|
|
{ message: "Verifying downloaded chunks", level: "info" },
|
|
"verification copy",
|
|
);
|
|
assertEquals(
|
|
gameTransferStatusPresentation(GameTransferStatus.Retrying),
|
|
{
|
|
message: "A source sent invalid data; retrying another nearby peer",
|
|
level: "warning",
|
|
},
|
|
"retry copy",
|
|
);
|
|
assertEquals(
|
|
gameTransferStatusPresentation(GameTransferStatus.Exhausted),
|
|
{
|
|
message: "No nearby peer could provide the verified catalog version",
|
|
level: "error",
|
|
},
|
|
"exhaustion copy",
|
|
);
|
|
assertEquals(
|
|
gameTransferStatusPresentation(undefined),
|
|
undefined,
|
|
"None must remain invisible",
|
|
);
|
|
});
|
|
|
|
Deno.test("download progress promotes transient verification and retry copy", () => {
|
|
const verifying = {
|
|
...game("Alpha"),
|
|
transfer_status: GameTransferStatus.Verifying,
|
|
};
|
|
const retrying = {
|
|
...game("Bravo"),
|
|
transfer_status: GameTransferStatus.Retrying,
|
|
};
|
|
const exhausted = {
|
|
...game("Charlie"),
|
|
transfer_status: GameTransferStatus.Exhausted,
|
|
};
|
|
|
|
assertEquals(
|
|
downloadProgressTransferLabel(verifying),
|
|
"Verifying downloaded chunks",
|
|
"verification should replace the primary progress copy",
|
|
);
|
|
assertEquals(
|
|
downloadProgressAriaLabel(verifying),
|
|
"Verifying downloaded chunks: Alpha",
|
|
"verification should replace the progressbar accessible name",
|
|
);
|
|
assertEquals(
|
|
downloadProgressTransferLabel(retrying),
|
|
"A source sent invalid data; retrying another nearby peer",
|
|
"retry should replace the primary progress copy",
|
|
);
|
|
assertEquals(
|
|
downloadProgressAriaLabel(retrying),
|
|
"A source sent invalid data; retrying another nearby peer: Bravo",
|
|
"retry should replace the progressbar accessible name",
|
|
);
|
|
assertEquals(
|
|
downloadProgressTransferLabel(exhausted),
|
|
undefined,
|
|
"terminal exhaustion should remain on the card/modal status surface",
|
|
);
|
|
});
|