The wiki recorded 2,100 on 2026-01-05 and the owner confirmed it in game; Game8's page still says 1,300. Same id, so logged numbers are kept — a week logged at 1,300 now reads 1,300 of 2,100. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1534 lines
57 KiB
TypeScript
1534 lines
57 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
import { NextUp } from "../src/client/components/NextUp.tsx";
|
||
import {
|
||
boardWindow,
|
||
markerLabel,
|
||
splitAt,
|
||
startMarkers,
|
||
Timeline,
|
||
} from "../src/client/components/Timeline.tsx";
|
||
import {
|
||
CatchUpPanel,
|
||
chipLabel,
|
||
Dailies,
|
||
dailyGroups,
|
||
dailyTally,
|
||
} from "../src/client/components/Dailies.tsx";
|
||
import type { GoalStatus } from "../src/shared/goal.ts";
|
||
import { Welcome } from "../src/client/components/Welcome.tsx";
|
||
import {
|
||
chooseGoal,
|
||
chooseMeasure,
|
||
GoalControl,
|
||
parseDraft,
|
||
} from "../src/client/components/GoalControl.tsx";
|
||
import { AmountEditor, parseAmount } from "../src/client/components/AmountEditor.tsx";
|
||
import type { Task } from "../src/shared/task.ts";
|
||
import { routinesFor } from "../src/shared/routines.ts";
|
||
import { DailyChecklist } from "../src/client/components/DailyChecklist.tsx";
|
||
import { timelineLanes } from "../src/client/state/lanes.ts";
|
||
import { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||
import { metaFor } from "../src/shared/games.ts";
|
||
import { dayKey } from "../src/shared/daily.ts";
|
||
import { clockFor } from "../src/shared/time.ts";
|
||
import { GachaEvent, type GameId } from "../src/shared/schema.ts";
|
||
|
||
/**
|
||
* Static-render checks on the two surfaces a reader meets first.
|
||
*
|
||
* Not a substitute for using the thing, but they pin the claims each one makes:
|
||
* the headline carries the deadlines behind the closest one, and the first run
|
||
* asks how the reader wants to read the app rather than deciding for them.
|
||
*/
|
||
|
||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||
const HOUR = 60 * 60 * 1000;
|
||
|
||
function render(node: React.ReactElement): string {
|
||
return renderToStaticMarkup(
|
||
<GameMetaProvider value={(id) => metaFor(id, {})}>{node}</GameMetaProvider>,
|
||
);
|
||
}
|
||
|
||
function row(title: string, game: GameId, endsInHours: number | null) {
|
||
// Through the schema rather than cast into shape: it is the single source of
|
||
// truth for this type, and it is what would catch a fixture that no longer
|
||
// resembles a real event.
|
||
const event = GachaEvent.parse({
|
||
id: `${game}:${title.toLowerCase().replace(/\W+/g, "-")}:2026-08-10`,
|
||
game,
|
||
title,
|
||
type: "story",
|
||
summary: null,
|
||
startsAt: "2026-08-10T00:00:00.000Z",
|
||
startPrecision: "day",
|
||
endsAt:
|
||
endsInHours === null
|
||
? null
|
||
: new Date(NOW + endsInHours * HOUR).toISOString(),
|
||
endPrecision: endsInHours === null ? "unknown" : "exact",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.invalid/events",
|
||
sourceId: "example-events",
|
||
status: "published",
|
||
confidence: 1,
|
||
extractionMethod: "parser",
|
||
version: 1,
|
||
firstSeenAt: "2026-08-17T00:00:00.000Z",
|
||
updatedAt: "2026-08-17T00:00:00.000Z",
|
||
});
|
||
return { event, clock: clockFor(event, "europe", NOW) };
|
||
}
|
||
|
||
/**
|
||
* An event whose start is still ahead of `NOW`.
|
||
*
|
||
* Its own helper rather than a flag on `row`, because everything about it is
|
||
* different: the start is what places it, the id is cut from the start's date,
|
||
* and it is the case the board deliberately withholds.
|
||
*/
|
||
function upcoming(
|
||
title: string,
|
||
game: GameId,
|
||
startsInHours: number,
|
||
runsForHours = 240,
|
||
) {
|
||
const startsAt = new Date(NOW + startsInHours * HOUR).toISOString();
|
||
const event = GachaEvent.parse({
|
||
id: `${game}:${title.toLowerCase().replace(/\W+/g, "-")}:${startsAt.slice(0, 10)}`,
|
||
game,
|
||
title,
|
||
type: "banner",
|
||
summary: null,
|
||
startsAt,
|
||
startPrecision: "exact",
|
||
endsAt: new Date(NOW + (startsInHours + runsForHours) * HOUR).toISOString(),
|
||
endPrecision: "exact",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.invalid/events",
|
||
sourceId: "example-events",
|
||
status: "published",
|
||
confidence: 1,
|
||
extractionMethod: "parser",
|
||
version: 1,
|
||
firstSeenAt: "2026-08-17T00:00:00.000Z",
|
||
updatedAt: "2026-08-17T00:00:00.000Z",
|
||
});
|
||
return { event, clock: clockFor(event, "europe", NOW) };
|
||
}
|
||
|
||
describe("NextUp", () => {
|
||
const rows = [
|
||
row("Closing Ceremony", "genshin", 6),
|
||
row("Second Wind", "hsr", 30),
|
||
row("Third Rail", "zzz", 100),
|
||
];
|
||
|
||
test("leads with the closest deadline and lists the ones behind it", () => {
|
||
// A reader asked for the next three, and was right that one is too few:
|
||
// finishing the headline event left the panel pointing at nothing.
|
||
const html = render(<NextUp rows={rows} focused={null} onOpen={() => {}} />);
|
||
expect(html).toContain("Closing Ceremony");
|
||
expect(html).toContain("Second Wind");
|
||
expect(html).toContain("Third Rail");
|
||
// The lead keeps the big countdown; the rest are a queue under it.
|
||
expect(html.indexOf("Closing Ceremony")).toBeLessThan(html.indexOf("Then"));
|
||
expect(html.indexOf("Then")).toBeLessThan(html.indexOf("Second Wind"));
|
||
});
|
||
|
||
test("one deadline is a headline with nothing behind it", () => {
|
||
const html = render(
|
||
<NextUp rows={rows.slice(0, 1)} focused={null} onOpen={() => {}} />,
|
||
);
|
||
expect(html).toContain("Closing Ceremony");
|
||
expect(html).not.toContain(">Then<");
|
||
});
|
||
|
||
test("no deadlines says so rather than rendering an empty panel", () => {
|
||
const html = render(<NextUp rows={[]} focused={null} onOpen={() => {}} />);
|
||
expect(html).toContain("Nothing running");
|
||
});
|
||
|
||
test("an unannounced end is never dressed up as a countdown", () => {
|
||
// The rule the whole product rests on, at the largest type size it has.
|
||
const html = render(
|
||
<NextUp rows={[row("Unknown End", "wuwa", null)]} focused={null} onOpen={() => {}} />,
|
||
);
|
||
expect(html).toContain("unknown");
|
||
expect(html).toContain("no end date");
|
||
});
|
||
});
|
||
|
||
describe("Welcome (first run)", () => {
|
||
const html = () =>
|
||
render(<Welcome available={["genshin", "hsr"]} onConfirm={() => {}} />);
|
||
|
||
test("asks how the reader wants to see their events", () => {
|
||
expect(html()).toContain("How do you want to see them?");
|
||
expect(html()).toContain("Checklist");
|
||
expect(html()).toContain("Timeline");
|
||
});
|
||
|
||
test("says where the choice lives afterwards", () => {
|
||
// The tabs are small text in a corner — the one control a first-time reader
|
||
// will not find on their own, so the screen that sets it says where it is.
|
||
expect(html()).toContain("top right");
|
||
});
|
||
|
||
test("opens on the list, with the choice already answered", () => {
|
||
// Games stay unanswered because only the reader knows which ones they play.
|
||
// This one has a defensible default, and a reader cannot choose between two
|
||
// layouts they have not seen yet.
|
||
expect(html()).toContain('aria-checked="true"');
|
||
});
|
||
|
||
test("lists the games alphabetically by the name on the button", () => {
|
||
// Two games here each pin one half of the sort, and neither is decoration:
|
||
//
|
||
// `nikke` is Goddess of Victory: Nikke, so its id sorts fifth and its name
|
||
// third. That is what makes this a test of sorting by *name* rather than by
|
||
// `LaneId` — every other set of ids in this table happens to sort into the
|
||
// same order as its names, so a comparator on the id passes them all.
|
||
//
|
||
// `holodori` is hololive Dreams, the one lowercase name in `games.ts`. A
|
||
// code-point sort files it after every capitalised game instead of between
|
||
// Goddess and Honkai, which is why the comparator has to be `localeCompare`.
|
||
//
|
||
// The input order is neither feed order nor id order, so nothing lines up
|
||
// by luck.
|
||
const markup = render(
|
||
<Welcome
|
||
available={["zzz", "genshin", "holodori", "hsr", "nikke", "arknights"]}
|
||
onConfirm={() => {}}
|
||
/>,
|
||
);
|
||
expect(pickerNames(markup)).toEqual([
|
||
"Arknights",
|
||
"Genshin Impact",
|
||
"Goddess of Victory: Nikke",
|
||
"hololive Dreams",
|
||
"Honkai: Star Rail",
|
||
"Zenless Zone Zero",
|
||
]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The game names on the picker buttons, in the order they render.
|
||
*
|
||
* Pinned to the name span's `flex-1` class: edit that className and this
|
||
* returns nothing and the assertion fails loudly, which is the safe direction
|
||
* to break in. The text arrives HTML-escaped, so a fixture using a name with an
|
||
* apostrophe — Girls' Frontline 2: Exilium — must expect `'` in it.
|
||
*/
|
||
function pickerNames(markup: string): string[] {
|
||
return [...markup.matchAll(/<span class="flex-1[^"]*"[^>]*>([^<]+)<\/span>/g)].map(
|
||
(m) => m[1] ?? "",
|
||
);
|
||
}
|
||
|
||
describe("Timeline window", () => {
|
||
const DAY = 86_400_000;
|
||
|
||
test("reaches a week past the oldest running event", () => {
|
||
// So "when did this start?" is answerable without the reader hunting for
|
||
// an edge, and so a bar that began days ago shows its real start.
|
||
const started = NOW - 20 * DAY;
|
||
const { min } = boardWindow([started], [NOW + 5 * DAY], NOW);
|
||
expect(min).toBe(started - 7 * DAY);
|
||
});
|
||
|
||
test("stops two months back however long something has been running", () => {
|
||
// A standing login campaign can have started half a year ago. Drawing from
|
||
// its start bought months of empty calendar that nobody scrolls through and
|
||
// pushed every other bar off to the right.
|
||
const ancient = NOW - 200 * DAY;
|
||
const { min } = boardWindow([ancient, NOW - 3 * DAY], [NOW + 5 * DAY], NOW);
|
||
expect(min).toBe(NOW - 60 * DAY);
|
||
// The bar is then older than the board, which is what the faded left edge
|
||
// says — it must not be redrawn as though it started at the edge.
|
||
expect(ancient).toBeLessThan(min);
|
||
});
|
||
|
||
test("today is on the board even when everything is still to come", () => {
|
||
const { min, max } = boardWindow([NOW + 30 * DAY], [NOW + 40 * DAY], NOW);
|
||
expect(min).toBeLessThan(NOW);
|
||
expect(max).toBeGreaterThan(NOW);
|
||
});
|
||
});
|
||
|
||
describe("timelineLanes", () => {
|
||
// Deliberately not in deadline order, and with a game interleaved, so the
|
||
// two modes cannot both pass by accident.
|
||
const rows = [
|
||
row("Closing Ceremony", "genshin", 100),
|
||
row("Second Wind", "hsr", 6),
|
||
row("Third Rail", "genshin", 30),
|
||
row("Open Ended", "zzz", null),
|
||
];
|
||
|
||
test("by game: a lane each, and the order inside one is left alone", () => {
|
||
// The rows arrive sorted by whatever the reader chose in the list.
|
||
// Grouping them is not a licence to re-sort within a game.
|
||
const lanes = timelineLanes(rows, "game");
|
||
expect(lanes.map((l) => l.game)).toEqual(["genshin", "hsr", "zzz"]);
|
||
expect(lanes[0]?.rows.map((r) => r.event.title)).toEqual([
|
||
"Closing Ceremony",
|
||
"Third Rail",
|
||
]);
|
||
});
|
||
|
||
test("the reader's game order stacks the lanes", () => {
|
||
const lanes = timelineLanes(rows, "game", true, ["zzz", "hsr", "genshin"]);
|
||
expect(lanes.map((l) => l.game)).toEqual(["zzz", "hsr", "genshin"]);
|
||
});
|
||
|
||
test("ordering the lanes does not re-sort the rows inside one", () => {
|
||
// The same rule the mode itself follows, read one level up.
|
||
const lanes = timelineLanes(rows, "game", true, ["genshin"]);
|
||
expect(lanes[0]?.rows.map((r) => r.event.title)).toEqual([
|
||
"Closing Ceremony",
|
||
"Third Rail",
|
||
]);
|
||
});
|
||
|
||
test("a game the order does not name keeps its place behind the ones it does", () => {
|
||
// Total for a lane the reader never placed — and stable, so the unplaced
|
||
// games stay in the order their rows arrived rather than shuffling.
|
||
const lanes = timelineLanes(rows, "game", true, ["zzz"]);
|
||
expect(lanes.map((l) => l.game)).toEqual(["zzz", "genshin", "hsr"]);
|
||
});
|
||
|
||
test("no order given stacks exactly as it did before", () => {
|
||
expect(timelineLanes(rows, "game", true).map((l) => l.game)).toEqual(
|
||
timelineLanes(rows, "game", true, undefined).map((l) => l.game),
|
||
);
|
||
});
|
||
|
||
test("the merged stack ignores it, having one lane and no game", () => {
|
||
const lanes = timelineLanes(rows, "ending", true, ["zzz", "hsr", "genshin"]);
|
||
expect(lanes).toHaveLength(1);
|
||
expect(lanes[0]?.game).toBeNull();
|
||
});
|
||
|
||
test("ending soonest: every game in one stack, deadline order", () => {
|
||
const lanes = timelineLanes(rows, "ending");
|
||
expect(lanes).toHaveLength(1);
|
||
// No heading to name the game, so the renderer has to say it per bar.
|
||
expect(lanes[0]?.game).toBeNull();
|
||
expect(lanes[0]?.rows.map((r) => r.event.title)).toEqual([
|
||
"Second Wind",
|
||
"Third Rail",
|
||
"Closing Ceremony",
|
||
// An unannounced end is still on the board, behind every dated one — it
|
||
// is real, but it is not a deadline.
|
||
"Open Ended",
|
||
]);
|
||
});
|
||
|
||
test("neither mode loses a row", () => {
|
||
for (const mode of ["game", "ending"] as const) {
|
||
const plotted = timelineLanes(rows, mode).flatMap((l) => l.rows);
|
||
expect(plotted).toHaveLength(rows.length);
|
||
}
|
||
});
|
||
|
||
test("an empty board is no lanes, not one empty lane", () => {
|
||
expect(timelineLanes([], "ending")).toEqual([]);
|
||
expect(timelineLanes([], "game")).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("Timeline stacking", () => {
|
||
const rows = [
|
||
row("Closing Ceremony", "genshin", 100),
|
||
row("Second Wind", "hsr", 6),
|
||
];
|
||
|
||
const board = (group: "game" | "ending") =>
|
||
render(
|
||
<Timeline
|
||
rows={rows}
|
||
now={NOW}
|
||
dayWidth={13}
|
||
onZoom={() => {}}
|
||
group={group}
|
||
onGroup={() => {}}
|
||
showUpcoming={false}
|
||
splitUpcoming
|
||
onOpen={() => {}}
|
||
isDone={() => false}
|
||
/>,
|
||
);
|
||
|
||
test("mixed in, lane mode re-sorts rather than keeping the block", () => {
|
||
// The one place `timelineLanes` is allowed to reorder a lane. Leaving the
|
||
// given order alone would draw exactly the block it was told not to, minus
|
||
// the heading that explained it.
|
||
// Given live-first, as every sort this board can be handed produces. B has
|
||
// the nearer end (48h against 100h) but has not opened yet.
|
||
const given = [row("A", "hsr", 100), upcoming("B", "hsr", 24, 24)];
|
||
expect(timelineLanes(given, "game", true)[0]?.rows[0]?.event.title).toBe("A");
|
||
expect(timelineLanes(given, "game", false)[0]?.rows[0]?.event.title).toBe("B");
|
||
});
|
||
|
||
test("both stackings plot every event", () => {
|
||
for (const group of ["game", "ending"] as const) {
|
||
const html = board(group);
|
||
expect(html).toContain("Closing Ceremony");
|
||
expect(html).toContain("Second Wind");
|
||
}
|
||
});
|
||
|
||
test("the merged board names each bar's game, since no heading does", () => {
|
||
// Colour cannot carry it once every game shares one stack, and a reader
|
||
// who cannot tell whose event is ending tonight has not been told the
|
||
// thing they came for.
|
||
const html = board("ending");
|
||
expect(html).toContain(metaFor("hsr", {}).short);
|
||
expect(html).toContain(metaFor("genshin", {}).short);
|
||
// Deadline order, across games.
|
||
expect(html.indexOf("Second Wind")).toBeLessThan(
|
||
html.indexOf("Closing Ceremony"),
|
||
);
|
||
});
|
||
|
||
test("the reader can see which stacking they are on", () => {
|
||
// The control is the only thing on the board saying which of the two
|
||
// shapes they are reading, so it has to say it, not just accept a tap.
|
||
const pressed = (group: "game" | "ending") =>
|
||
/aria-pressed="true"[\s\S]*?>([^<]+)</.exec(board(group))?.[1];
|
||
expect(pressed("game")).toBe("By game");
|
||
expect(pressed("ending")).toBe("Ending soonest");
|
||
});
|
||
});
|
||
|
||
describe("Timeline: expand", () => {
|
||
const rows = [
|
||
row("Closing Ceremony", "genshin", 100),
|
||
row("Second Wind", "hsr", 6),
|
||
];
|
||
|
||
const board = (
|
||
expand: (min: number, max: number) => ReturnType<typeof row>[],
|
||
showUpcoming = false,
|
||
) =>
|
||
render(
|
||
<Timeline
|
||
rows={rows}
|
||
now={NOW}
|
||
dayWidth={13}
|
||
onZoom={() => {}}
|
||
group="game"
|
||
onGroup={() => {}}
|
||
showUpcoming={showUpcoming}
|
||
splitUpcoming
|
||
onOpen={() => {}}
|
||
isDone={() => false}
|
||
expand={expand}
|
||
/>,
|
||
);
|
||
|
||
test("an occurrence expand hands back is drawn alongside the base rows", () => {
|
||
// The lists only ever carry a rule's first two occurrences — expand exists
|
||
// so the rest of its rhythm still reaches the board. This is the
|
||
// straightforward half of that promise: something expand hands back that
|
||
// is not already among the base rows has to actually appear.
|
||
const extra = row("Third Rail", "zzz", 50);
|
||
const html = board(() => [extra]);
|
||
expect(html).toContain("Third Rail");
|
||
});
|
||
|
||
test("an occurrence already among the base rows is not drawn twice", () => {
|
||
// expand does not know what the lists already showed, so it is free to
|
||
// hand back the same occurrences that are already in the base rows —
|
||
// exactly what happens for the first two of every rule. Undeduped, each
|
||
// would draw a second bar directly on top of the first: not a visible
|
||
// duplicate row a reader would notice, but one bar reading subtly bolder
|
||
// than the rest, which is a far easier thing to miss. Comparing the whole
|
||
// rendered markup, rather than counting how many times a title appears,
|
||
// is what catches a bar drawn twice in the same place.
|
||
const extra = row("Third Rail", "zzz", 50);
|
||
const withDuplicates = board(() => [rows[0]!, rows[1]!, extra]);
|
||
const withoutDuplicates = board(() => [extra]);
|
||
expect(withDuplicates).toBe(withoutDuplicates);
|
||
});
|
||
|
||
test("showUpcoming={false} holds back an upcoming extra same as a base row", () => {
|
||
// Expanded occurrences answer to the same switch as everything else on the
|
||
// board. An extra that has not started yet is exactly the "next patch
|
||
// queued behind it" noise showUpcoming exists to hold back — it does not
|
||
// get a pass for having arrived through expand instead of rows.
|
||
const html = board(() => [upcoming("Future Wave", "zzz", 48)], false);
|
||
expect(html).not.toContain("Future Wave");
|
||
});
|
||
|
||
test("no drawn bar's right edge runs past the board's own width", () => {
|
||
// `boardWindow`'s `max` comes from `plotted` alone, so a base row can
|
||
// never cross it — but an expanded occurrence can: `occurrencesOf` admits
|
||
// anything that only *starts* at or before the window's edge, and one
|
||
// with no stated end then runs a full interval past it. Uncapped, that
|
||
// grows the pane's scrollWidth past the gridlines and the axis, which is
|
||
// the reader scrolling into empty space the spec says the board must
|
||
// never have. A year out is a stand-in for that: nowhere near the two
|
||
// base rows' window, so nothing but a clamp keeps it on the board.
|
||
const farEvent = GachaEvent.parse({
|
||
id: "zzz:open-ended-rerun:2026-08-10",
|
||
game: "zzz",
|
||
title: "Open-Ended Rerun",
|
||
type: "story",
|
||
summary: null,
|
||
startsAt: "2026-08-10T00:00:00.000Z",
|
||
startPrecision: "day",
|
||
endsAt: "2027-08-10T00:00:00.000Z",
|
||
endPrecision: "exact",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.invalid/events",
|
||
sourceId: "example-events",
|
||
status: "published",
|
||
confidence: 1,
|
||
extractionMethod: "parser",
|
||
version: 1,
|
||
firstSeenAt: "2026-08-17T00:00:00.000Z",
|
||
updatedAt: "2026-08-17T00:00:00.000Z",
|
||
});
|
||
const farRow = { event: farEvent, clock: clockFor(farEvent, "europe", NOW) };
|
||
const html = board(() => [farRow], true);
|
||
|
||
const chartWidth = Number(
|
||
/style="width:([\d.]+)px;min-width:100%"/.exec(html)?.[1],
|
||
);
|
||
// The bar's box is `margin-left` plus `width`; both are inline styles on
|
||
// the same button this fixture's title makes unique to find.
|
||
const bar = /title="Open-Ended Rerun"[^>]*style="([^"]+)"/.exec(html)?.[1] ?? "";
|
||
const marginLeft = Number(/margin-left:([\d.]+)px/.exec(bar)?.[1]);
|
||
const width = Number(/width:([\d.]+)px/.exec(bar)?.[1]);
|
||
|
||
expect(marginLeft + width).toBeLessThanOrEqual(chartWidth);
|
||
});
|
||
|
||
test("a start marker counts an occurrence that only arrived through expand", () => {
|
||
// Neither base row has started yet as far as this window's concerned —
|
||
// both `row()` fixtures already opened on the 10th — so with no expanded
|
||
// extra there is nothing upcoming to mark. An extra that has not started
|
||
// yet is exactly the case a start marker exists to label, and it has to
|
||
// show up whether it came from the base rows or from expand.
|
||
const html = board(() => [upcoming("Future Wave", "zzz", 48)], true);
|
||
expect(html).toContain("starts ");
|
||
});
|
||
});
|
||
|
||
describe("Timeline: events that have not started", () => {
|
||
const rows = [
|
||
row("Closing Ceremony", "genshin", 100),
|
||
upcoming("Frost Parade", "hsr", 3 * 24),
|
||
upcoming("Second Coming", "zzz", 3 * 24 + 2),
|
||
upcoming("Long Way Round", "wuwa", 30 * 24),
|
||
];
|
||
|
||
const board = (
|
||
showUpcoming: boolean,
|
||
all = rows,
|
||
group: "game" | "ending" = "ending",
|
||
splitUpcoming = true,
|
||
) =>
|
||
render(
|
||
<Timeline
|
||
rows={all}
|
||
now={NOW}
|
||
dayWidth={32}
|
||
onZoom={() => {}}
|
||
group={group}
|
||
onGroup={() => {}}
|
||
showUpcoming={showUpcoming}
|
||
splitUpcoming={splitUpcoming}
|
||
onOpen={() => {}}
|
||
isDone={() => false}
|
||
/>,
|
||
);
|
||
|
||
test("the board holds them back by default", () => {
|
||
// Off is the default because the board answers "how does the time I am in
|
||
// lay out?", and it draws its span from what it plots — so a next patch on
|
||
// every lane pushes the running bars the reader came for off to the left.
|
||
const html = board(false);
|
||
expect(html).toContain("Closing Ceremony");
|
||
expect(html).not.toContain("Frost Parade");
|
||
expect(html).not.toContain("Long Way Round");
|
||
});
|
||
|
||
test("switching it on plots them", () => {
|
||
const html = board(true);
|
||
expect(html).toContain("Frost Parade");
|
||
expect(html).toContain("Long Way Round");
|
||
});
|
||
|
||
test("a board with only future events says so rather than reading empty", () => {
|
||
// Otherwise the reader is looking at "nothing to plot" while three events
|
||
// are scheduled, and an absence nobody mentioned is indistinguishable from
|
||
// a quiet fortnight. It names the setting, since the switch is not on the
|
||
// board any more.
|
||
const html = board(false, rows.slice(1));
|
||
expect(html).toContain("Nothing is running right now");
|
||
expect(html).toContain("3 events have not started yet");
|
||
expect(html).toContain("Show events that haven't started");
|
||
});
|
||
|
||
test("one waiting event is counted in words, not as \"1 events\"", () => {
|
||
const html = board(false, [rows[1]!]);
|
||
expect(html).toContain("One event has not started yet");
|
||
});
|
||
|
||
test("each clump of starts is marked in words", () => {
|
||
const html = board(true);
|
||
// Two of them open on the same day, so that is one mark saying two.
|
||
expect(html).toContain("2 start");
|
||
// And the far one is its own mark, singular.
|
||
expect(html).toContain("starts ");
|
||
});
|
||
|
||
test("start markers are absent while the events are held back", () => {
|
||
expect(board(false)).not.toContain("2 start");
|
||
});
|
||
|
||
test("a heading marks where the running bars stop", () => {
|
||
// The dashed edge says "this bar has not started"; it does not say where
|
||
// the running ones ended, which is what a board read at a glance needs.
|
||
const html = board(true);
|
||
const at = html.indexOf("Not started yet");
|
||
expect(at).toBeGreaterThan(html.indexOf("Closing Ceremony"));
|
||
expect(at).toBeLessThan(html.indexOf("Frost Parade"));
|
||
});
|
||
|
||
test("every lane gets its own, since every lane has its own boundary", () => {
|
||
// Stacked by game, "where does this game stop running?" is a different
|
||
// answer per lane — one heading for the board would be in the wrong place
|
||
// for all but one of them.
|
||
const html = board(true, rows, "game");
|
||
expect(html.split("Not started yet")).toHaveLength(4);
|
||
});
|
||
|
||
test("mixed in, there is no block to head and no heading", () => {
|
||
// Not the heading switched off: the rows are one deadline queue, so a
|
||
// label would be pointing at the middle of it.
|
||
const html = board(true, rows, "ending", false);
|
||
expect(html).toContain("Frost Parade");
|
||
expect(html).not.toContain("Not started yet");
|
||
});
|
||
|
||
test("mixed in, a nearer deadline wins whether or not it has opened", () => {
|
||
// The whole point of the option, and the one thing the split order can
|
||
// never show. Frost Parade opens in 3 days and closes 10 days after that;
|
||
// Closing Ceremony is running now until 100 hours from now — so it is
|
||
// still the nearer deadline, and Frost Parade sits under it rather than
|
||
// behind every running row.
|
||
const near = upcoming("Quick Turnaround", "hsr", 24, 24);
|
||
const html = board(true, [row("Closing Ceremony", "genshin", 100), near], "ending", false);
|
||
expect(html.indexOf("Quick Turnaround")).toBeLessThan(
|
||
html.indexOf("Closing Ceremony"),
|
||
);
|
||
// Split, the same two rows go the other way round.
|
||
const kept = board(true, [row("Closing Ceremony", "genshin", 100), near], "ending", true);
|
||
expect(kept.indexOf("Closing Ceremony")).toBeLessThan(
|
||
kept.indexOf("Quick Turnaround"),
|
||
);
|
||
});
|
||
|
||
test("no heading where nothing is waiting", () => {
|
||
// A label with nothing under it is a section that does not exist.
|
||
expect(board(true, [row("Closing Ceremony", "genshin", 100)])).not.toContain(
|
||
"Not started yet",
|
||
);
|
||
expect(board(false)).not.toContain("Not started yet");
|
||
});
|
||
});
|
||
|
||
describe("splitAt", () => {
|
||
const live = { clock: { upcoming: false } };
|
||
const soon = { clock: { upcoming: true } };
|
||
|
||
test("finds the boundary", () => {
|
||
expect(splitAt([live, live, soon, soon])).toBe(2);
|
||
});
|
||
|
||
test("a lane that is all future breaks at the top", () => {
|
||
// Not a divider then but a heading, which is the honest reading: nothing
|
||
// in this lane has started.
|
||
expect(splitAt([soon, soon])).toBe(0);
|
||
});
|
||
|
||
test("nothing waiting is no boundary at all", () => {
|
||
expect(splitAt([live, live])).toBe(-1);
|
||
expect(splitAt([])).toBe(-1);
|
||
});
|
||
});
|
||
|
||
describe("startMarkers", () => {
|
||
const DAY = 86_400_000;
|
||
const at = (ms: number) => ({ clock: { upcoming: true, startsMs: ms } });
|
||
|
||
/** A generous scale, so nothing merges unless the test asks it to. */
|
||
const wide = (ms: number) => (ms / DAY) * 108;
|
||
|
||
test("events starting the same day are one mark", () => {
|
||
// A patch ships six things at once; six rules stacked on one date is not a
|
||
// reading of that, it is a smear.
|
||
const marks = startMarkers(
|
||
[at(5 * DAY), at(5 * DAY + 3600_000), at(5 * DAY + 7200_000)],
|
||
wide,
|
||
);
|
||
expect(marks).toHaveLength(1);
|
||
expect(marks[0]?.count).toBe(3);
|
||
});
|
||
|
||
test("the mark sits at the earliest start in its day", () => {
|
||
// So the rule lands on the leftmost bar of the clump rather than at a
|
||
// midnight no event actually begins at.
|
||
const marks = startMarkers([at(5 * DAY + 7200_000), at(5 * DAY)], wide);
|
||
expect(marks[0]?.ms).toBe(5 * DAY);
|
||
});
|
||
|
||
test("separate days stay separate when the scale has room", () => {
|
||
const marks = startMarkers([at(5 * DAY), at(9 * DAY)], wide);
|
||
expect(marks).toHaveLength(2);
|
||
});
|
||
|
||
test("days closer than a label are merged, and the label says the range", () => {
|
||
// At six pixels a day, four days apart is 24px — two labels on top of each
|
||
// other. Merged, and honest about what it covers.
|
||
const tight = (ms: number) => (ms / DAY) * 6;
|
||
const marks = startMarkers([at(5 * DAY), at(9 * DAY)], tight);
|
||
expect(marks).toHaveLength(1);
|
||
expect(marks[0]?.count).toBe(2);
|
||
expect(marks[0]?.ms).toBe(5 * DAY);
|
||
expect(marks[0]?.through).toBe(9 * DAY);
|
||
});
|
||
|
||
test("events already running are not starts", () => {
|
||
expect(
|
||
startMarkers([{ clock: { upcoming: false, startsMs: 5 * DAY } }], wide),
|
||
).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("markerLabel", () => {
|
||
const DAY = 86_400_000;
|
||
|
||
test("one event says it starts", () => {
|
||
expect(markerLabel({ ms: 5 * DAY, through: 5 * DAY, count: 1 })).toStartWith(
|
||
"starts ",
|
||
);
|
||
});
|
||
|
||
test("several on one day are counted", () => {
|
||
expect(markerLabel({ ms: 5 * DAY, through: 5 * DAY, count: 4 })).toStartWith(
|
||
"4 start ",
|
||
);
|
||
});
|
||
|
||
test("a merged mark names the span it covers, not just its first day", () => {
|
||
// Claiming one date for a mark that stands for eleven days is the kind of
|
||
// small confident wrongness this codebase exists to avoid.
|
||
const label = markerLabel({ ms: 5 * DAY, through: 9 * DAY, count: 2 });
|
||
expect(label).toContain("–");
|
||
});
|
||
});
|
||
|
||
describe("dailyGroups", () => {
|
||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||
const meta = (id: string) => metaFor(id, {});
|
||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||
|
||
const repeating = (id: string, game: string, title: string) =>
|
||
({
|
||
id,
|
||
game,
|
||
title,
|
||
type: "login",
|
||
summary: null,
|
||
startsAt: "2026-08-10T00:00:00.000Z",
|
||
startPrecision: "day",
|
||
endsAt: null,
|
||
endPrecision: "unknown",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.test",
|
||
}) as never;
|
||
|
||
test("a game's chore and its events are adjacent, chore first", () => {
|
||
// This is the whole point: the strip used to list every chore and then
|
||
// every event, so Genshin's commissions and Genshin's login event sat at
|
||
// opposite ends with a dozen games between them.
|
||
const groups = dailyGroups(
|
||
["genshin", "hsr"],
|
||
[repeating("e1", "genshin", "Login Bonus"), repeating("e2", "hsr", "Sign In")],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
);
|
||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([
|
||
["dailies:genshin", "e1"],
|
||
["dailies:hsr", "e2"],
|
||
]);
|
||
});
|
||
|
||
test("groups follow the order the games arrive in", () => {
|
||
// The reader's order, resolved upstream by `orderGames`.
|
||
const groups = dailyGroups(["hsr", "genshin"], [], NOW, "europe", meta, startOf);
|
||
expect(groups.map((g) => g.game)).toEqual(["hsr", "genshin"]);
|
||
});
|
||
|
||
test("events keep their given order inside a game", () => {
|
||
// Grouping is not a licence to re-sort within a group.
|
||
const groups = dailyGroups(
|
||
["genshin"],
|
||
[
|
||
repeating("late", "genshin", "Second"),
|
||
repeating("early", "genshin", "First"),
|
||
],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
);
|
||
expect(groups[0]?.items.map((i) => i.key)).toEqual([
|
||
"dailies:genshin",
|
||
"late",
|
||
"early",
|
||
]);
|
||
});
|
||
|
||
test("a lane the reader invented gets no standing chore", () => {
|
||
// There is no routine we could name on their behalf — but their repeating
|
||
// events still group under it.
|
||
const groups = dailyGroups(
|
||
["mygame:mine"],
|
||
[repeating("m1", "mygame:mine", "My Daily")],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
);
|
||
expect(groups[0]?.items.map((i) => i.key)).toEqual(["m1"]);
|
||
});
|
||
|
||
test("a lane with nothing to tick is dropped, not rendered as an empty heading", () => {
|
||
const groups = dailyGroups(["mygame:empty"], [], NOW, "europe", meta, startOf);
|
||
expect(groups).toEqual([]);
|
||
});
|
||
|
||
test("an event whose lane is not listed still gets a line", () => {
|
||
// Dropping it would quietly remove something tickable. It trails the lanes
|
||
// that were listed.
|
||
const groups = dailyGroups(
|
||
["genshin"],
|
||
[repeating("stray", "zzz", "Stray Daily")],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
);
|
||
expect(groups.map((g) => g.game)).toEqual(["genshin", "zzz"]);
|
||
});
|
||
|
||
test("a done event contributes nothing, because it never arrives here", () => {
|
||
// The strip is an instruction, not a record: App filters completed and
|
||
// ignored events out before this sees them (`outstanding` in lens.ts), so
|
||
// catch-up cannot resurrect a chip for something the reader finished.
|
||
const groups = dailyGroups(["genshin"], [], NOW, "europe", meta, startOf);
|
||
expect(groups[0]?.items.map((i) => i.key)).toEqual(["dailies:genshin"]);
|
||
});
|
||
|
||
test("a chore has no start to clip a catch-up strip at; an event does", () => {
|
||
const groups = dailyGroups(
|
||
["genshin"],
|
||
[repeating("e1", "genshin", "Login Bonus")],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
);
|
||
const [chore, event] = groups[0]?.items ?? [];
|
||
expect(chore?.notBefore).toBeNull();
|
||
expect(event?.notBefore).toBe(Date.parse("2026-08-10T00:00:00.000Z"));
|
||
});
|
||
|
||
test("each item carries its own game's reset clock", () => {
|
||
// Endfield serves Europe off the Americas machine, so its day rolls at
|
||
// 09:00 UTC. A section-wide "today" would tick the wrong box for hours.
|
||
const dawn = Date.parse("2026-08-17T05:00:00.000Z");
|
||
const groups = dailyGroups(
|
||
["genshin", "endfield"],
|
||
[],
|
||
dawn,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
);
|
||
const today = groups.map((g) => g.items[0]?.today);
|
||
expect(today[0]).not.toBe(today[1]);
|
||
});
|
||
});
|
||
|
||
describe("CatchUpPanel", () => {
|
||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||
const meta = (id: string) => metaFor(id, {});
|
||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||
|
||
const groups = () =>
|
||
dailyGroups(["genshin"], [], NOW, "europe", meta, startOf);
|
||
|
||
const panel = (logged: string[] = []) =>
|
||
render(
|
||
<CatchUpPanel
|
||
groups={groups()}
|
||
now={NOW}
|
||
region="europe"
|
||
daysFor={() => logged}
|
||
onToggleDay={() => {}}
|
||
/>,
|
||
);
|
||
|
||
test("a fortnight of days, each one tickable", () => {
|
||
const markup = panel();
|
||
const pips = [...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)];
|
||
expect(pips).toHaveLength(14);
|
||
});
|
||
|
||
test("no day later than today, because nobody can have done tomorrow", () => {
|
||
// The pip for a future day would be a control for a claim that cannot be
|
||
// true. It is absent rather than present-and-disabled.
|
||
expect(panel()).not.toContain('disabled=""');
|
||
});
|
||
|
||
test("a logged day reads as done, an unlogged one does not", () => {
|
||
const markup = panel([dayKey(NOW, "europe", "genshin")]);
|
||
expect(markup).toContain(", done\"");
|
||
expect(markup).toContain(", not done\"");
|
||
});
|
||
|
||
test("names the game, so an expanded strip says whose days these are", () => {
|
||
expect(panel()).toContain("Genshin Impact");
|
||
});
|
||
|
||
test("an undated event's strip starts at the event, not a fortnight ago", () => {
|
||
const started = NOW - 2 * 86_400_000;
|
||
const withEvent = dailyGroups(
|
||
["genshin"],
|
||
[
|
||
{
|
||
id: "e1",
|
||
game: "genshin",
|
||
title: "Login Bonus",
|
||
type: "login",
|
||
summary: null,
|
||
startsAt: new Date(started).toISOString(),
|
||
startPrecision: "day",
|
||
endsAt: null,
|
||
endPrecision: "unknown",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.test",
|
||
} as never,
|
||
],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
);
|
||
const markup = render(
|
||
<CatchUpPanel
|
||
groups={withEvent}
|
||
now={NOW}
|
||
region="europe"
|
||
daysFor={() => []}
|
||
onToggleDay={() => {}}
|
||
/>,
|
||
);
|
||
// The chore's fourteen, plus three for an event that opened two days ago.
|
||
expect([...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)]).toHaveLength(17);
|
||
});
|
||
});
|
||
|
||
describe("boardWindow is not widened by expansion", () => {
|
||
test("a rule's occurrences cannot enlarge the board that generated them", () => {
|
||
// The circularity guard. boardWindow takes max from the ends it is given,
|
||
// so if expanded occurrences were fed back into it, each pass would widen
|
||
// the window, generate more occurrences and widen it again — a rule with
|
||
// until: null would never terminate. The fix is ordering: settle the window
|
||
// from the base rows, THEN expand into it. This test pins the ordering by
|
||
// asserting the window is a function of the base rows alone.
|
||
const now = Date.parse("2026-09-03T12:00:00.000Z");
|
||
const starts = [Date.parse("2026-09-01T00:00:00.000Z")];
|
||
const ends = [Date.parse("2026-09-08T00:00:00.000Z")];
|
||
|
||
const base = boardWindow(starts, ends, now);
|
||
|
||
// A year of weekly occurrences, as `expand` would return them.
|
||
const expandedEnds = Array.from({ length: 52 }, (_, i) =>
|
||
Date.parse("2026-09-08T00:00:00.000Z") + i * 7 * 24 * 60 * 60 * 1000,
|
||
);
|
||
const ifItLeaked = boardWindow(starts, [...ends, ...expandedEnds], now);
|
||
|
||
expect(base.max).toBeLessThan(ifItLeaked.max);
|
||
// Which is exactly why Timeline must compute starts/ends from `plotted`
|
||
// before calling expand — asserted structurally in the component below.
|
||
});
|
||
|
||
test("Timeline itself never widens the board it hands to expand", () => {
|
||
// The unit test above only pins `boardWindow`'s own purity; it never
|
||
// renders `Timeline`, so nothing here catches the ordering itself moving —
|
||
// `expand` called before `boardWindow` settles its window leaves the
|
||
// suite green otherwise. This renders the component and compares the
|
||
// actual chart width against a year of occurrences `expand` hands back,
|
||
// against the same board with no `expand` at all.
|
||
const rows = [row("Closing Ceremony", "genshin", 100)];
|
||
const chartWidthOf = (html: string) =>
|
||
Number(/style="width:([\d.]+)px;min-width:100%"/.exec(html)?.[1]);
|
||
|
||
const withoutExpand = render(
|
||
<Timeline
|
||
rows={rows}
|
||
now={NOW}
|
||
dayWidth={13}
|
||
onZoom={() => {}}
|
||
group="game"
|
||
onGroup={() => {}}
|
||
showUpcoming
|
||
splitUpcoming
|
||
onOpen={() => {}}
|
||
isDone={() => false}
|
||
/>,
|
||
);
|
||
|
||
// A year of weekly occurrences, exactly the shape a bare rotation with no
|
||
// `until` would hand back — if these reached `boardWindow` the chart
|
||
// would grow to fit them.
|
||
const yearOfOccurrences = Array.from({ length: 52 }, (_, i) =>
|
||
row(`Rerun ${i}`, "zzz", 100 + i * 7 * 24),
|
||
);
|
||
const withExpand = render(
|
||
<Timeline
|
||
rows={rows}
|
||
now={NOW}
|
||
dayWidth={13}
|
||
onZoom={() => {}}
|
||
group="game"
|
||
onGroup={() => {}}
|
||
showUpcoming
|
||
splitUpcoming
|
||
onOpen={() => {}}
|
||
isDone={() => false}
|
||
expand={() => yearOfOccurrences}
|
||
/>,
|
||
);
|
||
|
||
expect(chartWidthOf(withExpand)).toBe(chartWidthOf(withoutExpand));
|
||
});
|
||
});
|
||
|
||
describe("dailyGroups with the chores switched off", () => {
|
||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||
const meta = (id: string) => metaFor(id, {});
|
||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||
const repeating = (id: string, game: string, title: string) =>
|
||
({
|
||
id,
|
||
game,
|
||
title,
|
||
type: "login",
|
||
summary: null,
|
||
startsAt: "2026-08-10T00:00:00.000Z",
|
||
startPrecision: "day",
|
||
endsAt: null,
|
||
endPrecision: "unknown",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.test",
|
||
}) as never;
|
||
|
||
test("the invented chore goes and the events stay", () => {
|
||
// Only the fixed per-game list the app makes up goes. Anything with a
|
||
// checklist is something the reader engaged with, and stays.
|
||
const groups = dailyGroups(
|
||
["genshin"],
|
||
[repeating("e1", "genshin", "Login Bonus")],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
false,
|
||
);
|
||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([["e1"]]);
|
||
});
|
||
|
||
test("an event the reader added themselves is untouched", () => {
|
||
// Stated explicitly because it is the requirement most at risk of being
|
||
// filtered away by a switch aimed at something else.
|
||
const groups = dailyGroups(
|
||
["genshin"],
|
||
[repeating("myevent:k3f9qa2m01", "genshin", "My own grind")],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
false,
|
||
);
|
||
expect(groups[0]?.items.map((i) => i.key)).toEqual(["myevent:k3f9qa2m01"]);
|
||
});
|
||
|
||
test("with nothing else to show, a game contributes no group at all", () => {
|
||
// Not an empty group with a heading and no rows — the strip's own
|
||
// `total === 0` guard then drops it entirely, which is what a reader who
|
||
// turned this off is asking for.
|
||
const groups = dailyGroups(["genshin", "hsr"], [], NOW, "europe", meta, startOf, false);
|
||
expect(groups).toEqual([]);
|
||
});
|
||
|
||
test("left on, nothing about the strip moves", () => {
|
||
const groups = dailyGroups(
|
||
["genshin"],
|
||
[repeating("e1", "genshin", "Login Bonus")],
|
||
NOW,
|
||
"europe",
|
||
meta,
|
||
startOf,
|
||
true,
|
||
);
|
||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([
|
||
["dailies:genshin", "e1"],
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("dailyGroups with the chores switched per game", () => {
|
||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||
const meta = (id: string) => metaFor(id, {});
|
||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||
|
||
test("a game the rule hides loses its chore, and its neighbour keeps one", () => {
|
||
const groups = dailyGroups(["genshin", "hsr"], [], NOW, "europe", meta, startOf, (lane) => lane === "genshin");
|
||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([["dailies:genshin"]]);
|
||
});
|
||
});
|
||
|
||
describe("the chores toggle reaches the screen", () => {
|
||
// `dailyGroups` is pure and well covered, but nothing tied `prefs.showChores`
|
||
// to what renders. Review proved four plausible breaks shipped green —
|
||
// including App passing the wrong pref, and Dailies hardcoding `true` — so
|
||
// these assert the rendered strip rather than the function behind it.
|
||
const NOW = Date.parse("2026-08-17T12:00:00.000Z");
|
||
const repeating = {
|
||
id: "e1",
|
||
game: "genshin",
|
||
title: "Lantern Rite login",
|
||
type: "login",
|
||
summary: null,
|
||
startsAt: "2026-08-10T00:00:00.000Z",
|
||
startPrecision: "day",
|
||
endsAt: null,
|
||
endPrecision: "unknown",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.test",
|
||
} as never;
|
||
|
||
const strip = (showChores: boolean) =>
|
||
render(
|
||
<Dailies
|
||
games={["genshin"]}
|
||
events={[repeating]}
|
||
region="europe"
|
||
now={NOW}
|
||
showChores={showChores}
|
||
daysFor={() => []}
|
||
onToggleDay={() => {}}
|
||
/>,
|
||
);
|
||
|
||
test("on, the invented chore is on screen", () => {
|
||
const html = strip(true);
|
||
expect(html).toContain("Commissions, resin");
|
||
expect(html).toContain("Lantern Rite login");
|
||
});
|
||
|
||
test("off, the chore is gone and the event and its count are not", () => {
|
||
// The count is asserted too: it derives from the items, so a chore hidden
|
||
// from view but still counted would leave the reader chasing a tick that
|
||
// is not there.
|
||
const html = strip(false);
|
||
expect(html).not.toContain("Commissions, resin");
|
||
expect(html).toContain("Lantern Rite login");
|
||
expect(html).toContain("0/1");
|
||
});
|
||
});
|
||
|
||
describe("goals in the dailies strip", () => {
|
||
// A goal lets an event want five days of seven. A met week completes the
|
||
// item without a tick being invented, and the chip says which it is.
|
||
const NOW = Date.parse("2026-10-15T12:00:00.000Z");
|
||
const meta = (id: string) => metaFor(id, {});
|
||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||
const ev = {
|
||
id: "g1",
|
||
game: "genshin",
|
||
title: "To Temper Thyself",
|
||
type: "other",
|
||
summary: null,
|
||
startsAt: "2026-08-10T00:00:00.000Z",
|
||
startPrecision: "day",
|
||
endsAt: "2026-11-02T00:00:00.000Z",
|
||
endPrecision: "day",
|
||
regionScoped: false,
|
||
regionEnds: null,
|
||
sourceUrl: "https://example.test",
|
||
} as never;
|
||
const status = (over: Partial<GoalStatus> = {}): GoalStatus => ({
|
||
scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 3,
|
||
met: false, needed: 2, open: 4, shortfall: false, ...over,
|
||
});
|
||
const strip = (goal: GoalStatus) =>
|
||
render(
|
||
<Dailies
|
||
games={["genshin"]}
|
||
events={[ev]}
|
||
region="europe"
|
||
now={NOW}
|
||
showChores={false}
|
||
daysFor={() => []}
|
||
onToggleDay={() => {}}
|
||
goalFor={() => goal}
|
||
/>,
|
||
);
|
||
|
||
test("an event's item carries its goal status", () => {
|
||
const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, (id) =>
|
||
id === "g1" ? status() : undefined,
|
||
);
|
||
expect(group!.items[0]!.goal).toEqual(status());
|
||
});
|
||
|
||
test("a met goal completes the item without counting as a tick", () => {
|
||
const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, () =>
|
||
status({ count: 5, met: true, needed: 0 }),
|
||
);
|
||
expect(dailyTally(group!.items, () => [])).toMatchObject({
|
||
total: 1,
|
||
complete: 1,
|
||
tickedToday: 0,
|
||
});
|
||
});
|
||
|
||
test("labels separate done today, done this week and goal reached", () => {
|
||
expect(chipLabel("X", true, status())).toBe("X, done today, 3 of 5 this week");
|
||
expect(chipLabel("X", false, status())).toBe("X, not done today, 3 of 5 this week");
|
||
expect(chipLabel("X", false, status({ met: true }))).toBe("X, done this week");
|
||
expect(chipLabel("X", false, status({ scope: "event", met: true }))).toBe(
|
||
"X, goal reached",
|
||
);
|
||
expect(chipLabel("X", true, status({ met: true }))).toBe("X, done today, done this week");
|
||
expect(chipLabel("X", false, undefined)).toBe("X, not done today");
|
||
});
|
||
|
||
test("the chip shows the count, and an unmet week is still waiting", () => {
|
||
const html = strip(status());
|
||
expect(html).toContain("3/5 wk");
|
||
expect(html).toMatch(/dailies · (<!-- -->)?0(<!-- -->)?\/(<!-- -->)?1/);
|
||
});
|
||
|
||
test("a met week counts as complete in the header", () => {
|
||
expect(strip(status({ count: 5, met: true, needed: 0 }))).toMatch(
|
||
/dailies · (<!-- -->)?1(<!-- -->)?\/(<!-- -->)?1/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("GoalControl", () => {
|
||
test("off shows the three choices with Off pressed", () => {
|
||
const html = render(
|
||
<GoalControl goal={undefined} defaultWeekStartsOn={1} maxDays={84} onChange={() => {}} />,
|
||
);
|
||
expect(html).toContain("Check-in goal");
|
||
expect(html).toMatch(/aria-pressed="true"[^>]*>Off</);
|
||
expect(html).toContain("Each week");
|
||
expect(html).toContain("Across the event");
|
||
expect(html).not.toContain('type="number"');
|
||
});
|
||
|
||
test("a weekly goal shows its count and start day", () => {
|
||
const html = render(
|
||
<GoalControl
|
||
goal={{ period: "week", measure: "days", required: 5, weekStartsOn: 1 }}
|
||
defaultWeekStartsOn={1}
|
||
maxDays={84}
|
||
onChange={() => {}}
|
||
/>,
|
||
);
|
||
expect(html).toContain('value="5"');
|
||
expect(html).toMatch(/<option value="1" selected="">Monday<\/option>/);
|
||
});
|
||
|
||
test("an event-wide goal has no weekday", () => {
|
||
const html = render(
|
||
<GoalControl
|
||
goal={{ period: "event", measure: "days", required: 5 }}
|
||
defaultWeekStartsOn={1}
|
||
maxDays={7}
|
||
onChange={() => {}}
|
||
/>,
|
||
);
|
||
expect(html).toContain("days across the event");
|
||
expect(html).not.toContain("<select");
|
||
});
|
||
});
|
||
|
||
describe("DailyChecklist with a goal", () => {
|
||
test("the caption reports the goal, not missed days", () => {
|
||
const html = render(
|
||
<DailyChecklist
|
||
startsMs={Date.parse("2026-10-12T03:00:00Z")}
|
||
endsMs={Date.parse("2026-11-02T03:00:00Z")}
|
||
region="europe"
|
||
game="genshin"
|
||
now={Date.parse("2026-10-15T12:00:00Z")}
|
||
logged={["2026-10-12"]}
|
||
onToggleDay={() => {}}
|
||
goal={{
|
||
scope: "week", measure: "days", periodStart: "2026-10-12", required: 5, count: 1,
|
||
met: false, needed: 4, open: 4, shortfall: false,
|
||
}}
|
||
/>,
|
||
);
|
||
expect(html).toContain("1 of 5 this week · 4 needed · 4 days left this week");
|
||
expect(html).not.toContain("missed");
|
||
});
|
||
});
|
||
|
||
describe("GoalControl decisions", () => {
|
||
const weekly = { period: "week", measure: "days", required: 5, weekStartsOn: 1 } as const;
|
||
|
||
test("pressing Off when no goal is readable changes nothing", () => {
|
||
// An unreadable stored goal reads as none, so Off already shows pressed;
|
||
// pressing it must not delete what is stored.
|
||
expect(chooseGoal("off", undefined, 1, 84)).toBeNull();
|
||
expect(chooseGoal("off", weekly, 1, 84)).toBeUndefined();
|
||
});
|
||
|
||
test("pressing the mode already chosen changes nothing", () => {
|
||
expect(chooseGoal("week", weekly, 1, 84)).toBeNull();
|
||
});
|
||
|
||
test("switching modes keeps the count, clamped", () => {
|
||
expect(chooseGoal("event", weekly, 1, 3)).toEqual({
|
||
period: "event", measure: "days", required: 3,
|
||
});
|
||
expect(chooseGoal("week", undefined, 4, 84)).toEqual({
|
||
period: "week", measure: "days", required: 5, weekStartsOn: 4,
|
||
});
|
||
});
|
||
|
||
test("an emptied or unreadable field is mid-edit, not an answer", () => {
|
||
expect(parseDraft("", "week", null)).toBeNull();
|
||
expect(parseDraft("abc", "week", null)).toBeNull();
|
||
expect(parseDraft("3", "week", null)).toBe(3);
|
||
expect(parseDraft("53", "week", null)).toBe(7);
|
||
});
|
||
});
|
||
|
||
describe("weekly and monthly tasks in the dailies strip", () => {
|
||
const NOW = Date.parse("2026-10-15T12:00:00.000Z");
|
||
const meta = (id: string) => metaFor(id, {});
|
||
const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
|
||
const AT = "2026-10-01T00:00:00.000Z";
|
||
const points: Task = {
|
||
id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points",
|
||
period: { unit: "week", startsOn: 1 },
|
||
measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT,
|
||
};
|
||
const monthly: Task = {
|
||
id: "mytask:bbbbbb2222", game: "zzz", title: "Shop reset",
|
||
period: { unit: "month", startsOn: 1 }, measure: { kind: "days", required: 1 },
|
||
at: AT, updatedAt: AT,
|
||
};
|
||
const hidden: Task = { ...monthly, id: "mytask:cccccc3333", game: "hsr", title: "Hidden" };
|
||
const amountStatus: GoalStatus = {
|
||
scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 1900,
|
||
met: false, needed: 200, open: 4, shortfall: false,
|
||
};
|
||
const monthStatus: GoalStatus = {
|
||
scope: "month", measure: "days", periodStart: "2026-10-01", required: 1, count: 1,
|
||
met: true, needed: 0, open: 16, shortfall: false,
|
||
};
|
||
const statusOf = (t: Pick<Task, "measure">) =>
|
||
t.measure.kind === "amount" ? amountStatus : monthStatus;
|
||
const ev = {
|
||
id: "z1", game: "zzz", title: "Login", type: "login", summary: null,
|
||
startsAt: "2026-10-01T00:00:00.000Z", startPrecision: "day", endsAt: null,
|
||
endPrecision: "unknown", regionScoped: false, regionEnds: null, sourceUrl: "https://example.test",
|
||
} as never;
|
||
|
||
test("a game's tasks follow its chore and events, and a hidden game's stay hidden", () => {
|
||
const groups = dailyGroups(["zzz"], [ev], NOW, "europe", meta, startOf, true, undefined,
|
||
[points, monthly, hidden], statusOf);
|
||
expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([
|
||
["dailies:zzz", "z1", points.id, monthly.id],
|
||
]);
|
||
expect(groups[0]!.items.map((i) => i.cadence)).toEqual(["day", "day", "week", "month"]);
|
||
});
|
||
|
||
test("tasks are counted apart from today's dailies", () => {
|
||
const [group] = dailyGroups(["zzz"], [], NOW, "europe", meta, startOf, true, undefined,
|
||
[points, monthly], statusOf);
|
||
expect(dailyTally(group!.items, () => [])).toEqual({
|
||
total: 1, complete: 0, tickedToday: 0,
|
||
week: { total: 1, complete: 0 }, month: { total: 1, complete: 1 },
|
||
});
|
||
});
|
||
|
||
test("the header shows the period counts, and an amount chip is not a toggle", () => {
|
||
const html = render(
|
||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores
|
||
daysFor={() => []} onToggleDay={() => {}} tasks={[points, monthly]}
|
||
taskStatusFor={statusOf} onSetAmount={() => {}} amountFor={() => undefined} />,
|
||
);
|
||
expect(html).toMatch(/Weekly (<!-- -->)?0(<!-- -->)?\/(<!-- -->)?1/);
|
||
expect(html).toMatch(/Monthly (<!-- -->)?1(<!-- -->)?\/(<!-- -->)?1/);
|
||
expect(html).toContain("1,900/2,100 wk");
|
||
expect(html).toMatch(/aria-label="Activity points \(Zenless Zone Zero\), 1,900 of 2,100 this week"/);
|
||
const chip = html.match(/<button[^>]*Activity points \(Zenless[^>]*>/)![0];
|
||
expect(chip).not.toContain("aria-pressed");
|
||
expect(chip).toContain('aria-expanded="false"');
|
||
});
|
||
|
||
test("a strip of only tasks still renders", () => {
|
||
const html = render(
|
||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
|
||
daysFor={() => []} onToggleDay={() => {}} tasks={[points]} taskStatusFor={statusOf}
|
||
onSetAmount={() => {}} amountFor={() => undefined} />,
|
||
);
|
||
expect(html).toContain("Activity points");
|
||
});
|
||
|
||
test("labels for amounts and months", () => {
|
||
expect(chipLabel("X", false, amountStatus)).toBe("X, 1,900 of 2,100 this week");
|
||
expect(chipLabel("X", false, monthStatus)).toBe("X, done this month");
|
||
});
|
||
});
|
||
|
||
describe("AmountEditor", () => {
|
||
test("shows the value against the target", () => {
|
||
const html = render(
|
||
<AmountEditor label="Activity points this week" value={1900} target={2100} unit="points"
|
||
onSave={() => {}} onCancel={() => {}} />,
|
||
);
|
||
expect(html).toContain('value="1900"');
|
||
expect(html).toContain("/ 2,100 points");
|
||
expect(html).toContain("Save");
|
||
});
|
||
|
||
test("amounts are whole, non-negative numbers; anything else is mid-edit", () => {
|
||
expect(parseAmount("1900")).toBe(1900);
|
||
expect(parseAmount("1,900")).toBe(1900);
|
||
expect(parseAmount(" 2 100 ")).toBe(2100);
|
||
expect(parseAmount("1900.6")).toBe(1901);
|
||
expect(parseAmount("")).toBeNull();
|
||
expect(parseAmount("abc")).toBeNull();
|
||
expect(parseAmount("-5")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("GoalControl with points", () => {
|
||
const points = { period: "week", measure: "amount", target: 2100, weekStartsOn: 3 } as const;
|
||
|
||
test("switching period keeps the measure and the number", () => {
|
||
expect(chooseGoal("event", points, 1, 84)).toEqual({
|
||
period: "event", measure: "amount", target: 2100,
|
||
});
|
||
expect(chooseGoal("week", { period: "event", measure: "amount", target: 50 }, 2, 84)).toEqual({
|
||
period: "week", measure: "amount", target: 50, weekStartsOn: 2,
|
||
});
|
||
});
|
||
|
||
test("switching measure keeps the period", () => {
|
||
const days = { period: "week", measure: "days", required: 5, weekStartsOn: 3 } as const;
|
||
expect(chooseMeasure("amount", days, 84)).toEqual({ ...points, target: 100 });
|
||
expect(chooseMeasure("days", points, 84)).toEqual({ ...days, required: 5 });
|
||
expect(chooseMeasure("days", days, 84)).toBeNull();
|
||
});
|
||
|
||
test("a points goal shows its target and the measure toggle", () => {
|
||
const html = render(
|
||
<GoalControl goal={points} defaultWeekStartsOn={1} maxDays={84} onChange={() => {}} />,
|
||
);
|
||
expect(html).toMatch(/aria-pressed="true"[^>]*>Points</);
|
||
expect(html).toContain('value="2100"');
|
||
expect(html).toContain("points each week, weeks start");
|
||
});
|
||
});
|
||
|
||
describe("review fixes: an event's points goal is not a daily", () => {
|
||
test("it is counted with the week, not as waiting today", () => {
|
||
const NOW = Date.parse("2026-10-15T12:00:00.000Z");
|
||
const ev = {
|
||
id: "p1", game: "zzz", title: "Points event", type: "other", summary: null,
|
||
startsAt: "2026-10-01T00:00:00.000Z", startPrecision: "day", endsAt: null,
|
||
endPrecision: "unknown", regionScoped: false, regionEnds: null,
|
||
sourceUrl: "https://example.test",
|
||
} as never;
|
||
const week: GoalStatus = {
|
||
scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 100,
|
||
met: false, needed: 2000, open: 4, shortfall: false,
|
||
};
|
||
const total: GoalStatus = { ...week, scope: "event", periodStart: "2026-10-01" };
|
||
const tally = (goal: GoalStatus) => {
|
||
const [group] = dailyGroups(["zzz"], [ev], NOW, "europe", (id) => metaFor(id, {}),
|
||
(e) => Date.parse(e.startsAt), false, () => goal);
|
||
return dailyTally(group!.items, () => []);
|
||
};
|
||
expect(tally(week)).toMatchObject({ total: 0, week: { total: 1, complete: 0 } });
|
||
expect(tally(total)).toMatchObject({ total: 0, week: { total: 0 }, month: { total: 0 } });
|
||
});
|
||
});
|
||
|
||
describe("built-in weekly and monthly tasks in the strip", () => {
|
||
const NOW = Date.parse("2026-10-15T12:00:00.000Z");
|
||
const ridu = routinesFor("zzz")[0]!;
|
||
const status: GoalStatus = {
|
||
scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 0,
|
||
met: false, needed: 2100, open: 4, shortfall: false,
|
||
};
|
||
|
||
test("a routine draws like a task, and its tooltip says its source date", () => {
|
||
const [group] = dailyGroups(["zzz"], [], NOW, "europe", (id) => metaFor(id, {}),
|
||
(e) => Date.parse(e.startsAt), false, undefined, [ridu], () => status);
|
||
expect(group!.items[0]!.key).toBe("routine:zzz:ridu-weekly");
|
||
expect(group!.items[0]!.title).toContain("as of 31 Aug 2026");
|
||
});
|
||
|
||
test("the question shows on its own, and names the game", () => {
|
||
const html = render(
|
||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
|
||
daysFor={() => []} onToggleDay={() => {}} ask={{ game: "zzz", onAnswer: () => {} }} />,
|
||
);
|
||
expect(html).toContain("Track Zenless Zone Zero");
|
||
expect(html).toContain("weekly and monthly tasks?");
|
||
expect(html).toContain("No thanks");
|
||
// Heard out of context, "Yes" has to say what it agrees to.
|
||
expect(html).toContain(`aria-label="Yes, track Zenless Zone Zero's built-in weekly and monthly tasks"`.replace("'", "'"));
|
||
expect(html).toContain(`aria-label="No thanks to Zenless Zone Zero's built-in weekly and monthly tasks"`.replace("'", "'"));
|
||
});
|
||
|
||
test("with nothing to show and nothing to ask, the strip is absent", () => {
|
||
const html = render(
|
||
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
|
||
daysFor={() => []} onToggleDay={() => {}} />,
|
||
);
|
||
expect(html).toBe("");
|
||
});
|
||
});
|