Moltenship Time

Testing

Test the calendar you built with a fixed clock, typed builders, a render harness, and keyboard and pointer drivers.

@moltenship/calendar-testing tests the calendar your app builds: your copies of the registry views, your policies and the code that saves changes. It renders your Root with a fixed clock, time zone and locale, owns the events the way your app's state would, records what the calendar tells your app, and drives it with the keyboard and the pointer as a person would.

It uses only the public API of @moltenship/calendar and @moltenship/calendar-recurrence, and depends on no test runner. The examples here use Vitest with jsdom; happy-dom works too, and pointer tests run in Vitest's browser mode.

Install

pnpm add -D @moltenship/calendar-testing vitest jsdom

React 19 and React DOM 19 are peer dependencies: the harness renders and hydrates with React DOM.

Set up Vitest

Run the tests in a DOM environment:

vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { environment: "jsdom", setupFiles: ["./tests/setup.ts"] },
});

Node.js 26 and later have Temporal. On an older runtime, or a browser without it, install the polyfill in the setup file, as the app does. The package never installs one itself, and its helpers throw an error that says so when Temporal is missing.

tests/setup.ts
if (!("Temporal" in globalThis)) await import("temporal-polyfill/global");

The harness turns on React's IS_REACT_ACT_ENVIRONMENT flag when it first renders, unless your setup set it already.

A fixed clock and zone

The calendar reads the current time only from Root's now or clock, and the time zone from timeZone or defaultTimeZone; locale and weekRules are required props. So a test makes it deterministic through props, without faking timers or patching Temporal.Now.

createTestEnvironment(options?) fixes all of them. Its defaults are Thursday, 8 October 2026 at noon UTC, US English, weeks from Monday, and left to right.

const environment = createTestEnvironment({ timeZone: "Europe/Berlin" });

environment.now; // 2026-10-08T12:00:00Z, a Temporal.Instant
environment.today; // 2026-10-08, now's date in the zone
environment.at("09:00"); // 09:00 today in Berlin: 2026-10-08T07:00:00Z
environment.at("2026-10-26T09:00"); // a wall time in Berlin, after the clocks go back
environment.rootProps; // { now, defaultTimeZone, locale, weekRules, direction, defaultAnchorDate }

at reads a time alone on the environment's own date, today, however far the calendar has moved.

OptionDefault
now"2026-10-08T12:00:00Z"; text without a zone is a wall time on timeZone
timeZone"UTC"
locale"en-US"
weekRules{ firstDay: 1, minimalDays: 4, weekend: [6, 7] }
direction"ltr"
anchorDatetoday

To show a later time, call the harness's setNow: the calendar re-renders with it at once. To let time pass as an app's clock does, pass createTestClock(start) as the harness's clock option, or Root's clock in your own render, and move it with set or advance. The calendar samples a clock once a minute, so move fake timers with it. A clock reads text without a zone as UTC.

Builders

Builders make records in the canonical shape createCanonicalEventAdapter reads. Every field has a default, so a test names only what it's about. Text times without a zone are wall times on the event's timeZone, UTC by default, and timeZone becomes the event's sourceTimeZone.

BuilderMakes
buildEvent(input?)A timed event: an hour at 09:00 on the default date. start with end or duration
buildAllDayEvent(input?)An all-day event: start with end (exclusive) or days
buildResource(input?)A CalendarResource, with timeZone, parentId and availability
buildRecurringEvent(input)A timed series for @moltenship/calendar-recurrence: rule as RRULE text, exceptionDates
withRecurrence(event, input)Makes any event, all-day ones too, the master of a series
buildAvailability(input?)Working hours: days, hours such as "09:00-17:00", exceptions; 9 to 5 on weekdays

An event's id defaults to its title and its title to its ID, so buildEvent({ title: "Standup" }) is the event "Standup".

Render a calendar

Create your calendar as the app does, then render it with renderCalendar. Clean up after each test.

calendar.test.tsx
import {
  createCalendar,
  createCanonicalEventAdapter,
  type CalendarEvent,
} from "@moltenship/calendar";
import {
  buildEvent,
  cleanup,
  createTestEnvironment,
  hydrateCalendar,
  renderCalendar,
} from "@moltenship/calendar-testing";

import { calendarAnnouncements } from "@/components/calendar/announcements";
import { createWeekView } from "@/components/calendar/time-grid-view/time-grid-view";

