Readers who knew what they wanted could not see which answers in Add an event or Add a task produced it. Both add forms now open with a row of examples (templates.ts) that set the form's shape only: kind, how often, how it repeats, how it is measured. Never a name, date, target or game, and no date is guessed, so nothing an example set can be saved unread. Both forms also had a pass: plain words and pills for how often and repeat, the name asked first and the kind after the dates, each message beside its field, and a "Still needed" line read from the same check that disables Save. The event form's derived state is now one pure function, eventFormState, so the examples are proved to complete into a saveable event without a click. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1341 lines
49 KiB
TypeScript
1341 lines
49 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import {
|
|
cadenceLabel,
|
|
endStated,
|
|
repeatOf,
|
|
unknownEndNote,
|
|
EventForm,
|
|
repeatFrom,
|
|
strandedNotice,
|
|
} from "../src/client/components/CustomForms.tsx";
|
|
import { eventCaption, YourOwn } from "../src/client/components/YourOwn.tsx";
|
|
import { parseMonthDay, TaskForm, taskDraftFrom } from "../src/client/components/TaskForm.tsx";
|
|
import { EventRow } from "../src/client/components/EventRow.tsx";
|
|
import { EventDetail } from "../src/client/components/EventDetail.tsx";
|
|
import { AUTHOR, Colophon, REPO_URL, RESET_CLOCK_CREDIT } 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(
|
|
<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,
|
|
now: Date.parse("2026-10-01T12:00:00.000Z"),
|
|
onOpen: 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(),
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: null,
|
|
eventCount: 9,
|
|
|
|
parsedCount: 9,
|
|
statesNoEvents: false,
|
|
};
|
|
|
|
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("differentiates blame when data was pulled recently but site has not changed", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
fresh,
|
|
{
|
|
...fresh,
|
|
sourceId: "endfield-wikigg-events",
|
|
game: "endfield",
|
|
url: "https://endfield.wiki.gg/wiki/Event",
|
|
lastSuccessAt: new Date(NOW - 57 * HOUR).toISOString(),
|
|
lastConfirmedAt: new Date(NOW - 11 * HOUR).toISOString(),
|
|
contentChangedAt: new Date(NOW - 57 * HOUR).toISOString(),
|
|
},
|
|
]}
|
|
now={NOW}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Arknights: Endfield");
|
|
expect(html).toContain("data pulled 11h 0m ago, site updated 2d 9h ago");
|
|
expect(html).toContain("Source breakdown");
|
|
expect(html).toContain("wiki.gg");
|
|
expect(html).toContain("site has no new updates");
|
|
});
|
|
|
|
test("source breakdown does not report recent content updates as pulled never or overdue", () => {
|
|
const PULL_TIME = new Date(NOW - 11 * HOUR).toISOString();
|
|
const OLD_SITE = new Date(NOW - 57 * HOUR).toISOString();
|
|
const RECENT_CONTENT = new Date(NOW - 1 * HOUR).toISOString();
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
fresh,
|
|
{
|
|
...fresh,
|
|
sourceId: "endfield-wikigg-events",
|
|
game: "endfield",
|
|
url: "https://endfield.wiki.gg/wiki/Event",
|
|
lastSuccessAt: OLD_SITE,
|
|
lastConfirmedAt: PULL_TIME,
|
|
contentChangedAt: OLD_SITE,
|
|
},
|
|
{
|
|
...fresh,
|
|
sourceId: "endfield-game8-events",
|
|
game: "endfield",
|
|
url: "https://game8.co/games/Arknights-Endfield/archives/535443",
|
|
lastSuccessAt: RECENT_CONTENT,
|
|
lastConfirmedAt: null,
|
|
contentChangedAt: RECENT_CONTENT,
|
|
},
|
|
]}
|
|
now={NOW}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Arknights: Endfield");
|
|
expect(html).toContain("data pulled 11h 0m ago, site updated 2d 9h ago");
|
|
expect(html).toContain("Game8");
|
|
expect(html).toContain("pulled 1h 0m ago, site updated 1h 0m ago (up to date)");
|
|
expect(html).not.toContain("pulled never");
|
|
expect(html).not.toContain("pull overdue");
|
|
});
|
|
|
|
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("None of the games you have switched on have 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("None of the games you have switched on have 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("says nothing about a game the reader has switched off", () => {
|
|
// The notice is an instruction, and its only remedy is "go and check that
|
|
// game's source page". A reader who turned Infinity Nikki off cannot see a
|
|
// row of it, so being told its dates may have moved is a chore with no
|
|
// point — and naming lanes they do not play buries the one they do.
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
fresh,
|
|
{ ...fresh, sourceId: "nikki-game8-events", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
|
]}
|
|
now={NOW}
|
|
hiddenGames={["nikki"]}
|
|
/>,
|
|
);
|
|
expect(html).not.toContain("not refreshed in over two days");
|
|
expect(html).not.toContain("Infinity Nikki (");
|
|
// Credit is owed to every source we read regardless of what is on screen,
|
|
// so the game keeps its line in the thanks.
|
|
expect(html).toContain("Infinity Nikki");
|
|
// And the headline age is unchanged: it reports the feed, not the lane.
|
|
expect(html).toContain("3h 0m ago");
|
|
});
|
|
|
|
test("still names the lagging games the reader does play", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
fresh,
|
|
{ ...fresh, sourceId: "nikki-src", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
|
{ ...fresh, sourceId: "hsr-src", game: "hsr", lastSuccessAt: new Date(NOW - 90 * HOUR).toISOString() },
|
|
]}
|
|
now={NOW}
|
|
hiddenGames={["hsr"]}
|
|
/>,
|
|
);
|
|
expect(html).toContain("this one has");
|
|
expect(html).toContain("Infinity Nikki (");
|
|
expect(html).not.toContain("Honkai: Star Rail (");
|
|
// And it says whose games it counted. Narrowing what the footer measures
|
|
// without saying so would leave a reader with most lanes off reading a
|
|
// sentence about two games as one about the whole calendar.
|
|
expect(html).toContain("Of the games you have switched on");
|
|
});
|
|
|
|
test("summarises against the games the reader can see, not the feed", () => {
|
|
// Both of this reader's two lanes are behind, so "nothing has refreshed" is
|
|
// the true sentence for them — even though a third, hidden game is current.
|
|
// Measuring against the whole feed instead would list them one by one and
|
|
// never reach this branch for anyone with most of the calendar switched off.
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
fresh,
|
|
{ ...fresh, sourceId: "nikki-src", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
|
{ ...fresh, sourceId: "hsr-src", game: "hsr", lastSuccessAt: new Date(NOW - 90 * HOUR).toISOString() },
|
|
]}
|
|
now={NOW}
|
|
hiddenGames={["genshin"]}
|
|
/>,
|
|
);
|
|
expect(html).toContain("None of the games you have switched on have refreshed in over two days");
|
|
expect(html).not.toContain("Infinity Nikki (");
|
|
});
|
|
|
|
test("a reader with every lagging lane switched off sees no notice at all", () => {
|
|
const html = renderToStaticMarkup(
|
|
<Colophon
|
|
sources={[
|
|
{ ...fresh, sourceId: "nikki-src", game: "nikki", lastSuccessAt: new Date(NOW - 80 * HOUR).toISOString() },
|
|
]}
|
|
now={NOW}
|
|
hiddenGames={["nikki"]}
|
|
/>,
|
|
);
|
|
// Not the summarising branch either: zero of zero shown games is not
|
|
// "nothing has refreshed", it is nothing to say.
|
|
expect(html).not.toContain("not refreshed in over two days");
|
|
expect(html).not.toContain("None of the games you have switched on have refreshed in over two days");
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test("thanks Game-Time-Master for the reset clocks, beside the sources", () => {
|
|
// Every daily reset the app counts down to was checked against it, so it is
|
|
// credited where the schedules' sources are rather than among the ideas.
|
|
const html = renderToStaticMarkup(<Colophon sources={[fresh]} now={NOW} />);
|
|
expect(html).toContain(RESET_CLOCK_CREDIT.url);
|
|
expect(html).toContain(RESET_CLOCK_CREDIT.name);
|
|
const credit = html.indexOf(RESET_CLOCK_CREDIT.url);
|
|
expect(credit).toBeGreaterThan(html.indexOf("With thanks to"));
|
|
expect(credit).toBeLessThan(html.indexOf("Not affiliated"));
|
|
});
|
|
});
|
|
|
|
describe("stating a repeat", () => {
|
|
const repeating = (over: Record<string, unknown> = {}) =>
|
|
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,
|
|
});
|
|
|
|
// The fixture runs 1-8 Sep inclusive with no times given, so it closes at
|
|
// 23:59:59 on the 8th and a successor opening "the moment it closes" opens
|
|
// at midnight on the 9th — eight days after the anchor, not seven. Its rule
|
|
// is every two weeks, which leaves a gap, so `repeating()` is the delay
|
|
// case; `forever()` narrows the interval to exactly that eight-day step.
|
|
const forever = () => repeating({ repeat: { unit: "days", interval: 8, until: null } });
|
|
|
|
test("a custom cadence offers the three answers", () => {
|
|
// The three-way control lives under `custom` now: a preset answers the
|
|
// question by construction and a one-off has nothing to answer, so this
|
|
// is the only cadence that has to ask.
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating()}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Repeat");
|
|
expect(html).toContain("Comes back after a wait");
|
|
});
|
|
|
|
test("a rule that reopens as it closes shows its cadence rather than a control", () => {
|
|
// The whole point of "forever": the dates already say how often, so the
|
|
// form reports what it measured instead of asking for a number.
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={forever()}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("on an 8-day cycle");
|
|
expect(html).toContain("from your dates");
|
|
expect(html).not.toContain("Wait");
|
|
});
|
|
|
|
test("a measured cadence can still be overridden by hand", () => {
|
|
// Measuring is the convenience, not a cage — an irregular rotation has to
|
|
// be sayable even when the first window does not describe it.
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={forever()}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("state it myself");
|
|
});
|
|
|
|
test("a rule with a gap opens as a delay, showing the gap", () => {
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating()}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Wait");
|
|
expect(html).toContain("after it ends");
|
|
// A week's gap after a week's window is a fortnightly rule; both readings
|
|
// are shown so the reader can check the one against the other.
|
|
expect(html).toContain("on a 2-week cycle");
|
|
});
|
|
|
|
test("with no end date there is nothing to measure, so it asks", () => {
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 2, until: null } })}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Cycle length");
|
|
});
|
|
|
|
test("with no end date, the delay option is closed off", () => {
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 2, until: null } })}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("needs an end date");
|
|
});
|
|
|
|
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(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating({ endsAt: null, endPrecision: "unknown" })}
|
|
onSave={() => {}}
|
|
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(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: null })}
|
|
onSave={() => {}}
|
|
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("on a 2-week cycle");
|
|
});
|
|
|
|
test("a cycle of one unit is named, not numbered", () => {
|
|
// "on a 1-week cycle" is what the general form would produce and nobody
|
|
// says it. The adverb is the natural reading and costs one lookup.
|
|
expect(cadenceLabel({ unit: "days", interval: 1, until: null })).toBe("on a daily cycle");
|
|
expect(cadenceLabel({ unit: "weeks", interval: 1, until: null })).toBe("on a weekly cycle");
|
|
expect(cadenceLabel({ unit: "months", interval: 1, until: null })).toBe("on a monthly cycle");
|
|
});
|
|
|
|
test("a longer cycle takes the singular unit as an adjective", () => {
|
|
// "26-day", not "26-days" — the hyphenated form is adjectival.
|
|
expect(cadenceLabel({ unit: "days", interval: 26, until: null })).toBe("on a 26-day cycle");
|
|
expect(cadenceLabel({ unit: "months", interval: 3, until: null })).toBe("on a 3-month cycle");
|
|
});
|
|
|
|
test("the article follows how the number sounds, not how it is spelled", () => {
|
|
// "a 8-day cycle" is the kind of wrong that reads as sloppiness rather
|
|
// than as a bug. Eight, eleven and eighteen open on a vowel; so does
|
|
// everything in the eighties, which is still inside the 365 ceiling.
|
|
expect(cadenceLabel({ unit: "days", interval: 8, until: null })).toBe("on an 8-day cycle");
|
|
expect(cadenceLabel({ unit: "days", interval: 11, until: null })).toBe("on an 11-day cycle");
|
|
expect(cadenceLabel({ unit: "days", interval: 18, until: null })).toBe("on an 18-day cycle");
|
|
expect(cadenceLabel({ unit: "days", interval: 84, until: null })).toBe("on an 84-day cycle");
|
|
expect(cadenceLabel({ unit: "days", interval: 7, until: null })).toBe("on a 7-day cycle");
|
|
expect(cadenceLabel({ unit: "days", interval: 80, until: null })).toBe("on an 80-day cycle");
|
|
});
|
|
|
|
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: [],
|
|
goal: undefined,
|
|
hasStoredGoal: false,
|
|
amounts: {},
|
|
onSetAmount: noop,
|
|
onDaily: noop,
|
|
onGoal: 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(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
|
<EventDetail
|
|
{...detailProps}
|
|
row={{ event: PARSED, clock: clockFor(PARSED, detailProps.region, NOW) }}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
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(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
|
<EventDetail
|
|
{...detailProps}
|
|
row={{ event: own, clock: clockFor(own, detailProps.region, NOW) }}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
expect(html).not.toContain("server reset");
|
|
});
|
|
|
|
// Reaching an event-wide check-in count may not have claimed the rewards,
|
|
// so the sheet offers "Mark event done" and never does it on its own.
|
|
test("a reached event-wide goal offers to mark the event done, once", () => {
|
|
const sheet = (status: "done" | undefined) =>
|
|
renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
|
<EventDetail
|
|
{...detailProps}
|
|
status={status}
|
|
daily
|
|
goal={{ period: "event", measure: "days", required: 5 }}
|
|
hasStoredGoal
|
|
dailyDays={["2026-08-20", "2026-08-21", "2026-08-22", "2026-08-23", "2026-08-24"]}
|
|
row={{ event: PARSED, clock: clockFor(PARSED, detailProps.region, NOW) }}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
const open = sheet(undefined);
|
|
expect(open).toContain("5 of 5 · Goal reached");
|
|
expect(open).toContain("Mark event done");
|
|
expect(open).toContain("This isn't a daily event — removes the goal");
|
|
expect(sheet("done")).not.toContain("Mark event done");
|
|
});
|
|
|
|
test("a points goal shows this week's number, editable in place", () => {
|
|
const html = renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
|
<EventDetail
|
|
{...detailProps}
|
|
daily
|
|
goal={{ period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }}
|
|
hasStoredGoal
|
|
amounts={{ "2026-08-24": { value: 1900, at: AT } }}
|
|
row={{ event: PARSED, clock: clockFor(PARSED, detailProps.region, NOW) }}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
expect(html).toContain("1,900 of 2,100 this week · 200 to go");
|
|
expect(html).toContain('value="1900"');
|
|
expect(html).toContain("/ 2,100");
|
|
});
|
|
|
|
test("'not daily' warns it removes a goal even when this build cannot read it", () => {
|
|
const html = renderToStaticMarkup(
|
|
<GameMetaProvider value={(id) => metaFor(id, GAMES)}>
|
|
<EventDetail
|
|
{...detailProps}
|
|
daily
|
|
goal={undefined}
|
|
hasStoredGoal
|
|
row={{ event: PARSED, clock: clockFor(PARSED, detailProps.region, NOW) }}
|
|
/>
|
|
</GameMetaProvider>,
|
|
);
|
|
expect(html).toContain("removes the goal");
|
|
});
|
|
});
|
|
|
|
describe("the cadence control", () => {
|
|
const event = (over: Record<string, unknown> = {}) =>
|
|
CustomEvent.parse({
|
|
id: "myevent:k3f9qa2m01",
|
|
game: "mygame:limbus-company",
|
|
title: "Mirror Dungeon",
|
|
type: "challenge",
|
|
summary: null,
|
|
startsAt: "2026-09-01T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-08T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
repeat: null,
|
|
at: AT,
|
|
updatedAt: AT,
|
|
...over,
|
|
});
|
|
|
|
const render = (initial?: ReturnType<typeof event>) =>
|
|
renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={initial}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
|
|
test("a fresh form offers all five answers and opens on one-off", () => {
|
|
const html = render();
|
|
expect(html).toContain("How often");
|
|
for (const answer of ["Once", "Every day", "Every week", "Every month", "On its own cycle"]) {
|
|
expect(html).toContain(answer);
|
|
}
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*>Once</);
|
|
// A one-off is what the form was before any of this, so it still asks for
|
|
// an end and still lets the reader say they do not know it.
|
|
expect(html).toContain("Ends");
|
|
expect(html).toContain("I don't know when it ends");
|
|
});
|
|
|
|
test("a preset asks for a start and nothing else", () => {
|
|
// The period is the window, so there is no end to type and no ignorance
|
|
// to admit. This is the whole reason the presets exist.
|
|
const html = render(
|
|
event({
|
|
endsAt: null,
|
|
endPrecision: "unknown",
|
|
repeat: { unit: "weeks", interval: 1, until: null },
|
|
}),
|
|
);
|
|
expect(html).toContain("Starts");
|
|
expect(html).not.toContain("Ends");
|
|
expect(html).not.toContain("I don't know when it ends");
|
|
expect(html).not.toContain("Cycle length");
|
|
expect(html).not.toContain("Wait");
|
|
});
|
|
|
|
test("each preset reopens as itself", () => {
|
|
const daily = render(
|
|
event({ endsAt: null, endPrecision: "unknown", repeat: { unit: "days", interval: 1, until: null } }),
|
|
);
|
|
expect(daily).toMatch(/aria-pressed="true"[^>]*>Every day</);
|
|
const monthly = render(
|
|
event({ endsAt: null, endPrecision: "unknown", repeat: { unit: "months", interval: 1, until: null } }),
|
|
);
|
|
expect(monthly).toMatch(/aria-pressed="true"[^>]*>Every month</);
|
|
});
|
|
|
|
test("a dated recurring event reopens as a custom, with its window intact", () => {
|
|
// Presets carry no window, so anything that states one has to land here —
|
|
// and the end date it stated has to still be on screen.
|
|
const html = render(event({ repeat: { unit: "days", interval: 26, until: null } }));
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*>On its own cycle</);
|
|
expect(html).toContain("Ends");
|
|
expect(html).toContain("Repeat");
|
|
});
|
|
|
|
test("a one-off shows no repeat machinery at all", () => {
|
|
const html = render(event());
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*>Once</);
|
|
expect(html).not.toContain("Cycle length");
|
|
expect(html).not.toContain("Wait");
|
|
expect(html).not.toContain("worked out from your dates");
|
|
});
|
|
});
|
|
|
|
describe("a preset says its piece exactly once", () => {
|
|
test("no end-date note tags along when there is no end-date field", () => {
|
|
// Both of the older notes explain the end-date field. A preset has no such
|
|
// field, so rendering them there left two sentences saying almost the same
|
|
// thing — caught by looking at the form, not by any assertion on content.
|
|
const html = renderToStaticMarkup(
|
|
<EventForm
|
|
lanes={["mygame:limbus-company"]}
|
|
customGames={GAMES}
|
|
initial={CustomEvent.parse({
|
|
id: "myevent:k3f9qa2m01",
|
|
game: "mygame:limbus-company",
|
|
title: "Weekly missions",
|
|
type: "challenge",
|
|
summary: null,
|
|
startsAt: "2026-09-01T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: null,
|
|
endPrecision: "unknown",
|
|
repeat: { unit: "weeks", interval: 1, until: null },
|
|
at: AT,
|
|
updatedAt: AT,
|
|
})}
|
|
onSave={() => {}}
|
|
onCancel={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("no end date to give");
|
|
expect(html).not.toContain("still counts down");
|
|
expect(html).not.toContain("no countdown");
|
|
});
|
|
});
|
|
|
|
describe("endStated", () => {
|
|
// Extracted because the bug it now pins was unreachable from any test in
|
|
// this project: every static render drives the form through `initial`, and
|
|
// `cadenceOf` never returns a preset for an event that has an end. The
|
|
// broken state — a *fresh* form switched to a preset — needs a click.
|
|
test("a cadence with no end field has nothing to answer", () => {
|
|
// The Critical case. A fresh form has endKnown true and endDate empty;
|
|
// picking weekly hides both the field and the checkbox, so requiring an
|
|
// end here disables Save with nothing on screen that could satisfy it.
|
|
expect(endStated(false, true, "")).toBe(true);
|
|
expect(endStated(false, false, "")).toBe(true);
|
|
});
|
|
|
|
test("a cadence that asks still has to be answered", () => {
|
|
expect(endStated(true, true, "")).toBe(false);
|
|
expect(endStated(true, true, "2026-09-08")).toBe(true);
|
|
expect(endStated(true, false, "")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("unknownEndNote", () => {
|
|
// Keyed off the rule, not the cadence: `custom` with Repeat set to never is
|
|
// a real state, and it has no interval to bound anything.
|
|
test("no rule means no countdown, whatever the cadence", () => {
|
|
expect(unknownEndNote(null)).toContain("no countdown");
|
|
});
|
|
|
|
test("a rule bounds it, so it still counts down", () => {
|
|
const note = unknownEndNote({ unit: "weeks", interval: 1, until: null });
|
|
expect(note).toContain("until the next one opens");
|
|
expect(note).not.toContain("no countdown");
|
|
});
|
|
});
|
|
|
|
describe("repeatOf", () => {
|
|
const base = {
|
|
mode: "forever" as const,
|
|
measuring: false,
|
|
measured: null,
|
|
startMs: Date.parse("2026-09-01T00:00:00.000Z"),
|
|
contiguousMs: Date.parse("2026-09-08T00:00:00.000Z"),
|
|
unit: "weeks" as const,
|
|
amount: 1,
|
|
until: null as string | null,
|
|
};
|
|
|
|
test("a one-off states no rule", () => {
|
|
expect(repeatOf({ ...base, cadence: "one-off" })).toBe(null);
|
|
});
|
|
|
|
test("each preset is its own unit, once", () => {
|
|
expect(repeatOf({ ...base, cadence: "daily" })).toEqual({ unit: "days", interval: 1, until: null });
|
|
expect(repeatOf({ ...base, cadence: "weekly" })).toEqual({ unit: "weeks", interval: 1, until: null });
|
|
expect(repeatOf({ ...base, cadence: "monthly" })).toEqual({ unit: "months", interval: 1, until: null });
|
|
});
|
|
|
|
test("a preset carries an existing `until` through", () => {
|
|
// The form has no control for `until`, so the only way it survives an
|
|
// edit is by being threaded through every path that builds a rule — and
|
|
// the preset path is the newest of them.
|
|
const until = "2027-01-01T00:00:00.000Z";
|
|
expect(repeatOf({ ...base, cadence: "weekly", until })?.until).toBe(until);
|
|
expect(repeatOf({ ...base, cadence: "custom", until })?.until).toBe(until);
|
|
});
|
|
|
|
test("a delay that cannot land on a whole unit states no rule", () => {
|
|
// Start at 10:00, end at midnight: no whole number of any unit steps from
|
|
// one to the other, so there is nothing the schema could store.
|
|
expect(
|
|
repeatOf({
|
|
...base,
|
|
cadence: "custom",
|
|
mode: "delay",
|
|
startMs: Date.parse("2026-09-01T10:00:00.000Z"),
|
|
contiguousMs: Date.parse("2026-09-08T00:00:00.000Z"),
|
|
}),
|
|
).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe("eventCaption", () => {
|
|
// What each row in the settings index says about itself. The point is to
|
|
// explain why an event is not on the front page — a list of bare titles
|
|
// leaves the reader guessing which of two entries is the dead one.
|
|
const NOW = Date.parse("2026-10-01T12:00:00.000Z");
|
|
const at = (over: Record<string, unknown>) =>
|
|
CustomEvent.parse({
|
|
id: "myevent:k3f9qa2m01",
|
|
game: "mygame:limbus-company",
|
|
title: "Season 7",
|
|
type: "story",
|
|
summary: null,
|
|
startsAt: "2026-09-01T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-08T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
repeat: null,
|
|
at: AT,
|
|
updatedAt: AT,
|
|
...over,
|
|
});
|
|
|
|
test("a repeating event says how often, not when", () => {
|
|
// Its dates roll forward, so a date would be out of step with the row the
|
|
// reader would find if they went looking on the front page.
|
|
expect(
|
|
eventCaption(at({ repeat: { unit: "weeks", interval: 1, until: null } }), NOW),
|
|
).toBe("on a weekly cycle");
|
|
});
|
|
|
|
test("an event whose end has passed says so", () => {
|
|
// The whole reason this index exists: this one is on no other surface.
|
|
expect(eventCaption(at({}), NOW)).toContain("ended");
|
|
});
|
|
|
|
test("an event still to come says when it starts", () => {
|
|
// Asserted on what it says, not on what it avoids saying: `not.toContain`
|
|
// passed even with this whole branch deleted, because the ended branch was
|
|
// never the thing at risk.
|
|
expect(
|
|
eventCaption(
|
|
at({ startsAt: "2026-11-01T00:00:00.000Z", endsAt: "2026-11-08T00:00:00.000Z" }),
|
|
NOW,
|
|
),
|
|
).toContain("starts");
|
|
});
|
|
|
|
test("an unannounced end says so rather than reading as passed", () => {
|
|
// Date.parse(null) is NaN and NaN < now is false, so the null guard was
|
|
// unobservable through a `not.toContain("ended")` assertion.
|
|
expect(eventCaption(at({ endsAt: null, endPrecision: "unknown" }), NOW)).toBe(
|
|
"no end date",
|
|
);
|
|
});
|
|
|
|
test("a repeating series that has stopped says so, not just how often", () => {
|
|
// This list's whole job is explaining why an event is on no other surface.
|
|
// A finished series that reports a healthy cadence explains nothing.
|
|
const caption = eventCaption(
|
|
at({
|
|
endsAt: null,
|
|
endPrecision: "unknown",
|
|
repeat: { unit: "weeks", interval: 1, until: "2026-09-20T00:00:00.000Z" },
|
|
}),
|
|
NOW,
|
|
);
|
|
expect(caption).toContain("stopped");
|
|
});
|
|
});
|
|
|
|
describe("the settings index of your own events", () => {
|
|
test("lists an ended event that no other surface shows", () => {
|
|
const ended = CustomEvent.parse({
|
|
id: "myevent:seasonseven",
|
|
game: "mygame:limbus-company",
|
|
title: "Season 7",
|
|
type: "story",
|
|
summary: null,
|
|
startsAt: "2026-09-01T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-08T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
repeat: null,
|
|
at: AT,
|
|
updatedAt: AT,
|
|
});
|
|
const html = render(
|
|
<YourOwn
|
|
games={GAMES}
|
|
events={{ [ended.id]: ended }}
|
|
lanes={["mygame:limbus-company"]}
|
|
now={Date.parse("2026-10-01T12:00:00.000Z")}
|
|
onAddGame={() => {}}
|
|
onEditGame={() => {}}
|
|
onRemoveGame={() => ({ removed: true, blockedBy: 0 })}
|
|
onAddEvent={() => {}}
|
|
onOpen={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Season 7");
|
|
expect(html).toContain("ended");
|
|
});
|
|
});
|
|
|
|
describe("the index covers events filed under a tracked game", () => {
|
|
test("an event under Genshin is listed too", () => {
|
|
// The form deliberately allows this — a source can miss an event — so an
|
|
// index that only walked the reader's own games would leave exactly the
|
|
// same hole it exists to close.
|
|
const own = CustomEvent.parse({
|
|
id: "myevent:undergenshin",
|
|
game: "genshin",
|
|
title: "Something the wiki missed",
|
|
type: "other",
|
|
summary: null,
|
|
startsAt: "2026-09-01T00:00:00.000Z",
|
|
startPrecision: "day",
|
|
endsAt: "2026-09-08T00:00:00.000Z",
|
|
endPrecision: "day",
|
|
repeat: null,
|
|
at: AT,
|
|
updatedAt: AT,
|
|
});
|
|
const html = render(
|
|
<YourOwn
|
|
games={GAMES}
|
|
events={{ [own.id]: own }}
|
|
lanes={["genshin", "mygame:limbus-company"]}
|
|
now={Date.parse("2026-10-01T12:00:00.000Z")}
|
|
onAddGame={() => {}}
|
|
onEditGame={() => {}}
|
|
onRemoveGame={() => ({ removed: true, blockedBy: 0 })}
|
|
onAddEvent={() => {}}
|
|
onOpen={() => {}}
|
|
/>,
|
|
);
|
|
expect(html).toContain("Something the wiki missed");
|
|
expect(html).toContain("ended");
|
|
});
|
|
});
|
|
|
|
describe("the reader's weekly and monthly tasks in settings", () => {
|
|
const noop = () => {};
|
|
const base = {
|
|
games: GAMES,
|
|
events: {},
|
|
lanes: ["zzz", "mygame:limbus-company"],
|
|
onAddGame: noop,
|
|
onEditGame: noop,
|
|
onRemoveGame: () => ({ removed: true, blockedBy: 0 }),
|
|
onAddEvent: noop,
|
|
now: Date.parse("2026-10-01T12:00:00.000Z"),
|
|
onOpen: noop,
|
|
onAddTask: noop,
|
|
onEditTask: noop,
|
|
onRemoveTask: noop,
|
|
};
|
|
const task = {
|
|
id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points",
|
|
period: { unit: "week" as const, startsOn: 1 },
|
|
measure: { kind: "amount" as const, target: 2100, unit: "points" },
|
|
at: AT, updatedAt: AT,
|
|
};
|
|
|
|
test("lists a task with what it asks for", () => {
|
|
const html = render(<YourOwn {...base} tasks={[task]} />);
|
|
expect(html).toContain("Activity points");
|
|
expect(html).toContain("every week from Monday · reach 2,100 points");
|
|
expect(html).toContain("Add a task");
|
|
});
|
|
|
|
test("with none yet, the way to add one is still there", () => {
|
|
expect(render(<YourOwn {...base} tasks={[]} />)).toContain("Add a task");
|
|
});
|
|
|
|
test("the form starts weekly, from Monday, ticked off once", () => {
|
|
const html = render(<TaskForm lanes={["zzz"]} onSave={noop} onCancel={noop} />);
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*>Every week</);
|
|
expect(html).toMatch(/<option value="1" selected="">Monday<\/option>/);
|
|
expect(html).toMatch(/aria-pressed="true"[^>]*>Tick it off</);
|
|
});
|
|
|
|
test("a draft is only valid when the schema would take it", () => {
|
|
const fields = {
|
|
game: "zzz", title: "Activity points", unit: "week" as const, weekday: 1, monthDay: 1,
|
|
kind: "amount" as const, required: "1", target: "2,100", amountUnit: "points",
|
|
};
|
|
expect(taskDraftFrom(fields)).toEqual({
|
|
game: "zzz", title: "Activity points",
|
|
period: { unit: "week", startsOn: 1 },
|
|
measure: { kind: "amount", target: 2100, unit: "points" },
|
|
});
|
|
expect(taskDraftFrom({ ...fields, title: " " })).toBeNull();
|
|
expect(taskDraftFrom({ ...fields, target: "" })).toBeNull();
|
|
expect(taskDraftFrom({ ...fields, kind: "days", required: "9", unit: "month", monthDay: 31 }))
|
|
.toEqual({
|
|
game: "zzz", title: "Activity points",
|
|
period: { unit: "month", startsOn: 31 },
|
|
measure: { kind: "days", required: 9 },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("review fixes: the month-day field", () => {
|
|
test("an emptied field is mid-edit, not day 1", () => {
|
|
expect(parseMonthDay("")).toBeNull();
|
|
expect(parseMonthDay("5")).toBe(5);
|
|
expect(parseMonthDay("40")).toBe(31);
|
|
expect(parseMonthDay("0")).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("built-in weekly tasks in Your own", () => {
|
|
test("listed with their source's date, and a way to make one yours", async () => {
|
|
const { routinesFor } = await import("../src/shared/routines.ts");
|
|
const noop = () => {};
|
|
const html = render(
|
|
<YourOwn games={GAMES} events={{}} lanes={["zzz"]} now={0} onOpen={noop}
|
|
onAddGame={noop} onEditGame={noop} onRemoveGame={() => ({ removed: true, blockedBy: 0 })}
|
|
onAddEvent={noop} onAddTask={noop} onEditTask={noop} onRemoveTask={noop} tasks={[]}
|
|
routines={routinesFor("zzz")} onMakeItMine={noop} />,
|
|
);
|
|
expect(html).toContain("Ridu Weekly");
|
|
expect(html).toContain("as of 2 Dec 2025");
|
|
expect(html).toContain('href="https://game8.co/games/Zenless-Zone-Zero/archives/484723"');
|
|
expect(html).toContain("Make it mine");
|
|
});
|
|
});
|