Files
gacha-event-tracker/test/adapters/game8.test.ts
T
Lucas WintherandClaude Opus 5 817ad41dcd feat: add Star Rail, Wuthering Waves and Zenless Zone Zero
Three more Game8 templates. ZZZ spans each event name across two rows with a
rowspan, so a flat cell reader sees [title, "Start", date] then ["End",
date]; losing that pairing would silently halve the calendar.

Section matching also had to widen — these pages head their sections
"Featured Events", "List of Events and Schedule" and "Ongoing Events".

Verified independently of the parser: HSR's year-less summary table lists
six ongoing events and all six are captured via their detail rows, so
nothing is silently dropped.

28 events added across the three games.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 00:47:50 +02:00

208 lines
7.8 KiB
TypeScript

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" },
];
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");
});
});