type Meeting = CalendarEvent<null>;

const Calendar = createCalendar<Meeting>()({
  eventAdapter: createCanonicalEventAdapter<null>(),
  views: [createWeekView<Meeting>()],
});

// Unmounts every calendar a test rendered.
afterEach(cleanup);

afterEach, expect and test come from vitest.

renderCalendar(Calendar, options) renders Root with the environment's props inside act, and <Calendar.ActiveView /> inside it. It owns the events: a change your calendar saves is applied to them, so the next render shows it, as your app's state would.

calendar.test.tsx
const environment = createTestEnvironment({ timeZone: "Europe/Berlin" });
const standup = buildEvent({ title: "Standup", start: environment.at("09:00") });

test("a saved move reaches onEventChange and the events", async () => {
  const calendar = await renderCalendar(Calendar, {
    environment,
    events: [standup],
    messages: calendarAnnouncements,
  });

  await calendar.propose({
    cause: "move",
    eventId: "Standup",
    proposed: {
      kind: "timed",
      start: environment.at("10:00"),
      end: environment.at("10:30"),
    },
  });

  expect(calendar.changes.map((change) => change.cause)).toEqual(["move"]);
  expect(calendar.results[0]?.result.status).toBe("committed");
  expect(String(calendar.events()[0]?.start)).toBe("2026-10-08T08:00:00Z");
});
OptionEffect
eventsThe events the calendar starts with
viewThe view it opens on; the first registered one by default
environmentcreateTestEnvironment() by default
clockA clock to read the time from instead of the environment's fixed now
resourcesRoot's resources
onEventChangeCalled with each change before the harness applies it. Return a promise to keep the change pending; throw or reject to roll it back
onEventChangeResultCalled with each result after the harness records it
applyChangeApplies a saved change to your events. The default, applyCanonicalChange, needs canonical events; pass your own for your app's records
createEventThe record a created event gets, from the change and a free new-<n> ID; the proposed placement goes over it. { id, title: "New event", data: null } by default
readOnlyRenders Root without onEventChange
messagesRenders <Calendar.Announcer messages={messages} />
rootPropsAny other Root prop, such as policies or availability
childrenWhat Root renders instead of <Calendar.ActiveView />
wrapperA component around the calendar, such as a provider
strictModeRenders in StrictMode
containerWhere to render; a new element in document.body by default, which unmounting removes
identifierPrefixuseId's prefix

The harness it returns:

MemberContents
containerThe element the calendar rendered into
changesEvery change onEventChange received, in order
resultsEvery onEventChangeResult call: { result, detail }
diagnosticsWhat onDiagnosticsChange last reported
events()The events now, with every saved change applied
state(), meta()useCalendarState() and useCalendarMeta(), read from inside Root
pendingChanges()useCalendarPendingChanges(): changes still validating, saving or awaiting events
actions, act(callback)The calendar's actions, and a way to call them inside act
propose(proposal)proposeEventChange inside act; resolves with its result once it settles
setEvents, setNow, rerenderChange the events, the fixed now, or the options that apply after the first render
unmount()Unmounts it; cleanup() unmounts every one

setNow takes exact text, a wall time on the environment's zone, or a time such as "10:00" on the calendar's current date. rerender changes the events, the handlers, children, messages, resources, readOnly and rootProps; the view, the environment, the wrapper and the container set up the first render. Change the view or date through act, as a person would: await calendar.act((actions) => actions.setView("day")). Change the locale, week rules or direction through rootProps: await calendar.rerender({ rootProps: { locale: "de-DE" } }).

Name the harness type with CalendarHarnessFor<typeof Calendar>.

Saves that take time

When onEventChange returns a promise, the calendar shows the change as pending until it settles. Hold the promise in the test to check what the calendar shows meanwhile.

calendar.test.tsx
test("a slow save stays pending, then settles", async () => {
  let finish = () => {};
  const calendar = await renderCalendar(Calendar, {
    environment,
    events: [standup],
    onEventChange: () => new Promise<void>((resolve) => (finish = resolve)),
  });

  // Propose through act without waiting for the save, which the test holds.
  let saving: ReturnType<typeof calendar.actions.proposeEventChange> | undefined;
  await calendar.act((actions) => {
    saving = actions.proposeEventChange({
      cause: "move",
      eventId: "Standup",
      proposed: { kind: "timed", start: environment.at("11:00"), end: environment.at("12:00") },
    });
  });
  expect([...calendar.pendingChanges().values()].map(({ phase }) => phase)).toEqual(["saving"]);

  await calendar.act(() => finish());
  expect((await saving)?.status).toBe("committed");
  expect(calendar.pendingChanges().size).toBe(0);
});

