Adds the game and its Game8 events page, the second of the games named in
the release thread. `parseAdjacentFullRange` reads the shape this page
uses: two full dates divided by an `<hr>` rather than a dash, so a
tag-stripping reader sees only whitespace between them. It is anchored at
both ends and needs a year on each half, because without that "August 12,
2026 Day 3 rewards" reads "Day 3" as an end.
Three of the four live rows publish. "Take Your Heart" ends 30 days after
each player makes an account, so it has no calendar date and no honest
place on a calendar. "Login Campaigns" names two candidate ends
("July 16/30, 2026") — it keeps its real start and takes no end, and the
leftover is not shown as a summary either, since a date the parser
refused to trust must not reappear dressed as information.
The page lists each live event twice: once in a bare Event|Duration table
and again under its own heading with Start Date / End Date rows and a
paragraph of prose. The second copy corroborates the dates, so deduping
now fills a missing summary from the copy it drops. Dates are still taken
wholesale from the better-dated copy and never blended.
Verified by re-extracting the page independently of the parser: 4 rows,
3 events, the fourth correctly skipped.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
345 lines
14 KiB
TypeScript
345 lines
14 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" },
|
|
{ adapter: adapter("endfield-wikigg-events"), fixture: "fixtures/endfield/wikigg-events-2026-08-14" },
|
|
{ adapter: adapter("nikki-game8-events"), fixture: "fixtures/nikki/game8-events-2026-08-17" },
|
|
{ adapter: adapter("p5x-game8-events"), fixture: "fixtures/p5x/game8-events-2026-08-17" },
|
|
];
|
|
|
|
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("nikki", () => {
|
|
const fixture = "fixtures/nikki/game8-events-2026-08-17";
|
|
|
|
test("reads a labelled Start/End cell, and takes no end from 'Permanent'", async () => {
|
|
// The duration cell is "Start: January 24, 2025 End: Permanent" — two
|
|
// labelled halves separated by a <br>, which a tag-stripping reader sees as
|
|
// one run of text.
|
|
const events = await runAdapter(adapter("nikki-game8-events"), fixture);
|
|
expect(events).toHaveLength(7); // every row of the current-events table
|
|
|
|
const fiesta = events.find((e) => e.title === "Fireworks Fiesta");
|
|
expect(fiesta?.startsAt).toBe("2025-01-24T00:00:00.000Z");
|
|
// "Permanent" is not a date and does not become one.
|
|
expect(fiesta?.endsAt).toBeNull();
|
|
expect(fiesta?.endPrecision).toBe("unknown");
|
|
|
|
const bubble = events.find((e) => e.title === "Bubble Season");
|
|
expect(bubble?.startsAt).toBe("2025-04-28T00:00:00.000Z");
|
|
expect(bubble?.endsAt).toBe("2025-06-12T00:00:00.000Z");
|
|
});
|
|
|
|
test("never presents the labelled cell's own text as a summary", async () => {
|
|
// "End: Permanent" is structure, not a blurb. Leaving it in the summary
|
|
// slot would show a reader a date the parser deliberately refused to use.
|
|
const events = await runAdapter(adapter("nikki-game8-events"), fixture);
|
|
for (const e of events) {
|
|
expect(e.summary).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("p5x", () => {
|
|
const fixture = "fixtures/p5x/game8-events-2026-08-17";
|
|
|
|
test("reads a range whose halves are separated by an <hr>", async () => {
|
|
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
|
const beach = events.find((e) =>
|
|
e.title.startsWith("Haunted Beach Shack Summer Event"),
|
|
);
|
|
expect(beach?.startsAt).toBe("2026-07-30T00:00:00.000Z");
|
|
expect(beach?.endsAt).toBe("2026-08-13T00:00:00.000Z");
|
|
});
|
|
|
|
test("keeps the finished-events back catalogue off the calendar", async () => {
|
|
// Fifty-odd past events sit in a table fenced off by nothing but an
|
|
// <h4>Finished Events</h4> inside a collapsed accordion. A reader blind to
|
|
// h4 sees one uninterrupted run of tables and publishes the lot.
|
|
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
|
expect(events).toHaveLength(3);
|
|
const titles = events.map((e) => e.title);
|
|
expect(titles).not.toContain("Tycoon Season 1"); // finished
|
|
expect(titles).not.toContain("New Year's Gifts"); // finished
|
|
});
|
|
|
|
test("skips a row whose duration states no date at all", async () => {
|
|
// "Take Your Heart" ends 30 days after each player makes an account, so it
|
|
// has no calendar date and no honest place on a calendar.
|
|
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
|
expect(events.map((e) => e.title)).not.toContain("Take Your Heart");
|
|
});
|
|
|
|
test("takes no end from an ambiguous one, and does not show it as prose", async () => {
|
|
// "June 25, 2026 July 16/30, 2026" names two candidate ends. Picking either
|
|
// would be a guess, and echoing the leftover into the summary would dress
|
|
// the same guess up as information.
|
|
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
|
const login = events.find((e) => e.title === "Login Campaigns");
|
|
expect(login?.startsAt).toBe("2026-06-25T00:00:00.000Z");
|
|
expect(login?.endsAt).toBeNull();
|
|
expect(login?.summary).toBeNull();
|
|
});
|
|
|
|
test("recovers the blurb from the event's own section", async () => {
|
|
// The event is listed twice: once in a bare Event|Duration table, and again
|
|
// under its own heading with a paragraph of prose. Deduping by ID must not
|
|
// throw the prose away.
|
|
const events = await runAdapter(adapter("p5x-game8-events"), fixture);
|
|
const anniversary = events.find(
|
|
(e) => e.title === "1st Anniversary Celebration",
|
|
);
|
|
expect(anniversary?.summary).toContain("first anniversary");
|
|
});
|
|
});
|
|
|
|
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/");
|
|
}
|
|
});
|
|
});
|