import { resolve } from "node:path";
import { fireEvent, render } from "@testing-library/preact";
import { describe, expect, it, vi } from "vitest";
import { buildMonthGrid, CalendarGrid } from "./CalendarGrid.js";
// GEOMETRY -- that every well is the same size, and that a well past its
// capacity does not grow its row -- belongs to the layout tier in real
// Chromium. jsdom does not lay out. What this file covers is the date
// arithmetic, the ARIA grid contract, and 10.6's counted remainder.
describe("buildMonthGrid", () => {
// August 2026 starts on a Saturday, so a Sunday-start month needs six
// leading days and spills into September.
it("pads the first week with the previous month's days", () => {
const weeks = buildMonthGrid(2026, 7);
expect(weeks[0]).toHaveLength(7);
expect(weeks[0][0].adjacent).toBe(true);
expect(weeks[0][6].day).toBe(1);
expect(weeks[0][6].adjacent).toBe(false);
});
it("every row is exactly seven days", () => {
// The invariant the whole production rests on: a week has seven days
// at every viewport and in every month.
for (const [y, m] of [[2026, 0], [2026, 1], [2026, 7], [2027, 11]]) {
for (const week of buildMonthGrid(y, m)) expect(week).toHaveLength(7);
}
});
// The case a naive implementation gets wrong: a month already starting on
// the week start must NOT gain a blank leading row.
it("adds no leading week when the month starts on the week start", () => {
// February 2026 starts on a Sunday.
const weeks = buildMonthGrid(2026, 1, 0);
expect(weeks[0][0].day).toBe(1);
expect(weeks[0][0].adjacent).toBe(false);
});
it("honours a Monday week start", () => {
const weeks = buildMonthGrid(2026, 1, 1);
// With Monday first, a Sunday-starting month gains six leading days.
expect(weeks[0][0].adjacent).toBe(true);
expect(weeks[0][6].day).toBe(1);
});
it("gives a six-row month six rows and a five-row month five", () => {
// A fixed six rows would append a whole trailing week to most months;
// a fixed five would truncate the ones that genuinely need six.
expect(buildMonthGrid(2026, 7).length).toBe(6);
expect(buildMonthGrid(2026, 1).length).toBe(4);
});
// A property sweep rather than a handful of examples. The row-count bug
// this caught -- February 2026 is 28 days starting on the week start, so
// it fills exactly four weeks and the grid grew a fifth row of March --
// was invisible to every example test that happened to pick a month
// ending mid-week, which is eleven months in twelve.
it("holds its invariants for every month across seven years, both week starts", () => {
let checked = 0;
for (let y = 2024; y <= 2030; y++) {
for (let m = 0; m < 12; m++) {
for (const ws of [0, 1] as const) {
const weeks = buildMonthGrid(y, m, ws);
const days = weeks.flat();
const own = days.filter((d) => !d.adjacent);
const daysInMonth = new Date(y, m + 1, 0).getDate();
// Every row is a full week.
expect(days).toHaveLength(weeks.length * 7);
// Every day of the month appears exactly once, in order, and no
// day of the month is ever marked adjacent.
expect(own).toHaveLength(daysInMonth);
expect(own.map((d) => d.day)).toEqual(
Array.from({ length: daysInMonth }, (_, i) => i + 1),
);
// A month needs at least four rows and never more than six.
expect(weeks.length).toBeGreaterThanOrEqual(4);
expect(weeks.length).toBeLessThanOrEqual(6);
// Every row starts on the configured week start.
for (const week of weeks) expect(week[0].date.getDay()).toBe(ws);
checked++;
}
}
}
// An empty sweep reporting success is the failure mode this guards
// against in others.
expect(checked).toBe(7 * 12 * 2);
});
// ── fixedWeeks: the grid must not resize the page when the month turns ──
//
// Measured on a consumer: paging from a six-row month to a five-row month
// resizes the region by ~112px and moves everything beneath it. That is
// 6.6.1's space contract and 10.6's argument one level up -- 10.6 says a
// bounded cell must not grow its row, this says the grid must not grow
// its page.
it("pads every month to the same row count when asked", () => {
// The three shapes a month can take: six rows, five, and four.
for (const [y, m] of [[2026, 7], [2026, 8], [2026, 1]]) {
expect(buildMonthGrid(y, m, 0, 6)).toHaveLength(6);
}
});
it("pads from the adjacent months rather than emitting a blank band", () => {
// The grid already renders and already marks adjacent days, so a padded
// row is the same context the first and last rows always carry.
const weeks = buildMonthGrid(2026, 1, 0, 6);
const trailing = weeks[5];
expect(trailing).toHaveLength(7);
expect(trailing.every((d) => d.adjacent)).toBe(true);
expect(trailing[0].date.getMonth()).toBe(2);
});
it("still contains every day of the month exactly once when padded", () => {
// The padding must not cost or duplicate a real day.
const own = buildMonthGrid(2026, 1, 0, 6).flat().filter((d) => !d.adjacent);
expect(own.map((d) => d.day)).toEqual(
Array.from({ length: 28 }, (_, i) => i + 1),
);
});
it("keeps the variable row count when fixedWeeks is unset", () => {
// The old behaviour is the default, so this is additive for consumers
// that were relying on a month-sized grid.
expect(buildMonthGrid(2026, 1)).toHaveLength(4);
expect(buildMonthGrid(2026, 7)).toHaveLength(6);
});
it("never truncates a month at six, which is the most any month needs", () => {
let maxRows = 0;
for (let y = 2024; y <= 2030; y++) {
for (let m = 0; m < 12; m++) {
for (const ws of [0, 1] as const) {
maxRows = Math.max(maxRows, buildMonthGrid(y, m, ws).length);
const padded = buildMonthGrid(y, m, ws, 6);
const own = padded.flat().filter((d) => !d.adjacent);
expect(own).toHaveLength(new Date(y, m + 1, 0).getDate());
}
}
}
expect(maxRows).toBe(6);
});
it("handles a leap February", () => {
const days = buildMonthGrid(2028, 1).flat().filter((d) => !d.adjacent);
expect(days).toHaveLength(29);
expect(days[28].day).toBe(29);
});
// new Date("2026-08-01") parses as UTC midnight and renders as July 31
// for anyone west of Greenwich. Constructing from y/m/d avoids it, and
// the iso string must agree with the local date it was built from.
it("emits an iso key that matches the local date, not a UTC shift", () => {
const weeks = buildMonthGrid(2026, 7);
for (const day of weeks.flat()) {
const [y, m, d] = day.iso.split("-").map(Number);
expect(y).toBe(day.date.getFullYear());
expect(m).toBe(day.date.getMonth() + 1);
expect(d).toBe(day.date.getDate());
}
});
it("zero-pads the iso key so it sorts lexically", () => {
const jan = buildMonthGrid(2026, 0).flat().find((d) => d.day === 5 && !d.adjacent);
expect(jan?.iso).toBe("2026-01-05");
});
});
const BASE = {
year: 2026,
month: 7,
label: "August 2026",
today: new Date(2026, 7, 12),
};
describe("CalendarGrid", () => {
it("is a named grid of rows and cells", () => {
// Unnamed, a grid is announced as an unlabelled table of numbers.
const { container } = render();
const grid = container.querySelector('[role="grid"]');
expect(grid?.getAttribute("aria-label")).toBe("August 2026");
expect(container.querySelectorAll('[role="row"]').length).toBeGreaterThan(1);
expect(container.querySelectorAll('[role="columnheader"]')).toHaveLength(7);
});
it("names each column in full for a screen reader", () => {
// "Mon" read aloud is not a weekday. abbr carries the full name.
const { container } = render();
const first = container.querySelector('[role="columnheader"]');
// The full name is present for a screen reader and hidden from sight;
// the abbreviation is present for sight and hidden from the reader.
expect(first?.querySelector(".strand-sr-only")?.textContent).toBe("Sunday");
expect(first?.querySelector('[aria-hidden="true"]')?.textContent).toBe("Sun");
});
// The roving tabindex. Thirty-one tab stops in a month would make the
// keyboard path through any page carrying a calendar unusable.
it("is exactly one tab stop", () => {
const { container } = render();
const reachable = container.querySelectorAll('[role="gridcell"][tabindex="0"]');
expect(reachable).toHaveLength(1);
});
it("puts the tab stop on the selected day when there is one", () => {
const { container } = render();
const reachable = container.querySelector('[role="gridcell"][tabindex="0"]');
expect(reachable?.getAttribute("data-iso")).toBe("2026-08-20");
});
it("marks today with aria-current, not only with a colour", () => {
const { container } = render();
const marked = container.querySelectorAll('[aria-current="date"]');
expect(marked).toHaveLength(1);
expect(marked[0].getAttribute("data-iso")).toBe("2026-08-12");
});
it("marks the selection with aria-selected", () => {
const { container } = render();
expect(
container.querySelector('[aria-selected="true"]')?.getAttribute("data-iso"),
).toBe("2026-08-03");
});
it("reports the day that was chosen", () => {
const onSelect = vi.fn();
const { container } = render();
fireEvent.click(container.querySelector('[data-iso="2026-08-14"]') as HTMLElement);
expect(onSelect).toHaveBeenCalledWith("2026-08-14");
});
it("chooses a day from the keyboard", () => {
const onSelect = vi.fn();
const { container } = render();
const cell = container.querySelector('[data-iso="2026-08-14"]') as HTMLElement;
fireEvent.keyDown(cell, { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith("2026-08-14");
});
it("changes month when arrowing off the end", () => {
// The grid shows adjacent days, so arrowing past the last of the month
// must tell the consumer to advance rather than silently doing nothing.
const onMonthChange = vi.fn();
const { container } = render(
,
);
const last = container.querySelector('[data-iso="2026-08-31"]') as HTMLElement;
fireEvent.keyDown(last, { key: "ArrowRight" });
expect(onMonthChange).toHaveBeenCalledWith(2026, 8);
});
it("pages by month", () => {
const onMonthChange = vi.fn();
const { container } = render(
,
);
const cell = container.querySelector('[data-iso="2026-08-12"]') as HTMLElement;
fireEvent.keyDown(cell, { key: "PageDown" });
expect(onMonthChange).toHaveBeenCalledWith(2026, 8);
fireEvent.keyDown(cell, { key: "PageUp" });
expect(onMonthChange).toHaveBeenCalledWith(2026, 6);
});
it("crosses the year boundary correctly", () => {
const onMonthChange = vi.fn();
const { container } = render(
,
);
const cell = container.querySelector('[data-iso="2026-12-01"]') as HTMLElement;
fireEvent.keyDown(cell, { key: "PageDown" });
expect(onMonthChange).toHaveBeenCalledWith(2027, 0);
});
// ── 10.6, the reason this needed a design-language change ──
it("states the remainder as a count rather than clipping it", () => {
// The obligation: content beyond capacity is information the reader can
// act on, not information they never learn exists.
const { container } = render(
,
);
const cell = container.querySelector('[data-iso="2026-08-12"]');
expect(cell?.querySelector(".strand-calendar-grid__remainder")?.textContent).toBe(
"+3 more",
);
});
it("shows no remainder when everything fits", () => {
const { container } = render(
,
);
expect(
container.querySelector('[data-iso="2026-08-12"] .strand-calendar-grid__remainder'),
).toBeNull();
});
it("never renders a negative or zero remainder", () => {
const { container } = render(
,
);
expect(container.textContent).not.toContain("more");
});
it("renders per-day content through the consumer's callback", () => {
const { container } = render(
(d.iso === "2026-08-12" ? ship : null)}
/>,
);
expect(
container.querySelector('[data-iso="2026-08-12"]')?.textContent,
).toContain("ship");
});
it("marks adjacent days so they read as context", () => {
const { container } = render();
const adjacent = container.querySelectorAll(
".strand-calendar-grid__day--adjacent",
);
expect(adjacent.length).toBeGreaterThan(0);
// August 1 2026 is a Saturday, so the six days before it are July's.
expect(adjacent[0].getAttribute("data-iso")).toBe("2026-07-26");
});
});
import { snapshotFixtures } from "../../test/snapshot.js";
import { snapshotStylesheet } from "../../test/stylesheet.js";
import { fixtures } from "./CalendarGrid.fixtures.js";
snapshotFixtures(CalendarGrid, fixtures);
snapshotStylesheet(resolve(__dirname, "./CalendarGrid.css"));