Find what the calendar shows

Queries find events through the calendar's own events, by ID, title or start, so they don't depend on the text a view renders or its language. Cells are found by the attributes every registry view carries for the pointer layer: data-event-id on events, data-date on day cells and time columns, data-all-day on a time grid's all-day cells, data-axis="inline" on a timeline's time strips, and data-resource-id on a resource's column or row. A view of your own that follows the same contract works the same way: a data-date element without data-all-day counts as a time column, and in a time grid, events sit inside their column.

calendar.test.tsx
test("queries find events by title, and cells by date", async () => {
  const calendar = await renderCalendar(Calendar, { environment, events: [standup] });

  const event = calendar.getEvent("Standup");
  expect(calendar.getTimeColumn("2026-10-08").contains(event)).toBe(true);
  expect(calendar.queryEvent("Lunch")).toBeNull();

  // A slot exists only while it has focus, so the keyboard driver focuses it.
  await calendar.keyboard.focusCell({ date: "2026-10-08", time: "14:00" });
  expect(document.activeElement?.getAttribute("aria-label")).toBe("Thursday, October 8 at 2:00 PM");
});
QueryReturns
getEvent(event)The first element showing an event
getAllEvents(event)Every element showing it: one per part the view draws, such as a time grid's day, a month's week row, a resource's column
queryEvent(event)The first element, or null when the view doesn't show it
getDayCell(date, { resourceId? })A time grid's all-day cell, or else a month's day
getTimeColumn(date, { resourceId? })The column of a date's hours, or its strip along a timeline row
getResource(resource)The element a resource's column or row carries its ID on; resource is an ID, null for the unassigned row, or { title }
placementOf(event)Where it shows now, pending changes included, as text: range, wallRange, isPending

event is an ID or a title as text (an ID wins), or { id }, { title } or { start }. A start is a date for an all-day event, a date and time for a floating one, or a time for a timed one, exact or on the calendar's wall clock. The get queries throw a CalendarQueryError that lists the calendar's events when they find nothing, and every query throws when it finds several events. placementOf gives text, such as "2026-10-09T08:30:00Z/2026-10-09T09:30:00Z", which reads plainly in a failing assertion; wallRange gives the same ends on the display zone's clock, to the minute.

Keyboard

calendar.keyboard presses keys on the focused element inside act, as a person would, so it works in jsdom, happy-dom and a browser alike, and through every check your views make. Focus goes through the calendar's own focus, as arrow keys move it, so a slot, which renders only while it has focus, can take it too.

calendar.test.tsx
test("Space, arrow keys and Enter move the standup a day on and half an hour later", async () => {
  const calendar = await renderCalendar(Calendar, {
    environment,
    events: [standup],
    messages: calendarAnnouncements,
  });

  // Focus the event, Space, two slots down and a day across, then Enter.
  await calendar.keyboard.moveEvent("Standup", { across: 1, down: 2 });
  await calendar.settled();

  expect(calendar.placementOf("Standup").wallRange).toBe("2026-10-09T09:30/2026-10-09T10:30");
  expect(calendar.lastAnnouncement()).toBe("Moved Standup to Friday, October 9, 9:30 – 10:30 AM.");
  expect(calendar.results[0]?.detail.input).toBe("keyboard");
});
MethodKeys
focusEvent(event, { date?, resourceId? })Focuses the event's part on date, by default the day it starts or the first day shown, in resourceId
focusCell({ date, time?, resourceId? })Focuses a day, or a slot with time
press(key, modifiers?), arrows({ up, down, left, right })Any key on whatever has focus, or arrow keys by count
step({ across, down })Arrow keys by direction
grab(), grabEdge()Space picks up the focused event or starts a create; Shift+Space picks up its end, and again its start
drop(), cancel()Enter and Escape
moveEvent(event, { across?, down?, date?, resourceId? })Focus, Space, the steps, Enter
resizeEvent(event, { edge?, across?, down?, date?, resourceId? })Focus, Shift+Space (twice for the start), the steps, Enter
createEvent(cell, steps?)Focus the cell, Space, the steps, Enter

