From 6d0272b078a071f6bfb2c75c0e8dddbae26333e4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 22:50:01 +0200 Subject: [PATCH] Phase 28 (Step 11): Vitest coverage for MailStore threading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/mail-store.test.ts` exercises the `entries` derived rune with handcrafted inbox + sent fixtures: - personal messages exchanged with one race collapse into a per-race thread with messages sorted oldest → newest; - system mail (`sender_kind=system`) and admin notifications (`sender_kind=admin`) surface as stand-alone items even when a race-name snapshot is present; - the caller's own paid-tier broadcasts (`broadcast_scope= game_broadcast`) render as stand-alone outgoing items; - `unreadCount` counts inbox rows with `readAt === null`. The store fields are mutated directly to avoid wiring a fake `GalaxyClient`; the underlying `$derived` rune fires whenever those fields change. Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/frontend/tests/mail-store.test.ts | 125 +++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 ui/frontend/tests/mail-store.test.ts diff --git a/ui/frontend/tests/mail-store.test.ts b/ui/frontend/tests/mail-store.test.ts new file mode 100644 index 0000000..e9aad9c --- /dev/null +++ b/ui/frontend/tests/mail-store.test.ts @@ -0,0 +1,125 @@ +// 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 { + 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("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); + }); +});