// 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 { MIDDAY, axis, blocks, box, clockAt, columnX, gridFit, gridReady, headerDates, hourY, 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); }); }); // Every test here pins the clock to the middle of the working day. The axis takes in the hour it // is now when the events have left it out, so the shape of a quiet evening is not the shape of a // busy morning, and an axis test that did not say which one it meant would pass or fail on when it // was run. What the clock does to the axis is the last test in the file. test.describe("the axis holds still", () => { test("paging a week keeps the same hours on screen", async ({ page }) => { await openApp(page, { now: MIDDAY() }); 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, { now: MIDDAY() }); 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, { now: MIDDAY() }); 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, { now: MIDDAY() }); 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("folds", () => { test("an event created inside a fold takes its hour back and leaves the rest folded", async ({ page, }) => { await openApp(page, { now: MIDDAY() }); // The fixture's last event ends at half six, so the evening is the one empty run long enough // to fold and then land an event in. Hold it open first, then fold it by hand. await page.locator(".grid-strip", { hasText: /to 12am/ }).click(); await settle(page); const nine = await hourY(page, "9pm"); expect(nine).not.toBeNull(); const fit = await gridFit(page); await page.mouse.move(await columnX(page, 3), nine! + fit.rowHeight / 2); await page.keyboard.press("z"); await settle(page); await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(1); await page.keyboard.press("ControlOrMeta+k"); await page.keyboard.type("Yoga 11pm"); await page.keyboard.press("Enter"); await settle(page); // The hour the event landed in is back on the axis with the event in it at full scale, and // the empty hours before it are still folded. const yoga = page.locator(".grid-event", { hasText: "Yoga" }); await expect(yoga).toBeVisible(); expect((await box(yoga)).height).toBeGreaterThan(fit.rowHeight / 2); const hours = (await axis(page)).map((entry) => entry.text); expect(hours).toContain("11pm"); expect(hours).not.toContain("8pm"); await expect(page.locator(".grid-strip", { hasText: "7pm to 11pm" })).toHaveCount(1); await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(0); }); test("a remembered fold over a busy hour shows that hour, and only that hour", async ({ page, }) => { // 4pm has a quarter hour block on every workday; 5pm has nothing on any day. await openApp(page, { now: MIDDAY(), storage: { "margincal-folds": '[{"start":16,"end":18}]' }, }); const hours = (await axis(page)).map((entry) => entry.text); expect(hours).toContain("4pm"); expect(hours).not.toContain("5pm"); await expect(page.locator(".grid-strip", { hasText: "5pm to 6pm" })).toHaveCount(1); await expect(page.locator(".grid-strip", { hasText: "4pm to 6pm" })).toHaveCount(0); }); test("expanding one strip of a split fold leaves the other where it was", async ({ page }) => { // 6pm has a block on every day, so a fold from 5pm to 9pm shows as a strip either side of it, // the later one merged into the strip at the end of the day. await openApp(page, { now: MIDDAY(), storage: { "margincal-folds": '[{"start":17,"end":21}]' }, }); const five = page.locator(".grid-strip", { hasText: "5pm to 6pm" }); await expect(five).toHaveCount(1); await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(1); await five.click(); await settle(page); expect((await axis(page)).map((entry) => entry.text)).toContain("5pm"); await expect(page.locator(".grid-strip", { hasText: "7pm to 12am" })).toHaveCount(1); }); }); test.describe("the hour it is now", () => { // Half eleven at night, which the fixture leaves empty: the axis the events drew stops hours // earlier, so without the pin there is nowhere for the line to be. const LATE = () => clockAt(23, 30); test("is on the axis even when the events stopped hours ago", async ({ page }) => { await openApp(page, { now: LATE() }); expect((await axis(page)).map((entry) => entry.text)).toContain("11pm"); await expect(page.locator(".grid-now")).toHaveCount(1); // On today's column, and inside the row it belongs to rather than on the strip above it. const line = await box(page.locator(".grid-now")); const row = (await gridFit(page)).rowHeight; const eleven = (await axis(page)).find((entry) => entry.text === "11pm"); const canvas = await box(page.locator(".grid-canvas")); expect(line.top - canvas.top).toBeGreaterThan(eleven!.y); expect(line.top - canvas.top).toBeLessThan(eleven!.y + row); }); test("costs one row, not the whole evening it reached over", async ({ page }) => { await openApp(page, { now: LATE() }); const hours = (await axis(page)).map((entry) => entry.text); // The hours between the last event and now are not on the axis; they are in a strip. expect(hours).not.toContain("9pm"); expect(hours).not.toContain("10pm"); await expect(page.locator(".grid-strip", { hasText: /to 11pm/ })).toHaveCount(1); // And the day still fits. const fit = await gridFit(page); expect(fit.canvasHeight).toBeCloseTo(fit.bodyHeight, 0); expect(fit.overflow).toBe(false); }); test("is on the axis inside a fold you made that covers it", async ({ page }) => { // A fold over the whole evening, made on some earlier day and remembered since. It used to // swallow the clock's hour along with the trailing strip it merged into, and the line with it. await openApp(page, { now: LATE(), storage: { "margincal-folds": JSON.stringify([{ start: 19, end: 24 }]) }, }); expect((await axis(page)).map((entry) => entry.text)).toContain("11pm"); await expect(page.locator(".grid-now")).toHaveCount(1); // The fold is still there, split around the hour it is now rather than dropped. await expect(page.locator(".grid-strip", { hasText: "7pm to 11pm" })).toHaveCount(1); await expect(page.locator(".grid-strip", { hasText: /to 12am/ })).toHaveCount(0); // And the bounds the grid remembers are the events', not the clock's. const saved = await page.evaluate(() => localStorage.getItem("margincal-bounds")); expect(JSON.parse(saved ?? "null")).toEqual({ start: 7, end: 20 }); }); test("is gone again on a week that does not contain today", async ({ page }) => { await openApp(page, { now: LATE() }); const before = (await axis(page)).map((entry) => entry.text); expect(before).toContain("11pm"); // A whole week on, today is not one of the columns and neither is the hour it is now. await page.keyboard.press("L"); await settle(page); expect((await axis(page)).map((entry) => entry.text)).not.toContain("11pm"); await expect(page.locator(".grid-now")).toHaveCount(0); }); // The two below are about an app left open, so the clock is a running one rather than a pinned // instant: `install` lets it be moved, and the strip that reads "8pm to 10pm" only exists once // the hour it holds open has moved from nine to ten. test("moves on with the clock while the app stays open", async ({ page }) => { await page.clock.install({ time: clockAt(21, 58) }); await openApp(page); await page.clock.pauseAt(clockAt(21, 59)); const before = (await axis(page)).map((entry) => entry.text); expect(before).toContain("9pm"); expect(before).not.toContain("10pm"); await page.clock.runFor(2 * 60_000); await expect(page.locator(".grid-strip", { hasText: "8pm to 10pm" })).toHaveCount(1); const after = (await axis(page)).map((entry) => entry.text); expect(after).toContain("10pm"); expect(after).not.toContain("9pm"); await expect(page.locator(".grid-now")).toHaveCount(1); }); test("catches up when the clock jumped, as it does after the lid was closed", async ({ page }) => { await page.clock.install({ time: clockAt(21, 30) }); await openApp(page); await page.clock.pauseAt(clockAt(21, 31)); expect((await axis(page)).map((entry) => entry.text)).toContain("9pm"); // Asleep, the wall clock moves on and the timers do not: one set for the top of the hour is // still waiting for the rest of its half hour when the machine wakes at five past ten. What // the app gets is one minute of being awake. await page.clock.setSystemTime(clockAt(22, 5)); await page.clock.runFor(60_000); await expect(page.locator(".grid-strip", { hasText: "8pm to 10pm" })).toHaveCount(1); const hours = (await axis(page)).map((entry) => entry.text); expect(hours).toContain("10pm"); expect(hours).not.toContain("9pm"); await expect(page.locator(".grid-now")).toHaveCount(1); const line = await box(page.locator(".grid-now")); const row = (await gridFit(page)).rowHeight; const ten = (await axis(page)).find((entry) => entry.text === "10pm"); const canvas = await box(page.locator(".grid-canvas")); expect(line.top - canvas.top).toBeGreaterThan(ten!.y); expect(line.top - canvas.top).toBeLessThan(ten!.y + row); }); }); 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); }); });