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
131 lines
4.2 KiB
TypeScript
131 lines
4.2 KiB
TypeScript
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',
|
|
);
|
|
});
|