import { describe, expect, test } from "bun:test"; import { adapterById } from "../../src/ingest/adapters/index.ts"; import type { Adapter } from "../../src/ingest/adapters/types.ts"; import { inferType } from "../../src/ingest/parsers/game8.ts"; import { GachaEvent, type EventType } from "../../src/shared/schema.ts"; /** * Pinned clock. Parsers take `now` from context and never read it themselves, * so a fixture captured months ago still asserts byte-identical output. */ const NOW = "2026-08-14T00:00:00.000Z"; function adapter(id: string): Adapter { const found = adapterById(id); if (found === undefined) throw new Error(`no adapter '${id}'`); return found; } const genshinGame8 = adapter("genshin-game8-events"); const nteGame8 = adapter("nte-game8-events"); const CASES: Array<{ adapter: Adapter; fixture: string }> = [ { adapter: genshinGame8, fixture: "fixtures/genshin/game8-events-2026-08-14" }, { adapter: nteGame8, fixture: "fixtures/nte/game8-events-2026-08-14" }, { adapter: adapter("hsr-game8-events"), fixture: "fixtures/hsr/game8-events-2026-08-14" }, { adapter: adapter("wuwa-game8-events"), fixture: "fixtures/wuwa/game8-events-2026-08-14" }, { adapter: adapter("zzz-game8-events"), fixture: "fixtures/zzz/game8-events-2026-08-14" }, { adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/game8-events-2026-08-14" }, { adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" }, ]; async function runAdapter(adapter: Adapter, fixture: string) { const html = await Bun.file(`${fixture}.html`).text(); return adapter.parse(html, { now: NOW, sourceUrl: adapter.url, sourceId: adapter.id, game: adapter.game, }); } describe.each(CASES)("$adapter.id", ({ adapter, fixture }) => { test("matches the checked-in expected output", async () => { const events = await runAdapter(adapter, fixture); const expected = await Bun.file(`${fixture}.expected.json`).json(); expect(events).toEqual(expected); }); test("every event satisfies the schema", async () => { for (const event of await runAdapter(adapter, fixture)) { expect(() => GachaEvent.parse(event)).not.toThrow(); } }); test("is deterministic across runs", async () => { const a = await runAdapter(adapter, fixture); const b = await runAdapter(adapter, fixture); expect(a).toEqual(b); }); test("never emits an end before its start", async () => { for (const e of await runAdapter(adapter, fixture)) { if (e.endsAt !== null) expect(e.endsAt > e.startsAt).toBe(true); } }); test("no event runs longer than 180 days", async () => { // Patch cycles are ~6 weeks. A longer span means a misread year, which is // the failure mode most likely to reach a user as a confident wrong date. for (const e of await runAdapter(adapter, fixture)) { if (e.endsAt === null) continue; const days = (Date.parse(e.endsAt) - Date.parse(e.startsAt)) / 86_400_000; expect(days).toBeLessThanOrEqual(180); } }); test("event IDs are unique and stable in shape", async () => { const events = await runAdapter(adapter, fixture); const ids = events.map((e) => e.id); expect(new Set(ids).size).toBe(ids.length); for (const e of events) { expect(e.id).toBe( `${e.game}:${e.id.split(":")[1]}:${e.startsAt.slice(0, 10)}`, ); } }); test("excludes permanent and past sections", async () => { const titles = (await runAdapter(adapter, fixture)).map((e) => e.title); // Permanent entries carry no dates; past entries ended before the fixture // date. Neither belongs on a "what's live / what's next" calendar. for (const t of titles) expect(t).not.toMatch(/permanent/i); }); }); describe("genshin fixture specifics", () => { test("yields the nine dated events on the page", async () => { const events = await runAdapter( genshinGame8, "fixtures/genshin/game8-events-2026-08-14", ); expect(events).toHaveLength(9); const byTitle = new Map(events.map((e) => [e.title, e])); const mutual = byTitle.get("Mutual Aid in Bloom: Into the Frostlands"); expect(mutual?.startsAt).toBe("2026-08-12T00:00:00.000Z"); expect(mutual?.endsAt).toBe("2026-08-24T00:00:00.000Z"); expect(mutual?.startPrecision).toBe("day"); // Sourced from a one-cell "Availability Period" range rather than // Start/End rows — the other table shape on the same page. expect(byTitle.get("Battle Pass - Frostfarer")?.endsAt).toBe( "2026-09-21T00:00:00.000Z", ); }); test("year-less summary rows produce no events", async () => { // The page's summary tables show "08/12 - 08/24" with no year. Those must // be skipped, not year-guessed — and they must not duplicate the detail // tables that carry the same events with real years. const events = await runAdapter( genshinGame8, "fixtures/genshin/game8-events-2026-08-14", ); const dupes = events.filter( (e) => e.title === "Mutual Aid in Bloom: Into the Frostlands", ); expect(dupes).toHaveLength(1); }); }); describe("nte fixture specifics", () => { test("yields current and upcoming events only", async () => { const events = await runAdapter( nteGame8, "fixtures/nte/game8-events-2026-08-14", ); expect(events).toHaveLength(13); // 9 current + 4 upcoming const titles = events.map((e) => e.title); expect(titles).toContain("Market Opening Rehearsal"); // current expect(titles).toContain("Fons Rush"); // upcoming expect(titles).not.toContain("Login Gift"); // permanent expect(titles).not.toContain("Tiger Perks"); // previous }); test("carries the summary column through", async () => { const events = await runAdapter( nteGame8, "fixtures/nte/game8-events-2026-08-14", ); const circleGift = events.find((e) => e.title === "Circle Gift"); expect(circleGift?.summary).toContain("Log in"); }); }); describe("inferType", () => { const cases: Array<[string, EventType]> = [ ["Overflowing Abundance Rerun", "rerun"], ["Stygian Onslaught", "challenge"], ["Character Test Runs", "challenge"], ["Gold Clash", "challenge"], ["Seize the Day Login Bonus", "login"], ["Epitome Invocation Banner", "banner"], ["Mutual Aid in Bloom: Into the Frostlands", "other"], ]; test.each(cases)("%s → %s", (title, expected) => { expect(inferType(title)).toBe(expected); }); }); describe("new source shapes", () => { test("zzz recovers events from rowspan Start/End rows", async () => { // The event name spans two rows, so a flat cell reader sees // [title, "Start", date] then ["End", date]. Losing the pairing would // silently halve the calendar. const events = await runAdapter( adapter("zzz-game8-events"), "fixtures/zzz/game8-events-2026-08-14", ); const summer = events.find((e) => e.title === "Summer Waves Rolls In"); expect(summer?.startsAt).toBe("2026-07-29T00:00:00.000Z"); expect(summer?.endsAt).toBe("2026-09-07T00:00:00.000Z"); expect(events.every((e) => e.endsAt !== null)).toBe(true); }); test("hsr keeps events whose end is not announced", async () => { // "Jul. 24, 2026 - End of 4.6" has a real start and no knowable end. // Publishing it with a guessed end would be the worst possible outcome. const events = await runAdapter( adapter("hsr-game8-events"), "fixtures/hsr/game8-events-2026-08-14", ); const open = events.filter((e) => e.endsAt === null); expect(open.length).toBeGreaterThan(0); for (const e of open) expect(e.endPrecision).toBe("unknown"); }); test("wuwa parses ranges carrying a year on both sides", async () => { const events = await runAdapter( adapter("wuwa-game8-events"), "fixtures/wuwa/game8-events-2026-08-14", ); const jade = events.find((e) => e.title === "In Search of Lost Jade"); expect(jade?.startsAt).toBe("2026-07-30T00:00:00.000Z"); expect(jade?.endsAt).toBe("2026-08-13T00:00:00.000Z"); }); }); describe("endfield", () => { test("reads MM/DD/YY ranges from a combined schedule cell", async () => { // This page hides its only dated events in an "Event | Schedule & Summary" // table, where one cell holds the label, the range and the blurb. const events = await runAdapter( adapter("endfield-game8-events"), "fixtures/endfield/game8-events-2026-08-14", ); expect(events).toHaveLength(2); const rooted = events.find((e) => e.title === "The Rooted Realm"); expect(rooted?.startsAt).toBe("2026-08-09T00:00:00.000Z"); expect(rooted?.endsAt).toBe("2026-08-30T00:00:00.000Z"); // The prose after the dates becomes the blurb, without the label. expect(rooted?.summary).not.toBeNull(); expect(rooted?.summary).not.toMatch(/^Period:/); }); }); describe("wiki.gg parser", () => { test("reads exact per-region timers", async () => { // The first source that states region-scoped ends. Asia and the Americas // differ by hours, which is precisely what regionEnds exists to carry. const events = await runAdapter( adapter("endfield-wikigg-events"), "fixtures/endfield/wikigg-events-2026-08-14", ); expect(events).toHaveLength(6); const heat = events.find((e) => e.title === "HEAT RAGE! MEGA ARENA!"); expect(heat?.startPrecision).toBe("exact"); expect(heat?.regionScoped).toBe(true); expect(heat?.regionEnds?.asia).toBe("2026-08-12T20:00:00.000Z"); expect(heat?.regionEnds?.america).toBe("2026-08-13T09:00:00.000Z"); // endsAt is the fallback for a region the source did not list, so it takes // the earliest — never promise more time than some region actually gets. expect(heat?.endsAt).toBe("2026-08-12T20:00:00.000Z"); }); test("links each event to its own wiki page", async () => { const events = await runAdapter( adapter("endfield-wikigg-events"), "fixtures/endfield/wikigg-events-2026-08-14", ); for (const e of events) { expect(e.sourceUrl).toStartWith("https://endfield.wiki.gg/wiki/"); } }); });