Operation admission withdraws local availability before changing game files. The UI previously received that withdrawal before the busy state, then filtered out installations because only downloads counted as local activity. Publish the active operation first while preserving atomic network withdrawal. Keep active operations in All Games and Local, retain prior Installed filter membership until settlement, and show operation-specific status labels. Local availability and playability remain derived from the backend. Document the installation presentation and cover withdrawal, settlement, and filter counts. Test Plan: - just fmt and just clippy: passed. - just test: passed all workspace suites. - just frontend-test: 98 passed, including installation lifecycle regressions. - deno task build: TypeScript and Vite production build passed. - Native GUI interaction was not manually exercised.
573 lines
15 KiB
TypeScript
573 lines
15 KiB
TypeScript
import {
|
|
actionLabel,
|
|
activeStatusById,
|
|
applyFilterAndSort,
|
|
applyGameTransferStatusSnapshot,
|
|
canStreamInstall,
|
|
countByFilter,
|
|
deriveState,
|
|
downloadProgressPercent,
|
|
formatBytesPerSecond,
|
|
formatDownloadBytes,
|
|
formatDownloadEta,
|
|
formatDownloadSpeed,
|
|
formatDownloadSpeedShort,
|
|
gameStatusLabel,
|
|
mergeGameUpdate,
|
|
primaryActionFor,
|
|
stateChipLabel,
|
|
} from "../src/lib/gameState.ts";
|
|
import {
|
|
ActiveOperationKind,
|
|
type Game,
|
|
GameAvailability,
|
|
type GameFilter,
|
|
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",
|
|
);
|
|
});
|
|
|
|
const assertFilterMembership = (
|
|
current: Game,
|
|
expected: Record<GameFilter, number>,
|
|
) => {
|
|
const counts = countByFilter([current]);
|
|
for (const filter of ["all", "local", "installed"] as const) {
|
|
assertEquals(
|
|
applyFilterAndSort([current], filter, "az", "").length,
|
|
expected[filter],
|
|
`${current.install_status} visibility in ${filter}`,
|
|
);
|
|
assertEquals(counts[filter], expected[filter], `${filter} count`);
|
|
}
|
|
};
|
|
|
|
Deno.test("offline local install stays visible through withdrawal and settlement", () => {
|
|
const local = game({ downloaded: true, peer_count: 0 });
|
|
const active = mergeGameUpdate(local, local, InstallStatus.Installing);
|
|
// The peer withdraws shareable local state while staging the installation.
|
|
const withdrawn = mergeGameUpdate(
|
|
game({ peer_count: 0 }),
|
|
active,
|
|
InstallStatus.Installing,
|
|
);
|
|
for (const current of [local, active, withdrawn]) {
|
|
assertFilterMembership(current, { all: 1, local: 1, installed: 0 });
|
|
}
|
|
assertEquals(
|
|
withdrawn.downloaded,
|
|
false,
|
|
"keep backend availability truthful",
|
|
);
|
|
assertEquals(stateChipLabel(withdrawn), "Installing", "card status chip");
|
|
assertEquals(gameStatusLabel(withdrawn), "Installing…", "detail status");
|
|
assertEquals(actionLabel(withdrawn), "Installing…", "action label");
|
|
assertEquals(
|
|
primaryActionFor(withdrawn),
|
|
"busy",
|
|
"disable duplicate installs",
|
|
);
|
|
assertEquals(
|
|
applyFilterAndSort([withdrawn], "all", "az", "unrelated").length,
|
|
0,
|
|
"busy cards still obey search",
|
|
);
|
|
|
|
const settled = mergeGameUpdate(
|
|
game({ downloaded: true, installed: true, peer_count: 0 }),
|
|
withdrawn,
|
|
);
|
|
assertFilterMembership(settled, { all: 1, local: 1, installed: 1 });
|
|
assertEquals(
|
|
actionLabel(settled),
|
|
"Play",
|
|
"successful install becomes playable",
|
|
);
|
|
|
|
const failed = mergeGameUpdate(local, withdrawn);
|
|
assertFilterMembership(failed, { all: 1, local: 1, installed: 0 });
|
|
assertEquals(actionLabel(failed), "Install", "failed install can be retried");
|
|
});
|
|
|
|
Deno.test("stream install remains local after its last source leaves", () => {
|
|
const active = mergeGameUpdate(game(), game(), InstallStatus.Installing);
|
|
const withdrawn = mergeGameUpdate(
|
|
game({ peer_count: 0 }),
|
|
active,
|
|
InstallStatus.Installing,
|
|
);
|
|
assertFilterMembership(withdrawn, { all: 1, local: 1, installed: 0 });
|
|
|
|
const settled = mergeGameUpdate(game({ peer_count: 0 }), withdrawn);
|
|
assertFilterMembership(settled, { all: 0, local: 0, installed: 0 });
|
|
});
|
|
|
|
Deno.test("installed filter retains busy games until authoritative settlement", () => {
|
|
for (
|
|
const operation of [
|
|
ActiveOperationKind.Downloading,
|
|
ActiveOperationKind.Updating,
|
|
ActiveOperationKind.Uninstalling,
|
|
]
|
|
) {
|
|
const installed = game({
|
|
downloaded: true,
|
|
installed: true,
|
|
peer_count: 0,
|
|
});
|
|
const activeStatus = activeStatusById([{ id: "game", operation }]).get(
|
|
"game",
|
|
);
|
|
const active = mergeGameUpdate(installed, installed, activeStatus);
|
|
const withdrawn = mergeGameUpdate(
|
|
game({ peer_count: 0 }),
|
|
active,
|
|
activeStatus,
|
|
);
|
|
const repeated = mergeGameUpdate(
|
|
game({ peer_count: 0 }),
|
|
withdrawn,
|
|
activeStatus,
|
|
);
|
|
for (const current of [active, withdrawn, repeated]) {
|
|
assertFilterMembership(current, { all: 1, local: 1, installed: 1 });
|
|
}
|
|
assertEquals(
|
|
repeated.installed,
|
|
false,
|
|
"filter pin must not imply playable state",
|
|
);
|
|
assertEquals(
|
|
primaryActionFor(repeated),
|
|
"busy",
|
|
"operation stays disabled",
|
|
);
|
|
|
|
const settled = mergeGameUpdate(
|
|
game({ downloaded: true, peer_count: 0 }),
|
|
repeated,
|
|
);
|
|
assertFilterMembership(settled, { all: 1, local: 1, installed: 0 });
|
|
assertEquals(
|
|
settled.installed_before_operation,
|
|
undefined,
|
|
"clear prior membership",
|
|
);
|
|
const nextInstall = mergeGameUpdate(
|
|
settled,
|
|
settled,
|
|
InstallStatus.Installing,
|
|
);
|
|
assertFilterMembership(nextInstall, { all: 1, local: 1, installed: 0 });
|
|
}
|
|
});
|
|
|
|
Deno.test("local removal stays visible with its operation label until settled", () => {
|
|
const local = game({ downloaded: true, peer_count: 0 });
|
|
const active = mergeGameUpdate(local, local, InstallStatus.Removing);
|
|
const withdrawn = mergeGameUpdate(
|
|
game({ peer_count: 0 }),
|
|
active,
|
|
InstallStatus.Removing,
|
|
);
|
|
assertFilterMembership(withdrawn, { all: 1, local: 1, installed: 0 });
|
|
assertEquals(stateChipLabel(withdrawn), "Removing", "card status chip");
|
|
assertEquals(gameStatusLabel(withdrawn), "Removing…", "detail status");
|
|
assertFilterMembership(
|
|
mergeGameUpdate(game({ peer_count: 0 }), withdrawn),
|
|
{ all: 0, local: 0, installed: 0 },
|
|
);
|
|
});
|
|
|
|
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",
|
|
);
|
|
});
|