ui/phase-24: push events, turn-ready toast, single SubscribeEvents consumer
Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
// Vitest coverage for the toast primitive in
|
||||
// `src/lib/toast.svelte.ts`. The store keeps one active toast at a
|
||||
// time, replaces it on a fresh `show`, auto-dismisses after the
|
||||
// configured duration, runs the `onAction` callback once on the
|
||||
// action button, and ignores a stale `dismiss(id)` whose target was
|
||||
// already replaced.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { toast } from "../src/lib/toast.svelte";
|
||||
|
||||
beforeEach(() => {
|
||||
toast.resetForTests();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
toast.resetForTests();
|
||||
});
|
||||
|
||||
describe("toast.show", () => {
|
||||
test("sets current and assigns a fresh id", () => {
|
||||
const id = toast.show({
|
||||
messageKey: "common.loading",
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
expect(toast.current).not.toBeNull();
|
||||
expect(toast.current?.id).toBe(id);
|
||||
expect(toast.current?.messageKey).toBe("common.loading");
|
||||
});
|
||||
|
||||
test("a second show replaces the previous descriptor", () => {
|
||||
const first = toast.show({ messageKey: "common.loading" });
|
||||
const second = toast.show({
|
||||
messageKey: "common.dismiss",
|
||||
});
|
||||
expect(second).not.toBe(first);
|
||||
expect(toast.current?.id).toBe(second);
|
||||
expect(toast.current?.messageKey).toBe("common.dismiss");
|
||||
});
|
||||
|
||||
test("auto-dismisses after durationMs", () => {
|
||||
toast.show({
|
||||
messageKey: "common.loading",
|
||||
durationMs: 2_000,
|
||||
});
|
||||
expect(toast.current).not.toBeNull();
|
||||
vi.advanceTimersByTime(1_999);
|
||||
expect(toast.current).not.toBeNull();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(toast.current).toBeNull();
|
||||
});
|
||||
|
||||
test("durationMs=null makes the toast sticky", () => {
|
||||
toast.show({
|
||||
messageKey: "common.loading",
|
||||
durationMs: null,
|
||||
});
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(toast.current).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toast.dismiss", () => {
|
||||
test("clears current when called without an id", () => {
|
||||
toast.show({ messageKey: "common.loading" });
|
||||
toast.dismiss();
|
||||
expect(toast.current).toBeNull();
|
||||
});
|
||||
|
||||
test("ignores a stale id whose target was replaced", () => {
|
||||
const first = toast.show({
|
||||
messageKey: "common.loading",
|
||||
durationMs: 5_000,
|
||||
});
|
||||
const second = toast.show({ messageKey: "common.dismiss" });
|
||||
toast.dismiss(first);
|
||||
expect(toast.current?.id).toBe(second);
|
||||
expect(toast.current?.messageKey).toBe("common.dismiss");
|
||||
});
|
||||
|
||||
test("auto-dismiss timer of the replaced toast does not clobber the live one", () => {
|
||||
toast.show({ messageKey: "common.loading", durationMs: 500 });
|
||||
const second = toast.show({
|
||||
messageKey: "common.dismiss",
|
||||
durationMs: null,
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
// The first toast's timer fired but the dismiss is a no-op
|
||||
// because `current.id !== first`. The sticky second toast
|
||||
// stays alive.
|
||||
expect(toast.current?.id).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onAction", () => {
|
||||
test("ignored unless the action button is invoked manually", () => {
|
||||
const onAction = vi.fn();
|
||||
toast.show({
|
||||
messageKey: "common.loading",
|
||||
actionLabelKey: "common.dismiss",
|
||||
onAction,
|
||||
});
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(onAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("toast-host wiring is exercised by the layout: callback fires when host calls onAction then dismiss", () => {
|
||||
// The host component runs `onAction()` and then `dismiss(id)`.
|
||||
// We simulate that sequence here to pin the contract the host
|
||||
// relies on: a single invocation of the user callback per
|
||||
// descriptor, and the toast clears afterwards.
|
||||
const onAction = vi.fn();
|
||||
const id = toast.show({
|
||||
messageKey: "common.loading",
|
||||
actionLabelKey: "common.dismiss",
|
||||
onAction,
|
||||
});
|
||||
const current = toast.current;
|
||||
current?.onAction?.();
|
||||
toast.dismiss(current?.id);
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(toast.current).toBeNull();
|
||||
// id stays unique — a follow-up show must return a different one.
|
||||
expect(toast.show({ messageKey: "common.loading" })).not.toBe(id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user