Clamp an expanded bar to the board, and count it in the start markers

Two related ways an occurrence that only arrived through Timeline's expand
prop could disagree with the board it was drawn onto:

- `right` was computed but never clamped to chartWidth, while `left` was
  clamped to 0. boardWindow's max is derived from `plotted` alone, so a base
  row can never exceed it — but occurrencesOf admits any occurrence starting
  at or before the window's edge, and one with no stated end then runs a full
  interval past it. Inside overflow-auto that grows the pane's scrollWidth,
  so the reader scrolls into empty space with no gridlines or axis. The spec
  says a rule may fill the board but must never enlarge it; `right` is now
  clamped the same way `left` already was.

- startMarkers read from `plotted` rather than `drawn`, so a "3 events start
  <day>" label under-counted whatever expand had added to that day. Pointed
  at `drawn`.

The existing "boardWindow is not widened by expansion" test only pinned
boardWindow's own purity and never rendered Timeline, despite its comment
claiming the ordering was "asserted structurally in the component below" —
no such assertion existed, and moving the expand call above boardWindow left
the suite green. Added a render-level regression test that compares the
rendered chart width with and without an expand returning far-future
occurrences; confirmed it fails if expand is called before boardWindow.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:04:39 +02:00
co-authored by Claude Opus 5
parent b0523a7cb9
commit d1ffac4b9f
2 changed files with 132 additions and 2 deletions
+106
View File
@@ -448,6 +448,61 @@ describe("Timeline: expand", () => {
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", () => {
@@ -907,4 +962,55 @@ describe("boardWindow is not widened by expansion", () => {
// 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));
});
});