Endfield was previously written off as undatable. That was wrong: a first pass only inspected its Duration rows (all "Permanently Available") and its year-less version grid, and missed an "Event | Schedule & Summary" table whose cells read "Period: 08/09/26 - 08/30/26 During the event...". Two real events, with a year. Adds MM/DD/YY range parsing for that shape. Month-first ordering is not assumed lightly — Endfield's own version grid reads 01/22, 04/17, 07/16 for versions 1.0, 1.2 and 1.4, chronological only if the month comes first. Also stops presenting unlock conditions as descriptions. Several templates put "Reach Union Level 8" where a blurb would go; filling the summary slot with text that never says what the event is is worse than leaving it empty. Wuthering Waves correctly drops to zero summaries as a result. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
227 lines
8.6 KiB
TypeScript
227 lines
8.6 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" },
|
|
{ adapter: adapter("endfield-game8-events"), fixture: "fixtures/endfield/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");
|
|
});
|
|
});
|
|
|
|
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:/);
|
|
});
|
|
});
|