Store an empty parse when the page itself says it lists nothing

Refusing every zero-event body is right when the alternative is silently
emptying a calendar, and wrong when the page has told us it is empty. A
gacha game goes quiet between versions, and read strictly that is a source
failing every cycle until the next patch ships: three of them reach the
broken tier and fail the workflow over a lane that is correctly empty,
while the snapshot being held ages out of date. Infinity Nikki sat in
exactly that state for four cycles.

So an empty parse is stored when the source's `statesNoEvents` vouches for
it, and the cycle counts as confirmed rather than failed. Everything else
is unchanged: a body that parses to nothing on its own still keeps the
previous snapshot, and a first fetch that yields nothing still stores
nothing.

The gate turns on the page's statement, never on the adapter merely being
able to make one — otherwise implementing `statesNoEvents` would quietly
switch the zero-events gate off for that source. A test pins that.

The run says which of the two empties it saw. "0 events — down from 6"
reads as the shape change it is not, and that note is what somebody
checking on a quiet lane actually sees.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-03 18:45:57 +02:00
co-authored by Claude Opus 5
parent a73f35b7cd
commit 4b2b3d0958
2 changed files with 67 additions and 8 deletions
+38
View File
@@ -516,6 +516,44 @@ describe("a source being down never blanks the feed", () => {
expect(await store.read("genshin-game8-events")).toBeNull();
});
test("a page that states it lists no events is stored, not rejected", async () => {
// Infinity Nikki, 2026-09-03: 2.7's events had ended, 2.8 was not listed
// yet, and the wiki replaced both tables with "There are no Events in this
// category." Rejecting that costs the source its snapshot every cycle until
// the next version ships, and three cycles of it reach the `broken` tier —
// a build failing over a game that is merely between patches.
await seed("<html><event></event></html>", "2026-08-01T00:00:00.000Z", 1);
const { opts } = options({
adapters: [
adapter({ statesNoEvents: (html: string) => html.includes("<nothing-on>") }),
],
responder: () => new Response("<html><nothing-on></nothing-on></html>"),
});
const summary = await runRefresh(opts);
expect(summary.outcomes[0]?.result).toBe("fetched");
expect(summary.outcomes[0]?.eventCount).toBe(0);
expect((await store.read("genshin-game8-events"))?.meta.eventCount).toBe(0);
expect(summary.broken).toEqual([]);
});
test("an empty parse the page does not vouch for is still rejected", async () => {
// The gate turns on the page's statement, never on the adapter merely being
// able to make one — otherwise implementing `statesNoEvents` would quietly
// switch off the zero-events gate for that source.
await seed("<html><event></event></html>", "2026-08-01T00:00:00.000Z", 1);
const { opts } = options({
adapters: [
adapter({ statesNoEvents: (html: string) => html.includes("<nothing-on>") }),
],
responder: () => new Response("<html>redesigned</html>"),
});
const summary = await runRefresh(opts);
expect(summary.outcomes[0]?.result).toBe("rejected");
expect((await store.read("genshin-game8-events"))?.meta.eventCount).toBe(1);
});
test("a steep drop is stored but flagged", async () => {
await seed("<html>" + "<event></event>".repeat(10) + "</html>", "2026-08-01T00:00:00.000Z", 10);
const { opts } = options({