The focus methods throw when the view doesn't show what they name, and leave the calendar's focus as it was. A step across goes toward the end of the line: Arrow Right in a left-to-right calendar, Arrow Left in a right-to-left one, from Root's direction. A step down is Arrow Down; negative steps go back and up. What a step is depends on the view:

Viewacrossdown
Week, dayA dayA slot, 15 minutes by default
MonthA dayA week
Resource columnsA column: the next resource, then the next dayA slot
Resource timelineA slot, or a day over daysThe next resource

Wait for saves

Drivers return once their keys or moves are handled, not when the change is saved. settled() waits until no change is pending: every proposal validated, saved, and in the events. It gives up after five seconds of real time, and says which changes are still pending. It doesn't advance fake timers, so a save that waits on one needs them advanced first.

announcements() lists what the Announcer said, oldest first, a message said twice included, and lastAnnouncement() what it says now, with Intl's thin and no-break spaces made plain. Render one with the messages option, or your own in children: only the one mounted last speaks, and the harness follows whichever does. normalizeText(text) makes your own text plain the same way.

Pointer and touch

calendar.pointer drags with Pointer Events at measured points, as a mouse, a pen or a finger would. It needs a page that's laid out and real timers, so it runs in a browser:

vitest.config.ts
import { defineConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: "chromium" }],
    },
  },
});
calendar.browser.test.tsx
test("a mouse moves the standup to Friday at 11:00", async () => {
  const calendar = await renderCalendar(Calendar, {
    // Give the calendar a height, as the app's layout does.
    children: (
      <Calendar.Frame className="flex h-dvh flex-col">
        <Calendar.Viewport className="min-h-0 flex-1">
          <Calendar.ActiveView />
        </Calendar.Viewport>
      </Calendar.Frame>
    ),
    events: [standup],
  });

  await calendar.pointer.moveEvent("Standup", { date: "2026-10-09", time: "11:00" });
  await calendar.settled();

  expect(calendar.placementOf("Standup").range).toBe("2026-10-09T11:00:00Z/2026-10-09T12:00:00Z");
});
MethodDrag
moveEvent(event, { date, time?, resourceId? })By the event's middle, so it starts at time on date; without time, by whole days, keeping its time. It stays in its resource unless resourceId names another
resizeEvent(event, { date, time?, edge? })Its end (or start) handle to time, or to date in a view that resizes by days, as a timeline over days does
createEvent(from, to)Across empty slots or days, both included
drag(from, to)From one element or point to another

Each takes { pointerType }: "mouse" by default, "pen", or "touch", which holds still until the long press starts a drag. The press and the drop must be on screen: the drivers scroll the event into view, and throw when its middle is covered or when the drop point isn't on its cell, so make the calendar tall enough. Where they let go is measured again after each frame, so a view that auto-scrolls under the pointer still gets the drop where it was meant. The repository runs these drivers on its views in Chromium on every pull request, and in Firefox and WebKit, WebKit on temporal-polyfill, in the full gate.

What jsdom can't do

jsdom and happy-dom don't lay pages out: every element is an empty box at the top left, and nothing scrolls. So in them:

  • the pointer drivers throw a PointerLayoutError that says to use a browser;
  • views measure nothing, so anything that depends on sizes, such as virtualized resource columns past the first screen, auto-scroll at an edge, or which part of a scrolling view is visible, isn't there to test;
  • CSS doesn't apply: hidden, clipped and focus-visible states are classes, not what's shown.

Keyboard drivers, queries, the harness, saves and announcements work in all three.

Fixtures

Fixtures are ready datasets for contract tests: tests that every view, or your custom one, should pass on the same data. Each is a function that builds new records, with the environment, resources and Root props it's meant for, so spreading it into the harness's options renders it. Merge rootProps when you pass your own. Their events are canonical, with data: { group }, so type your calendar with FixtureEvent and FixtureResource.

calendar.test.tsx
import { dstWeekFixture, type FixtureEvent } from "@moltenship/calendar-testing";

const FixtureCalendar = createCalendar<FixtureEvent>()({
  eventAdapter: createCanonicalEventAdapter<{ readonly group: string }>(),
  views: [createWeekView<FixtureEvent>()],
});

