import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import {
cadenceLabel,
EventForm,
repeatFrom,
strandedNotice,
} from "../src/client/components/CustomForms.tsx";
import { YourOwn } from "../src/client/components/YourOwn.tsx";
import { EventRow } from "../src/client/components/EventRow.tsx";
import { EventDetail } from "../src/client/components/EventDetail.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,
type DisplayEvent,
} 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(
metaFor(id, GAMES)}>{node} ,
);
}
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( );
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( );
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(
{}}
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(
metaFor(id, twoOfMine)}>
{}}
onCancel={() => {}}
/>
,
);
// The game picker is the first select; the second is the event kind.
const picker = html.slice(0, html.indexOf(""));
const order = [...picker.matchAll(/ 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(
{}}
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(
,
);
expect(html).toContain("yours");
});
test("does not mark a scraped event as theirs", () => {
const html = render(
,
);
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(
,
);
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( );
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(`]*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(
,
);
// 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( );
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(
,
);
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(
,
);
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(
,
);
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( );
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( );
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);
});
});
describe("stating a repeat", () => {
const repeating = (over: Record = {}) =>
CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Abyss",
type: "challenge",
summary: null,
startsAt: "2026-09-01T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-08T00:00:00.000Z",
endPrecision: "day",
repeat: { unit: "weeks", interval: 2, until: null },
at: AT,
updatedAt: AT,
...over,
});
test("a fresh form offers a repeat, set to never", () => {
const html = renderToStaticMarkup(
{}} onCancel={() => {}} />,
);
expect(html).toContain("Repeats");
// The interval field is hidden until there is something to count, so the
// form a reader already knows is unchanged until they reach for this.
expect(html).not.toContain("Every");
});
test("editing a rule shows the rule it already has", () => {
const html = renderToStaticMarkup(
{}}
onCancel={() => {}}
/>,
);
expect(html).toContain("Every");
expect(html).toContain('value="2"');
});
test("an unknown end with a rule stops claiming there is no countdown", () => {
// "It'll show with no countdown and no daily checklist" is true of an
// unbounded event and false once an interval bounds it. Leaving it there
// would talk a reader out of the simplest way to record a weekly reset.
const html = renderToStaticMarkup(
{}}
onCancel={() => {}}
/>,
);
expect(html).toContain("until the next one opens");
expect(html).not.toContain("no countdown");
});
test("an unknown end with no rule keeps the original note", () => {
const html = renderToStaticMarkup(
{}}
onCancel={() => {}}
/>,
);
expect(html).toContain("no countdown");
});
});
describe("repeatFrom", () => {
test("a fresh rule has no `until` to carry — there is no control for one", () => {
expect(repeatFrom("weeks", 2, null)).toEqual({ unit: "weeks", interval: 2, until: null });
});
test("an edit preserves the `until` already on the record", () => {
// The form has no field for this, so the only way it can end up on the
// save is by surviving from what was already there — an import-sourced
// rule, since that is the only door `until` has today.
const existing = "2027-01-01T00:00:00.000Z";
expect(repeatFrom("weeks", 2, existing)).toEqual({
unit: "weeks",
interval: 2,
until: existing,
});
// It survives a schedule change too — changing the unit or interval is a
// different edit from changing when the series stops, and the reader
// never touched the latter.
expect(repeatFrom("months", 1, existing)).toEqual({
unit: "months",
interval: 1,
until: existing,
});
});
test("turning the repeat off drops it, existing `until` included", () => {
expect(repeatFrom("never", 2, "2027-01-01T00:00:00.000Z")).toBeNull();
});
test("an invalid interval is refused the same way regardless of `until`", () => {
expect(repeatFrom("weeks", 0, "2027-01-01T00:00:00.000Z")).toBeNull();
expect(repeatFrom("weeks", 400, null)).toBeNull();
});
});
describe("what a reschedule costs", () => {
test("says nothing when nothing would be stranded", () => {
expect(strandedNotice(0)).toBe(null);
});
test("counts, and agrees with itself about plurals", () => {
expect(strandedNotice(1)).toContain("1 tick");
expect(strandedNotice(1)).not.toContain("ticks");
expect(strandedNotice(3)).toContain("3 ticks");
});
test("says what happens, not what is forbidden", () => {
// It informs; it never blocks. Their data is theirs to reorganise, and a
// form that refused the edit would be a worse answer than one that says
// what it costs — removeGame refuses because a cascade is unrecoverable,
// and an orphaned mark is not.
expect(strandedNotice(3)!.toLowerCase()).toContain("strand");
});
});
describe("the sheet says how often", () => {
test("a repeating event shows its cadence", () => {
const rule = CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Abyss",
type: "challenge",
summary: null,
startsAt: "2026-09-01T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-08T00:00:00.000Z",
endPrecision: "day",
repeat: { unit: "weeks", interval: 2, until: null },
at: AT,
updatedAt: AT,
});
expect(cadenceLabel(rule.repeat)).toBe("every 2 weeks");
});
test("an interval of one drops the number and the plural", () => {
expect(cadenceLabel({ unit: "weeks", interval: 1, until: null })).toBe("every week");
expect(cadenceLabel({ unit: "months", interval: 1, until: null })).toBe("every month");
});
test("a non-repeating event has no cadence to show", () => {
expect(cadenceLabel(null)).toBe(null);
});
});
describe("the derived-boundary note", () => {
const NOW = Date.parse("2026-08-25T12:00:00.000Z");
const noop = () => {};
const detailProps = {
completed: false,
ignored: false,
status: undefined,
effort: undefined,
note: "",
region: "europe" as const,
now: NOW,
daily: false,
detectedDaily: false,
dailyDays: [],
onDaily: noop,
onToggleDay: noop,
onIgnore: noop,
onStatus: noop,
onEffort: noop,
onNote: noop,
onClose: noop,
};
// A parser declining to guess a time of day — the case the note was written
// for. `dates.ts` stores the placeholder as 00:00Z, and `boundaryMs` reads
// it against the game's own server reset rather than literally.
const PARSED: DisplayEvent = {
id: "genshin:walpurgisnacht:2026-09-03",
game: "genshin",
title: "Walpurgisnacht",
type: "banner",
summary: null,
startsAt: "2026-08-20T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-03T00:00:00.000Z",
endPrecision: "day",
regionScoped: false,
regionEnds: null,
sourceUrl: "https://example.com/events",
sourceId: "genshin-game8-events",
status: "published",
confidence: 1,
extractionMethod: "parser",
version: 1,
firstSeenAt: AT,
updatedAt: AT,
};
test("present for a parser-sourced day-precision event", () => {
const html = renderToStaticMarkup(
metaFor(id, GAMES)}>
,
);
expect(html).toContain("server reset");
});
test("absent for a reader's own day-precision event", () => {
// False three times over: no source, nobody "gave" a date, and
// `boundaryMs` only applies the reset shift for `extractionMethod ===
// "parser"` — a reader's own event is "manual" even at day precision.
const own = asDisplayEvent(OWN);
const html = renderToStaticMarkup(
metaFor(id, GAMES)}>
,
);
expect(html).not.toContain("server reset");
});
});