feat(sources): Star Rail's Nameless Honor pass windows from Fandom

New `fandomtable` parser (src/ingest/parsers/fandomTable.ts) reads Fandom's
dated pass-history table by its Name/Start/End headings and emits runs not
yet ended by ctx.now, at day precision. Titles come from a per-source
PASS_SOURCES table so the next pass pages are one entry each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-16 06:17:00 +02:00
co-authored by Claude Opus 5.5
parent 40d0dbef5a
commit 0a4be4e339
10 changed files with 444 additions and 14 deletions
+1
View File
@@ -52,6 +52,7 @@ Consequences worth internalising:
| `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield |
| `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights |
| `fandom` | Fandom wikis via the MediaWiki `action=parse` API — five page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables, Infinity Nikki's `Event \| Duration \| Description \| Type` article-tables, and Genshin Impact's `Event \| Duration \| Type(s)` tables | Reverse: 1999, Fate/Grand Order, Nikke, Infinity Nikki, Genshin Impact |
| `fandomtable` | Fandom's dated **history table** via the same `action=parse` API — one `article-table` headed `Name \| Start \| End` (plus Image/Version), a row per run of a battle pass, found by heading rather than column position and skipped when its heading row names `Duration` (that is a version table). Titles come from a per-source `PASS_SOURCES` entry; only rows not yet ended by `ctx.now` are emitted, at day precision | Star Rail (Nameless Honor) |
| `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — redesigned `#eventtable` unified schedule (and legacy JP/Global tabber) | Blue Archive |
| `holodoriwiki` | holodori.wiki's rendered `/wiki/Events` — `Current Events` and `Past Events` wikitables over `Event \| Type \| Start Date \| End Date` | hololive Dreams |
| `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 |
+15
View File
@@ -88,6 +88,7 @@ Two things this method cannot tell you, and both matter:
| Aether Gazer | — | — | — | **Do not build. The game is shutting down.** |
| Silver Palace | — | — | — | Unreleased; beta only |
| Genshin Impact (Fandom) | `genshin-impact.fandom.com/api.php?…page=Event` | `fandom`, fifth template | yes (via `api.php`) | **BUILT** 2026-09-12 — adds 11 events Game8 missed; Game8 priority preserves localStorage IDs |
| Star Rail — Nameless Honor | `honkai-star-rail.fandom.com/api.php?…page=Nameless_Honor` | new (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the pass window as one `other` event, day precision |
| hololive Dreams | `holodori.wiki/wiki/Events` (still) | `holodoriwiki` | yes, but nothing new to fetch | **LAPSED** 2026-10-14 — NamuWiki is behind a Cloudflare challenge, the official site posts no events, Game8 has no page |
---
@@ -854,6 +855,20 @@ page lists events again, the refresh warns that the source is listing events aga
**Re-check:** `bun run refresh --only holodori-holodoriwiki-events` once the six-hour floor has
passed. A `fetched` with more than zero events means the wiki is back.
## 17. Star Rail — Nameless Honor pass window (Fandom) — BUILT 2026-10-16
**Source:** `https://honkai-star-rail.fandom.com/api.php?action=parse&page=Nameless_Honor&prop=text&formatversion=2&format=json` (page `https://honkai-star-rail.fandom.com/wiki/Nameless_Honor`), source id `hsr-fandom-nameless-honor`. First of the pass-window sources in `docs/superpowers/specs/2026-10-16-pass-windows-design.md`.
**What it is:** Star Rail's battle pass. Event pages state no pass dates, but this page keeps a history table, one row per run, with the version each belonged to. Each row that has not ended by `ctx.now` becomes one event titled exactly `Nameless Honor`, type `other`.
**Conduct:** `robots.txt` read with the runner's own `RobotsCache` and real User-Agent on 2026-10-16: `200`, allowed. `Allow: /api.php?action=` for `*`, no `Disallow: /` for `*`; the `Disallow: /` groups name other crawlers (GPTBot, CCBot, OAI-SearchBot, ClaudeBot, ImagesiftBot, Grok*, Semrush, serpstatbot), and the only `Crawl-delay` belongs to `ias_crawler`. Recorded in the `SOURCES` entry.
**Shape:** the second `article-table` on the page, `Image | Name | Start | End | Version`, 31 rows (2023-04-26 to 2026-09-28; verified by counting the page's `<tr>`s independently of the parser). Cells print a bare date (`September 28, 2026`). Each cell also has a `data-sort-value` with a clock (`11:00:00` starts, `03:59:59` ends) that the page ties to no zone, so it is not read.
**Precision:** day, start and end. Nothing is inferred: no clock, no region, no end for a row without one (skipped). At capture one row is live, 2026-09-28 to 2026-11-09 (4.6); each row ends on the day the next one starts. The Name cell reads `Nameless Honor <start date>`, so the title is constant and the start is the only thing that tells runs apart in the ID.
**Reuse:** the parser is `fandomtable` (`src/ingest/parsers/fandomTable.ts`). The next pass pages (ZZZ `New_Eridu_City_Fund`, Genshin `Miliastra_Pass`) are one `PASS_SOURCES` entry each (title as a function of the row's Name cell) plus a `SOURCES` entry; columns are found by heading. A table headed with `Duration` is deliberately not this template (it is a version table).
## What every one of these costs, beyond the source
Adding a game is never only a `SOURCES` entry:
@@ -0,0 +1,23 @@
[
{
"id": "hsr:nameless-honor:2026-09-28",
"game": "hsr",
"title": "Nameless Honor",
"type": "other",
"summary": null,
"startsAt": "2026-09-28T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-11-09T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://honkai-star-rail.fandom.com/wiki/Nameless_Honor/2026-09-28",
"sourceId": "hsr-fandom-nameless-honor",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-10-16T00:00:00.000Z",
"updatedAt": "2026-10-16T00:00:00.000Z"
}
]
File diff suppressed because one or more lines are too long
+21
View File
@@ -116,6 +116,27 @@ const SOURCES: SourceSpec[] = [
url: "https://genshin-impact.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json",
parserId: "fandom",
},
{
id: "hsr-fandom-nameless-honor",
game: "hsr",
// The API, as every Fandom source (AGENTS.md § Fandom). robots.txt of
// honkai-star-rail.fandom.com read with the runner's own client
// (`RobotsCache`, the real User-Agent) on 2026-10-16: HTTP 200,
// `robots.txt ok`, and this API path allowed for `User-agent: *`
// (`Allow: /api.php?action=`, no `Disallow: /` for `*`). The groups that
// do `Disallow: /` name other crawlers (GPTBot, CCBot, OAI-SearchBot,
// ClaudeBot, ImagesiftBot, Grok*, Semrush, serpstatbot), not us; the only
// `Crawl-delay` (1) is `ias_crawler`'s, and the runner's 2s host gap
// exceeds it anyway.
//
// A pass history, not an event page: one `Name | Start | End | Version`
// table with a row per run, at day precision (the page prints no times).
// Read by the `fandomtable` parser, which emits only runs that have not
// ended by `ctx.now`. The title comes from `PASS_SOURCES` in
// `parsers/fandomTable.ts`, keyed by this id.
url: "https://honkai-star-rail.fandom.com/api.php?action=parse&page=Nameless_Honor&prop=text&formatversion=2&format=json",
parserId: "fandomtable",
},
{
id: "r1999-fandom-events",
game: "r1999",
+227
View File
@@ -0,0 +1,227 @@
import { eventId, type GachaEvent } from "../../shared/schema.ts";
import { latestBoundaryMs } from "../../shared/time.ts";
import { parseMonthDayYear, type ParsedInstant } from "../dates.ts";
import { text } from "../html.ts";
import type { ParseContext } from "../adapters/types.ts";
import { renderedHtml } from "./fandom.ts";
import type { SourceParser } from "./types.ts";
/**
* Fandom's dated *history table* — the template behind the battle-pass pages
* (`Nameless_Honor`, and next `New_Eridu_City_Fund` and `Miliastra_Pass`).
*
* It shares nothing with the event pages `fandom.ts` reads: there is no section
* fence and no "ongoing" heading, only one `article-table` with a row per past
* and current run of the pass —
*
* Image | Name | Start | End | Version
*
* — whose Start and End cells print a bare date (`September 28, 2026`). The
* cells also carry a `data-sort-value` with a clock (`2026-09-28 11:00:00`),
* which is deliberately **not read**: the page states no zone for it, and the
* visible text is the only claim the editors made. Every event is therefore
* day precision, as in the Infinity Nikki reader (AGENTS.md § Fandom).
*
* Because the table is a history, inclusion is decided against `ctx.now` and
* nothing else: a row whose end has passed is skipped, a current or upcoming
* row is emitted. A row without a readable end is skipped, never given one.
*
* ## Adding the next source
*
* Reuse is one `PASS_SOURCES` entry below plus one `SOURCES` entry. Columns are
* found by their *headings*, not by position, so a page that drops the Image
* column or the Version column still reads. The entry says how that source
* titles an event: a constant for a pass with one name, or a function of the
* row's own name cell (`Miliastra Pass: <name>`). A source id with no entry
* here emits nothing — a missing title is skipped rather than guessed.
*/
/** How one source titles the events it reads from a history table. */
export interface PassSource {
/** The wiki's own words for what this page lists, for the doc and the log. */
readonly pass: string;
/** The event title, given the row's Name cell (already tag-stripped). */
title(rowName: string): string;
}
/** Keyed by source id, because the title is a fact about the source. */
export const PASS_SOURCES: Readonly<Record<string, PassSource>> = {
"hsr-fandom-nameless-honor": {
// The Name cell reads `Nameless Honor 2026-09-28` — the run's dated
// subpage, which `eventId` already carries as the start. Constant title.
pass: "Nameless Honor",
title: () => "Nameless Honor",
},
};
/** The columns a pass table is recognised by, as normalised heading text. */
const NAME_HEADINGS = new Set(["name", "pass", "pass name", "event"]);
interface Columns {
name: number;
start: number;
end: number;
}
interface Table {
columns: Columns;
rows: string[][];
/** The anchor in each row's Name cell, for `sourceUrl`; `null` when absent. */
links: Array<string | null>;
}
function cells(row: string, tag: "th" | "td"): string[] {
return [
...row.matchAll(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, "gi")),
].map((c) => c[1] ?? "");
}
/**
* Every table on the page whose headings are `Name | Start | End` (any order,
* any extra columns except `Duration`).
*
* `Duration` is the one exclusion, and it is structural: the Version page
* (Infinity Nikki's) also heads a table `Name | Version | Start | End`, but
* adds a `Duration` column, and that table is a different template with a
* different reader — it dates a *version*, not a pass.
*/
function passTables(rendered: string): Table[] {
const out: Table[] = [];
for (const table of rendered.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
const body = table[1] ?? "";
// A nested table would cut this match short and shift every column.
if (/<table\b/i.test(body)) continue;
const rowHtml = body.match(/<tr\b[\s\S]*?<\/tr>/gi) ?? [];
const head = rowHtml[0];
if (head === undefined) continue;
const headings = cells(head, "th").map((h) =>
text(h).toLowerCase().replace(/\s+/g, " "),
);
if (headings.includes("duration")) continue;
const name = headings.findIndex((h) => NAME_HEADINGS.has(h));
const start = headings.indexOf("start");
const end = headings.indexOf("end");
if (name === -1 || start === -1 || end === -1) continue;
// Row cells only line up with the headings when the row has the same
// number of them: a rowspan'd or short row is skipped, not realigned.
const width = headings.length;
const rows: string[][] = [];
const links: Array<string | null> = [];
for (const row of rowHtml.slice(1)) {
const raw = cells(row, "td");
if (raw.length !== width) continue;
rows.push(raw);
links.push(/<a\b[^>]*\bhref="([^"]+)"/i.exec(raw[name] ?? "")?.[1] ?? null);
}
out.push({ columns: { name, start, end }, rows, links });
}
return out;
}
/** A cell that is exactly one `Month D, YYYY` date and nothing else. */
function wholeDate(cell: string): ParsedInstant | null {
const value = text(cell);
if (!/^[A-Za-z]+\.? \d{1,2}, \d{4}$/.test(value)) return null;
return parseMonthDayYear(value);
}
/**
* The wiki page the source reads, as a URL, from the API URL's `page=`.
* Pure, and only used when a row's own Name cell carries no link.
*/
function pageUrl(sourceUrl: string): string {
const url = new URL(sourceUrl);
const page = url.searchParams.get("page");
return page === null
? url.origin
: `${url.origin}/wiki/${encodeURIComponent(page).replace(/%2F/g, "/")}`;
}
export function parseFandomHistoryTable(
body: string,
ctx: ParseContext,
): GachaEvent[] {
const source = PASS_SOURCES[ctx.sourceId];
if (source === undefined) return [];
const rendered = renderedHtml(body);
if (rendered === null) return [];
const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
for (const table of passTables(rendered)) {
const { name, start, end } = table.columns;
table.rows.forEach((row, i) => {
const rowName = text(row[name] ?? "");
if (rowName === "") return;
const startsAt = wholeDate(row[start] ?? "");
const endsAt = wholeDate(row[end] ?? "");
// No readable end is not an open end here: a history row is one run of a
// pass, and a run with no stated end is skipped rather than guessed.
if (startsAt === null || endsAt === null) return;
if (endsAt.iso <= startsAt.iso) return;
// `latestBoundaryMs`, not `Date.parse`: the end is day precision, and the
// raw value is UTC midnight — a placeholder the countdown resolves to the
// reader's own reset (AGENTS.md § Domain rules). Retiring the row on the
// placeholder would drop a pass hours before the game does.
if (latestBoundaryMs(endsAt.iso, endsAt.precision, ctx.game) < nowMs) {
return;
}
const title = source.title(rowName);
const href = table.links[i];
out.push({
id: eventId(ctx.game, title, startsAt.iso),
game: ctx.game,
title,
type: "other",
summary: null,
startsAt: startsAt.iso,
startPrecision: startsAt.precision,
endsAt: endsAt.iso,
endPrecision: endsAt.precision,
// The page prints a date and no clock or server; nothing here is per
// region, and nothing is invented to make it so.
regionScoped: false,
regionEnds: null,
sourceUrl:
href === null || href === undefined
? pageUrl(ctx.sourceUrl)
: new URL(href, ctx.sourceUrl).toString(),
sourceId: ctx.sourceId,
status: "published",
confidence: 0.85,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
});
});
}
return out;
}
export const fandomTableParser: SourceParser = {
id: "fandomtable",
label: "Fandom dated history table",
canParse(body: string): boolean {
// Structural: an `action=parse` envelope holding a table headed
// `Name | Start | End`. Never a row, so a pass page that is between runs
// still identifies itself.
const rendered = renderedHtml(body);
return rendered !== null && passTables(rendered).length > 0;
},
parse: parseFandomHistoryTable,
};
+3
View File
@@ -2,6 +2,7 @@ import { arknightsWikiParser } from "./akwiki.ts";
import { aruStatsParser } from "./arustats.ts";
import { blueArchiveWikiParser } from "./bawiki.ts";
import { fandomParser } from "./fandom.ts";
import { fandomTableParser } from "./fandomTable.ts";
import { game8Parser } from "./game8.ts";
import { holodoriWikiParser } from "./holodori.ts";
import { iopWikiParser } from "./iopwiki.ts";
@@ -20,6 +21,7 @@ export const PARSERS: SourceParser[] = [
wikiGgParser,
arknightsWikiParser,
fandomParser,
fandomTableParser,
blueArchiveWikiParser,
holodoriWikiParser,
iopWikiParser,
@@ -37,6 +39,7 @@ export { arknightsWikiParser } from "./akwiki.ts";
export { aruStatsParser } from "./arustats.ts";
export { blueArchiveWikiParser } from "./bawiki.ts";
export { fandomParser } from "./fandom.ts";
export { fandomTableParser } from "./fandomTable.ts";
export { game8Parser } from "./game8.ts";
export { holodoriWikiParser } from "./holodori.ts";
export { iopWikiParser } from "./iopwiki.ts";
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import { Glob } from "bun";
import { adapterById } from "../../src/ingest/adapters/index.ts";
import { PARSERS } from "../../src/ingest/parsers/index.ts";
import {
fandomTableParser,
PASS_SOURCES,
} from "../../src/ingest/parsers/fandomTable.ts";
const FIXTURE = "fixtures/hsr/fandom-nameless-honor-2026-10-16.html";
const SOURCE_ID = "hsr-fandom-nameless-honor";
function ctx(now: string, sourceId = SOURCE_ID) {
const a = adapterById(SOURCE_ID)!;
return { now, sourceUrl: a.url, sourceId, game: a.game };
}
/** A minimal `action=parse` body holding one pass table. */
function body(rows: Array<[string, string, string]>, headings = "Name | Start | End") {
const th = headings.split(" | ").map((h) => `<th>${h}</th>`).join("");
const tr = rows
.map(([n, s, e]) => `<tr><td>${n}</td><td>${s}</td><td>${e}</td></tr>`)
.join("");
return JSON.stringify({
parse: { title: "T", text: `<table><tr>${th}</tr>${tr}</table>` },
});
}
describe("fandomtable parser", () => {
const html = Bun.file(FIXTURE).text();
test("reads every row of the page's table (31, an independent count)", async () => {
// Early enough that no row has ended: all 31 `<tr>` data rows must parse.
const events = fandomTableParser.parse(await html, ctx("2020-01-01T00:00:00.000Z"));
expect(events).toHaveLength(31);
expect(new Set(events.map((e) => e.id)).size).toBe(31);
});
test("current and upcoming rows only: past rows are absent", async () => {
const events = fandomTableParser.parse(await html, ctx("2026-10-16T00:00:00.000Z"));
expect(events.map((e) => [e.title, e.startsAt, e.endsAt])).toEqual([
["Nameless Honor", "2026-09-28T00:00:00.000Z", "2026-11-09T00:00:00.000Z"],
]);
// Earlier clock: the live row, the one that follows it, and 4.6 upcoming.
const earlier = fandomTableParser.parse(await html, ctx("2026-08-14T00:00:00.000Z"));
expect(earlier.map((e) => e.startsAt.slice(0, 10))).toEqual([
"2026-07-15",
"2026-08-26",
"2026-09-28",
]);
});
test("a row stays on its last day: the placeholder midnight is not the end", async () => {
const events = fandomTableParser.parse(await html, ctx("2026-11-09T05:00:00.000Z"));
expect(events).toHaveLength(1);
});
test("day precision, never a clock the page did not state", async () => {
for (const e of fandomTableParser.parse(await html, ctx("2020-01-01T00:00:00.000Z"))) {
expect(e.startPrecision).toBe("day");
expect(e.endPrecision).toBe("day");
expect(e.type).toBe("other");
expect(e.startsAt.endsWith("T00:00:00.000Z")).toBe(true);
}
});
test("a row without a readable end is skipped, not given one", () => {
const events = fandomTableParser.parse(
body([
["A", "September 1, 2026", "TBA"],
["B", "September 1, 2026", ""],
["C", "September 1, 2026", "October 6"],
["D", "September 1, 2026", "October 12, 2026"],
]),
ctx("2026-09-10T00:00:00.000Z"),
);
expect(events.map((e) => e.endsAt)).toEqual(["2026-10-12T00:00:00.000Z"]);
});
test("a source id with no title entry emits nothing", () => {
const b = body([["A", "September 1, 2026", "October 12, 2026"]]);
expect(fandomTableParser.parse(b, ctx("2026-09-10T00:00:00.000Z"))).toHaveLength(1);
expect(fandomTableParser.parse(b, ctx("2026-09-10T00:00:00.000Z", "x-unknown"))).toEqual([]);
});
test("every PASS_SOURCES key is a registered source on this parser", () => {
for (const id of Object.keys(PASS_SOURCES)) {
expect(adapterById(id)?.parserId).toBe("fandomtable");
}
});
test("non-API bodies are refused rather than read as empty", () => {
expect(fandomTableParser.canParse("<html><table></table></html>")).toBe(false);
expect(fandomTableParser.canParse('{"error":{"code":"missingtitle"}}')).toBe(false);
});
test("a table with a Duration column (a version table) is not this template", () => {
expect(
fandomTableParser.canParse(
body([], "Name | Version | Start | End | Duration"),
),
).toBe(false);
expect(fandomTableParser.canParse(body([], "Image | Name | Start | End | Version"))).toBe(true);
});
});
describe("fandomtable canParse across every fixture", () => {
const files = [...new Glob("fixtures/**/*.html").scanSync(".")].sort();
test("there are fixtures to check", () => {
expect(files.length).toBeGreaterThan(20);
});
test("true on the Nameless Honor fixture only", async () => {
const accepted: string[] = [];
for (const f of files) {
if (fandomTableParser.canParse(await Bun.file(f).text())) accepted.push(f);
}
expect(accepted).toEqual([FIXTURE]);
});
test("no other parser claims the Nameless Honor fixture", async () => {
const text = await Bun.file(FIXTURE).text();
const claimants = PARSERS.filter((p) => p.canParse(text)).map((p) => p.id);
expect(claimants).toEqual(["fandomtable"]);
});
});
+16 -12
View File
@@ -24,7 +24,7 @@ function adapter(id: string): Adapter {
const genshinGame8 = adapter("genshin-game8-events");
const nteGame8 = adapter("nte-game8-events");
const CASES: Array<{ adapter: Adapter; fixture: string }> = [
const CASES: Array<{ adapter: Adapter; fixture: string; now?: 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" },
@@ -69,39 +69,43 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [
{ adapter: adapter("hi3-arustats-events"), fixture: "fixtures/hi3/arustats-events-2026-08-27" },
// Punishing: Gray Raven (karendar.com). Exact UTC timestamps for the Global server.
{ adapter: adapter("pgr-karendar-events"), fixture: "fixtures/pgr/karendar-events-2026-09-12" },
// Star Rail's Nameless Honor pass history (Fandom, `fandomtable` parser).
// Pinned at the capture day, not NOW: the page is a history, so which rows
// survive depends on the clock, and at capture exactly one does (4.6).
{ adapter: adapter("hsr-fandom-nameless-honor"), fixture: "fixtures/hsr/fandom-nameless-honor-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
];
async function runAdapter(adapter: Adapter, fixture: string) {
async function runAdapter(adapter: Adapter, fixture: string, now: string = NOW) {
const html = await Bun.file(`${fixture}.html`).text();
return adapter.parse(html, {
now: NOW,
now,
sourceUrl: adapter.url,
sourceId: adapter.id,
game: adapter.game,
});
}
describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture, now }) => {
test("matches the checked-in expected output", async () => {
const events = await runAdapter(adapter, fixture);
const events = await runAdapter(adapter, fixture, now);
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)) {
for (const event of await runAdapter(adapter, fixture, now)) {
expect(() => GachaEvent.parse(event)).not.toThrow();
}
});
test("is deterministic across runs", async () => {
const a = await runAdapter(adapter, fixture);
const b = await runAdapter(adapter, fixture);
const a = await runAdapter(adapter, fixture, now);
const b = await runAdapter(adapter, fixture, now);
expect(a).toEqual(b);
});
test("never emits an end before its start", async () => {
for (const e of await runAdapter(adapter, fixture)) {
for (const e of await runAdapter(adapter, fixture, now)) {
if (e.endsAt !== null) expect(e.endsAt > e.startsAt).toBe(true);
}
});
@@ -121,7 +125,7 @@ describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
// the genuinely year-long event, which is a shape this domain does have:
// the same widening clears Fire Emblem Heroes' real seven-month
// new-player banner (docs/SOURCES.md § 12b).
for (const e of await runAdapter(adapter, fixture)) {
for (const e of await runAdapter(adapter, fixture, now)) {
if (e.endsAt === null) continue;
const days =
(Date.parse(e.endsAt) - Date.parse(e.startsAt)) / 86_400_000;
@@ -130,7 +134,7 @@ describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
});
test("event IDs are unique and stable in shape", async () => {
const events = await runAdapter(adapter, fixture);
const events = await runAdapter(adapter, fixture, now);
const ids = events.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
for (const e of events) {
@@ -141,7 +145,7 @@ describe.each(CASES)("$adapter.id $fixture", ({ adapter, fixture }) => {
});
test("excludes permanent and past sections", async () => {
const titles = (await runAdapter(adapter, fixture)).map((e) => e.title);
const titles = (await runAdapter(adapter, fixture, now)).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);
+10 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { adapterById, ADAPTERS } from "../src/ingest/adapters/index.ts";
import { parserById } from "../src/ingest/parsers/index.ts";
import { pickFixture } from "../src/ingest/snapshots.ts";
import {
LIMITS,
sanitizeEvent,
@@ -537,8 +538,15 @@ describe("the adapter seam", () => {
const parser = parserById(adapter.parserId);
if (parser === undefined) throw new Error(`no parser ${adapter.parserId}`);
const pattern = `fixtures/${adapter.game}/${adapter.parserId}-*.html`;
const file = [...new Bun.Glob(pattern).scanSync(".")].sort().at(-1);
// The source's own fixture, selected exactly as `build-feed` selects it
// (`<id without game>-<date>.html`). The parser id is not the prefix for
// a template that shares a site with another: `fandomtable` reads
// `fandom-nameless-honor-*`.
const file = pickFixture(
adapter.id,
adapter.game,
[...new Bun.Glob(`fixtures/${adapter.game}/*.html`).scanSync(".")],
);
if (file === undefined) throw new Error(`no fixture for ${adapter.id}`);
const html = await Bun.file(file).text();