diff --git a/fixtures/hi3/arustats-events-2026-08-27.expected.json b/fixtures/hi3/arustats-events-2026-08-27.expected.json
new file mode 100644
index 0000000..190beae
--- /dev/null
+++ b/fixtures/hi3/arustats-events-2026-08-27.expected.json
@@ -0,0 +1,338 @@
+[
+ {
+ "id": "hi3:7-day-login-120-crystal-1-fov-dlc-stigma-box-and-more-materials:2026-08-20",
+ "game": "hi3",
+ "title": "7-Day Login: 120 crystal, 1 FoV DLC stigma box and more materials",
+ "type": "login",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:7-day-login-300-crystals-cont-from-v8-9:2026-08-20",
+ "game": "hi3",
+ "title": "7-Day Login: 300 crystals (cont from v8.9)",
+ "type": "login",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-02T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:captains-wishing-tree-secrets:2026-08-20",
+ "game": "hi3",
+ "title": "Captain's Wishing Tree Secrets",
+ "type": "other",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:fenghuang-of-vicissitude-dlc:2026-08-20",
+ "game": "hi3",
+ "title": "Fenghuang of Vicissitude DLC",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-02T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:jovial-deception-equipment:2026-08-20",
+ "game": "hi3",
+ "title": "Jovial Deception Equipment",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:jovial-deception-shadowdimmer:2026-08-20",
+ "game": "hi3",
+ "title": "Jovial Deception: Shadowdimmer",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:mad-pleasure-equipment:2026-08-20",
+ "game": "hi3",
+ "title": "Mad Pleasure Equipment",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-09-11T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:mad-pleasure:2026-08-20",
+ "game": "hi3",
+ "title": "Mad Pleasure",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-09-11T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:senadina:2026-08-20",
+ "game": "hi3",
+ "title": "Senadina",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-02T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:topop-event:2026-08-20",
+ "game": "hi3",
+ "title": "Topop Event",
+ "type": "other",
+ "summary": "100USD to get up to 3x Sparkle/Mini-Dudu stamps",
+ "startsAt": "2026-08-20T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:8-day-login-5-equip-cards:2026-08-28",
+ "game": "hi3",
+ "title": "8-Day Login: 5 Equip Cards",
+ "type": "login",
+ "summary": null,
+ "startsAt": "2026-08-28T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:crane-of-taixuan:2026-08-28",
+ "game": "hi3",
+ "title": "Crane of Taixuan",
+ "type": "shop",
+ "summary": null,
+ "startsAt": "2026-08-28T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-09-11T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:p2-finale:2026-09-04",
+ "game": "hi3",
+ "title": "P2 Finale",
+ "type": "other",
+ "summary": "It's finally over ๐",
+ "startsAt": "2026-09-04T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:flying-to-oxia-invitation-contest:2026-09-11",
+ "game": "hi3",
+ "title": "Flying to Oxia invitation Contest",
+ "type": "other",
+ "summary": null,
+ "startsAt": "2026-09-11T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-22T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:lone-destruction-equipment:2026-09-11",
+ "game": "hi3",
+ "title": "Lone Destruction Equipment",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-09-11T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-02T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ },
+ {
+ "id": "hi3:lone-destruction:2026-09-11",
+ "game": "hi3",
+ "title": "Lone Destruction",
+ "type": "banner",
+ "summary": null,
+ "startsAt": "2026-09-11T00:00:00.000Z",
+ "startPrecision": "day",
+ "endsAt": "2026-10-02T00:00:00.000Z",
+ "endPrecision": "day",
+ "regionScoped": false,
+ "regionEnds": null,
+ "sourceUrl": "https://www.arustats.com/en-us/hi3/timeline",
+ "sourceId": "hi3-arustats-events",
+ "status": "published",
+ "confidence": 0.4,
+ "extractionMethod": "parser",
+ "version": 1,
+ "firstSeenAt": "2026-08-14T00:00:00.000Z",
+ "updatedAt": "2026-08-14T00:00:00.000Z"
+ }
+]
diff --git a/fixtures/hi3/arustats-events-2026-08-27.html b/fixtures/hi3/arustats-events-2026-08-27.html
new file mode 100644
index 0000000..08ddfc3
--- /dev/null
+++ b/fixtures/hi3/arustats-events-2026-08-27.html
@@ -0,0 +1 @@
+
Timeline - Honkai ImpactTimeline
EQUIPMENT SUPPLY A
Fenghuang of Vicissitude DLC
BATTLESUIT SUPPLY B
Jovial Deception: Shadowdimmer
EQUIPMENT SUPPLY B
Jovial Deception Equipment
ARMAMENT ASCENSION
Lone DestructionEquipment
EVENT 1
Topop Event100USD to get up to 3x Sparkle/Mini-Dudu stamps
EVENT 2
7-Day Login:120 crystal, 1 FoV DLC stigma box and more materials
EVENT 3
7-Day Login:300 crystals (cont from v8.9)
EVENT 4
Captain's Wishing Tree Secrets
EVENT 5
8-Day Login: 5 Equip Cards
EVENT 6
P2 FinaleIt's finally over ๐
EVENT 7
Flying to Oxia invitation Contest
This page has been designed and created by 'alpaquitaay'
AruStats is not affiliated with miHoYo. Honkai Impact game content and materials are trademarks and copyrights of HoYoverse.
\ No newline at end of file
diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts
index 70c9a7a..5abbc82 100644
--- a/src/ingest/adapters/index.ts
+++ b/src/ingest/adapters/index.ts
@@ -194,6 +194,22 @@ const SOURCES: SourceSpec[] = [
url: "https://game8.co/games/Umamusume-Pretty-Derby/archives/536311",
parserId: "game8",
},
+ {
+ id: "hi3-arustats-events",
+ game: "hi3",
+ // **Version-less on purpose.** This path answers `307` to the current
+ // version โ `/en-us/hi3/timeline/9.0` today โ so the site names its own
+ // live version and the runner follows it. Pinning `/9.0` here would publish
+ // a finished schedule as current the day the game moves on.
+ //
+ // **These dates are estimates.** The page schedules by week bucket and heads
+ // the grid `ESTIMATED WEEK`; this source publishes bucket edges, not
+ // announced dates, which is the trade `docs/SOURCES.md` ยง 14 records and
+ // `ESTIMATE_CONFIDENCE` (0.4) carries into the data. A real Honkai Impact
+ // 3rd source outranks it on confidence alone if one is ever found.
+ url: "https://www.arustats.com/en-us/hi3/timeline",
+ parserId: "arustats",
+ },
];
function toAdapter(spec: SourceSpec): Adapter {
diff --git a/test/adapters/arustats.test.ts b/test/adapters/arustats.test.ts
new file mode 100644
index 0000000..d43c272
--- /dev/null
+++ b/test/adapters/arustats.test.ts
@@ -0,0 +1,248 @@
+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);
+ });
+});
diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts
index 82d82e8..25c7fd8 100644
--- a/test/adapters/game8.test.ts
+++ b/test/adapters/game8.test.ts
@@ -51,6 +51,10 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
{ adapter: adapter("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" },
{ adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-events-2026-08-19" },
{ adapter: adapter("nikki-fandom-events"), fixture: "fixtures/nikki/fandom-events-2026-08-19" },
+ // Honkai Impact 3rd. Every boundary in this expected file is a week-bucket
+ // edge the source labels `ESTIMATED WEEK`, not an announced date โ see
+ // src/ingest/parsers/arustats.ts and docs/SOURCES.md ยง 14.
+ { adapter: adapter("hi3-arustats-events"), fixture: "fixtures/hi3/arustats-events-2026-08-27" },
];
async function runAdapter(adapter: Adapter, fixture: string) {