feat(sources): Infinity Nikki's Mira Journey window, from its version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-16 06:24:40 +02:00
1 parent a6a8efa661
commit ec669675ed
9 files changed
+392

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ Consequences worth internalising:
| `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) |
| `fandomversion` | Fandom's **version table** via the `action=parse` API — tables headed `Version Name \| Version Number \| Release Date \| End Date \| Duration` (the `Duration` heading is what `fandomtable` refuses, so the readers are disjoint). A pass is emitted for each version row not yet ended by `ctx.now`, only where the source's `VERSION_PASSES` entry states the pass follows the version, with who said so and when; day precision | Infinity Nikki (Mira Journey) |
| `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 |
+13
View File
@@ -91,6 +91,7 @@ Two things this method cannot tell you, and both matter:
| 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 |
| Genshin — Miliastra Pass | `genshin-impact.fandom.com/api.php?…page=Miliastra_Pass` | existing (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the live pass run as one `other` event, day precision |
| ZZZ — New Eridu City Fund | `zenless-zone-zero.fandom.com/api.php?…page=New_Eridu_City_Fund` | existing (`fandomtable`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the live fund run as one `other` event, day precision |
| Infinity Nikki — Mira Journey | `infinity-nikki.fandom.com/api.php?…page=Version` | new (`fandomversion`) | yes (via `api.php`) | **BUILT** 2026-10-16 — the current version's window as one `other` event, on the owner's in-game confirmation; 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 |
---
@@ -887,6 +888,18 @@ passed. A `fetched` with more than zero events means the wiki is back.
**Conduct:** the `RobotsCache` gets `403` (the interstitial challenge) for this host's `robots.txt`. The repository owner read it in a browser on 2026-10-16: the `User-agent: *` group allows `/api.php?`, `/api.php?action=` and `/api.php?*&action=` and disallows only `/wiki/Special:`, `/wiki/User_talk:`, `/wiki/Template:`, `/wiki/Template_talk:`, `/wiki/Help:`, `/wiki/User:`, `/wiki/UserProfile:` (and language variants); `Disallow: /` applies only to named crawlers (SemrushBot, serpstatbot, GPTBot, ImagesiftBot, CCBot, OAI-SearchBot, ClaudeBot, GrokBot, xAI-SearchBot, xAI-Grok, GrokApp, xAI-Bot); `ias_crawler` has `Crawl-delay: 1`. With the standing `--assume-robots-on-403` decision that covers this source. The API answered `200` to the real User-Agent for the single fixture fetch.
## 20. Infinity Nikki — Mira Journey window, from its version (Fandom) — BUILT 2026-10-16
**Source:** `https://infinity-nikki.fandom.com/api.php?action=parse&page=Version&prop=text&formatversion=2&format=json` (page `https://infinity-nikki.fandom.com/wiki/Version`), source id `nikki-fandom-mira-journey`, parser `fandomversion` (`src/ingest/parsers/versionTable.ts`).
**What it is:** Mira Journey has no dated page. The `Version` page dates *versions*: three tables headed `Version Name | Version Number | Release Date | End Date | Duration` (2.x, 1.x, prerelease), 25 rows in all (counted independently of the parser), e.g. `The Feather of Justice | 2.10 | September 23, 2026 | October 29, 2026 | 36 Days`. A version stands in for the pass only because the owner confirmed in game, 2026-10-16, that the pass ends with it (it read 13d 19h at 00:47 UTC, ending 2026-10-29 with version 2.10). That claim and its evidence are one entry in `VERSION_PASSES`, keyed by source id; the event's summary repeats it (`Follows version 2.10 (The Feather of Justice). That the pass ends with the version was confirmed in game, 2026-10-16.`). Rows already ended are skipped, a row without a readable end is skipped; at capture exactly one row survives: 2026-09-23 to 2026-10-29, day precision, titled `Mira Journey`. The page states no zone, so nothing is region-scoped.
**Not the history-table reader:** `fandomtable` refuses any table with a `Duration` heading and this reader requires one, so neither claims the other's page (tested across every fixture).
**Conduct:** same host as `nikki-fandom-events` (§ 11), whose recorded robots reading (the standard Fandom file) applies. The `RobotsCache` with the real User-Agent got `403` (the interstitial challenge) on 2026-10-16, so that reading and the standing `--assume-robots-on-403` decision cover it. The API answered `200` to the real User-Agent for the single fixture fetch.
**Reuse:** Endfield's wiki.gg source can copy the entry shape (title, stated link, evidence); whether it shares this reader is its own call, as wiki.gg's markup differs.
## What every one of these costs, beyond the source
Adding a game is never only a `SOURCES` entry:
@@ -0,0 +1,23 @@
[
{
"id": "nikki:mira-journey:2026-09-23",
"game": "nikki",
"title": "Mira Journey",
"type": "other",
"summary": "Follows version 2.10 (The Feather of Justice). That the pass ends with the version was confirmed in game, 2026-10-16.",
"startsAt": "2026-09-23T00:00:00.000Z",
"startPrecision": "day",
"endsAt": "2026-10-29T00:00:00.000Z",
"endPrecision": "day",
"regionScoped": false,
"regionEnds": null,
"sourceUrl": "https://infinity-nikki.fandom.com/wiki/Version",
"sourceId": "nikki-fandom-mira-journey",
"status": "published",
"confidence": 0.85,
"extractionMethod": "parser",
"version": 1,
"firstSeenAt": "2026-10-16T00:00:00.000Z",
"updatedAt": "2026-10-16T00:00:00.000Z"
}
]
@@ -0,0 +1 @@
{"parse":{"title":"Version","pageid":6305,"text":"<div class=\"mw-content-ltr mw-parser-output\" lang=\"en\" dir=\"ltr\"><p class=\"mw-empty-elt\">\n</p><aside role=\"region\" class=\"portable-infobox pi-background pi-border-color pi-theme-wikia pi-layout-default\">\n<h2 class=\"pi-item pi-item-spacing pi-title pi-secondary-background\" data-source=\"title\">Version</h2>\n\n<div class=\"pi-item pi-data pi-item-spacing pi-border-color\" data-source=\"type\">\n\t\n\t\t<h3 class=\"pi-data-label pi-secondary-font\">Type</h3>\n\t\n\t<div class=\"pi-data-value pi-font\"><ul><li><a href=\"/wiki/Infinity_Nikki\" title=\"Infinity Nikki\">Infinity Nikki</a></li></ul></div>\n</div>\n</aside>\n\n<b>Versions</b> (or <b>Updates</b>) are important updates in <i><a href=\"/wiki/Infinity_Nikki\" title=\"Infinity Nikki\">Infinity Nikki</a></i>, typically lasting 25 days. \nSee <a href=\"/wiki/Event\" title=\"Event\">Events</a> for the current and past events found in Version updates!\n<p class=\"mw-empty-elt\"></p>\n<div id=\"toc\" class=\"toc\" role=\"navigation\" aria-labelledby=\"mw-toc-heading\"><input type=\"checkbox\" role=\"button\" id=\"toctogglecheckbox\" class=\"toctogglecheckbox\" style=\"display:none\" /><div class=\"toctitle\" lang=\"en\" dir=\"ltr\"><h2 id=\"mw-toc-heading\">Contents</h2><span class=\"toctogglespan\"><label class=\"toctogglelabel\" for=\"toctogglecheckbox\"></label></span></div>\n<ul>\n<li class=\"toclevel-1 tocsection-1\"><a href=\"#Latest_Version\"><span class=\"tocnumber\">1</span> <span class=\"toctext\">Latest Version</span></a></li>\n<li class=\"toclevel-1 tocsection-2\"><a href=\"#Version_History\"><span class=\"tocnumber\">2</span> <span class=\"toctext\">Version History</span></a>\n<ul>\n<li class=\"toclevel-2 tocsection-3\"><a href=\"#Version_2\"><span class=\"tocnumber\">2.1</span> <span class=\"toctext\">Version 2</span></a></li>\n<li class=\"toclevel-2 tocsection-4\"><a href=\"#Version_1\"><span class=\"tocnumber\">2.2</span> <span class=\"toctext\">Version 1</span></a></li>\n<li class=\"toclevel-2 tocsection-5\"><a href=\"#Pre-release\"><span class=\"tocnumber\">2.3</span> <span class=\"toctext\">Pre-release</span></a></li>\n</ul>\n</li>\n<li class=\"toclevel-1 tocsection-6\"><a href=\"#Navigation\"><span class=\"tocnumber\">3</span> <span class=\"toctext\">Navigation</span></a></li>\n</ul>\n</div>\n\n<h2><span class=\"mw-headline\" id=\"Latest_Version\">Latest Version</span><span class=\"mw-editsection\"><span class=\"mw-editsection-bracket\">[</span><a title=\"Sign in to edit\" class=\"mw-editsection-visualeditor\" data-tracking-label=\"log-in-edit-section\" data-action=\"edit-section\" href=\"https://auth.fandom.com/signin?redirect=https%3A%2F%2Finfinity-nikki.fandom.com%2Fwiki%2FVersion%3Fveaction%3Dedit%26section%3D1&amp;uselang=en&amp;metadata=article-registration-edit-article-section\" data-auth-modal=\"signin\" data-testid=\"log-in-edit-section\" aria-label=\"Sign in to edit\"><svg class=\"wds-icon wds-icon-tiny\" aria-hidden=\"true\" focusable=\"false\"><use xlink:href=\"#wds-icons-pencil-tiny\"></use></svg></a><span class=\"mw-editsection-bracket\">]</span></span></h2>\n<div style=\"display: none\">\n<aside role=\"region\" class=\"portable-infobox pi-background pi-border-color pi-theme-wikia pi-layout-default\">\n<h2 class=\"pi-item pi-item-spacing pi-title pi-secondary-background\">Version 2.10</h2>\n<h2 class=\"pi-item pi-header pi-secondary-font pi-item-spacing pi-secondary-background\" data-item-name=\"secondary_title\">The Feather of Justice</h2>\n\n\t<section class=\"pi-item pi-panel pi-border-color\">\n\t\t\n\t\t\n\t\t\n\t\t\t<div class=\"wds-tab__content wds-is-current\">\n\t\t\t\t\n<figure class=\"pi-item pi-image\" data-source=\"image1\">\n\t<a href=\"https://static.wikia.nocookie.net/infinity-nikki/images/f/ff/Version_2.10.png/revision/latest?cb=20260926034925\" class=\"image image-thumbnail\" title=\"Version 2.10\">\n\t\t<img src=\"https://static.wikia.nocookie.net/infinity-nikki/images/f/ff/Version_2.10.png/revision/latest/scale-to-width-down/268?cb=20260926034925\" srcset=\"https://static.wikia.nocookie.net/infinity-nikki/images/f/ff/Version_2.10.png/revision/latest/scale-to-width-down/268?cb=20260926034925 1x, https://static.wikia.nocookie.net/infinity-nikki/images/f/ff/Version_2.10.png/revision/latest/scale-to-width-down/536?cb=20260926034925 2x\" class=\"pi-image-thumbnail\" data-relevant=\"1\" alt=\"Version 2.10\" width=\"270\" height=\"152\" data-image-key=\"Version_2.10.png\" data-image-name=\"Version 2.10.png\" />\n\t\t\n\t</a>\n\t\n</figure>\n\n\t\t\t</div>\n\t\t\n\t</section>\n\n\n\n\n<div class=\"pi-item pi-data pi-item-spacing pi-border-color\" data-source=\"date\">\n\t\n\t\t<h3 class=\"pi-data-label pi-secondary-font\">Release Date</h3>\n\t\n\t<div class=\"pi-data-value pi-font\">September 23, 2026</div>\n</div>\n<section class=\"pi-item pi-group pi-border-color\">\n\t<table class=\"pi-horizontal-group\">\n\t\n\t\n\t<thead>\n\t\t<tr>\n\t\t\n\t\Line truncated
+19
View File
@@ -263,6 +263,25 @@ const SOURCES: SourceSpec[] = [
url: "https://infinity-nikki.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json",
parserId: "fandom",
},
{
id: "nikki-fandom-mira-journey",
game: "nikki",
// Same host as `nikki-fandom-events` above, whose robots.txt reading (the
// standard Fandom file) is recorded there. Re-checked with the runner's own
// client (`RobotsCache`, the real User-Agent) on 2026-10-16: it answered
// 403 (the interstitial challenge), so that person-recorded reading and the
// standing `--assume-robots-on-403` decision (AGENTS.md § Scraping conduct)
// cover this source, as they do the other Fandom sources. The API path
// answered 200 to the real User-Agent for the single fixture fetch; no
// browser-shaped headers.
//
// A version table, not a pass page: Mira Journey has no dated page, so the
// event takes the version's window, on the owner's in-game confirmation
// that the pass ends with the version. That claim and its evidence live in
// `VERSION_PASSES` in `parsers/versionTable.ts`, keyed by this id.
url: "https://infinity-nikki.fandom.com/api.php?action=parse&page=Version&prop=text&formatversion=2&format=json",
parserId: "fandomversion",
},
{
id: "nikke-fandom-events",
game: "nikke",
+3
View File
@@ -3,6 +3,7 @@ import { aruStatsParser } from "./arustats.ts";
import { blueArchiveWikiParser } from "./bawiki.ts";
import { fandomParser } from "./fandom.ts";
import { fandomTableParser } from "./fandomTable.ts";
import { fandomVersionParser } from "./versionTable.ts";
import { game8Parser } from "./game8.ts";
import { holodoriWikiParser } from "./holodori.ts";
import { iopWikiParser } from "./iopwiki.ts";
@@ -22,6 +23,7 @@ export const PARSERS: SourceParser[] = [
arknightsWikiParser,
fandomParser,
fandomTableParser,
fandomVersionParser,
blueArchiveWikiParser,
holodoriWikiParser,
iopWikiParser,
@@ -40,6 +42,7 @@ export { aruStatsParser } from "./arustats.ts";
export { blueArchiveWikiParser } from "./bawiki.ts";
export { fandomParser } from "./fandom.ts";
export { fandomTableParser } from "./fandomTable.ts";
export { fandomVersionParser } from "./versionTable.ts";
export { game8Parser } from "./game8.ts";
export { holodoriWikiParser } from "./holodori.ts";
export { iopWikiParser } from "./iopwiki.ts";
+206
View File
@@ -0,0 +1,206 @@
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 *version table* — Infinity Nikki's `Version` page, one table per
* major version —
*
* Version Name | Version Number | Release Date | End Date | Duration
*
* (`The Feather of Justice | 2.10 | September 23, 2026 | October 29, 2026 |
* 36 Days`). It dates a **version**, not a pass. A pass is emitted from it only
* where somebody has *stated* that the pass runs with the version, and that
* statement is written down once, per source, in `VERSION_PASSES` below with
* who made it and when. A source id with no entry emits nothing.
*
* This is the other half of `fandomTable.ts`: that reader takes a table headed
* `Name | Start | End` and refuses one with a `Duration` heading; this one
* requires the version headings and a `Duration`. The two cannot claim each
* other's page.
*
* Cells print a bare date, so every event is day precision. Inclusion is
* decided against `ctx.now` only: a row already ended is skipped, a current or
* upcoming row is emitted, and a row without a readable end is skipped, never
* given one.
*
* ## Reuse
*
* Another site's version page (Endfield's wiki.gg) is a different markup and
* its own reader; what carries over is the *entry shape* — a title, the stated
* link, and the evidence for it — so the claim lives beside the code that
* relies on it.
*/
/** Who said the pass follows the version, and when: never inferred. */
export interface VersionLinkEvidence {
/** `owner` (in game) or `wiki` (a sentence the source prints). */
readonly by: "owner" | "wiki";
/** ISO date of the reading or capture. */
readonly on: string;
/** What was seen, in the words of the person or the wiki. */
readonly reading: string;
}
/** What one source claims about the pass and its version. */
export interface VersionPass {
/** The event title. */
readonly title: string;
/** The sentence the summary closes with: the stated link, with its source. */
readonly link: string;
readonly evidence: VersionLinkEvidence;
}
/** Keyed by source id, because the claim is a fact about the source. */
export const VERSION_PASSES: Readonly<Record<string, VersionPass>> = {
"nikki-fandom-mira-journey": {
title: "Mira Journey",
link: "That the pass ends with the version was confirmed in game, 2026-10-16.",
evidence: {
by: "owner",
on: "2026-10-16",
reading:
"in game, the pass read 13d 19h at 00:47 UTC, ending 2026-10-29 with version 2.10",
},
},
};
interface VersionRow {
name: string;
number: string;
start: ParsedInstant;
end: ParsedInstant;
}
function cells(row: string, tag: "th" | "td"): string[] {
return [
...row.matchAll(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`, "gi")),
].map((c) => c[1] ?? "");
}
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);
}
/**
* Every row of every table headed `Version Name | Version Number | Release
* Date | End Date | Duration` (found by heading, any order). All five are
* required: the Duration column is what marks this template.
*/
function versionRows(rendered: string): { tables: number; rows: VersionRow[] } {
const rows: VersionRow[] = [];
let tables = 0;
for (const table of rendered.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
const body = table[1] ?? "";
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, " "),
);
const name = headings.indexOf("version name");
const number = headings.indexOf("version number");
const start = headings.indexOf("release date");
const end = headings.indexOf("end date");
if (
name === -1 ||
number === -1 ||
start === -1 ||
end === -1 ||
!headings.includes("duration")
) {
continue;
}
tables += 1;
for (const row of rowHtml.slice(1)) {
const raw = cells(row, "td");
if (raw.length !== headings.length) continue;
const startsAt = wholeDate(raw[start] ?? "");
const endsAt = wholeDate(raw[end] ?? "");
const rowName = text(raw[name] ?? "");
const rowNumber = text(raw[number] ?? "");
if (startsAt === null || endsAt === null) continue;
if (rowName === "" || rowNumber === "") continue;
rows.push({ name: rowName, number: rowNumber, start: startsAt, end: endsAt });
}
}
return { tables, rows };
}
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 parseFandomVersionTable(
body: string,
ctx: ParseContext,
): GachaEvent[] {
const pass = VERSION_PASSES[ctx.sourceId];
if (pass === undefined) return [];
const rendered = renderedHtml(body);
if (rendered === null) return [];
const nowMs = Date.parse(ctx.now);
const out: GachaEvent[] = [];
for (const row of versionRows(rendered).rows) {
if (row.end.iso <= row.start.iso) continue;
// Day-precision end: judged by the last moment any reset could put it at,
// not the UTC-midnight placeholder (AGENTS.md § Domain rules).
if (latestBoundaryMs(row.end.iso, row.end.precision, ctx.game) < nowMs) {
continue;
}
out.push({
id: eventId(ctx.game, pass.title, row.start.iso),
game: ctx.game,
title: pass.title,
type: "other",
summary: `Follows version ${row.number} (${row.name}). ${pass.link}`,
startsAt: row.start.iso,
startPrecision: row.start.precision,
endsAt: row.end.iso,
endPrecision: row.end.precision,
regionScoped: false,
regionEnds: null,
sourceUrl: pageUrl(ctx.sourceUrl),
sourceId: ctx.sourceId,
status: "published",
confidence: 0.85,
extractionMethod: "parser",
version: 1,
firstSeenAt: ctx.now,
updatedAt: ctx.now,
});
}
return out;
}
export const fandomVersionParser: SourceParser = {
id: "fandomversion",
label: "Fandom version table",
canParse(body: string): boolean {
// Structural: an `action=parse` envelope holding a table headed with the
// version columns and a Duration. Never a row.
const rendered = renderedHtml(body);
return rendered !== null && versionRows(rendered).tables > 0;
},
parse: parseFandomVersionTable,
};
+2
View File
@@ -75,6 +75,8 @@ const CASES: Array<{ adapter: Adapter; fixture: string; now?: string }> = [
{ adapter: adapter("hsr-fandom-nameless-honor"), fixture: "fixtures/hsr/fandom-nameless-honor-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
// Genshin's Miliastra Pass history (same reader).
{ adapter: adapter("genshin-fandom-miliastra-pass"), fixture: "fixtures/genshin/fandom-miliastra-pass-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
// Infinity Nikki's Mira Journey, from the Version table.
{ adapter: adapter("nikki-fandom-mira-journey"), fixture: "fixtures/nikki/fandom-mira-journey-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
// ZZZ's New Eridu City Fund history (same reader).
{ adapter: adapter("zzz-fandom-city-fund"), fixture: "fixtures/zzz/fandom-city-fund-2026-10-16", now: "2026-10-16T00:00:00.000Z" },
];
+124
View File
@@ -0,0 +1,124 @@
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 } from "../../src/ingest/parsers/fandomTable.ts";
import {
fandomVersionParser,
VERSION_PASSES,
} from "../../src/ingest/parsers/versionTable.ts";
const FIXTURE = "fixtures/nikki/fandom-mira-journey-2026-10-16.html";
const SOURCE_ID = "nikki-fandom-mira-journey";
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 version table. */
function body(rows: Array<[string, string, string, string]>, headings = HEAD) {
const th = headings.split(" | ").map((h) => `<th>${h}</th>`).join("");
const tr = rows
.map(([n, v, s, e]) => `<tr><td>${n}</td><td>${v}</td><td>${s}</td><td>${e}</td><td>x Days</td></tr>`)
.join("");
return JSON.stringify({ parse: { title: "T", text: `<table><tr>${th}</tr>${tr}</table>` } });
}
const HEAD = "Version Name | Version Number | Release Date | End Date | Duration";
describe("fandomversion parser", () => {
const html = Bun.file(FIXTURE).text();
test("the page has 25 version rows and 1 not yet ended (independent count)", async () => {
const page = await html;
// Counted off the raw markup, not through the parser: `Duration` cells
// are `<n> Days` and exist once per data row.
const rows = (page.match(/\d+ Days/g) ?? []).length;
expect(rows).toBe(25);
// A clock before the first row: every row parses.
const all = fandomVersionParser.parse(page, ctx("2020-01-01T00:00:00.000Z"));
expect(all).toHaveLength(25);
expect(new Set(all.map((e) => e.id)).size).toBe(25);
// Only 2.10 (to October 29) has not ended on 2026-10-16.
const now = fandomVersionParser.parse(page, ctx("2026-10-16T00:00:00.000Z"));
expect(now.map((e) => [e.title, e.startsAt, e.endsAt])).toEqual([
["Mira Journey", "2026-09-23T00:00:00.000Z", "2026-10-29T00:00:00.000Z"],
]);
});
test("the summary names the version and the in-game confirmation", async () => {
const [e] = fandomVersionParser.parse(await html, ctx("2026-10-16T00:00:00.000Z"));
expect(e?.summary).toBe(
"Follows version 2.10 (The Feather of Justice). That the pass ends with the version was confirmed in game, 2026-10-16.",
);
expect(e?.startPrecision).toBe("day");
expect(e?.endPrecision).toBe("day");
expect(e?.type).toBe("other");
});
test("after 2026-10-29 the 2.10 row is gone", async () => {
expect(fandomVersionParser.parse(await html, ctx("2026-10-30T00:00:00.000Z"))).toEqual([]);
});
test("the row stays on its last day", async () => {
expect(
fandomVersionParser.parse(await html, ctx("2026-10-29T05:00:00.000Z")),
).toHaveLength(1);
});
test("a row without a readable end is skipped, not given one", () => {
const events = fandomVersionParser.parse(
body([
["A", "3.0", "September 1, 2026", "TBA"],
["B", "3.1", "September 1, 2026", ""],
["C", "3.2", "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 entry emits nothing", () => {
const b = body([["A", "3.0", "September 1, 2026", "October 12, 2026"]]);
expect(fandomVersionParser.parse(b, ctx("2026-09-10T00:00:00.000Z"))).toHaveLength(1);
expect(fandomVersionParser.parse(b, ctx("2026-09-10T00:00:00.000Z", "x-unknown"))).toEqual([]);
});
test("every entry carries its evidence and is a registered source on this parser", () => {
for (const [id, pass] of Object.entries(VERSION_PASSES)) {
expect(adapterById(id)?.parserId).toBe("fandomversion");
expect(pass.evidence.reading.length).toBeGreaterThan(0);
expect(pass.evidence.on).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
});
test("the two Fandom table readers do not claim each other's tables", () => {
const version = body([], HEAD);
const history = body([], "Image | Name | Start | End | Version");
expect(fandomVersionParser.canParse(version)).toBe(true);
expect(fandomTableParser.canParse(version)).toBe(false);
expect(fandomVersionParser.canParse(history)).toBe(false);
expect(fandomTableParser.canParse(history)).toBe(true);
// A table with Duration but without version headings is neither.
const other = body([], "Name | Start | End | Duration");
expect(fandomVersionParser.canParse(other)).toBe(false);
expect(fandomTableParser.canParse(other)).toBe(false);
});
});
describe("fandomversion canParse across every fixture", () => {
const files = [...new Glob("fixtures/**/*.html").scanSync(".")].sort();
test("true on the Mira Journey fixture only", async () => {
const accepted: string[] = [];
for (const f of files) {
if (fandomVersionParser.canParse(await Bun.file(f).text())) accepted.push(f);
}
expect(accepted).toEqual([FIXTURE]);
});
test("no other parser claims the Version page, and fandomtable stays off it", async () => {
const text = await Bun.file(FIXTURE).text();
expect(PARSERS.filter((p) => p.canParse(text)).map((p) => p.id)).toEqual(["fandomversion"]);
});
});