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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40d0dbef5a
commit
0a4be4e339
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user