Files
gacha-event-tracker/test/article-ends.test.ts
T

213 lines
8.2 KiB
TypeScript

import { expect, test } from "bun:test";
import { ADAPTERS } from "../src/ingest/adapters/index.ts";
import {
applyArticleEnds,
articleTitle,
latestStatedMs,
parseArticleEnd,
type StatedEnd,
} from "../src/ingest/articleEnds.ts";
import { mergeEvents } from "../src/ingest/merge.ts";
import type { GachaEvent } from "../src/shared/schema.ts";
const dir = "fixtures/genshin/articles";
const hourOfRuin = await Bun.file(`${dir}/hour-of-ruin-2026-11-07.html`).text();
const raiment = await Bun.file(`${dir}/raiment-collection-2026-11-04-2026-11-07.html`).text();
const table = await Bun.file(`${dir}/event-table-2026-11-06.html`).text();
const withEnd = (value: string) =>
hourOfRuin.replace("December 15, 2026 14:59 (Server Time)", value);
const fandom = ADAPTERS.find((a) => a.id === "genshin-fandom-events")!;
const parseTable = (now: string, articleEnds?: ReadonlyMap<string, StatedEnd>) =>
fandom.parse(table, {
now,
sourceUrl: fandom.url,
sourceId: fandom.id,
game: fandom.game,
...(articleEnds ? { articleEnds } : {}),
});
const NOW = "2026-11-07T00:00:00.000Z";
const listed = parseTable(NOW);
const row = (title: string): GachaEvent => listed.find((e) => e.title === title)!;
const HOUR_OF_RUIN_ID = "genshin:hour-of-ruin:2026-11-04";
const ends = new Map([["Hour_of_Ruin_(Event)", parseArticleEnd(hourOfRuin)!]]);
test("server time gives every region its own end", () => {
const end = parseArticleEnd(hourOfRuin)!;
expect(end.day).toBe("2026-12-15");
expect(end.regionEnds).toEqual({
asia: "2026-12-15T06:59:00.000Z",
europe: "2026-12-15T13:59:00.000Z",
america: "2026-12-15T19:59:00.000Z",
});
// The earliest, as wikigg.ts does: never promise more than some region gets.
expect(end.endsAt).toBe("2026-12-15T06:59:00.000Z");
});
test("GMT+8 is one instant", () => {
expect(parseArticleEnd(raiment)).toEqual({
day: "2026-12-16",
endsAt: "2026-12-15T21:59:00.000Z",
regionEnds: null,
});
});
test.each([
"December 15, 2026",
"TBA",
"December 15, 2026 14:59 UTC",
"December 15, 2026 14:59 GMT+9",
"Smarch 15, 2026 14:59 (Server Time)",
"December 45, 2026 14:59 (Server Time)",
"December 15, 2026 25:59 (Server Time)",
"",
])("%p is no usable time", (value) => {
expect(parseArticleEnd(withEnd(value))).toBeNull();
});
test("malformed JSON is no usable time", () => {
expect(parseArticleEnd("<html>not the API</html>")).toBeNull();
});
test("a page with no infobox is no usable time", () => {
expect(parseArticleEnd(JSON.stringify({ parse: { text: "<p>Stub.</p>" } }))).toBeNull();
});
test("the End header cell is not mistaken for the value", () => {
// The <th> carries data-source="time_end" too, and precedes the value.
const noValue = JSON.stringify({
parse: { text: '<table><tr><th data-source="time_end">End</th></tr></table>' },
});
expect(parseArticleEnd(noValue)).toBeNull();
});
test("markup inside the value cell is read as its text", () => {
expect(
parseArticleEnd(withEnd("<span>December 15, 2026 14:59</span> (Server Time)"))?.day,
).toBe("2026-12-15");
});
test("the latest stated instant is the last region's", () => {
expect(latestStatedMs(parseArticleEnd(hourOfRuin)!)).toBe(Date.parse("2026-12-15T19:59:00.000Z"));
expect(latestStatedMs(parseArticleEnd(raiment)!)).toBe(Date.parse("2026-12-15T21:59:00.000Z"));
});
test("an article title is decoded from a wiki URL and nothing else", () => {
expect(articleTitle("https://genshin-impact.fandom.com/wiki/The_Forge_Realm%27s_Temper/2026-11-04")).toBe(
"The_Forge_Realm's_Temper/2026-11-04",
);
expect(articleTitle(fandom.url)).toBeNull();
expect(articleTitle("https://game8.co/wiki/Hour_of_Ruin")).toBeNull();
});
test("the end lands on the row an article describes", () => {
const [applied] = applyArticleEnds([row("Hour of Ruin")], listed, ends, () => {});
expect(applied?.endPrecision).toBe("exact");
expect(applied?.regionScoped).toBe(true);
expect(applied?.regionEnds?.europe).toBe("2026-12-15T13:59:00.000Z");
expect(applied?.endsAt).toBe("2026-12-15T06:59:00.000Z");
});
test("a global end leaves the row unscoped", () => {
const raimentRow = row("Raiment Collection: Radiance Ablaze");
const [applied] = applyArticleEnds(
[raimentRow],
listed,
new Map([["Raiment_Collection/2026-11-04", parseArticleEnd(raiment)!]]),
() => {},
);
expect(applied?.endPrecision).toBe("exact");
expect(applied?.regionScoped).toBe(false);
expect(applied?.regionEnds).toBeNull();
expect(applied?.endsAt).toBe("2026-12-15T21:59:00.000Z");
});
test("a merged Game8 survivor takes the end through mergedIds", () => {
const survivor: GachaEvent = {
...row("Hour of Ruin"),
id: "genshin:game8-title:2026-11-04",
sourceId: "genshin-game8-events",
sourceUrl: "https://game8.co/games/Genshin-Impact/archives/301601",
mergedIds: [HOUR_OF_RUIN_ID],
};
const [applied] = applyArticleEnds([survivor], listed, ends, () => {});
expect(applied?.endPrecision).toBe("exact");
expect(applied?.regionEnds?.america).toBe("2026-12-15T19:59:00.000Z");
});
test("a survivor of a real merge keeps its identity and takes the end", () => {
const game8Copy: GachaEvent = {
...row("Hour of Ruin"),
sourceId: "genshin-game8-events",
sourceUrl: "https://game8.co/games/Genshin-Impact/archives/301601",
confidence: 0.95,
};
const { events } = mergeEvents([[game8Copy], [row("Hour of Ruin")]]);
const [applied] = applyArticleEnds(events, listed, ends, () => {});
expect(applied?.sourceId).toBe("genshin-game8-events");
expect(applied?.endPrecision).toBe("exact");
});
test("id, startsAt, sourceUrl, sourceId and classification never change", () => {
const before = { ...row("Hour of Ruin"), classification: { lane: "miliastra" as const } };
const [after] = applyArticleEnds([before], listed, ends, () => {});
expect(after?.id).toBe(before.id);
expect(after?.startsAt).toBe(before.startsAt);
expect(after?.sourceUrl).toBe(before.sourceUrl);
expect(after?.sourceId).toBe(before.sourceId);
expect(after?.classification).toEqual(before.classification);
});
test("an article whose date disagrees with the table keeps the row and warns", () => {
const stale = new Map([
["Hour_of_Ruin_(Event)", parseArticleEnd(withEnd("December 20, 2026 14:59 (Server Time)"))!],
]);
const warnings: string[] = [];
const input = [row("Hour of Ruin")];
expect(applyArticleEnds(input, listed, stale, (w) => warnings.push(w))).toEqual(input);
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain("Hour of Ruin");
});
test("a survivor whose date disagrees with the table keeps the row and warns", () => {
const survivor: GachaEvent = { ...row("Hour of Ruin"), endsAt: "2026-12-17T00:00:00.000Z" };
const warnings: string[] = [];
expect(applyArticleEnds([survivor], listed, ends, (w) => warnings.push(w))).toEqual([survivor]);
expect(warnings.length).toBe(1);
});
test("a survivor already at exact precision is left alone without a warning", () => {
const survivor: GachaEvent = {
...row("Hour of Ruin"),
endsAt: "2026-12-15T10:00:00.000Z",
endPrecision: "exact",
};
const warnings: string[] = [];
expect(applyArticleEnds([survivor], listed, ends, (w) => warnings.push(w))).toEqual([survivor]);
expect(warnings).toEqual([]);
});
test("no articles means the same contents as merged", () => {
expect(applyArticleEnds(listed, listed, new Map(), () => {})).toEqual(listed);
});
test("a live row outlives the table's day on its stated end", () => {
// Past the last region's 04:00 on the printed day, before America's 14:59.
const lastDay = "2026-12-15T15:00:00.000Z";
expect(parseTable(lastDay).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false);
expect(parseTable(lastDay, ends).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(true);
});
test("a stated end also retires a row, once every region has passed it", () => {
expect(parseTable("2026-12-15T20:00:00.000Z", ends).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false);
});
test("an article that disagrees on the date does not keep a row alive", () => {
const stale = new Map([
["Hour_of_Ruin_(Event)", parseArticleEnd(withEnd("December 20, 2026 14:59 (Server Time)"))!],
]);
expect(parseTable("2026-12-15T15:00:00.000Z", stale).some((e) => e.id === HOUR_OF_RUIN_ID)).toBe(false);
});