Files
margin-calendar/tests/interaction.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

195 lines
7.3 KiB
TypeScript

// Clicking, dragging, creating, moving, resizing. The gestures the calendar is for.
//
// Every assertion here is about what came back on screen: a card naming the event you clicked, a
// block at the time you dragged out, a block that ended up an hour higher than it started.
import { expect, test, type Page } from "@playwright/test";
import { blocks, box, columnX, drag, gridFit, hourY, openApp, settle } from "./app";
/** Sub-pixel layout and a one pixel optical nudge on the gutter labels. */
const PIXEL_SLACK = 3;
const activeTag = (page: Page) => page.evaluate(() => document.activeElement?.tagName ?? null);
/** A block in a named column, which is how a title that repeats every day is addressed. */
const blockIn = (page: Page, column: number, title: string) =>
page.locator(`.grid-col[data-index="${column}"] .grid-event`, { hasText: title });
function near(actual: number, expected: number, slack = PIXEL_SLACK): void {
expect(
Math.abs(actual - expected),
`expected ${actual.toFixed(1)} to be within ${slack}px of ${expected.toFixed(1)}`,
).toBeLessThanOrEqual(slack);
}
test.describe("opening an event", () => {
test("clicking a block opens its details, and Escape closes them", async ({ page }) => {
await openApp(page);
const title = "Design review with the platform team";
const block = page.locator(".grid-event", { hasText: title }).first();
await block.click();
// The card is whatever the app puts on screen for it; what matters is that it is a dialog,
// that it names the event, and that it is not the block itself.
const card = page.getByRole("dialog");
await expect(card).toBeVisible();
await expect(card).toContainText(title);
await page.keyboard.press("Escape");
await expect(card).toHaveCount(0);
});
test("clicking a block selects it", async ({ page }) => {
await openApp(page);
const block = page.locator(".grid-event", { hasText: "Apply to 1 job" }).first();
await block.click();
await expect(block).toHaveAttribute("data-selected", "true");
});
});
test.describe("creating by dragging", () => {
test("dragging empty grid, typing a title and pressing Enter creates it there", async ({
page,
}) => {
await openApp(page);
const fit = await gridFit(page);
const column = 3;
const x = await columnX(page, column);
const top = await hourY(page, "5pm");
expect(top).not.toBeNull();
await drag(page, { x, y: top! + 3 }, { x, y: top! + fit.rowHeight - 3 });
// The create card takes the keyboard the moment it appears.
expect(await activeTag(page)).toBe("INPUT");
await page.keyboard.type("Coffee with Ana");
await page.keyboard.press("Enter");
const created = blockIn(page, column, "Coffee with Ana");
await expect(created).toHaveCount(1);
// On the day it was dragged on, at the hour it was dragged over, an hour long.
const rect = await box(created);
near(rect.top, top!);
near(rect.height, fit.rowHeight);
// And nowhere else.
await expect(page.locator(".grid-event", { hasText: "Coffee with Ana" })).toHaveCount(1);
});
test("Escape cancels the drag without creating anything", async ({ page }) => {
await openApp(page);
const before = (await blocks(page)).length;
const fit = await gridFit(page);
const x = await columnX(page, 2);
const top = await hourY(page, "5pm");
await drag(page, { x, y: top! + 3 }, { x, y: top! + fit.rowHeight - 3 });
expect(await activeTag(page)).toBe("INPUT");
await page.keyboard.press("Escape");
await settle(page);
expect(await activeTag(page)).not.toBe("INPUT");
expect((await blocks(page)).length).toBe(before);
});
});
test.describe("moving and resizing", () => {
test("the preview follows the pointer to the time it will land at", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const block = blockIn(page, 4, "Apply to 1 job");
const before = await box(block);
const fivePm = await hourY(page, "5pm");
const x = before.left + before.width / 2;
const y = before.top + before.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x, y - 12, { steps: 4 });
await page.mouse.move(x, y - fit.rowHeight, { steps: 6 });
// Half past six moved up an hour is half past five, and the preview says so in words as well
// as in pixels.
const ghost = page.locator(".grid-ghost");
await expect(ghost).toBeVisible();
await expect(ghost).toContainText("5pm to 5:30pm");
near((await box(ghost)).top, fivePm!);
await page.mouse.up();
});
test("dragging a block moves it to the time you dropped it at", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const column = 4;
const before = await box(blockIn(page, column, "Apply to 1 job"));
// Straight up by one hour: half six becomes half five, on the same day.
await drag(
page,
{ x: before.left + before.width / 2, y: before.top + before.height / 2 },
{ x: before.left + before.width / 2, y: before.top + before.height / 2 - fit.rowHeight },
);
const after = await box(blockIn(page, column, "Apply to 1 job"));
near(after.top, before.top - fit.rowHeight);
near(after.height, before.height);
// Still one of it, still on the same day.
await expect(blockIn(page, column, "Apply to 1 job")).toHaveCount(1);
});
test("dragging a block onto another day moves it there", async ({ page }) => {
await openApp(page);
const from = 4;
const to = 5;
const before = await box(blockIn(page, from, "Apply to 1 job"));
const target = await columnX(page, to);
await drag(
page,
{ x: before.left + before.width / 2, y: before.top + before.height / 2 },
{ x: target, y: before.top + before.height / 2 },
);
// The day it left has one fewer, the day it landed on has two.
await expect(blockIn(page, from, "Apply to 1 job")).toHaveCount(0);
await expect(blockIn(page, to, "Apply to 1 job")).toHaveCount(2);
});
test("dragging the bottom edge previews the new end", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const before = await box(blockIn(page, 4, "Apply to 1 job"));
const x = before.left + before.width / 2;
await page.mouse.move(x, before.bottom - 2);
await page.mouse.down();
await page.mouse.move(x, before.bottom + 10, { steps: 4 });
await page.mouse.move(x, before.bottom + fit.rowHeight / 2, { steps: 6 });
const ghost = page.locator(".grid-ghost");
await expect(ghost).toContainText("6pm to 7pm");
near((await box(ghost)).top, before.top);
near((await box(ghost)).height, before.height + fit.rowHeight / 2);
await page.mouse.up();
});
test("dragging the bottom edge resizes it", async ({ page }) => {
await openApp(page);
const fit = await gridFit(page);
const column = 4;
const before = await box(blockIn(page, column, "Apply to 1 job"));
const x = before.left + before.width / 2;
// Half an hour longer, which keeps it inside the hours the axis is already showing.
await drag(page, { x, y: before.bottom - 2 }, { x, y: before.bottom + fit.rowHeight / 2 });
const after = await box(blockIn(page, column, "Apply to 1 job"));
near(after.top, before.top);
near(after.height, before.height + fit.rowHeight / 2);
});
});