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