Files
Ilia Denisov 5b07bb4e14 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>
2026-05-11 16:16:31 +02:00

128 lines
3.8 KiB
TypeScript

// 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);
});
});