feat(app): expose local sharing and verified transfers
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
This commit is contained in:
@@ -1,286 +1,418 @@
|
||||
import {
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
canStreamInstall,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
formatDownloadBytes,
|
||||
formatBytesPerSecond,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
gameStatusLabel,
|
||||
mergeGameUpdate,
|
||||
stateChipLabel,
|
||||
} from '../src/lib/gameState.ts';
|
||||
actionLabel,
|
||||
activeStatusById,
|
||||
applyFilterAndSort,
|
||||
applyGameTransferStatusSnapshot,
|
||||
canStreamInstall,
|
||||
countByFilter,
|
||||
deriveState,
|
||||
downloadProgressPercent,
|
||||
formatBytesPerSecond,
|
||||
formatDownloadBytes,
|
||||
formatDownloadEta,
|
||||
formatDownloadSpeed,
|
||||
formatDownloadSpeedShort,
|
||||
gameStatusLabel,
|
||||
mergeGameUpdate,
|
||||
stateChipLabel,
|
||||
} from "../src/lib/gameState.ts";
|
||||
import {
|
||||
ActiveOperationKind,
|
||||
GameAvailability,
|
||||
InstallStatus,
|
||||
type Game,
|
||||
} from '../src/lib/types.ts';
|
||||
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}`);
|
||||
}
|
||||
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,
|
||||
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,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
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('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,
|
||||
);
|
||||
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",
|
||||
);
|
||||
|
||||
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',
|
||||
);
|
||||
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('active operation snapshot is the source of busy status', () => {
|
||||
const statuses = activeStatusById([
|
||||
{ id: 'game', operation: ActiveOperationKind.Downloading },
|
||||
{ id: 'other', operation: ActiveOperationKind.Updating },
|
||||
]);
|
||||
|
||||
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(
|
||||
statuses.get('game'),
|
||||
InstallStatus.Downloading,
|
||||
'download operation should render Downloading',
|
||||
);
|
||||
assertEquals(
|
||||
statuses.get('other'),
|
||||
InstallStatus.Installing,
|
||||
'update operation should render Installing',
|
||||
applyFilterAndSort(
|
||||
[{ ...departed, transfer_status: transferStatus }],
|
||||
"all",
|
||||
"az",
|
||||
"",
|
||||
).length,
|
||||
0,
|
||||
`${transferStatus} should not make an otherwise-invisible game visible`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
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('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');
|
||||
});
|
||||
|
||||
Deno.test('stream install is available only for idle remote games', () => {
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 1 })),
|
||||
true,
|
||||
'remote-only idle games should allow streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: true, installed: false, peer_count: 1 })),
|
||||
false,
|
||||
'downloaded games should install from local archives',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: true, peer_count: 1 })),
|
||||
false,
|
||||
'installed games should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({ downloaded: false, installed: false, peer_count: 0 })),
|
||||
false,
|
||||
'games without peers should not expose streamed install',
|
||||
);
|
||||
assertEquals(
|
||||
canStreamInstall(game({
|
||||
downloaded: false,
|
||||
installed: false,
|
||||
peer_count: 1,
|
||||
install_status: InstallStatus.CheckingPeers,
|
||||
})),
|
||||
false,
|
||||
'busy games should not expose streamed install',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('streamed local installs are labeled installed but not shareable', () => {
|
||||
const streamed = game({
|
||||
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: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
const downloadedInstall = game({
|
||||
downloaded: true,
|
||||
installed: true,
|
||||
install_status: InstallStatus.Installed,
|
||||
});
|
||||
installed: false,
|
||||
peer_count: 1,
|
||||
install_status: InstallStatus.Installing,
|
||||
}),
|
||||
true,
|
||||
),
|
||||
false,
|
||||
"busy games should not expose streamed install",
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user