2119f825d6
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>
153 lines
5.1 KiB
TypeScript
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);
|
|
});
|
|
});
|