Files
gacha-event-tracker/test/views.test.tsx
T
Lucas WintherandClaude Opus 5 7a3588fbf6 feat(timeline): hold back unstarted events, and say where they begin
Two halves of one reading. The board answers "how does the time I am in
lay out?", and plotting every lane's next patch stretched the window
weeks past today and squeezed the running bars down to nothing — so it
holds them back, and holds them back itself rather than being handed a
shorter list, because the control has to say *how many*. A board that
quietly withheld a third of the schedule is indistinguishable from a
quiet fortnight, which is the wrong thing for this app to imply by
accident. A board with nothing left running says so and points at the
control instead of reading as an empty calendar.

Switched on, a bar drawn right of the now rule is only implicitly in the
future, and implicitly is not a standard we hold anywhere else a date is
involved. Gacha schedules are not a smooth stream of starts either — a
patch ships six things at once — so the unit is the clump: a dashed rule
per group of starts, labelled in words. Clumps merge by distance on
screen rather than by calendar, since a week apart is one mark at six
pixels a day and two at a hundred and eight, and a merged one states the
span it covers rather than claiming a date the board is not ruling at.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 05:31:01 +02:00

448 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { NextUp } from "../src/client/components/NextUp.tsx";
import {
boardWindow,
markerLabel,
startMarkers,
Timeline,
} from "../src/client/components/Timeline.tsx";
import { Welcome } from "../src/client/components/Welcome.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 { 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"');
});
});
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("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}
onShowUpcoming={() => {}}
onOpen={() => {}}
isDone={() => false}
/>,
);
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: 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) =>
render(
<Timeline
rows={all}
now={NOW}
dayWidth={32}
onZoom={() => {}}
group="ending"
onGroup={() => {}}
showUpcoming={showUpcoming}
onShowUpcoming={() => {}}
onOpen={() => {}}
isDone={() => false}
/>,
);
test("the board holds them back by default and says how many", () => {
// Off is the default because the board answers "how does the time I am in
// lay out?" — but an absence nobody mentioned is indistinguishable from a
// quiet fortnight, which is the wrong thing for this app to imply.
const html = board(false);
expect(html).toContain("Closing Ceremony");
expect(html).not.toContain("Frost Parade");
expect(html).not.toContain("Long Way Round");
expect(html).toContain("Not started");
expect(html).toContain(">3<");
});
test("switching it on plots them", () => {
const html = board(true);
expect(html).toContain("Frost Parade");
expect(html).toContain("Long Way Round");
});
test("the control says which way it is set", () => {
expect(board(false)).toContain('aria-pressed="false"');
expect(board(true)).toContain('aria-pressed="true"');
});
test("nothing waiting and switched off means no control at all", () => {
// A toggle that cannot change anything invites a tap that does nothing.
expect(board(false, [row("Closing Ceremony", "genshin", 100)])).not.toContain(
"Not started",
);
});
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 the reason is a control they did not notice.
const html = board(false, rows.slice(1));
expect(html).toContain("Nothing is running right now");
expect(html).toContain("3 events have not started yet");
// The control is still there to act on what the sentence just told them.
expect(html).toContain("Not started");
});
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");
});
});
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("");
});
});