Files
margin-calendar/tests/grid.spec.ts
T
pj 661100dfdc Margin Calendar: a Google Calendar client for desktop and phone
Tauri 2, React 19 and zustand on the front, Rust behind. Rust owns auth,
all HTTP to Google, the SQLite store, the sync loop, recurrence expansion
and timezone maths. TypeScript owns rendering and never talks to Google,
which keeps the content security policy locked to ipc:.

Week, day and agenda views, and no month view: it would be a second layout
engine, and the fit and fold logic that makes a day fit the window without
scrolling is the whole point of the app.

Runs on macOS, Linux, Android and iOS. Desktop catches Google's OAuth
redirect on a loopback port. A phone cannot, and Google rejects loopback
for mobile client types anyway, so it redirects to a custom URI scheme and
needs its own public OAuth clients, which docs/mobile.md covers. Refresh
tokens are sealed with XChaCha20-Poly1305 in the app data directory on
every platform, with no OS credential store in the picture.

On a phone the chrome becomes a top bar and a bottom tab bar, overlays
become sheets, hover affordances become taps, and dragging out an event
waits for a long press. Navigation moves one day at a time everywhere,
a swipe included.
2026-08-12 17:09:21 +05:30

197 lines
8.7 KiB
TypeScript

// The grid renders, and it fits.
//
// The whole product rests on two properties: the day you are looking at fills the window without
// scrolling, and the axis does not move under you while you navigate. Both are geometry, so both
// are measured rather than asserted about the DOM.
import { expect, test } from "@playwright/test";
import { axis, blocks, box, gridFit, gridReady, headerDates, openApp, settle } from "./app";
test.describe("the grid fits the window", () => {
test("the body is exactly the height the window left it, with nothing to scroll", async ({
page,
}) => {
await openApp(page);
const fit = await gridFit(page);
// The body reaches the bottom of the window: no dead band under the last hour.
expect(fit.bodyBottom).toBeCloseTo(fit.windowHeight, 0);
// And the day was drawn at exactly that height, which is what "the grid never scrolls" means.
expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.bodyScrollHeight).toBeLessThanOrEqual(Math.ceil(fit.bodyHeight) + 1);
expect(fit.overflow).toBe(false);
expect(fit.documentScrollHeight).toBeLessThanOrEqual(fit.documentClientHeight);
// A row solved from a real window is nowhere near the floor the fit gives up at.
expect(fit.rowHeight).toBeGreaterThan(30);
});
test("resizing rescales the day rather than growing a scrollbar", async ({ page }) => {
await openApp(page);
const before = await gridFit(page);
const hoursBefore = (await axis(page)).map((entry) => entry.text);
await page.setViewportSize({ width: 1440, height: 680 });
await settle(page);
const after = await gridFit(page);
expect(after.bodyBottom).toBeCloseTo(after.windowHeight, 0);
expect(after.canvasHeight).toBeCloseTo(after.bodyHeight, 0);
expect(after.overflow).toBe(false);
// Same hours, less room, so the rows are shorter. Nothing was cut off instead.
expect(after.rowHeight).toBeLessThan(before.rowHeight);
expect((await axis(page)).map((entry) => entry.text)).toEqual(hoursBefore);
});
test("the day view fills the window too", async ({ page }) => {
await openApp(page, { view: "day" });
const fit = await gridFit(page);
expect(fit.bodyBottom).toBeCloseTo(fit.windowHeight, 0);
expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0);
expect(fit.overflow).toBe(false);
});
// FAILING, and it is the app, not the test. `GridView` returns null in the agenda, so the body
// it measured is thrown away, and the ResizeObserver is installed by a `useLayoutEffect` with an
// empty dependency list: it never observes the new body. The grid comes back solved for a
// viewport of zero, sticks at the minimum row height, and no amount of resizing recovers it.
// Only a reload does.
test("coming back from the agenda leaves the grid fitted", async ({ page }) => {
await openApp(page);
const before = await gridFit(page);
await page.keyboard.press("a");
await expect(page.locator(".agenda-row").first()).toBeVisible();
await page.keyboard.press("w");
await gridReady(page);
const after = await gridFit(page);
expect(after.canvasHeight).toBeCloseTo(after.bodyHeight, 0);
expect(after.overflow).toBe(false);
expect(after.rowHeight).toBeCloseTo(before.rowHeight, 0);
});
});
test.describe("the axis holds still", () => {
test("paging a week keeps the same hours on screen", async ({ page }) => {
await openApp(page);
const before = (await axis(page)).map((entry) => entry.text);
const dates = await headerDates(page);
expect(before.length).toBeGreaterThan(6);
for (let i = 0; i < 6; i++) {
await page.keyboard.press("l");
await settle(page);
}
// The dates moved.
expect(await headerDates(page)).not.toEqual(dates);
// The hours did not. The axis itself is the assertion; the header no longer restates it,
// because the hours it printed were already on screen an inch below.
expect((await axis(page)).map((entry) => entry.text)).toEqual(before);
});
// FAILING, and it is the app, not the test. `GridAllDay` sizes itself from its content, one to
// three rows, so the band is 84px on a week with all-day events and 32px on a week without. The
// axis is solved from what the band left over, so paging past them rescales every hour on the
// grid. The comment at the top of GridAllDay.tsx says the band never does this.
test("paging a week does not move the hours on screen either", async ({ page }) => {
await openApp(page);
const before = await axis(page);
const band = (await box(page.locator(".grid-allday"))).height;
const row = (await gridFit(page)).rowHeight;
// Six days on, the fixture's all-day events are behind us. The band has nothing to show, and
// the axis below it must not notice.
for (let i = 0; i < 6; i++) {
await page.keyboard.press("l");
await settle(page);
}
expect((await box(page.locator(".grid-allday"))).height).toBeCloseTo(band, 0);
expect((await gridFit(page)).rowHeight).toBeCloseTo(row, 0);
expect(await axis(page)).toEqual(before);
});
test("paging back does not reflow them either", async ({ page }) => {
await openApp(page);
const before = (await axis(page)).map((entry) => entry.text);
for (let i = 0; i < 4; i++) {
await page.keyboard.press("h");
await settle(page);
}
expect((await axis(page)).map((entry) => entry.text)).toEqual(before);
});
test("the empty ends of the day are folded into a strip at each end", async ({ page }) => {
await openApp(page);
const strips = page.locator(".grid-strip");
await expect(strips).toHaveCount(2);
// Midnight to the first event, and the last event to midnight: both say what they cover.
await expect(strips.first()).toContainText(/12am to \d/);
await expect(strips.last()).toContainText(/to 12am/);
});
});
test.describe("the all-day band", () => {
test("a multi-day event spans exactly the days it covers", async ({ page }) => {
await openApp(page);
const chip = page.locator(".grid-chip", { hasText: "Trip to Goa" });
await expect(chip).toHaveCount(1);
// The week starts on the configured day rather than on today, so which columns the trip
// lands in depends on what day of the week it is. Read them off the header instead of
// assuming, and clip to the week, since a trip starting on a Saturday runs off the end.
const days = await page.locator(".grid-head-date").allTextContents();
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const startCol = days.indexOf(String(tomorrow.getDate()));
expect(startCol, "the trip starts on a visible day").toBeGreaterThanOrEqual(0);
const endCol = Math.min(startCol + 3, days.length - 1);
const first = await box(page.locator(`.grid-col[data-index="${startCol}"]`));
const last = await box(page.locator(`.grid-col[data-index="${endCol}"]`));
const rect = await box(chip);
expect(rect.left).toBeGreaterThanOrEqual(first.left - 4);
expect(rect.left).toBeLessThan(first.right);
expect(rect.right).toBeGreaterThan(last.left);
expect(rect.right).toBeLessThanOrEqual(last.right + 4);
});
test("the band never eats the axis: what does not fit hides behind a count", async ({ page }) => {
await openApp(page);
const band = await box(page.locator(".grid-allday"));
const more = page.locator(".grid-chip-more");
await expect(more).toHaveCount(1);
const fitBefore = await gridFit(page);
await more.click();
await settle(page);
// Expanding shows the rest without pushing the axis around underneath it.
await expect(page.locator(".grid-chip", { hasText: "Dentist" })).toBeVisible();
const fitAfter = await gridFit(page);
expect(fitAfter.rowHeight).toBeCloseTo(fitBefore.rowHeight, 0);
expect((await box(page.locator(".grid-allday"))).height).toBeCloseTo(band.height, 0);
});
});
test.describe("what is on the grid", () => {
test("today is marked and every column carries the fixture's daily events", async ({ page }) => {
await openApp(page);
// Exactly one day is today, and the column under it agrees. Which index that is depends on
// the day of the week, because the week starts on the configured day, not on today.
await expect(page.locator(".grid-head-day[data-today]")).toHaveCount(1);
const marked = await page.locator(".grid-head-day[data-today]").getAttribute("data-index");
await expect(page.locator(`.grid-col[data-index="${marked}"]`)).toHaveAttribute(
"data-today",
"true",
);
const all = await blocks(page);
const columns = new Set(all.map((b) => b.column));
// Seven columns, all of them populated: the fixture's daily habits are on every one.
expect([...columns].sort()).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(all.length).toBeGreaterThan(30);
});
});