Files
gacha-event-tracker/test/custom.test.ts
T
Lucas WintherandClaude Opus 5 3702ee7a4c feat(custom): model reader-authored games and events
The data layer for PRD F13, with no UI and no behaviour change yet.

src/shared/custom.ts defines the two key spaces, their schemas, and the
projection into what the views read. Two properties are the point of it:

- A reader's event id is random, not derived from their title. They can type a
  scraped event's exact name and date, which under ${game}:${slug}:${date} is a
  byte-identical key — one completion mark and one streak silently shared by two
  events. Randomness also means renaming their own event never moves its id.
- A reader's event carries no sourceUrl, so a hand-entered date can never be
  attributed to a source, and claims no region split, because they entered one
  instant and inventing three would fabricate two of them.

The rest is widening what was GameId-shaped into a lane that may be one of
theirs: clockFor takes the boundary fields structurally so their events run on
the identical countdown rather than a second one, day keys fall back to the
regional default for a lane with no server map, and gameMeta becomes a context
resolver so metaFor stays pure and total — a lane can outlive its game when an
import carries an event whose game did not come with it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 18:16:21 +02:00

247 lines
8.4 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
asDisplayEvent,
CustomEvent,
CustomGame,
isCustomEventId,
isCustomGameId,
knownLane,
mintCustomEventId,
mintCustomGameId,
precisionOf,
RESERVED_ID_SEGMENTS,
type CustomGames,
} from "../src/shared/custom.ts";
import { metaFor } from "../src/shared/games.ts";
import { dailiesId } from "../src/shared/daily.ts";
import { eventId, GameId } from "../src/shared/schema.ts";
import { clockFor } from "../src/shared/time.ts";
const AT = "2026-08-17T12:00:00.000Z";
function ownEvent(over: Partial<CustomEvent> = {}): CustomEvent {
return CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Walpurgisnacht",
type: "banner",
summary: null,
startsAt: "2026-08-20T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-03T00:00:00.000Z",
endPrecision: "day",
at: AT,
updatedAt: AT,
...over,
});
}
describe("reserved id segments", () => {
test("no game id can ever occupy a reserved first segment", () => {
// Every id in the app is colon-separated and the first segment decides
// which key space it belongs to. The day a GameId is called "mygame" is the
// day two spaces merge silently, and localStorage has no other copy.
for (const reserved of RESERVED_ID_SEGMENTS) {
expect(GameId.options as readonly string[]).not.toContain(reserved);
}
});
test("the three spaces cannot produce the same key", () => {
const feed = eventId("genshin", "Walpurgisnacht", "2026-08-20T00:00:00.000Z");
const chore = dailiesId("genshin");
const own = mintCustomEventId(() => 0.5);
const ownGame = mintCustomGameId("Limbus Company");
const keys = [feed, chore, own, ownGame];
expect(new Set(keys).size).toBe(keys.length);
for (const key of keys) {
expect(key.split(":").length).toBeGreaterThanOrEqual(2);
}
});
test("a reader's event never collides with the scraped event it names", () => {
// The whole reason for a random suffix: they can type a tracked event's
// exact title and date. Under the feed's scheme that is byte-identical.
const scraped = eventId("genshin", "Windblume Festival", "2026-03-14T00:00:00.000Z");
const mine = mintCustomEventId(() => 0.123456);
expect(mine).not.toBe(scraped);
expect(isCustomEventId(mine)).toBe(true);
expect(isCustomEventId(scraped)).toBe(false);
});
});
describe("minting ids", () => {
test("a game id is slug-derived and disambiguated rather than overwritten", () => {
expect(mintCustomGameId("Limbus Company")).toBe("mygame:limbus-company");
// Two games called Nikke are two games.
expect(mintCustomGameId("Nikke", ["mygame:nikke"])).toBe("mygame:nikke-2");
expect(mintCustomGameId("Nikke", ["mygame:nikke", "mygame:nikke-2"])).toBe(
"mygame:nikke-3",
);
});
test("a game whose name slugifies to nothing still gets an id", () => {
expect(mintCustomGameId("???")).toBe("mygame:game");
});
test("event ids match their schema and vary with the source of randomness", () => {
const a = mintCustomEventId(() => 0.1);
const b = mintCustomEventId(() => 0.9);
expect(a).toMatch(/^myevent:[a-z0-9]{10}$/);
expect(b).toMatch(/^myevent:[a-z0-9]{10}$/);
expect(a).not.toBe(b);
expect(isCustomGameId(a)).toBe(false);
});
});
describe("CustomEvent", () => {
test("an unannounced end is expressible, and must pair with unknown", () => {
// A reader entering an event nobody has dated must not be forced to invent
// one — that is the failure this whole product is built against.
const open = ownEvent({ endsAt: null, endPrecision: "unknown" });
expect(open.endsAt).toBeNull();
expect(() =>
CustomEvent.parse({ ...ownEvent(), endsAt: null, endPrecision: "day" }),
).toThrow();
expect(() =>
CustomEvent.parse({
...ownEvent(),
endsAt: "2026-09-03T00:00:00.000Z",
endPrecision: "unknown",
}),
).toThrow();
});
test("rejects an end before its start", () => {
expect(() =>
CustomEvent.parse({ ...ownEvent(), endsAt: "2026-08-19T00:00:00.000Z" }),
).toThrow();
});
test("rejects an empty or oversized title", () => {
expect(() => CustomEvent.parse({ ...ownEvent(), title: "" })).toThrow();
expect(() =>
CustomEvent.parse({ ...ownEvent(), title: "x".repeat(201) }),
).toThrow();
});
});
describe("CustomGame", () => {
test("a hue must be a hex colour, because it reaches a style attribute", () => {
// An imported file is not necessarily one this reader wrote.
const ok = CustomGame.parse({
id: "mygame:limbus-company",
name: "Limbus Company",
hue: "#C74B50",
at: AT,
});
expect(ok.hue).toBe("#C74B50");
for (const hue of ["red", "url(javascript:alert(1))", "#fff", "#12345g", ""]) {
expect(() =>
CustomGame.parse({ id: "mygame:x", name: "X", hue, at: AT }),
).toThrow();
}
});
test("rejects an id from another key space", () => {
expect(() =>
CustomGame.parse({ id: "genshin", name: "Genshin", hue: "#4EA8DE", at: AT }),
).toThrow();
});
});
describe("asDisplayEvent", () => {
test("carries no source and claims no region split", () => {
const shown = asDisplayEvent(ownEvent());
// Never attributed to a source: there is no page to send a sceptic to.
expect(shown.sourceUrl).toBeNull();
// One instant was entered, so inventing three would fabricate two of them.
expect(shown.regionScoped).toBe(false);
expect(shown.regionEnds).toBeNull();
expect(shown.extractionMethod).toBe("manual");
expect(shown.status).toBe("published");
});
test("runs on the same clock as a scraped event", () => {
// Not a second countdown implementation — the identical one.
const clock = clockFor(
asDisplayEvent(ownEvent()),
"europe",
Date.parse("2026-08-27T00:00:00.000Z"),
);
expect(clock.live).toBe(true);
expect(clock.msRemaining).toBe(7 * 24 * 60 * 60 * 1000);
});
test("an unannounced end yields no countdown, exactly as the feed's does", () => {
const clock = clockFor(
asDisplayEvent(ownEvent({ endsAt: null, endPrecision: "unknown" })),
"europe",
Date.parse("2026-08-27T00:00:00.000Z"),
);
expect(clock.msRemaining).toBeNull();
expect(clock.urgency).toBe("calm");
});
});
describe("precisionOf", () => {
test("a date with no time of day is day precision", () => {
// So the detail sheet's "accurate to the day only" note is honest about
// the reader's input too, rather than presenting midnight as their choice.
expect(precisionOf(false)).toBe("day");
expect(precisionOf(true)).toBe("exact");
});
});
describe("metaFor", () => {
const mine: CustomGames = {
"mygame:limbus-company": {
id: "mygame:limbus-company",
name: "Limbus Company",
hue: "#C74B50",
at: AT,
},
};
test("answers for a tracked game unchanged", () => {
expect(metaFor("genshin", mine).name).toBe("Genshin Impact");
expect(metaFor("genshin", mine).studio).toBe("HoYoverse");
});
test("answers for one the reader defined, with no studio or chore", () => {
const meta = metaFor("mygame:limbus-company", mine);
expect(meta.name).toBe("Limbus Company");
expect(meta.hue).toBe("#C74B50");
// Nothing to credit in the colophon and no routine we could name for them.
expect(meta.studio).toBe("");
expect(meta.dailyTasks).toBe("");
});
test("is total, so a lane that outlived its game cannot blank the page", () => {
// An import can carry an event whose game did not come with it.
const meta = metaFor("mygame:deleted", mine);
expect(meta.name).toBe("Unknown game");
expect(meta.hue).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
test("shortens a long name rather than overflowing a chip", () => {
const long: CustomGames = {
"mygame:x": { id: "mygame:x", name: "Chaos Zero Nightmare", hue: "#123456", at: AT },
};
expect(metaFor("mygame:x", long).short.length).toBeLessThanOrEqual(12);
});
});
describe("knownLane", () => {
test("a tracked lane is always known; a reader's lane must still exist", () => {
const mine: CustomGames = {
"mygame:a": { id: "mygame:a", name: "A", hue: "#123456", at: AT },
};
expect(knownLane("genshin", mine)).toBe(true);
expect(knownLane("mygame:a", mine)).toBe(true);
expect(knownLane("mygame:gone", mine)).toBe(false);
});
});