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