Files
galaxy-game/ui/frontend/tests/mail-store.test.ts
Ilia Denisov 2119f825d6
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m6s
Tests · UI / test (pull_request) Successful in 2m24s
mail UI: dedupe broadcast fan-out and drop in-game admin compose
Two issues surfaced once the long-lived dev environment finally
reached the diplomail view:

1. `/sent` returns one row per recipient for broadcast and admin
   fan-outs (so the admin tooling can render the materialised
   audience). The list pane fed all rows into the stand-alone
   bucket, so the `{#each entries as e (entryKey(e))}` key in
   `thread-list.svelte` collapsed to the same `standalone:${id}`
   for every recipient and Svelte 5 aborted the render with
   `each_key_duplicate`. Dedupe stand-alones by `message_id` in
   `buildEntries`.

2. The compose dialog exposed an `admin` kind toggle gated on
   "owner of game". That was a Phase 28 plan decision, but admin
   compose is an operator tool (server admin), not an in-game
   action — every game owner should not be able to broadcast
   admin notifications. Drop the admin option, the audience
   sub-toggles, and the admin path through `submit`. The
   `MailStore.composeAdmin` wrapper and the backend RPC stay so
   the future admin UI can call them.

Vitest covers the fan-out dedup with three rows sharing one
`message_id` collapsing to a single stand-alone entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:38:59 +02:00

153 lines
5.1 KiB
TypeScript

// Phase 28 — MailStore threading projection tests. Exercises the
// `entries` derived rune end-to-end with handcrafted inbox + sent
// fixtures; the network surface (GalaxyClient) is left null since
// these tests do not call init / setGame.
import { describe, expect, it } from "vitest";
import {
MailStore,
type MailListEntry,
type MailStandalone,
type MailThread,
} from "../src/lib/mail-store.svelte";
import type { MailMessage } from "../src/api/diplomail";
function makeMessage(overrides: Partial<MailMessage>): MailMessage {
return {
messageId: overrides.messageId ?? crypto.randomUUID(),
gameId: overrides.gameId ?? "00000000-0000-0000-0000-000000000001",
gameName: overrides.gameName ?? "Test game",
kind: overrides.kind ?? "personal",
senderKind: overrides.senderKind ?? "player",
senderUserId: overrides.senderUserId ?? null,
senderUsername: overrides.senderUsername ?? null,
senderRaceName: overrides.senderRaceName ?? null,
subject: overrides.subject ?? "",
body: overrides.body ?? "",
bodyLang: overrides.bodyLang ?? "en",
broadcastScope: overrides.broadcastScope ?? "single",
createdAt: overrides.createdAt ?? new Date(0),
recipientUserId:
overrides.recipientUserId ?? "00000000-0000-0000-0000-000000000099",
recipientUserName: overrides.recipientUserName ?? "self",
recipientRaceName: overrides.recipientRaceName ?? null,
readAt: overrides.readAt ?? null,
deletedAt: overrides.deletedAt ?? null,
translatedSubject: overrides.translatedSubject ?? null,
translatedBody: overrides.translatedBody ?? null,
translationLang: overrides.translationLang ?? null,
translator: overrides.translator ?? null,
};
}
describe("MailStore.entries", () => {
it("groups inbox + sent messages into per-race threads", () => {
const store = new MailStore();
const greysIncoming = makeMessage({
messageId: "m1",
senderKind: "player",
senderRaceName: "Greys",
body: "hi",
createdAt: new Date("2026-05-15T12:00:00Z"),
});
const greysOutgoing = makeMessage({
messageId: "m2",
senderKind: "player",
senderRaceName: "Self",
recipientRaceName: "Greys",
body: "reply",
createdAt: new Date("2026-05-15T12:05:00Z"),
});
store.inbox = [greysIncoming];
store.sent = [greysOutgoing];
const entries = store.entries;
expect(entries.length).toBe(1);
const entry = entries[0];
expect(entry.kind).toBe("thread");
const thread = entry as MailThread;
expect(thread.raceName).toBe("Greys");
expect(thread.messages.map((m) => m.messageId)).toEqual(["m1", "m2"]);
});
it("surfaces system and admin messages as stand-alone items", () => {
const store = new MailStore();
const system = makeMessage({
messageId: "sys-1",
kind: "admin",
senderKind: "system",
subject: "game.paused: details",
createdAt: new Date("2026-05-15T13:00:00Z"),
});
const admin = makeMessage({
messageId: "adm-1",
kind: "admin",
senderKind: "admin",
createdAt: new Date("2026-05-15T13:05:00Z"),
});
store.inbox = [system, admin];
const entries: MailListEntry[] = store.entries;
expect(entries.every((e) => e.kind === "standalone")).toBe(true);
const standalones = entries as MailStandalone[];
expect(standalones.map((e) => e.message.messageId).sort()).toEqual(
["adm-1", "sys-1"],
);
});
it("treats the local player's broadcasts as stand-alone outgoing", () => {
const store = new MailStore();
const broadcast = makeMessage({
messageId: "br-1",
senderKind: "player",
senderRaceName: "Self",
recipientRaceName: "Greys",
broadcastScope: "game_broadcast",
createdAt: new Date("2026-05-15T14:00:00Z"),
});
store.sent = [broadcast];
const entries = store.entries;
expect(entries.length).toBe(1);
expect(entries[0].kind).toBe("standalone");
});
it("collapses fan-out rows for the same broadcast message_id", () => {
// `/sent` returns one row per recipient for broadcast and admin
// sends so the admin tooling can render the materialised
// audience. The list pane must collapse the duplicates by
// message_id, otherwise the {#each} key in `thread-list.svelte`
// repeats and Svelte 5 surfaces `each_key_duplicate`.
const store = new MailStore();
const base = {
messageId: "br-shared",
senderKind: "player" as const,
senderRaceName: "Self",
broadcastScope: "game_broadcast" as const,
createdAt: new Date("2026-05-15T14:00:00Z"),
};
store.sent = [
makeMessage({ ...base, recipientRaceName: "Greys" }),
makeMessage({ ...base, recipientRaceName: "Bajori" }),
makeMessage({ ...base, recipientRaceName: "Romulans" }),
];
const entries = store.entries;
expect(entries.length).toBe(1);
expect(entries[0].kind).toBe("standalone");
const standalone = entries[0] as MailStandalone;
expect(standalone.message.messageId).toBe("br-shared");
});
it("counts unread incoming messages in unreadCount", () => {
const store = new MailStore();
store.inbox = [
makeMessage({ readAt: null, createdAt: new Date(1) }),
makeMessage({ readAt: new Date(2), createdAt: new Date(2) }),
makeMessage({ readAt: null, createdAt: new Date(3) }),
];
expect(store.unreadCount).toBe(2);
});
});