fix(launcher): keep active installations visible in game filters

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.
This commit is contained in:
ddidderr committed 2026-09-12 20:01:40 +02:00
1 parent 6b65a66465
commit 1a394d2825
6 files changed
+227 -39

No files matched your search

@@ -14,12 +14,14 @@ import {
formatDownloadSpeedShort,
gameStatusLabel,
mergeGameUpdate,
primaryActionFor,
stateChipLabel,
} from "../src/lib/gameState.ts";
import {
ActiveOperationKind,
type Game,
GameAvailability,
type GameFilter,
GameTransferStatus,
InstallStatus,
} from "../src/lib/types.ts";
@@ -97,6 +99,158 @@ Deno.test("active operation snapshot is the source of busy status", () => {
);
});
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,