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:
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user