import { describe, expect, test } from "bun:test";
import { adapterById } from "../../src/ingest/adapters/index.ts";
import {
ESTIMATE_CONFIDENCE,
aruStatsParser,
} from "../../src/ingest/parsers/arustats.ts";
import type { EventType } from "../../src/shared/schema.ts";
const NOW = "2026-08-14T00:00:00.000Z";
const FIXTURE = "fixtures/hi3/arustats-events-2026-08-27.html";
const hi3 = adapterById("hi3-arustats-events");
if (hi3 === undefined) throw new Error("no adapter 'hi3-arustats-events'");
function parse(html: string) {
return hi3!.parse(html, {
now: NOW,
sourceUrl: hi3!.url,
sourceId: hi3!.id,
game: hi3!.game,
});
}
/**
* The parser alone, with no adapter seam. Used only for shapes `canParse`
* rejects — the adapter is supposed to *throw* on those, so it cannot be the
* thing under test when the question is what the parser returns.
*/
function parseRaw(html: string) {
return aruStatsParser.parse(html, {
now: NOW,
sourceUrl: hi3!.url,
sourceId: hi3!.id,
game: hi3!.game,
});
}
/** A page built from scratch, so a test can state one shape at a time. */
function page(timeline: unknown): string {
const body = JSON.stringify({ props: { pageProps: { timeline } } });
return `
`;
}
const WEEKS = [
{ startDate: "2026-8-20 0:0:0", endDate: "2026-8-28 0:0:0" },
{ startDate: "2026-8-28 0:0:0", endDate: "2026-9-4 0:0:0" },
{ startDate: "2026-9-4 0:0:0", endDate: "2026-9-11 0:0:0" },
];
function bar(over: Record = {}) {
return {
startWeek: 1,
endWeek: 2,
miniature: { titleTop: "A Banner", titleMid: null },
...over,
};
}
describe("arustats: the source URL is version-less on purpose", () => {
test("SOURCES points at the redirecting path, not a pinned version", () => {
// `/en-us/hi3/timeline` answers 307 to the live version. Pinning `/9.0`
// here would publish a finished schedule as current the day 9.1 ships,
// which is the stale-source failure docs/SOURCES.md § 11 exists about.
expect(hi3!.url).toBe("https://www.arustats.com/en-us/hi3/timeline");
expect(hi3!.url).not.toMatch(/\/timeline\/\d/);
});
});
describe("arustats: week index to date", () => {
test("endWeek is exclusive — a bar ends as its end week opens", () => {
const [e] = parse(
page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "EVENT 1", content: [bar({ startWeek: 1, endWeek: 3 })] }] }),
);
expect(e?.startsAt).toBe("2026-08-20T00:00:00.000Z");
// Week 3 opens 2026-09-04, so a 1→3 bar covers weeks 1–2 and ends there.
expect(e?.endsAt).toBe("2026-09-04T00:00:00.000Z");
});
test("an endWeek one past the grid means the end of the version", () => {
const [e] = parse(
page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "EVENT 1", content: [bar({ startWeek: 1, endWeek: 4 })] }] }),
);
// Not week 4 (there is none) — the last bucket's own endDate.
expect(e?.endsAt).toBe("2026-09-11T00:00:00.000Z");
});
test("a week index further out than that is skipped, never clamped", () => {
// Skip, never guess: pinning an unreadable bar to the version's edge would
// be inventing a boundary the page never drew.
expect(
parse(page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "EVENT 1", content: [bar({ startWeek: 1, endWeek: 9 })] }] })),
).toHaveLength(0);
expect(
parse(page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "EVENT 1", content: [bar({ startWeek: 8, endWeek: 9 })] }] })),
).toHaveLength(0);
});
test("an impossible bucket date yields no event", () => {
const html = page({
scheduleDates: [{ startDate: "2026-13-40 0:0:0", endDate: "2026-14-99 0:0:0" }],
scheduleActivities: [{ row: "EVENT 1", content: [bar({ startWeek: 1, endWeek: 2 })] }],
});
// `2026-13-40` must not roll over into a plausible 2027 date.
expect(parseRaw(html)).toHaveLength(0);
// And a grid that no longer states readable dates is a redesign, so the
// adapter refuses the document outright rather than reporting a quiet zero.
expect(() => parse(html)).toThrow(/redesigned/);
});
test("boundaries are day precision at 00:00Z, and the clock is discarded", () => {
// Every cell on the page reads `0:0:0` and the page states no zone. Both
// halves matter: there is no instant to publish and nothing to convert.
const [e] = parse(
page({
scheduleDates: [
{ startDate: "2026-8-20 13:45:07", endDate: "2026-8-28 9:1:2" },
{ startDate: "2026-8-28 9:1:2", endDate: "2026-9-4 0:0:0" },
],
scheduleActivities: [{ row: "EVENT 1", content: [bar({ startWeek: 1, endWeek: 2 })] }],
}),
);
expect(e?.startsAt).toBe("2026-08-20T00:00:00.000Z");
expect(e?.startPrecision).toBe("day");
expect(e?.endPrecision).toBe("day");
});
});
describe("arustats: titles arrive in two pieces", () => {
test("two 7-Day Login events on the real page keep distinct IDs", async () => {
// The regression that matters most here. `titleTop` is "7-Day Login:" for
// both and both start in week 1, so dropping the titleMid join gives them
// one ID and silently collapses two events into one.
const events = parse(await Bun.file(FIXTURE).text());
const logins = events.filter((e) => e.title.startsWith("7-Day Login"));
expect(logins).toHaveLength(2);
expect(new Set(logins.map((e) => e.id)).size).toBe(2);
expect(logins.every((e) => e.startsAt === "2026-08-20T00:00:00.000Z")).toBe(true);
});
test("a trailing colon on titleTop means the name continues", () => {
const [e] = parse(
page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "EVENT 2", content: [bar({ miniature: { titleTop: "7-Day Login:", titleMid: "300 crystals" } })] }] }),
);
expect(e?.title).toBe("7-Day Login: 300 crystals");
expect(e?.summary).toBeNull();
});
test("on a supply row titleMid continues the name too", () => {
const [e] = parse(
page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "ASCENSION SUPPLY", content: [bar({ miniature: { titleTop: "Lone", titleMid: "Destruction" } })] }] }),
);
expect(e?.title).toBe("Lone Destruction");
expect(e?.summary).toBeNull();
});
test("on an EVENT row it is a blurb", () => {
const [e] = parse(
page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "EVENT 6", content: [bar({ miniature: { titleTop: "P2 Finale", titleMid: "It's finally over" } })] }] }),
);
expect(e?.title).toBe("P2 Finale");
expect(e?.summary).toBe("It's finally over");
});
test("a bar with no title at all is skipped", () => {
expect(
parse(page({ scheduleDates: WEEKS, scheduleActivities: [{ row: "EVENT 1", content: [bar({ miniature: { titleTop: "", titleMid: null } })] }] })),
).toHaveLength(0);
});
});
describe("arustats: what the rows mean", () => {
const TYPES: Array<[string, string, EventType]> = [
["BATTLESUIT SUPPLY B", "Jovial Deception", "banner"],
["ARMAMENT ASCENSION", "Mad Pleasure Equipment", "banner"],
["OUTFIT", "Crane of Taixuan", "shop"],
["EVENT 2", "7-Day Login: 300 crystals", "login"],
["EVENT 4", "Captain's Wishing Tree Secrets", "other"],
];
test.each(TYPES)("%s / %s is a %s", (row, titleTop, expected) => {
const [e] = parse(
page({ scheduleDates: WEEKS, scheduleActivities: [{ row, content: [bar({ miniature: { titleTop, titleMid: null } })] }] }),
);
expect(e?.type).toBe(expected);
});
test("bosses are not read as events", async () => {
// `scheduleBosses` is the only exactly-dated material on the page, and it is
// a recurring Abyss/Memorial rotation with no end — not a deadline.
const events = parse(await Bun.file(FIXTURE).text());
expect(events.some((e) => /parvati|herrscher|rimestar/i.test(e.title))).toBe(false);
});
test("the whole page is read — one event per bar, nothing silently dropped", async () => {
const html = await Bun.file(FIXTURE).text();
const timeline = JSON.parse(
/`],
])("rejects %s", (_label, html) => {
expect(aruStatsParser.canParse(html)).toBe(false);
});
test.each([
["no week grid", { scheduleDates: [], scheduleActivities: [{ row: "EVENT 1", content: [bar()] }] }],
["no activity rows", { scheduleDates: WEEKS, scheduleActivities: [] }],
["a week grid that no longer states dates", { scheduleDates: [{ startDate: "Week 1", endDate: "Week 2" }], scheduleActivities: [{ row: "EVENT 1", content: [bar()] }] }],
])("rejects %s", (_label, timeline) => {
expect(aruStatsParser.canParse(page(timeline))).toBe(false);
});
test("the adapter refuses a rejected shape instead of publishing zero", () => {
// An empty calendar reads as "this game has nothing on" to a reader, which
// is why the seam throws here rather than returning nothing.
const html = page({ scheduleDates: [], scheduleActivities: [] });
expect(() => parse(html)).toThrow(/redesigned/);
expect(parseRaw(html)).toHaveLength(0);
});
});