test("a move a day on keeps an event's wall time across the clocks going back", async () => {
  // New York's week of Sunday 1 November 2026, with a 09:00 standup every day.
  const calendar = await renderCalendar(FixtureCalendar, { ...dstWeekFixture(), view: "week" });

  // `d6` is Saturday's standup; a day on is Sunday, after the change.
  await calendar.keyboard.moveEvent("d6", { across: 1 });
  await calendar.settled();

  expect(calendar.placementOf("d6").wallRange).toBe("2026-11-01T09:00/2026-11-01T09:30");
});
FixtureData
denseDayFixture()Thursday 8 October 2026 in UTC: 30 timed events, from back-to-back half hours that overlap nothing to five at once at 13:00 and a staircase, two across midnight, and 3 all-day events
dstWeekFixture({ change? })New York's week to Sunday 1 November 2026, when the clocks go back, or with change: "spring" to 8 March, when they go forward: a 09:00 standup a day, an event in the changing hour, one across the change
resourceDayFixture()A team in three zones, one with hours of its own, and rooms under a parent that opens expanded; events on one resource, on several, and on none; working hours from 9 to 6
multiZoneSeriesFixture()Series in Berlin, Los Angeles and Tokyo from 19 October 2026, across Berlin's and Los Angeles' DST changes a week apart

Each fixture's JSDoc lists its events and resources by ID, title and time. Those, and the counts, are part of the API: they change only in a major version, so tests can name them.

multiZoneSeriesFixture gives series masters, series, not events: a calendar shows a series once it's expanded into occurrences, as your app does with @moltenship/calendar-recurrence.

calendar.test.tsx
import {
  createCanonicalRecurrenceAdapter,
  createOccurrenceAdapter,
  createOccurrenceExpander,
  type RecurrenceOccurrence,
} from "@moltenship/calendar-recurrence";
import { multiZoneSeriesFixture, type FixtureSeriesEvent } from "@moltenship/calendar-testing";

type Occurrence = RecurrenceOccurrence<FixtureSeriesEvent, string>;

const SeriesCalendar = createCalendar<Occurrence>()({
  eventAdapter: createOccurrenceAdapter(createCanonicalEventAdapter<{ readonly group: string }>()),
  views: [createWeekView<Occurrence>()],
});

test("Berlin's standup is an hour later in UTC once its clocks go back; Los Angeles' isn't yet", async () => {
  const { environment, series } = multiZoneSeriesFixture();
  const week = {
    end: Temporal.PlainDate.from("2026-11-02"),
    start: Temporal.PlainDate.from("2026-10-26"),
  };
  const { occurrences } = createOccurrenceExpander(
    createCanonicalRecurrenceAdapter<{ readonly group: string }>(),
  ).expand(series, { range: week, timeZone: environment.timeZone });

  const calendar = await renderCalendar(SeriesCalendar, {
    environment,
    events: occurrences,
    // Occurrences are views of their series: save changes through the recurrence edits instead.
    readOnly: true,
    rootProps: { defaultAnchorDate: week.start },
    view: "week",
  });

  expect(calendar.placementOf({ start: "2026-10-26T08:00:00Z" }).title).toBe("Standup (Berlin)");
  expect(calendar.placementOf({ start: "2026-10-27T17:00:00Z" }).title).toBe(
    "Review (Los Angeles)",
  );
});

Server rendering and hydration

hydrateCalendar takes the same options. It renders the calendar with renderToString, puts the markup in a container and hydrates it, then returns the same harness, with the markup as serverMarkup. When the client renders something else, it throws a HydrationMismatchError with React's reasons: a recoverable error, or a warning such as an attribute that differs, which React only logs. It watches the console for those while hydration runs, so test with React's development build, which logs them. A component that throws on the client fails the test with its error, and nothing stays mounted. A fixed now renders today's date on the server too, so a calendar with one hydrates with nothing to patch.

calendar.test.tsx
test("the week renders on the server and hydrates without a mismatch", async () => {
  // Throws a HydrationMismatchError when the client renders something else.
  const calendar = await hydrateCalendar(Calendar, { environment, events: [standup] });

  expect(calendar.serverMarkup).toContain("Standup");
});

The server render runs in the test's DOM environment, so code that checks for window takes its browser branch there too. To test a real server boundary, render with react-dom/server in a Node environment and hydrate in another, as the repository's SSR fixture does.

Edit on GitHub

Last updated on

On this page