Every other surface here is handed `now` — the headline, the board, the dailies strip, the detail sheet — for the reason the parsers are: a function that reads the clock cannot be rendered against a fixed instant, so nothing about it can be asserted. The list row was the one exception, calling the wall clock twice for its window caption and its "starts in". Two things follow from fixing it. Those numbers were counted from a different instant than the `clock` sitting beside them in the same row, which is a disagreement nobody would ever notice and nobody could ever prove. And the countdown is now testable: rendering one row at two instants gives the two answers the injected clock implies, which is what the new test pins. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { EventForm } from "../src/client/components/CustomForms.tsx";
|
|
import { YourOwn } from "../src/client/components/YourOwn.tsx";
|
|
import { EventRow } from "../src/client/components/EventRow.tsx";
|
|
import { AUTHOR, Colophon, REPO_URL } from "../src/client/components/Colophon.tsx";
|
|
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
|
import {
|
|
asDisplayEvent,
|
|
CustomEvent,
|
|
type CustomEvents,
|
|
type CustomGames,
|
|
} from "../src/shared/custom.ts";
|
|
import { metaFor } from "../src/shared/games.ts";
|
|
import { clockFor } from "../src/shared/time.ts";
|
|
|
|
/**
|
|
* Static-render checks on the F13 surfaces.
|
|
*
|
|
* Not a substitute for using the thing, but they pin the two claims the feature
|
|
* makes to a reader: their event is marked as theirs, and the end date is
|
|
* allowed to be unknown.
|
|
*/
|
|
|
|
const AT = "2026-08-17T12:00:00.000Z";
|
|
|
|
const GAMES: CustomGames = {
|
|
"mygame:limbus-company": {
|
|
id: "mygame:limbus-company",
|
|
name: "Limbus Company",
|
|
hue: "#C74B50",
|
|
at: AT,
|
|
},
|
|
};
|
|
|
|
const OWN = CustomEvent.parse({
|
|
id: "myevent:k3f9qa2m01",
|
|
game: "mygame:limbus-company",
|
|
title: "Walpurgisnacht",
|
|
type: "banner",
|
|
summary: null,
|
|
startsAt: "2026-08-20T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-03T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
at: AT,
|
|
updatedAt: AT,
|
|
});
|
|
|
|
const EVENTS: CustomEvents = { [OWN.id]: OWN };
|
|
|
|
function render(node: React.ReactElement): string {
|
|
return renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>{node}</GameMetaProvider>,
|
|
);
|
|
}
|
|
|
|
describe("YourOwn", () => {
|
|
const noop = () => {};
|
|
const props = {
|
|
games: GAMES,
|
|
events: EVENTS,
|
|
lanes: ["genshin", "mygame:limbus-company"],
|
|
onAddGame: noop,
|
|
onEditGame: noop,
|
|
onRemoveGame: () => ({ removed: true, blockedBy: 0 }),
|
|
onAddEvent: noop,
|
|
};
|
|
|
|
test("lists a reader's game with the events it holds", () => {
|
|
const html = render(<YourOwn {...props} />);
|
|
expect(html).toContain("Limbus Company");
|
|
expect(html).toContain("1 event");
|
|
expect(html).toContain("#C74B50");
|
|
});
|
|
|
|
test("shows a game with nothing in it rather than hiding it", () => {
|
|
// A game they just made has to appear before it holds anything, or adding
|
|
// one looks like it did nothing.
|
|
const html = render(<YourOwn {...props} events={{}} />);
|
|
expect(html).toContain("no events yet");
|
|
});
|
|
});
|
|
|
|
describe("EventForm", () => {
|
|
test("offers an unknown end rather than demanding a date", () => {
|
|
// The whole point: a form that made the end mandatory would push the reader
|
|
// into inventing one, which is the failure the parsers are forbidden from.
|
|
const html = render(
|
|
<EventForm
|
|
lanes={["genshin", "mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("I don't know when it ends");
|
|
});
|
|
|
|
test("puts the reader's own games at the top of the picker, and defaults to one", () => {
|
|
// Someone entering an event by hand is usually doing it because the game
|
|
// isn't tracked. Two of theirs to prove the group order, not just a swap.
|
|
const twoOfMine: CustomGames = {
|
|
...GAMES,
|
|
"mygame:silver-palace": {
|
|
id: "mygame:silver-palace",
|
|
name: "Silver Palace",
|
|
hue: "#5C7CE0",
|
|
at: AT,
|
|
},
|
|
};
|
|
const html = renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, twoOfMine)}>
|
|
<EventForm
|
|
lanes={["genshin", "hsr", "mygame:limbus-company", "mygame:silver-palace"]}
|
|
customGames={twoOfMine}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
|
|
// The game picker is the first select; the second is the event kind.
|
|
const picker = html.slice(0, html.indexOf("</select>"));
|
|
const order = [...picker.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
|
|
expect(order).toEqual([
|
|
"mygame:limbus-company",
|
|
"mygame:silver-palace",
|
|
// Tracked games keep their feed order behind them — the sort groups, it
|
|
// does not reshuffle.
|
|
"genshin",
|
|
"hsr",
|
|
]);
|
|
// The default follows the top of the list rather than staying on Genshin.
|
|
expect(html).toContain('value="mygame:limbus-company" selected');
|
|
});
|
|
|
|
test("lets an event be filed under a tracked game too", () => {
|
|
// A source can miss an event in a game we do cover.
|
|
const html = render(
|
|
<EventForm
|
|
lanes={["genshin", "mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Genshin Impact");
|
|
expect(html).toContain("Limbus Company (yours)");
|
|
});
|
|
});
|
|
|
|
describe("EventRow provenance", () => {
|
|
const row = (id: string) => {
|
|
const event = { ...asDisplayEvent(OWN), id };
|
|
return { event, clock: clockFor(event, "europe", Date.parse(AT)) };
|
|
};
|
|
|
|
test("marks the reader's own event as theirs", () => {
|
|
const html = render(
|
|
<ul>
|
|
<EventRow
|
|
row={row(OWN.id)}
|
|
now={Date.parse(AT)}
|
|
completed={false}
|
|
onOpen={() => {}}
|
|
/>
|
|
</ul>,
|
|
);
|
|
expect(html).toContain("yours");
|
|
});
|
|
|
|
test("does not mark a scraped event as theirs", () => {
|
|
const html = render(
|
|
<ul>
|
|
<EventRow
|
|
row={row("genshin:windblume-festival:2026-03-14")}
|
|
now={Date.parse(AT)}
|
|
completed={false}
|
|
onOpen={() => {}}
|
|
/>
|
|
</ul>,
|
|
);
|
|
expect(html).not.toContain(">yours<");
|
|
});
|
|
|
|
test("counts from the instant it is handed, not from the wall clock", () => {
|
|
// The row used to ask `Date.now()` for its caption and its "starts in",
|
|
// which is why neither could be asserted at all: the numbers moved with
|
|
// whenever the suite happened to run. Rendering the same row at two instants
|
|
// has to produce two different countdowns, and both have to be the ones the
|
|
// injected clock implies.
|
|
const event = { ...asDisplayEvent(OWN), id: OWN.id };
|
|
const at = Date.parse(AT);
|
|
// A window opening in two days, so the row takes its "starts in" branch.
|
|
const upcoming = {
|
|
...event,
|
|
startsAt: new Date(at + 2 * 24 * 3_600_000).toISOString(),
|
|
};
|
|
const at2 = (ms: number) =>
|
|
render(
|
|
<ul>
|
|
<EventRow
|
|
row={{ event: upcoming, clock: clockFor(upcoming, "europe", ms) }}
|
|
now={ms}
|
|
completed={false}
|
|
onOpen={() => {}}
|
|
/>
|
|
</ul>,
|
|
);
|
|
|
|
expect(at2(at)).toContain("starts in 2d");
|
|
// A day later the same row says one day, with nothing about the real clock
|
|
// involved in either answer.
|
|
expect(at2(at + 24 * 3_600_000)).toContain("starts in 1d");
|
|
});
|
|
});
|
|
|
|
describe("Colophon freshness notice (PRD F7)", () => {
|
|
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
|
const HOUR = 60 * 60 * 1000;
|
|
|
|
const fresh = {
|
|
sourceId: "genshin-game8-events",
|
|
game: "genshin" as const,
|
|
url: "https://game8.co/games/Genshin-Impact/archives/301601",
|
|
lastSuccessAt: new Date(NOW - 3 * HOUR).toISOString(),
|
|
eventCount: 9,
|
|
};
|
|
|
|
test("states when the data was refreshed, unprompted", () => {
|
|
// Always rendered, not only on a problem: a footer that says nothing about
|
|
// its own age reads as current.
|
|
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
|
expect(html).toContain("Event data last refreshed");
|
|
expect(html).toContain("3h 0m ago");
|
|
// The machine-readable instant is there for anyone checking the claim.
|
|
// Matched case-insensitively: React emits the JSX spelling verbatim, and
|
|
// HTML attribute names are case-insensitive, so either is correct.
|
|
expect(html).toMatch(
|
|
new RegExp(`<time [^>]*datetime="${fresh.lastSuccessAt}"`, "i"),
|
|
);
|
|
expect(html).not.toContain("not refreshed in over two days");
|
|
});
|
|
|
|
test("names the games that are behind, with how far", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
fresh,
|
|
{ ...fresh, sourceId: "nikki-game8-events", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
|
]}
|
|
now={NOW}
|
|
/>,
|
|
);
|
|
// A count cannot be acted on; a name tells the reader which source page to
|
|
// go and check.
|
|
expect(html).toContain("Infinity Nikki");
|
|
expect(html).toContain("not refreshed in over two days");
|
|
expect(html).toContain("3d 8h ago");
|
|
// The headline still reports the freshest confirmation.
|
|
expect(html).toContain("3h 0m ago");
|
|
});
|
|
|
|
test("summarises instead of listing when every game is behind", () => {
|
|
// What a refresh that stopped running looks like. Ten names each repeating
|
|
// the same age is less readable than the count this replaced.
|
|
const behind = (["genshin", "hsr", "zzz"] as const).map((game, i) => ({
|
|
...fresh,
|
|
sourceId: `${game}-src`,
|
|
game,
|
|
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
|
}));
|
|
const html = renderToStaticMarkup(<Colophon sources={behind} now={NOW} />);
|
|
expect(html).toContain("Nothing has refreshed in over two days");
|
|
expect(html).not.toContain("Genshin Impact (");
|
|
});
|
|
|
|
test("caps the list and counts the remainder", () => {
|
|
const behind = (["hsr", "zzz", "wuwa", "nte", "nikki", "p5x"] as const).map(
|
|
(game, i) => ({
|
|
...fresh,
|
|
sourceId: `${game}-src`,
|
|
game,
|
|
lastSuccessAt: new Date(NOW - (80 + i) * HOUR).toISOString(),
|
|
}),
|
|
);
|
|
// `fresh` is Genshin, a game absent from the list above — otherwise its
|
|
// sibling source would drag Genshin stale too and every game would be
|
|
// behind, which is the other branch.
|
|
const html = renderToStaticMarkup(
|
|
<Colophon sources={[...behind, fresh]} now={NOW} />,
|
|
);
|
|
expect(html).toContain("and 2 other games");
|
|
// Oldest first, so the two dropped are the *least* overdue, not an
|
|
// arbitrary pair: P5X at 85h is named, Star Rail at 80h is summarised.
|
|
expect(html).toContain("Persona 5: The Phantom X (");
|
|
expect(html).not.toContain("Honkai: Star Rail (");
|
|
});
|
|
|
|
test("says so plainly when nothing has ever been fetched", () => {
|
|
// A fresh checkout with no fixtures. The headline must not format a null,
|
|
// and with every game unfetched the list collapses to the sentence.
|
|
const html = renderToStaticMarkup(
|
|
<Colophon sources={[{ ...fresh, lastSuccessAt: null }]} now={NOW} />,
|
|
);
|
|
expect(html).toContain("no source has been fetched yet");
|
|
expect(html).toContain("Nothing has refreshed in over two days");
|
|
});
|
|
|
|
test("marks a never-fetched source as never, beside games that have", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[fresh, { ...fresh, sourceId: "r1999-src", game: "r1999", lastSuccessAt: null }]}
|
|
now={NOW}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Reverse: 1999 (never)");
|
|
});
|
|
|
|
test("the author's links sit together, above the ideas credit", () => {
|
|
// The row is who built this and where to find them; the credit below it is
|
|
// other people. Reading order follows that, so a reader scanning the footer
|
|
// does not pass a stranger's handle on the way to the author's own links.
|
|
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
|
const links = html.indexOf("@StereotypicalCat");
|
|
const ideas = html.indexOf("Additional ideas");
|
|
expect(links).toBeGreaterThan(-1);
|
|
expect(ideas).toBeGreaterThan(-1);
|
|
expect(links).toBeLessThan(ideas);
|
|
});
|
|
|
|
test("carries the Ko-fi link, in the row and not as an appeal", () => {
|
|
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
|
expect(html).toContain(AUTHOR.kofi);
|
|
// Last in the author's row, so it never lands between the disclaimer and
|
|
// the reader — and never louder than the links beside it.
|
|
expect(html.indexOf(AUTHOR.kofi)).toBeGreaterThan(html.indexOf(REPO_URL));
|
|
expect(html.indexOf(AUTHOR.kofi)).toBeLessThan(html.indexOf("Additional ideas"));
|
|
// Unobtrusive is a property of the markup, not a matter of taste: the same
|
|
// muted class as its neighbours, and no ask anywhere in the footer.
|
|
expect(html).not.toMatch(/support me|buy me|donate|tip jar/i);
|
|
});
|
|
});
|