When merge fuses two sources' copies, the loser's id leaves the calendar, and it may already hold a reader's ticks from a day it was published alone. Until now that needed a hand-edited RETIRED_IDS entry per fusion. The survivor now lists what it absorbed in an optional mergedIds field. When a feed arrives the client turns those into carry-forward pairs, skipping any id the feed still publishes, and an import takes the same table. Optional, so an older cached feed still validates and an older bundle drops it unread. RETIRED_IDS stays for parser changes, whose old ids no feed will name again. Co-Authored-By: Claude Opus 5.5 <[email protected]>
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
brokenSources,
|
|
freshness,
|
|
staleSources,
|
|
quietSources,
|
|
STALE_AFTER_MS,
|
|
SourceHealth,
|
|
} from "../src/shared/feed.ts";
|
|
import { sourceHealth } from "../src/ingest/health.ts";
|
|
import {
|
|
GachaEvent as GachaEventSchema,
|
|
type GachaEvent,
|
|
type GameId,
|
|
} from "../src/shared/schema.ts";
|
|
|
|
/**
|
|
* Freshness disclosure (PRD F7).
|
|
*
|
|
* The footer's claim about its own age is load-bearing: a reader deciding
|
|
* whether to trust a countdown has nothing else to go on. These pin the two
|
|
* ways that claim could lie — reporting a build stamp instead of the data's age,
|
|
* and letting one fresh source speak for a game whose other source is a week
|
|
* behind.
|
|
*/
|
|
|
|
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
|
const HOUR = 60 * 60 * 1000;
|
|
|
|
function source(
|
|
game: GameId,
|
|
lastSuccessAt: string | null,
|
|
sourceId = `${game}-src`,
|
|
lastConfirmedAt: string | null = null,
|
|
contentChangedAt: string | null = lastSuccessAt,
|
|
): SourceHealth {
|
|
return {
|
|
sourceId,
|
|
game,
|
|
url: "https://example.test/events",
|
|
lastSuccessAt,
|
|
lastConfirmedAt,
|
|
contentChangedAt,
|
|
eventCount: 3,
|
|
|
|
parsedCount: 3,
|
|
statesNoEvents: false,
|
|
};
|
|
}
|
|
|
|
describe("freshness", () => {
|
|
test("reports the newest confirmation across sources", () => {
|
|
const result = freshness(
|
|
[
|
|
source("genshin", "2026-08-17T06:00:00.000Z"),
|
|
source("hsr", "2026-08-17T09:30:00.000Z"),
|
|
source("zzz", "2026-08-16T23:00:00.000Z"),
|
|
],
|
|
NOW,
|
|
);
|
|
expect(result.refreshedAt).toBe("2026-08-17T09:30:00.000Z");
|
|
expect(result.stale).toEqual([]);
|
|
});
|
|
|
|
test("a game is only as fresh as its oldest source", () => {
|
|
// Endfield has two. If the wiki refreshed an hour ago but Game8 has been
|
|
// down for a week, some of that lane's rows are a week old — so the lane is
|
|
// stale even though one of its sources is not.
|
|
const result = freshness(
|
|
[
|
|
source("endfield", "2026-08-17T11:00:00.000Z", "endfield-wikigg-events"),
|
|
source("endfield", "2026-08-10T11:00:00.000Z", "endfield-game8-events"),
|
|
],
|
|
NOW,
|
|
);
|
|
expect(result.refreshedAt).toBe("2026-08-17T11:00:00.000Z");
|
|
expect(result.stale).toEqual([
|
|
{
|
|
game: "endfield",
|
|
lastSuccessAt: "2026-08-10T11:00:00.000Z",
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: "2026-08-10T11:00:00.000Z",
|
|
sources: [
|
|
{
|
|
sourceId: "endfield-game8-events",
|
|
url: "https://example.test/events",
|
|
lastSuccessAt: "2026-08-10T11:00:00.000Z",
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: "2026-08-10T11:00:00.000Z",
|
|
},
|
|
{
|
|
sourceId: "endfield-wikigg-events",
|
|
url: "https://example.test/events",
|
|
lastSuccessAt: "2026-08-17T11:00:00.000Z",
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: "2026-08-17T11:00:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("a source that never succeeded makes its game stale, whatever a sibling says", () => {
|
|
const result = freshness(
|
|
[
|
|
source("endfield", "2026-08-17T11:00:00.000Z", "endfield-wikigg-events"),
|
|
source("endfield", null, "endfield-game8-events"),
|
|
],
|
|
NOW,
|
|
);
|
|
expect(result.stale).toEqual([
|
|
{
|
|
game: "endfield",
|
|
lastSuccessAt: null,
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: null,
|
|
sources: [
|
|
{
|
|
sourceId: "endfield-game8-events",
|
|
url: "https://example.test/events",
|
|
lastSuccessAt: null,
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: null,
|
|
},
|
|
{
|
|
sourceId: "endfield-wikigg-events",
|
|
url: "https://example.test/events",
|
|
lastSuccessAt: "2026-08-17T11:00:00.000Z",
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: "2026-08-17T11:00:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("order of sources does not change the answer", () => {
|
|
const a = source("endfield", null, "a");
|
|
const b = source("endfield", "2026-08-17T11:00:00.000Z", "b");
|
|
expect(freshness([a, b], NOW).stale).toEqual(freshness([b, a], NOW).stale);
|
|
});
|
|
|
|
test("48 hours is the boundary, and it is exclusive", () => {
|
|
const at = new Date(NOW - STALE_AFTER_MS).toISOString();
|
|
expect(freshness([source("genshin", at)], NOW).stale).toEqual([]);
|
|
|
|
const older = new Date(NOW - STALE_AFTER_MS - 1000).toISOString();
|
|
expect(freshness([source("genshin", older)], NOW).stale).toHaveLength(1);
|
|
});
|
|
|
|
test("lists the stale games oldest first, never-refreshed ahead of the rest", () => {
|
|
const result = freshness(
|
|
[
|
|
source("genshin", new Date(NOW - 50 * HOUR).toISOString()),
|
|
source("hsr", new Date(NOW - 200 * HOUR).toISOString()),
|
|
source("zzz", null),
|
|
source("wuwa", new Date(NOW - HOUR).toISOString()),
|
|
],
|
|
NOW,
|
|
);
|
|
expect(result.stale.map((s) => s.game)).toEqual(["zzz", "hsr", "genshin"]);
|
|
});
|
|
|
|
test("no source has ever succeeded", () => {
|
|
// A fresh checkout with no fixtures. Not a state a reader reaches, but the
|
|
// footer must say something honest rather than format a null.
|
|
const result = freshness([source("genshin", null)], NOW);
|
|
expect(result.refreshedAt).toBeNull();
|
|
expect(result.stale).toEqual([
|
|
{
|
|
game: "genshin",
|
|
lastSuccessAt: null,
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: null,
|
|
sources: [
|
|
{
|
|
sourceId: "genshin-src",
|
|
url: "https://example.test/events",
|
|
lastSuccessAt: null,
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: null,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("differentiates blame when data was pulled recently but site has not changed", () => {
|
|
const PULL_TIME = new Date(NOW - 3 * HOUR).toISOString();
|
|
const OLD_SITE = new Date(NOW - 60 * HOUR).toISOString();
|
|
const result = freshness(
|
|
[
|
|
{
|
|
...source("endfield", OLD_SITE, "endfield-wikigg-events"),
|
|
lastConfirmedAt: PULL_TIME,
|
|
contentChangedAt: OLD_SITE,
|
|
},
|
|
],
|
|
NOW,
|
|
);
|
|
expect(result.stale).toHaveLength(1);
|
|
const staleEndfield = result.stale[0]!;
|
|
expect(staleEndfield.lastConfirmedAt).toBe(PULL_TIME);
|
|
expect(staleEndfield.contentChangedAt).toBe(OLD_SITE);
|
|
expect(staleEndfield.sources[0]?.lastConfirmedAt).toBe(PULL_TIME);
|
|
expect(staleEndfield.sources[0]?.contentChangedAt).toBe(OLD_SITE);
|
|
});
|
|
|
|
test("differentiates blame across multiple sources when one updated recently and another confirmed 304", () => {
|
|
const PULL_TIME = new Date(NOW - 11 * HOUR).toISOString();
|
|
const OLD_SITE = new Date(NOW - 60 * HOUR).toISOString();
|
|
const RECENT_CONTENT = new Date(NOW - 1 * HOUR).toISOString();
|
|
const result = freshness(
|
|
[
|
|
{
|
|
...source("endfield", OLD_SITE, "endfield-wikigg-events"),
|
|
lastConfirmedAt: PULL_TIME,
|
|
contentChangedAt: OLD_SITE,
|
|
},
|
|
{
|
|
...source("endfield", RECENT_CONTENT, "endfield-game8-events"),
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: RECENT_CONTENT,
|
|
},
|
|
],
|
|
NOW,
|
|
);
|
|
expect(result.stale).toHaveLength(1);
|
|
const staleEndfield = result.stale[0]!;
|
|
expect(staleEndfield.lastConfirmedAt).toBe(PULL_TIME);
|
|
expect(staleEndfield.contentChangedAt).toBe(OLD_SITE);
|
|
});
|
|
|
|
test("an empty feed reports nothing rather than throwing", () => {
|
|
expect(freshness([], NOW)).toEqual({ refreshedAt: null, stale: [] });
|
|
});
|
|
});
|
|
|
|
describe("telling a broken source from a stale one", () => {
|
|
// CI failed on Infinity Nikki yielding nothing, and the check was wrong to.
|
|
// Its snapshot parses to seven events; every one of them had simply ended by
|
|
// the day the build ran. `eventCount` is measured after expiry, so "our
|
|
// parser broke" and "this source has nothing current left" arrived as the
|
|
// same zero — and only the first is a reason to redden a build.
|
|
const health = (over: Partial<SourceHealth>): SourceHealth => ({
|
|
sourceId: "nikki-fandom-events",
|
|
game: "nikki" as GameId,
|
|
url: "https://example.test/nikki",
|
|
lastSuccessAt: "2026-08-19T00:00:00.000Z",
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: "2026-08-19T00:00:00.000Z",
|
|
eventCount: 0,
|
|
parsedCount: 7,
|
|
statesNoEvents: false,
|
|
...over,
|
|
});
|
|
|
|
test("a source that parsed nothing is broken", () => {
|
|
// The failure this check exists for: a page changed shape and the parser
|
|
// now reads it as empty. Nothing to publish and nothing to expire.
|
|
expect(brokenSources([health({ parsedCount: 0 })]).map((s) => s.sourceId)).toEqual([
|
|
"nikki-fandom-events",
|
|
]);
|
|
});
|
|
|
|
test("a source whose page states it lists none is not broken", () => {
|
|
// The third empty, and the one the refresh runner already knew about while
|
|
// the feed did not. Infinity Nikki's wiki replaced both event tables with
|
|
// "There are no Events in this category" between 2.7 and 2.8, so the page
|
|
// parses to zero and is *answering*. A redesign yields zero too, which is
|
|
// why only the page's own words may say which — never the row count.
|
|
expect(
|
|
brokenSources([health({ parsedCount: 0, statesNoEvents: true })]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("a page that states its emptiness still fails once it parses nothing at all", () => {
|
|
// Guard on the guard. `statesNoEvents` excuses an empty parse, so a parser
|
|
// whose selectors all broke must not be able to reach it: the flag is only
|
|
// ever set from the page's own declaration, and a source claiming both a
|
|
// declaration and rows is a contradiction we do not have to honour.
|
|
expect(
|
|
brokenSources([health({ parsedCount: 0, statesNoEvents: false })]).map(
|
|
(s) => s.sourceId,
|
|
),
|
|
).toEqual(["nikki-fandom-events"]);
|
|
});
|
|
|
|
test("a source whose events have all ended is not broken", () => {
|
|
// The Nikki case exactly. The parser did its job; the calendar moved past
|
|
// everything the page still lists.
|
|
expect(brokenSources([health({})])).toEqual([]);
|
|
});
|
|
|
|
test("a healthy source is none of the three", () => {
|
|
const ok = health({ eventCount: 5, parsedCount: 5 });
|
|
expect(brokenSources([ok])).toEqual([]);
|
|
expect(staleSources([ok])).toEqual([]);
|
|
expect(quietSources([ok])).toEqual([]);
|
|
});
|
|
|
|
test("a source whose page states it lists none is reported as quiet", () => {
|
|
// Excusing it from the build is not the same as saying nothing about it.
|
|
// The build log prints a count per source, and an unexplained 0 reads as
|
|
// the fault this whole distinction exists to deny — so the one empty we
|
|
// are content with is the one that has to say why.
|
|
expect(
|
|
quietSources([health({ parsedCount: 0, statesNoEvents: true })]).map(
|
|
(s) => s.sourceId,
|
|
),
|
|
).toEqual(["nikki-fandom-events"]);
|
|
expect(staleSources([health({ parsedCount: 0, statesNoEvents: true })])).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("a source with nothing current left is reported as stale", () => {
|
|
// Worth saying out loud — a lane showing an empty calendar is a real
|
|
// problem — but it is a refresh problem, not a code one, so it is
|
|
// reported rather than thrown.
|
|
expect(staleSources([health({})]).map((s) => s.sourceId)).toEqual([
|
|
"nikki-fandom-events",
|
|
]);
|
|
});
|
|
|
|
test("a feed cached before the field existed is not called broken", () => {
|
|
// The service worker serves the last feed it downloaded, so a feed built
|
|
// before `statesNoEvents` shipped still has to validate and still has to
|
|
// mean what it meant. Absent parses to `false`, which is the strict
|
|
// reading — an old feed cannot vouch for a page it never asked.
|
|
const cached = SourceHealth.parse({
|
|
sourceId: "nikki-fandom-events",
|
|
game: "nikki",
|
|
url: "https://example.test/nikki",
|
|
lastSuccessAt: "2026-08-19T00:00:00.000Z",
|
|
eventCount: 0,
|
|
parsedCount: 7,
|
|
});
|
|
expect(cached.statesNoEvents).toBe(false);
|
|
expect(brokenSources([cached])).toEqual([]);
|
|
});
|
|
|
|
test("a feed that never recorded the count is not called broken", () => {
|
|
// An older feed — one the service worker cached before this field existed
|
|
// — says nothing either way, and absence of information is not evidence of
|
|
// a fault.
|
|
expect(brokenSources([health({ parsedCount: null })])).toEqual([]);
|
|
expect(staleSources([health({ parsedCount: null })])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* What the feed builder records about a source it just parsed.
|
|
*
|
|
* The distinction above is only worth having if something sets it, and this is
|
|
* the seam where it was missing: `scripts/refresh-sources.ts` had asked
|
|
* `statesNoEvents` since 2026-09-03 while `scripts/build-feed.ts` never did, so
|
|
* a page declaring itself empty reached CI as a bare zero and failed the build.
|
|
* The rule was in a script nothing could import, which is why it now lives in a
|
|
* module and is exercised here rather than grepped for.
|
|
*/
|
|
describe("recording a source's health at build time", () => {
|
|
const EMPTY = "<p>There are no Events in this category</p>";
|
|
const FULL = "<p>Song of the Wandering Sky</p>";
|
|
|
|
const adapter = (over: Partial<Parameters<typeof sourceHealth>[0]> = {}) => ({
|
|
id: "nikki-fandom-events",
|
|
game: "nikki" as GameId,
|
|
url: "https://example.test/nikki",
|
|
parse: (html: string) => (html === FULL ? ([{}] as unknown as GachaEvent[]) : []),
|
|
statesNoEvents: (html: string) => html.includes("There are no Events"),
|
|
...over,
|
|
});
|
|
|
|
test("a page that declares itself empty is recorded as having answered", () => {
|
|
const health = sourceHealth(adapter(), EMPTY, "2026-09-06T15:39:19.376Z", 0);
|
|
|
|
expect(health.parsedCount).toBe(0);
|
|
expect(health.statesNoEvents).toBe(true);
|
|
expect(brokenSources([health])).toEqual([]);
|
|
});
|
|
|
|
test("a page that reads empty without saying so is left to fail", () => {
|
|
// The redesign case, and the whole reason the flag may not be inferred
|
|
// from the row count: this parse is zero too.
|
|
const health = sourceHealth(
|
|
adapter({ statesNoEvents: () => false }),
|
|
EMPTY,
|
|
"2026-09-06T15:39:19.376Z",
|
|
0,
|
|
);
|
|
|
|
expect(health.statesNoEvents).toBe(false);
|
|
expect(brokenSources([health]).map((s) => s.sourceId)).toEqual([
|
|
"nikki-fandom-events",
|
|
]);
|
|
});
|
|
|
|
test("a source with rows is never marked as declaring itself empty", () => {
|
|
// A loose `statesNoEvents` — one matching prose that survives a redesign —
|
|
// must not be able to excuse a source that is working. The flag qualifies
|
|
// an empty parse and states nothing on its own, so it is asked only of a
|
|
// zero, exactly as the refresh runner asks it.
|
|
const health = sourceHealth(
|
|
adapter({ statesNoEvents: () => true }),
|
|
FULL,
|
|
"2026-09-06T15:39:19.376Z",
|
|
1,
|
|
);
|
|
|
|
expect(health.parsedCount).toBe(1);
|
|
expect(health.statesNoEvents).toBe(false);
|
|
});
|
|
|
|
test("a document of unknown age records neither count nor declaration", () => {
|
|
// No capture date means no "as of" to parse against, and inventing one
|
|
// manufactures a figure the check then trusts. `parsedCount` is null for
|
|
// that reason and the declaration goes with it: both answer a question
|
|
// about bytes we cannot date.
|
|
const health = sourceHealth(adapter(), EMPTY, null, 0);
|
|
|
|
expect(health.parsedCount).toBeNull();
|
|
expect(health.statesNoEvents).toBe(false);
|
|
expect(brokenSources([health])).toEqual([]);
|
|
});
|
|
|
|
test("lastConfirmedAt is never older than contentChangedAt", () => {
|
|
const OLD_CONFIRM = "2026-09-12T00:00:00.000Z";
|
|
const NEW_CONTENT = "2026-09-18T00:00:00.000Z";
|
|
const health = sourceHealth(adapter(), FULL, NEW_CONTENT, 1, {
|
|
lastConfirmedAt: OLD_CONFIRM,
|
|
contentChangedAt: NEW_CONTENT,
|
|
});
|
|
expect(health.lastConfirmedAt).toBe(NEW_CONTENT);
|
|
});
|
|
});
|
|
|
|
describe("mergedIds in the feed", () => {
|
|
// The service worker serves the last feed it downloaded, and an open tab may
|
|
// run a bundle from before this field existed — both directions must parse.
|
|
const sample = async () =>
|
|
(
|
|
(await Bun.file(
|
|
"fixtures/genshin/game8-events-2026-08-14.expected.json",
|
|
).json()) as unknown[]
|
|
)[0] as Record<string, unknown>;
|
|
|
|
test("an event without it still validates", async () => {
|
|
expect(GachaEventSchema.safeParse(await sample()).success).toBe(true);
|
|
});
|
|
|
|
test("an event with it keeps it", async () => {
|
|
const parsed = GachaEventSchema.safeParse({
|
|
...(await sample()),
|
|
mergedIds: ["genshin:other:2026-08-12"],
|
|
});
|
|
expect(parsed.success && parsed.data.mergedIds).toEqual([
|
|
"genshin:other:2026-08-12",
|
|
]);
|
|
});
|
|
});
|