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:
2026-08-10 13:59:40 +02:00
parent 60fd7ba0c2
commit 71dbf27d8b
42 changed files with 7457 additions and 2101 deletions
@@ -0,0 +1,776 @@
import {
AsyncAdoptionScope,
type AsyncCleanup,
AsyncOwner,
createSerializedAsyncWriter,
mergeHydratedState,
ownCompanionWindowCreation,
registerSequentially,
} from "../src/lib/asyncOwnership.ts";
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test("unmount before listener resolution cleans the late listener and stops registration", async () => {
const owner = new AsyncOwner();
const first = deferred<AsyncCleanup>();
let firstRegistrations = 0;
let secondRegistrations = 0;
let firstCleanups = 0;
const registration = registerSequentially(owner, [
() => {
firstRegistrations += 1;
return first.promise;
},
() => {
secondRegistrations += 1;
return Promise.resolve(() => {});
},
]);
assertEquals(
firstRegistrations,
1,
"the first listener should start immediately",
);
const disposal = owner.dispose();
first.resolve(() => {
firstCleanups += 1;
});
assertEquals(
await registration,
false,
"disposed registration should report cancellation",
);
assertEquals(
firstCleanups,
1,
"the late listener should unlisten immediately",
);
assertEquals(
secondRegistrations,
0,
"no later listener should start after disposal",
);
await disposal;
});
Deno.test("partial listener registration is fully cleaned across an in-flight listener", async () => {
const owner = new AsyncOwner();
const second = deferred<AsyncCleanup>();
const secondStarted = deferred<void>();
let firstCleanups = 0;
let secondCleanups = 0;
let thirdRegistrations = 0;
const registration = registerSequentially(owner, [
() =>
Promise.resolve(() => {
firstCleanups += 1;
}),
() => {
secondStarted.resolve();
return second.promise;
},
() => {
thirdRegistrations += 1;
return Promise.resolve(() => {});
},
]);
await secondStarted.promise;
const disposal = owner.dispose();
assertEquals(
firstCleanups,
1,
"an owned listener should clean up during disposal",
);
second.resolve(() => {
secondCleanups += 1;
});
assertEquals(
await registration,
false,
"the in-flight registration should stop the sequence",
);
assertEquals(
secondCleanups,
1,
"the in-flight listener should clean up when it resolves",
);
assertEquals(thirdRegistrations, 0, "the third listener should never start");
await disposal;
await owner.dispose();
assertEquals(
firstCleanups,
1,
"repeated disposal must not duplicate owned cleanup",
);
assertEquals(
secondCleanups,
1,
"repeated disposal must not duplicate late cleanup",
);
});
Deno.test("listener registration failure cleans the partial scope and skips later listeners", async () => {
const owner = new AsyncOwner();
const failure = new Error("registration failed");
let firstCleanups = 0;
let thirdRegistrations = 0;
let reported: unknown;
try {
await registerSequentially(owner, [
() =>
Promise.resolve(() => {
firstCleanups += 1;
}),
() => Promise.reject(failure),
() => {
thirdRegistrations += 1;
return Promise.resolve(() => {});
},
]);
} catch (error) {
reported = error;
}
assertEquals(reported, failure, "the registration error should be preserved");
assertEquals(
firstCleanups,
1,
"earlier listeners should clean up on setup failure",
);
assertEquals(
thirdRegistrations,
0,
"later listeners should not start after setup failure",
);
assertEquals(
owner.isActive(),
false,
"a failed registration scope should stay disposed",
);
});
Deno.test("late refresh results and post-disposal refreshes cannot publish", async () => {
const owner = new AsyncOwner();
const refresh = deferred<string>();
let refreshStarts = 0;
const applied: string[] = [];
const pending = owner.applyIfActive(
() => {
refreshStarts += 1;
return refresh.promise;
},
(value) => applied.push(value),
);
const disposal = owner.dispose();
refresh.resolve("stale");
assertEquals(
await pending,
false,
"a late refresh should report cancellation",
);
assertArrayEquals(applied, [], "a late refresh must not publish state");
const afterDispose = await owner.applyIfActive(
() => {
refreshStarts += 1;
return Promise.resolve("newer");
},
(value) => applied.push(value),
);
assertEquals(
afterDispose,
false,
"a disposed owner should reject new refresh work",
);
assertEquals(refreshStarts, 1, "refresh work must not start after disposal");
assertArrayEquals(
applied,
[],
"post-disposal refreshes must not publish state",
);
await disposal;
});
Deno.test("disposal joins a late asynchronous listener cleanup", async () => {
const owner = new AsyncOwner();
const listener = deferred<AsyncCleanup>();
const cleanupDone = deferred<void>();
let cleanupStarts = 0;
let disposalSettled = false;
const registration = owner.register(() => listener.promise);
const disposal = owner.dispose().then(() => {
disposalSettled = true;
});
listener.resolve(async () => {
cleanupStarts += 1;
await cleanupDone.promise;
});
await Promise.resolve();
assertEquals(
cleanupStarts,
1,
"the late cleanup should start immediately on resolution",
);
assertEquals(
disposalSettled,
false,
"disposal must wait for the asynchronous cleanup",
);
cleanupDone.resolve();
assertEquals(
await registration,
false,
"the disposed registration should remain cancelled",
);
await disposal;
assertEquals(
disposalSettled,
true,
"disposal should settle after cleanup completion",
);
});
Deno.test("asynchronous cleanup rejection is reported and disposal still drains", async () => {
const cleanupDone = deferred<void>();
const failure = new Error("unlisten failed");
const reported: unknown[] = [];
const owner = new AsyncOwner((error) => reported.push(error));
await owner.register(() =>
Promise.resolve(async () => {
await cleanupDone.promise;
throw failure;
})
);
const disposal = owner.dispose();
cleanupDone.resolve();
await disposal;
assertEquals(
reported.length,
1,
"the cleanup failure should be reported once",
);
assertEquals(
reported[0],
failure,
"the original cleanup failure should be reported",
);
});
Deno.test("a root adoption scope drains work and observes rejection", async () => {
const first = deferred<void>();
const failure = new Error("adopted cleanup failed");
const reported: unknown[] = [];
const scope = new AsyncAdoptionScope((error) => reported.push(error));
let drained = false;
scope.adopt(first.promise);
scope.adopt(Promise.reject(failure));
const drain = scope.drain().then(() => {
drained = true;
});
await Promise.resolve();
assertEquals(drained, false, "the adoption scope should retain pending work");
assertEquals(
reported.length,
1,
"an adopted rejection should be observed once",
);
assertEquals(
reported[0],
failure,
"the adoption scope should report the original error",
);
first.resolve();
await drain;
assertEquals(
drained,
true,
"the adoption scope should drain after all work settles",
);
});
Deno.test("window close drains every admitted operation and rejects late owner registration", async () => {
const scope = new AsyncAdoptionScope();
const cleanupDone = deferred<void>();
const unrelatedInvoke = deferred<string>();
const owner = new AsyncOwner(() => {}, scope);
let cleanupStarts = 0;
await owner.register(() =>
Promise.resolve(async () => {
cleanupStarts += 1;
await cleanupDone.promise;
})
);
const unrelated = owner.applyIfActive(
() => unrelatedInvoke.promise,
() => {},
);
let closeDrained = false;
const close = scope.disposeOwned().then(() => {
closeDrained = true;
});
const lateOwner = new AsyncOwner(() => {}, scope);
assertEquals(
lateOwner.isActive(),
false,
"owner admission must close synchronously",
);
assertEquals(cleanupStarts, 1, "listener cleanup should start during close");
assertEquals(closeDrained, false, "window close must await listener cleanup");
cleanupDone.resolve();
await Promise.resolve();
assertEquals(
closeDrained,
false,
"an admitted invoke must retain the webview after listener cleanup",
);
unrelatedInvoke.resolve("settled during close");
assertEquals(
await unrelated,
false,
"unrelated late invoke publication stays suppressed",
);
await close;
assertEquals(
closeDrained,
true,
"window close must join every admitted operation",
);
});
Deno.test("closing during a dialog prevents its chained invoke from starting", async () => {
const scope = new AsyncAdoptionScope();
const owner = new AsyncOwner(() => {}, scope);
const dialog = deferred<boolean>();
let invokes = 0;
const action = (async () => {
let confirmed = false;
if (
!await owner.applyIfActive(
() => dialog.promise,
(answer) => {
confirmed = answer;
},
) || !confirmed
) {
return;
}
await owner.applyIfActive(
() => {
invokes += 1;
return Promise.resolve();
},
() => {},
);
})();
const close = scope.disposeOwned();
dialog.resolve(true);
await action;
await close;
assertEquals(
invokes,
0,
"a dialog result must not start work after close admission",
);
});
Deno.test("closing during a helper import prevents later helper side effects", async () => {
const scope = new AsyncAdoptionScope();
const owner = new AsyncOwner(() => {}, scope);
const imported = deferred<string>();
let helperStarts = 0;
const helper = (async () => {
let moduleName: string | undefined;
if (
!await owner.applyIfActive(
() => imported.promise,
(value) => {
moduleName = value;
},
) || moduleName === undefined
) {
return;
}
await owner.applyIfActive(
() => {
helperStarts += 1;
return Promise.resolve();
},
() => {},
);
})();
const close = scope.disposeOwned();
imported.resolve("window helper");
await helper;
await close;
assertEquals(
helperStarts,
0,
"a late import must not focus or create a window",
);
});
Deno.test("close after companion construction drains creation and both listeners before destroy", async () => {
const scope = new AsyncAdoptionScope();
const owner = new AsyncOwner(() => {}, scope);
const createdCleanupDone = deferred<void>();
const errorCleanupDone = deferred<void>();
const bothCleanupsStarted = deferred<void>();
const companionDestroyStarted = deferred<void>();
const companionDestroyDone = deferred<void>();
const order: string[] = [];
let createdHandler: (() => void) | undefined;
let errorHandler: ((payload: unknown) => void) | undefined;
let cleanupStarts = 0;
let latePublications = 0;
let rootDestroyed = false;
const noteCleanupStart = () => {
cleanupStarts += 1;
if (cleanupStarts === 2) bothCleanupsStarted.resolve();
};
const creation = ownCompanionWindowCreation(owner, {
registerCreated: (handler) => {
createdHandler = handler;
return Promise.resolve(async () => {
order.push("created-listener-cleanup");
noteCleanupStart();
await createdCleanupDone.promise;
});
},
registerError: (handler) => {
errorHandler = handler;
return Promise.resolve(async () => {
order.push("error-listener-cleanup");
noteCleanupStart();
await errorCleanupDone.promise;
});
},
destroy: async () => {
order.push("companion-destroy");
companionDestroyStarted.resolve();
await companionDestroyDone.promise;
},
}).then((result) => {
if (owner.isActive()) latePublications += 1;
return result;
});
scope.adopt(creation);
if (createdHandler === undefined || errorHandler === undefined) {
throw new Error(
"construction must synchronously begin both creation listeners",
);
}
const close = scope.disposeOwned().then(() => {
order.push("root-destroy");
rootDestroyed = true;
});
await Promise.resolve();
assertEquals(
rootDestroyed,
false,
"close must wait for the native creation outcome",
);
order.push("created-event");
createdHandler();
errorHandler(new Error("late losing outcome"));
await bothCleanupsStarted.promise;
assertEquals(
rootDestroyed,
false,
"root destruction must wait for both native unlisten acknowledgements",
);
createdCleanupDone.resolve();
errorCleanupDone.resolve();
await companionDestroyStarted.promise;
assertEquals(
rootDestroyed,
false,
"a companion created after close must be destroyed before its parent realm",
);
companionDestroyDone.resolve();
assertEquals(
(await creation).kind,
"created",
"the first native outcome must win exactly once",
);
await close;
assertEquals(
latePublications,
0,
"late creation must not publish into disposed React state",
);
assertArrayEquals(
order,
[
"created-event",
"created-listener-cleanup",
"error-listener-cleanup",
"companion-destroy",
"root-destroy",
],
"creation, listener cleanup, companion destruction, and root destruction stay ordered",
);
});
Deno.test("listener-first bootstrap keeps an update that arrives during the initial refresh", async () => {
const owner = new AsyncOwner();
const initial = deferred<string>();
const update = deferred<string>();
const published: string[] = [];
let emitUpdate: (() => Promise<boolean>) | undefined;
const order: string[] = [];
await owner.register(() => {
order.push("listener");
emitUpdate = () =>
owner.applyLatestIfActive(
() => update.promise,
(value) => published.push(value),
);
return Promise.resolve(() => {});
});
order.push("snapshot");
const initialRefresh = owner.applyLatestIfActive(
() => initial.promise,
(value) => published.push(value),
);
const eventRefresh = emitUpdate?.();
if (eventRefresh === undefined) throw new Error("listener was not installed");
initial.resolve("stale-initial");
assertEquals(
await initialRefresh,
false,
"the event should supersede the bootstrap snapshot",
);
update.resolve("event-update");
assertEquals(await eventRefresh, true, "the event refresh should publish");
assertArrayEquals(
order,
["listener", "snapshot"],
"the listener must precede the snapshot",
);
assertArrayEquals(
published,
["event-update"],
"the bootstrap interval must not lose updates",
);
await owner.dispose();
});
Deno.test("out-of-order refresh completion publishes only the latest request", async () => {
const owner = new AsyncOwner();
const older = deferred<string>();
const newer = deferred<string>();
const published: string[] = [];
const olderRefresh = owner.applyLatestIfActive(
() => older.promise,
(value) => published.push(value),
);
const newerRefresh = owner.applyLatestIfActive(
() => newer.promise,
(value) => published.push(value),
);
newer.resolve("newer");
assertEquals(await newerRefresh, true, "the newest refresh should publish");
older.resolve("older");
assertEquals(
await olderRefresh,
false,
"the older refresh should be suppressed",
);
assertArrayEquals(
published,
["newer"],
"older completion must not overwrite newer state",
);
await owner.dispose();
});
Deno.test("settings hydration keeps saved fields while applying newer user edits", () => {
const restored = {
accent: "saved-accent",
density: "saved-density",
username: "saved-user",
};
const pendingEdits = { accent: "user-edit" };
const merged = mergeHydratedState(restored, pendingEdits);
assertEquals(
merged.accent,
"user-edit",
"the newer edit should win its field",
);
assertEquals(
merged.density,
"saved-density",
"unrelated saved fields must survive",
);
assertEquals(
merged.username,
"saved-user",
"the full saved baseline must be retained",
);
});
Deno.test("serialized writes do not start a newer value before the older value settles", async () => {
const firstDone = deferred<void>();
const firstStarted = deferred<void>();
const secondDone = deferred<void>();
const secondStarted = deferred<void>();
const starts: number[] = [];
const writer = createSerializedAsyncWriter<number>((value) => {
starts.push(value);
if (value === 1) {
firstStarted.resolve();
return firstDone.promise;
}
secondStarted.resolve();
return secondDone.promise;
}, () => {});
const firstWrite = writer.enqueue(1);
const secondWrite = writer.enqueue(2);
await firstStarted.promise;
assertArrayEquals(
starts,
[1],
"only the oldest write should start initially",
);
firstDone.resolve();
await firstWrite;
await secondStarted.promise;
assertArrayEquals(
starts,
[1, 2],
"the newer write should start after the older write",
);
secondDone.resolve();
await secondWrite;
await writer.waitForIdle();
});
Deno.test("a failed serialized write is reported and does not block the newer value", async () => {
const firstDone = deferred<void>();
const firstStarted = deferred<void>();
const secondStarted = deferred<void>();
const failure = new Error("first write failed");
const errors: unknown[] = [];
const starts: number[] = [];
const writer = createSerializedAsyncWriter<number>(async (value) => {
starts.push(value);
if (value === 1) {
firstStarted.resolve();
await firstDone.promise;
throw failure;
}
secondStarted.resolve();
}, (error) => errors.push(error));
const firstWrite = writer.enqueue(1);
const secondWrite = writer.enqueue(2);
await firstStarted.promise;
assertArrayEquals(
starts,
[1],
"the failed write should still own the queue first",
);
firstDone.resolve();
await firstWrite;
await secondStarted.promise;
await secondWrite;
assertArrayEquals(
starts,
[1, 2],
"a newer write should run after the failure is handled",
);
assertEquals(errors.length, 1, "the failed write should be reported once");
assertEquals(
errors[0],
failure,
"the original write failure should be reported",
);
});
Deno.test("closing a writer stops admission and drains the pending write", async () => {
const pending = deferred<void>();
const started = deferred<void>();
const writes: number[] = [];
const writer = createSerializedAsyncWriter<number>(async (value) => {
writes.push(value);
started.resolve();
await pending.promise;
}, () => {});
void writer.enqueue(1);
await started.promise;
let drained = false;
const drain = writer.closeAndWait().then(() => {
drained = true;
});
await writer.enqueue(2);
assertEquals(drained, false, "close must wait for the admitted write");
assertArrayEquals(writes, [1], "close must reject newer writes");
pending.resolve();
await drain;
assertEquals(drained, true, "close should settle after the pending write");
});
@@ -9,13 +9,13 @@ import {
phaseOf,
bumpTime,
normalizeTimeInput,
pruneCallToPlayEvents,
readyCountOf,
reduceCallToPlayEvents,
replaceCallToPlayView,
sortNominations,
statusOf,
} from '../src/lib/callToPlay.ts';
import { type CallToPlayAction, type CallToPlayEvent } from '../src/lib/types.ts';
import { type CallToPlayAction, type CallToPlayViewEvent } from '../src/lib/types.ts';
const NOW = 1_000_000;
@@ -33,11 +33,11 @@ const event = (
action: CallToPlayAction,
at = NOW,
actorName = actorId,
): CallToPlayEvent => ({
): CallToPlayViewEvent => ({
id,
call_id: 'call-1',
actor_id: actorId,
actor_name: actorName,
author_id: actorId,
author_name: actorName,
at,
action,
});
@@ -45,7 +45,7 @@ const event = (
const create = (
scheduledFor: number | null = null,
deadline = NOW + 30 * 60_000,
): CallToPlayEvent => event('create', 'Alice', {
): CallToPlayViewEvent => event('create', 'Alice', {
Create: {
game_id: 'game-1',
max_players: 3,
@@ -187,17 +187,17 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
'peer startup message',
);
assertEquals(
callToPlayPublishErrorMessage('Call to Play event is obsolete'),
callToPlayPublishErrorMessage('Call-to-Play call x is unknown or expired'),
'This Call to Play has expired or already finished.',
'obsolete call message',
);
assertEquals(
callToPlayPublishErrorMessage('Call to Play history is missing'),
callToPlayPublishErrorMessage('Call-to-Play call x is already terminal'),
'This Call to Play has expired or already finished.',
'missing expired history message',
);
assertEquals(
callToPlayPublishErrorMessage(new Error('Call to Play event history is full')),
callToPlayPublishErrorMessage(new Error('Call-to-Play local event history is full')),
'Call to Play has reached its active update limit. Start or cancel an active call, then try again.',
'active history limit message',
);
@@ -210,15 +210,12 @@ Deno.test('publish failures distinguish startup and store outcomes', () => {
Deno.test('reduction is order-independent and deduplicates events and messages', () => {
const message = event('message-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Ready?' },
SendMessage: { text: 'Ready?' },
}, NOW + 2);
const duplicateMessage = event('other-event', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'duplicate' },
}, NOW + 3);
const events = [message, create(), message, duplicateMessage];
const events = [message, create(), message];
const [nomination] = reduceCallToPlayEvents(events, NOW + 4);
assertEquals(nomination.messages.length, 1, 'unique message id');
assertEquals(nomination.messages[0].text, 'Ready?', 'first message wins');
assertEquals(nomination.messages.length, 1, 'event IDs deduplicate messages');
assertEquals(nomination.messages[0].text, 'Ready?', 'message retained');
});
Deno.test('actions timestamped before creation cannot mutate a call', () => {
@@ -237,11 +234,11 @@ Deno.test('terminal calls retain complete read-only history for fifteen minutes'
create(),
event('join', 'Bob', { Respond: { ready_at: null } }, NOW + 1),
event('message', 'Bob', {
SendMessage: { message_id: 'message-1', text: 'Launching' },
SendMessage: { text: 'Launching' },
}, NOW + 2),
event('start', 'Alice', 'Start', NOW + 3),
event('late-message', 'Bob', {
SendMessage: { message_id: 'message-2', text: 'Too late' },
SendMessage: { text: 'Too late' },
}, NOW + 4),
];
const [running] = reduceCallToPlayEvents(events, NOW + TERMINAL_RETENTION_MS);
@@ -273,34 +270,13 @@ Deno.test('terminal calls sort last and do not contribute to the badge', () => {
assertEquals(statusOf(cancelled, NOW + 3), 'cancelled', 'cancelled ticker status');
});
Deno.test('retired calls are pruned from the frontend raw event map', () => {
const terminalEvents = [
create(),
event('start', 'Alice', 'Start', NOW + 1),
];
const map = new Map(terminalEvents.map(item => [item.id, item]));
Deno.test('incoming Call to Play views replace removed author slices wholesale', () => {
const previous = { events: [create(), event('join', 'Bob', 'Rsvp', NOW + 1)] };
const incoming = { events: [create()] };
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS).size,
2,
'visible terminal history stays cached',
);
assertEquals(
pruneCallToPlayEvents(map, NOW + 1 + TERMINAL_RETENTION_MS + 1).size,
0,
'retired terminal history is pruned',
);
const tombstone = event('terminal-only', 'Alice', 'Cancel', NOW + 1);
const tombstoneMap = new Map([[tombstone.id, tombstone]]);
assertEquals(
pruneCallToPlayEvents(
tombstoneMap,
NOW + 1 + TERMINAL_RETENTION_MS + 1,
).size,
0,
'backend tombstone is pruned too',
);
const replaced = replaceCallToPlayView(previous, incoming);
assertEquals(replaced.events.length, 1, 'departed author slice is removed');
assertEquals(replaced.events[0].id, 'create', 'incoming projection is authoritative');
});
Deno.test('scheduled time input accepts design formats and wraps steppers', () => {
@@ -0,0 +1,325 @@
import {
CallToPlayAsyncScope,
type CallToPlayRetryCallback,
type CallToPlayRetryScheduler,
} from '../src/lib/callToPlayOwnership.ts';
import { type AsyncCleanup } from '../src/lib/asyncOwnership.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const assertArrayEquals = <T>(actual: T[], expected: T[], message: string) => {
assertEquals(JSON.stringify(actual), JSON.stringify(expected), message);
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
interface ScheduledRetry {
callback: CallToPlayRetryCallback;
cancelled: boolean;
}
class ManualRetryScheduler {
private readonly scheduled: ScheduledRetry[] = [];
public readonly schedule: CallToPlayRetryScheduler = callback => {
const retry = { callback, cancelled: false };
this.scheduled.push(retry);
return () => {
retry.cancelled = true;
};
};
public pendingCount(): number {
return this.scheduled.filter(retry => !retry.cancelled).length;
}
public fireNext(): Promise<void> {
const index = this.scheduled.findIndex(retry => !retry.cancelled);
if (index < 0) throw new Error('no retry is scheduled');
const [retry] = this.scheduled.splice(index, 1);
return retry.callback();
}
}
const noRetry: CallToPlayRetryScheduler = () => () => {};
Deno.test('queued listener callbacks are ignored and a late listener unlistens after disposal', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const listener = deferred<AsyncCleanup>();
const cleanupDone = deferred<void>();
const published: string[] = [];
let callback: ((value: string) => void) | undefined;
let unlistens = 0;
const registration = scope.registerListener(() => {
callback = scope.guard(value => published.push(value));
return listener.promise;
});
const disposal = scope.dispose();
callback?.('queued-after-dispose');
listener.resolve(async () => {
unlistens += 1;
await cleanupDone.promise;
});
await Promise.resolve();
assertEquals(unlistens, 1, 'the listener should unlisten as soon as registration resolves');
cleanupDone.resolve();
assertEquals(await registration, false, 'the late registration should report cancellation');
await disposal;
assertArrayEquals(published, [], 'a queued listener must not publish after disposal');
});
Deno.test('retry attempts never overlap and stop after readiness', async () => {
const scheduler = new ManualRetryScheduler();
const scope = new CallToPlayAsyncScope(scheduler.schedule);
const first = deferred<boolean>();
const second = deferred<boolean>();
const firstStarted = deferred<void>();
const secondStarted = deferred<void>();
let attempts = 0;
scope.startRetry(() => {
attempts += 1;
if (attempts === 1) {
firstStarted.resolve();
return first.promise;
}
secondStarted.resolve();
return second.promise;
}, 2_000);
assertEquals(scheduler.pendingCount(), 1, 'starting retry should schedule one attempt');
const firstRun = scheduler.fireNext();
await firstStarted.promise;
assertEquals(attempts, 1, 'the first retry should start');
assertEquals(scheduler.pendingCount(), 0, 'no retry should queue while one is in flight');
scope.startRetry(() => Promise.resolve(false), 2_000);
assertEquals(scheduler.pendingCount(), 0, 'starting again must not overlap the in-flight retry');
first.resolve(false);
await firstRun;
assertEquals(scheduler.pendingCount(), 1, 'a failed attempt should schedule its successor');
const secondRun = scheduler.fireNext();
await secondStarted.promise;
assertEquals(attempts, 2, 'the successor should start only after the first settled');
assertEquals(scheduler.pendingCount(), 0, 'the second in-flight retry must be exclusive');
second.resolve(true);
await secondRun;
assertEquals(scheduler.pendingCount(), 0, 'readiness should stop the retry loop');
await scope.dispose();
});
Deno.test('disposal waits for an in-flight retry attempt', async () => {
const scheduler = new ManualRetryScheduler();
const scope = new CallToPlayAsyncScope(scheduler.schedule);
const attempt = deferred<boolean>();
const started = deferred<void>();
let disposalSettled = false;
scope.startRetry(() => {
started.resolve();
return attempt.promise;
}, 2_000);
const retry = scheduler.fireNext();
await started.promise;
const disposal = scope.dispose().then(() => {
disposalSettled = true;
});
await Promise.resolve();
assertEquals(disposalSettled, false, 'disposal must join the running retry');
attempt.resolve(false);
await retry;
await disposal;
assertEquals(disposalSettled, true, 'disposal should settle after the retry exits');
assertEquals(scheduler.pendingCount(), 0, 'a disposed retry must not schedule a successor');
});
Deno.test('late snapshots and actions cannot publish after their scope is disposed', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const snapshot = deferred<string>();
const action = deferred<boolean>();
const publications: string[] = [];
let snapshotStarts = 0;
let actionStarts = 0;
const pendingSnapshot = scope.applyIfActive(
() => {
snapshotStarts += 1;
return snapshot.promise;
},
value => publications.push(`snapshot:${value}`),
);
const pendingAction = scope.applyIfActive(
() => {
actionStarts += 1;
return action.promise;
},
value => publications.push(`action:${value}`),
);
const disposal = scope.dispose();
snapshot.resolve('stale');
action.resolve(true);
assertEquals(await pendingSnapshot, false, 'the late snapshot should report cancellation');
assertEquals(await pendingAction, false, 'the late action should report cancellation');
await disposal;
assertArrayEquals(publications, [], 'late work must not publish into a disposed hook');
const postDisposeAction = await scope.applyIfActive(
() => {
actionStarts += 1;
return Promise.resolve(true);
},
value => publications.push(`post-dispose:${value}`),
);
assertEquals(postDisposeAction, false, 'an action must not start after disposal');
assertEquals(snapshotStarts, 1, 'the snapshot should start exactly once');
assertEquals(actionStarts, 1, 'only the pre-disposal action should start');
assertArrayEquals(publications, [], 'post-disposal actions must not publish');
});
Deno.test('an older action completion cannot overwrite a newer action status', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const older = deferred<boolean>();
const newer = deferred<boolean>();
const statuses: string[] = [];
const publishOlder = scope.guardLatestAction((accepted: boolean) => {
statuses.push(`older:${accepted}`);
});
const olderAction = scope.applyIfActive(
() => older.promise,
accepted => publishOlder(accepted),
);
const publishNewer = scope.guardLatestAction((accepted: boolean) => {
statuses.push(`newer:${accepted}`);
});
const newerAction = scope.applyIfActive(
() => newer.promise,
accepted => publishNewer(accepted),
);
newer.resolve(true);
assertEquals(await newerAction, true, 'the newer action should complete normally');
older.resolve(false);
assertEquals(await olderAction, true, 'the older action result should still complete');
assertArrayEquals(statuses, ['newer:true'], 'only the newest action may publish status');
await scope.dispose();
});
Deno.test('backend mutations start in user order even when the first invoke is delayed', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const first = deferred<string>();
const second = deferred<string>();
const started: string[] = [];
const applied: string[] = [];
const firstMutation = scope.applyMutationIfActive(
() => {
started.push('display-name:older');
return first.promise;
},
value => applied.push(value),
);
const secondMutation = scope.applyMutationIfActive(
() => {
started.push('action:newer');
return second.promise;
},
value => applied.push(value),
);
await Promise.resolve();
assertArrayEquals(started, ['display-name:older'], 'the newer mutation must remain queued');
first.resolve('older-applied');
assertEquals(await firstMutation, true, 'the first mutation should publish while active');
await Promise.resolve();
assertArrayEquals(
started,
['display-name:older', 'action:newer'],
'the second backend invoke must start after the first settles',
);
second.resolve('newer-applied');
assertEquals(await secondMutation, true, 'the second mutation should publish while active');
assertArrayEquals(
applied,
['older-applied', 'newer-applied'],
'mutation results should publish in backend order',
);
await scope.dispose();
});
Deno.test('disposal drains admitted mutations and rejects later admission', async () => {
const scope = new CallToPlayAsyncScope(noRetry);
const first = deferred<void>();
const second = deferred<void>();
const started: string[] = [];
const publications: string[] = [];
const firstMutation = scope.applyMutationIfActive(
() => {
started.push('first');
return first.promise;
},
() => publications.push('first'),
);
const secondMutation = scope.applyMutationIfActive(
() => {
started.push('second');
return second.promise;
},
() => publications.push('second'),
);
await Promise.resolve();
let disposed = false;
const disposal = scope.dispose().then(() => {
disposed = true;
});
const rejected = await scope.applyMutationIfActive(
() => {
started.push('rejected');
return Promise.resolve();
},
() => publications.push('rejected'),
);
assertEquals(rejected, false, 'a mutation submitted after disposal must be rejected');
assertEquals(disposed, false, 'disposal must wait for admitted mutations');
first.resolve();
assertEquals(await firstMutation, false, 'disposed scopes suppress the first result');
await Promise.resolve();
assertArrayEquals(started, ['first', 'second'], 'the admitted successor must still run');
assertEquals(disposed, false, 'disposal must also wait for the admitted successor');
second.resolve();
assertEquals(await secondMutation, false, 'disposed scopes suppress the second result');
await disposal;
assertEquals(disposed, true, 'disposal should settle after the admitted queue drains');
assertArrayEquals(started, ['first', 'second'], 'no post-disposal mutation may start');
assertArrayEquals(publications, [], 'disposed mutation results must not publish');
});
@@ -0,0 +1,257 @@
import {
bootstrapFrontend,
type FrontendCloseRequest,
} from '../src/lib/frontendBootstrap.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('close-listener registration failure destroys the empty window without mounting hooks', async () => {
const registrationFailure = new Error('registration failed');
const destructionStarted = deferred<void>();
const destruction = deferred<void>();
const order: string[] = [];
const reported: unknown[] = [];
let renders = 0;
let settled = false;
const bootstrap = bootstrapFrontend({
registerCloseRequested: () => Promise.reject(registrationFailure),
disposeOwners: () => {
order.push('dispose owners');
return Promise.resolve();
},
drainPersistence: () => {
order.push('drain persistence');
return Promise.resolve();
},
destroyWindow: () => {
order.push('destroy window');
destructionStarted.resolve();
return destruction.promise;
},
render: () => {
renders += 1;
},
reportFailure: (_message, error) => reported.push(error),
}).then(result => {
settled = true;
return result;
});
await destructionStarted.promise;
assertEquals(renders, 0, 'React and its hooks must remain unmounted');
assertEquals(settled, false, 'bootstrap must explicitly await window destruction');
assertEquals(
JSON.stringify(order),
JSON.stringify(['dispose owners', 'drain persistence', 'destroy window']),
'admissions must close and drain before destroying the empty window',
);
destruction.resolve();
assertEquals(await bootstrap, false, 'registration failure must report no render');
assertEquals(renders, 0, 'hooks must never mount after failure cleanup');
assertEquals(reported.length, 1, 'the registration failure must be handled once');
assertEquals(reported[0], registrationFailure, 'the original failure must be reported');
});
Deno.test('bootstrap handles failure of the fail-closed destroy action', async () => {
const registrationFailure = new Error('registration failed');
const destructionFailure = new Error('destroy failed');
const reported: unknown[] = [];
let renders = 0;
const rendered = await bootstrapFrontend({
registerCloseRequested: () => Promise.reject(registrationFailure),
disposeOwners: () => Promise.resolve(),
drainPersistence: () => Promise.resolve(),
destroyWindow: () => Promise.reject(destructionFailure),
render: () => {
renders += 1;
},
reportFailure: (_message, error) => reported.push(error),
});
assertEquals(rendered, false, 'a failed registration must never render');
assertEquals(renders, 0, 'destroy failure must not fall back to mounting hooks');
assertEquals(reported.length, 2, 'both terminal failures must be handled');
assertEquals(reported[0], registrationFailure, 'registration failure must be preserved');
assertEquals(reported[1], destructionFailure, 'destroy failure must be observed');
});
Deno.test('successful bootstrap mounts only after close registration resolves', async () => {
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
const registrationAcknowledged = deferred<() => void>();
let renders = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
registered.resolve(handler);
return registrationAcknowledged.promise;
},
disposeOwners: () => Promise.resolve(),
drainPersistence: () => Promise.resolve(),
destroyWindow: () => Promise.resolve(),
render: () => {
renders += 1;
},
reportFailure: () => {},
});
await registered.promise;
assertEquals(renders, 0, 'render must wait for registration acknowledgement');
registrationAcknowledged.resolve(() => {});
assertEquals(await bootstrap, true, 'successful registration should render the app');
assertEquals(renders, 1, 'the app should mount once');
});
Deno.test('close before registration acknowledgement owns late cleanup and suppresses render', async () => {
const registered = deferred<(event: FrontendCloseRequest) => void | Promise<void>>();
const registrationAcknowledged = deferred<() => void>();
const cleanupDone = deferred<void>();
const order: string[] = [];
let renders = 0;
let prevented = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: handler => {
registered.resolve(handler);
return registrationAcknowledged.promise;
},
disposeOwners: () => {
order.push('dispose owners');
return Promise.resolve();
},
drainPersistence: () => {
order.push('drain persistence');
return Promise.resolve();
},
destroyWindow: () => {
order.push('destroy window');
return Promise.resolve();
},
render: () => {
renders += 1;
},
reportFailure: () => {},
});
const close = (await registered.promise)({
preventDefault: () => {
prevented += 1;
},
});
assertEquals(prevented, 1, 'the pre-acknowledgement close must be intercepted');
assertEquals(renders, 0, 'React must remain unmounted while registration is unresolved');
registrationAcknowledged.resolve(async () => {
order.push('unlisten');
await cleanupDone.promise;
});
await Promise.resolve();
assertEquals(renders, 0, 'registration acknowledgement must not render after close began');
assertEquals(
order.includes('destroy window'),
false,
'the late listener cleanup must drain before destruction',
);
cleanupDone.resolve();
await close;
assertEquals(await bootstrap, false, 'a pre-render close must report no mounted app');
assertEquals(renders, 0, 'a closing realm must never mount React');
assertEquals(
JSON.stringify(order),
JSON.stringify(['dispose owners', 'drain persistence', 'unlisten', 'destroy window']),
'late listener ownership must join the same close finalizer',
);
});
Deno.test('render failure drains and unregisters before handled window destruction', async () => {
const renderFailure = new Error('render failed');
const unlistenFailure = new Error('unlisten failed');
const destroyFailure = new Error('destroy failed');
const ownerDrained = deferred<void>();
const persistenceDrained = deferred<void>();
const finalizerStarted = deferred<void>();
const order: string[] = [];
const reported: unknown[] = [];
let cleanups = 0;
const bootstrap = bootstrapFrontend({
registerCloseRequested: () => {
order.push('register');
return Promise.resolve(async () => {
order.push('unlisten');
cleanups += 1;
finalizerStarted.resolve();
throw unlistenFailure;
});
},
disposeOwners: async () => {
order.push('dispose owners');
await ownerDrained.promise;
},
drainPersistence: async () => {
order.push('drain persistence');
await persistenceDrained.promise;
},
destroyWindow: () => {
order.push('destroy window');
return Promise.reject(destroyFailure);
},
render: () => {
order.push('render');
throw renderFailure;
},
reportFailure: (_message, error) => reported.push(error),
});
await finalizerStarted.promise;
assertEquals(cleanups, 1, 'the installed close listener must unregister exactly once');
assertEquals(
JSON.stringify(order),
JSON.stringify(['register', 'render', 'dispose owners', 'drain persistence', 'unlisten']),
'render failure must synchronously start every frontend finalizer',
);
ownerDrained.resolve();
await Promise.resolve();
assertEquals(
order.includes('destroy window'),
false,
'window destruction must wait for persistence as well as owner cleanup',
);
persistenceDrained.resolve();
assertEquals(await bootstrap, false, 'a render failure must be fully handled');
assertEquals(
JSON.stringify(order),
JSON.stringify([
'register',
'render',
'dispose owners',
'drain persistence',
'unlisten',
'destroy window',
]),
'destruction must be the terminal finalizer',
);
assertEquals(reported.length, 3, 'render, cleanup, and destroy failures must be observed');
assertEquals(reported[0], renderFailure, 'the render failure must be reported first');
assertEquals(reported[1], unlistenFailure, 'listener cleanup rejection must be reported');
assertEquals(reported[2], destroyFailure, 'window destroy rejection must be reported');
});
@@ -0,0 +1,103 @@
import {
PersistenceShutdownScope,
startAdmittedPersistenceEffect,
} from '../src/lib/frontendPersistence.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('window close drains admitted persistence and rejects later registration', async () => {
const first = deferred<void>();
const starts: string[] = [];
const scope = new PersistenceShutdownScope();
scope.register(() => {
starts.push('first');
return first.promise;
});
let drained = false;
const drain = scope.closeAndDrain().then(() => {
drained = true;
});
const lateRegistration = scope.register(() => {
starts.push('late');
return Promise.resolve();
});
assertEquals(lateRegistration, undefined, 'closed persistence admission must stay closed');
assertEquals(JSON.stringify(starts), JSON.stringify(['first']), 'late queues must not start');
assertEquals(drained, false, 'close must remain pending while persistence is active');
first.resolve();
await drain;
assertEquals(drained, true, 'close should settle after every admitted queue');
});
Deno.test('settings hydration cannot start after close wins before its passive effect', async () => {
const scope = new PersistenceShutdownScope();
await scope.closeAndDrain();
let storeLoads = 0;
let publications = 0;
const cleanup = startAdmittedPersistenceEffect(
scope,
() => Promise.resolve(),
() => {
storeLoads += 1;
publications += 1;
},
);
cleanup();
assertEquals(storeLoads, 0, 'closed admission must prevent the settings store load');
assertEquals(publications, 0, 'closed admission must prevent settings publication');
});
Deno.test('game-directory hydration cannot load or invoke after pre-effect close', async () => {
const scope = new PersistenceShutdownScope();
await scope.closeAndDrain();
let storeLoads = 0;
let backendInvokes = 0;
const cleanup = startAdmittedPersistenceEffect(
scope,
() => Promise.resolve(),
() => {
storeLoads += 1;
backendInvokes += 1;
},
);
cleanup();
assertEquals(storeLoads, 0, 'closed admission must prevent the directory store load');
assertEquals(backendInvokes, 0, 'closed admission must prevent the directory invoke');
});
Deno.test('persistence shutdown rejection is observed without blocking other queues', async () => {
const failure = new Error('persistence drain failed');
const reported: unknown[] = [];
const scope = new PersistenceShutdownScope(error => reported.push(error));
let secondFinished = false;
scope.register(() => Promise.reject(failure));
scope.register(async () => {
secondFinished = true;
});
await scope.closeAndDrain();
assertEquals(reported.length, 1, 'the failed shutdown should be reported once');
assertEquals(reported[0], failure, 'the original shutdown failure should be reported');
assertEquals(secondFinished, true, 'one failure must not skip another persistence queue');
});
@@ -0,0 +1,157 @@
import {
acceptGameDirectory,
hydrateGameDirectory,
} from '../src/lib/gameDirectory.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('game directory is neither persisted nor used before backend acknowledgement', async () => {
const acknowledgement = deferred<unknown>();
const persisted: string[] = [];
let displayedPath = '/old/path';
const update = acceptGameDirectory('/picked/../path', {
updateBackend: requestedPath => {
assertEquals(
requestedPath,
'/picked/../path',
'backend should receive the requested path',
);
return acknowledgement.promise;
},
persist: acceptedPath => {
persisted.push(acceptedPath);
return Promise.resolve();
},
reportPersistenceError: () => {},
});
assertEquals(displayedPath, '/old/path', 'pending update should retain the old UI path');
assertEquals(persisted.length, 0, 'pending update should not write the requested path');
acknowledgement.resolve('/canonical/path');
displayedPath = await update;
assertEquals(displayedPath, '/canonical/path', 'UI should use the acknowledged path');
assertEquals(persisted.length, 1, 'acknowledged path should be persisted once');
assertEquals(persisted[0], '/canonical/path', 'store should receive the canonical path');
});
Deno.test('backend rejection leaves the old game directory untouched', async () => {
const rejected = new Error('directory rejected');
const persisted: string[] = [];
let displayedPath = '/old/path';
try {
displayedPath = await acceptGameDirectory('/rejected/path', {
updateBackend: () => Promise.reject(rejected),
persist: acceptedPath => {
persisted.push(acceptedPath);
return Promise.resolve();
},
reportPersistenceError: () => {},
});
throw new Error('expected backend rejection');
} catch (error) {
assertEquals(error, rejected, 'backend error should be preserved');
}
assertEquals(displayedPath, '/old/path', 'rejection should retain the old UI path');
assertEquals(persisted.length, 0, 'rejection should not change persistent state');
});
Deno.test('invalid backend acknowledgement is not persisted', async () => {
let persisted = false;
let rejected = false;
try {
await acceptGameDirectory('/picked/path', {
updateBackend: () => Promise.resolve(' '),
persist: () => {
persisted = true;
return Promise.resolve();
},
reportPersistenceError: () => {},
});
} catch {
rejected = true;
}
assertEquals(rejected, true, 'empty acknowledgement should be rejected');
assertEquals(persisted, false, 'invalid acknowledgement should not be persisted');
});
Deno.test('persistence failure does not undo an acknowledged backend update', async () => {
const persistenceError = new Error('store unavailable');
let reportedError: unknown;
const acceptedPath = await acceptGameDirectory('/picked/path', {
updateBackend: () => Promise.resolve('/canonical/path'),
persist: () => Promise.reject(persistenceError),
reportPersistenceError: error => {
reportedError = error;
},
});
assertEquals(acceptedPath, '/canonical/path', 'backend acknowledgement should remain accepted');
assertEquals(reportedError, persistenceError, 'persistence failure should be reported');
});
Deno.test('saved game-directory hydration settles only after backend acceptance', async () => {
const acceptance = deferred<void>();
let settled = false;
const hydration = hydrateGameDirectory({
loadSavedPath: () => Promise.resolve('/saved/path'),
acceptSavedPath: path => {
assertEquals(path, '/saved/path', 'saved path should be restored');
return acceptance.promise;
},
reportLoadError: () => {},
}).then(() => {
settled = true;
});
await Promise.resolve();
assertEquals(settled, false, 'hydration must wait for backend acceptance');
acceptance.resolve();
await hydration;
assertEquals(settled, true, 'hydration should settle after acceptance');
});
Deno.test('missing or failed game-directory state still completes hydration', async () => {
let accepts = 0;
await hydrateGameDirectory({
loadSavedPath: () => Promise.resolve(undefined),
acceptSavedPath: () => {
accepts += 1;
return Promise.resolve();
},
reportLoadError: () => {},
});
assertEquals(accepts, 0, 'missing state should not invoke the backend');
const failure = new Error('store unavailable');
let reported: unknown;
await hydrateGameDirectory({
loadSavedPath: () => Promise.reject(failure),
acceptSavedPath: () => Promise.resolve(),
reportLoadError: error => {
reported = error;
},
});
assertEquals(reported, failure, 'load failure should be reported before ready');
});
@@ -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",
);
});
@@ -0,0 +1,68 @@
import {
EPHEMERAL_IDENTITY_NOTICE,
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
type IdentityDiagnosticSnapshot,
newestIdentityDiagnosticSnapshot,
} from '../src/lib/identityDiagnostic.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)}`,
);
}
};
Deno.test('newer identity event beats delayed query and a later clear wins', () => {
const delayedQuery: IdentityDiagnosticSnapshot = { revision: 1, diagnostic: null };
const ephemeral: IdentityDiagnosticSnapshot = {
revision: 2,
diagnostic: 'ephemeral',
};
const afterEvent = newestIdentityDiagnosticSnapshot(
INITIAL_IDENTITY_DIAGNOSTIC_SNAPSHOT,
ephemeral,
);
assertEquals(
newestIdentityDiagnosticSnapshot(afterEvent, delayedQuery),
ephemeral,
'delayed query must lose',
);
const cleared: IdentityDiagnosticSnapshot = { revision: 3, diagnostic: null };
assertEquals(
newestIdentityDiagnosticSnapshot(ephemeral, cleared),
cleared,
'higher-revision clear must win',
);
assertEquals(
newestIdentityDiagnosticSnapshot(cleared, ephemeral),
cleared,
'stale event must not resurrect diagnostic',
);
});
Deno.test('ephemeral identity copy is exact and contains no implementation detail', () => {
assertEquals(
EPHEMERAL_IDENTITY_NOTICE,
"This installation's network identity could not be saved and will change the next time Lanspread starts.",
'identity diagnostic copy',
);
for (const forbidden of ['path', 'key', 'permission', '.json', 'error']) {
if (EPHEMERAL_IDENTITY_NOTICE.toLowerCase().includes(forbidden)) {
throw new Error(`identity copy leaked forbidden detail: ${forbidden}`);
}
}
});
Deno.test('identity initialization failure has a distinct redacted warning', () => {
assertEquals(
IDENTITY_STATUS_UNAVAILABLE_NOTICE,
"This installation's network identity status could not be checked.",
'unavailable diagnostic copy',
);
if (IDENTITY_STATUS_UNAVAILABLE_NOTICE.includes('could not be saved')) {
throw new Error('unavailable status must not claim confirmed ephemeral identity');
}
});
@@ -0,0 +1,201 @@
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
import {
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
LocalNetworkSharingAsyncScope,
type LocalNetworkSharingSnapshot,
displayedSharingTarget,
isLocalNetworkSharingActive,
localNetworkSharingNotice,
newestLocalNetworkSharingSnapshot,
} from '../src/lib/localNetworkSharing.ts';
const assert = (condition: boolean, message: string) => {
if (!condition) throw new Error(message);
};
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 snapshot = (
revision: number,
overrides: Partial<LocalNetworkSharingSnapshot> = {},
): LocalNetworkSharingSnapshot => ({
...INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
revision,
persistenceProblem: null,
...overrides,
});
Deno.test('sharing bootstrap remains visibly fail-closed if listener and query fail', () => {
assert(!displayedSharingTarget(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT), 'fallback switch');
assert(
!isLocalNetworkSharingActive(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
'fallback network admission',
);
assertEquals(
localNetworkSharingNotice(INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT),
'Local network sharing is off because its setting could not be loaded.',
'fallback copy',
);
});
Deno.test('delayed sharing query cannot overwrite a newer listener event', () => {
const delayedQuery = snapshot(1, { enabled: false, phase: 'disabled' });
const event = snapshot(2, { enabled: true, phase: 'enabled' });
const afterEvent = newestLocalNetworkSharingSnapshot(
INITIAL_LOCAL_NETWORK_SHARING_SNAPSHOT,
event,
);
assertEquals(
newestLocalNetworkSharingSnapshot(afterEvent, delayedQuery),
event,
'newer listener event must win',
);
assertEquals(
newestLocalNetworkSharingSnapshot(event, { ...event, enabled: false }),
event,
'equal revisions must not rewrite state',
);
});
Deno.test('pending policy target is distinct from effective network activity', () => {
const waiting = snapshot(1, {
enabled: true,
phase: 'waitingForGameDirectory',
});
assert(displayedSharingTarget(waiting), 'waiting policy should remain checked');
assert(!isLocalNetworkSharingActive(waiting), 'waiting policy must not claim active sharing');
const disabling = snapshot(2, {
enabled: true,
pendingTarget: false,
phase: 'enabled',
});
assert(!displayedSharingTarget(disabling), 'pending explicit off target should drive switch');
assert(
!isLocalNetworkSharingActive(disabling),
'an admitted off target must close network actions before the core event arrives',
);
});
Deno.test('sharing notices use exact redacted state copy', () => {
assertEquals(
localNetworkSharingNotice(snapshot(1, { enabled: false, phase: 'disabled' })),
'Local network sharing is off. Nearby devices cannot browse or request games from this library.',
'disabled copy',
);
assertEquals(
localNetworkSharingNotice(snapshot(2, { phase: 'waitingForGameDirectory' })),
'Local network sharing will start after you choose a game folder.',
'waiting copy',
);
assertEquals(
localNetworkSharingNotice(snapshot(3, {
enabled: true,
pendingTarget: false,
phase: 'enabled',
})),
'Stopping Local network sharing…',
'pending-off copy before core Disabling event',
);
assertEquals(
localNetworkSharingNotice(snapshot(4, {
enabled: false,
phase: 'disabled',
persistenceProblem: 'load',
})),
'Local network sharing is off because its setting could not be loaded.',
'load failure copy',
);
assertEquals(
localNetworkSharingNotice(snapshot(5, {
enabled: false,
phase: 'disabled',
persistenceProblem: 'save',
})),
'Local network sharing is off, but this setting could not be saved.',
'save failure copy',
);
});
Deno.test('sharing mutations execute in admission order', async () => {
const adoption = new AsyncAdoptionScope();
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
const order: string[] = [];
let releaseFirst = () => {};
const firstGate = new Promise<void>(resolve => {
releaseFirst = resolve;
});
const first = scope.applyMutationIfActive(async () => {
order.push('first-start');
await firstGate;
order.push('first-finish');
return 'first';
}, value => order.push(`${value}-apply`));
const second = scope.applyMutationIfActive(async () => {
order.push('second-start');
return 'second';
}, value => order.push(`${value}-apply`));
await Promise.resolve();
assertEquals(order, ['first-start'], 'second mutation must remain queued');
releaseFirst();
await Promise.all([first, second]);
assertEquals(
order,
['first-start', 'first-finish', 'first-apply', 'second-start', 'second-apply'],
'mutation order',
);
await scope.dispose();
});
Deno.test('failed mutation does not poison its admitted successor', async () => {
const adoption = new AsyncAdoptionScope();
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
const order: string[] = [];
const first = scope.applyMutationIfActive(async () => {
order.push('first');
throw new Error('injected');
}, () => order.push('unexpected-apply'));
const second = scope.applyMutationIfActive(async () => {
order.push('second');
return true;
}, () => order.push('second-apply'));
await first.catch(() => false);
assert(await second, 'successor should still publish');
assertEquals(order, ['first', 'second', 'second-apply'], 'successor ordering');
await scope.dispose();
});
Deno.test('sharing scope drains admitted mutation and rejects late work on dispose', async () => {
const adoption = new AsyncAdoptionScope();
const scope = new LocalNetworkSharingAsyncScope(() => {}, adoption);
let release = () => {};
const gate = new Promise<void>(resolve => {
release = resolve;
});
let published = false;
const admitted = scope.applyMutationIfActive(async () => {
await gate;
return true;
}, () => {
published = true;
});
const disposal = scope.dispose();
assert(
!await scope.applyMutationIfActive(async () => true, () => {}),
'dispose must close mutation admission',
);
release();
await Promise.all([admitted, disposal]);
assert(!published, 'disposed scope must suppress late React publication');
});
@@ -0,0 +1,45 @@
import {
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
newestProtocolMismatchSnapshot,
type ProtocolMismatchSnapshot,
} from "../src/lib/protocolMismatch.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)}`,
);
}
};
Deno.test("delayed bootstrap snapshot cannot overwrite a newer mismatch event", () => {
const delayedQuery = INITIAL_PROTOCOL_MISMATCH_SNAPSHOT;
const event: ProtocolMismatchSnapshot = {
revision: 1,
mismatch: { observed: 7, expected: 8 },
};
const afterEvent = newestProtocolMismatchSnapshot(
INITIAL_PROTOCOL_MISMATCH_SNAPSHOT,
event,
);
assertEquals(
newestProtocolMismatchSnapshot(afterEvent, delayedQuery),
event,
"delayed query must lose to event",
);
});
Deno.test("new runtime generation clears an older mismatch", () => {
const mismatch: ProtocolMismatchSnapshot = {
revision: 4,
mismatch: { observed: null, expected: 8 },
};
const cleared: ProtocolMismatchSnapshot = { revision: 5, mismatch: null };
assertEquals(
newestProtocolMismatchSnapshot(mismatch, cleared),
cleared,
"new generation clear must win",
);
});
@@ -0,0 +1,130 @@
import {
createThumbnailRequestKey,
startOwnedThumbnailGeneration,
ThumbnailRequestGeneration,
thumbnailIdsFromRequestKey,
} from '../src/lib/thumbnailRequests.ts';
import { AsyncAdoptionScope } from '../src/lib/asyncOwnership.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
Deno.test('thumbnail request keys are stable and deduplicate ids', () => {
const first = createThumbnailRequestKey(['bravo', 'alpha', 'bravo']);
const second = createThumbnailRequestKey(['alpha', 'bravo']);
assertEquals(first, second, 'equivalent id sets should have one effect key');
assertEquals(
JSON.stringify(thumbnailIdsFromRequestKey(first)),
JSON.stringify(['alpha', 'bravo']),
'the effect should receive each id once in stable order',
);
});
Deno.test('constructing a thumbnail generation does not start a request', async () => {
let starts = 0;
const generation = new ThumbnailRequestGeneration(
() => {
starts += 1;
return Promise.resolve('url');
},
() => {},
);
assertEquals(starts, 0, 'request work must wait for the owning effect to start it');
await generation.dispose();
await generation.start(['game']);
assertEquals(starts, 0, 'a disposed generation must not start later work');
});
Deno.test('a thumbnail generation cannot start after window admission closes', async () => {
const scope = new AsyncAdoptionScope();
await scope.disposeOwned();
let starts = 0;
const generation = new ThumbnailRequestGeneration(
() => {
starts += 1;
return Promise.resolve('url');
},
() => {},
);
const cleanup = startOwnedThumbnailGeneration(scope, generation, ['game']);
cleanup();
await scope.drain();
assertEquals(starts, 0, 'closed window admission must prevent the first invoke');
});
Deno.test('an unmounted thumbnail generation suppresses its in-flight result', async () => {
const result = deferred<string>();
const published: string[] = [];
const generation = new ThumbnailRequestGeneration(
() => result.promise,
(id, url) => published.push(`${id}:${url}`),
);
const loading = generation.start(['game']);
const disposal = generation.dispose();
result.resolve('thumbnail-url');
await loading;
await disposal;
assertEquals(published.length, 0, 'an in-flight result must not publish after unmount');
});
Deno.test('a replaced thumbnail generation cannot overwrite the current generation', async () => {
const abandonedResult = deferred<string>();
const published: string[] = [];
const abandoned = new ThumbnailRequestGeneration(
() => abandonedResult.promise,
(_id, url) => published.push(url),
);
const abandonedLoading = abandoned.start(['game']);
const abandonedDisposal = abandoned.dispose();
const current = new ThumbnailRequestGeneration(
() => Promise.resolve('current-url'),
(_id, url) => published.push(url),
);
await current.start(['game']);
abandonedResult.resolve('stale-url');
await abandonedLoading;
await abandonedDisposal;
assertEquals(
JSON.stringify(published),
JSON.stringify(['current-url']),
'only the current effect generation may publish',
);
});
Deno.test('an active failed thumbnail request publishes the empty fallback', async () => {
const failure = new Error('thumbnail unavailable');
const published: string[] = [];
const generation = new ThumbnailRequestGeneration(
() => Promise.reject(failure),
(id, url) => published.push(`${id}:${url}`),
);
await generation.start(['game']);
assertEquals(
JSON.stringify(published),
JSON.stringify(['game:']),
'active failures should retain the placeholder-cover behavior',
);
});
@@ -0,0 +1,266 @@
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",
);
});