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 Impact

AruStats
Arustats

English

Enable 3D models

Timeline

sushang
GLB/SEAESTIMATED WEEK
WEEK - 1
Aug 20 - 28
WEEK - 2
Aug 28 - Sep 4
WEEK - 3
Sep 4 - 11
WEEK - 4
Sep 11 - 18
WEEK - 5
Sep 18 - 25
WEEK - 6
Sep 25 - Oct 2
WEEK - 7
Oct 2 - 9
WEEK - 8
Oct 9 - 16
WEEK - 9
Oct 16 - 22
BATTLESUIT SUPPLY A
EQUIPMENT SUPPLY A
Fenghuang of Vicissitude DLC
Fenghuang of Vicissitude DLC
Fenghuang of Vicissitude DLC
BATTLESUIT SUPPLY B
Jovial Deception: Shadowdimmer
Jovial Deception: Shadowdimmer
EQUIPMENT SUPPLY B
Jovial Deception Equipment
Jovial Deception Equipment
Jovial Deception Equipment
ASTRAL OP SUPPLY
Senadina
Senadina
OUTFIT
Crane of Taixuan
Crane of Taixuan
ASCENSION SUPPLY
Mad Pleasure
Mad Pleasure
LoneDestruction
Lone
ARMAMENT ASCENSION
Mad Pleasure Equipment
Mad Pleasure Equipment
Mad Pleasure Equipment
Lone DestructionEquipment
Lone Destruction
Lone Destruction
EVENT 1
Topop Event100USD to get up to 3x Sparkle/Mini-Dudu stamps
Topop Event
EVENT 2
7-Day Login:120 crystal, 1 FoV DLC stigma box and more materials
7-Day Login:
EVENT 3
7-Day Login:300 crystals (cont from v8.9)
7-Day Login:
EVENT 4
Captain's Wishing Tree Secrets
Captain's Wishing Tree Secrets
EVENT 5
8-Day Login: 5 Equip Cards
8-Day Login: 5 Equip Cards
EVENT 6
P2 FinaleIt's finally over ๐Ÿ™
P2 Finale
EVENT 7
Flying to Oxia invitation Contest
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) {