Files
improvise/harness/tests/contract.spec.js
T

553 lines
27 KiB
JavaScript

// The RenderModel→DOM boundary and the pointer's half of the JS→wasm one: the
// two contracts only a browser can establish. Why these and not more, and what
// moved to `node --test web/tests`: context/code/clients.md.
import { test, expect } from "@playwright/test";
// The page's own key, imported rather than spelled: a spec with its own
// copy would pass while reading a key nothing writes (shell.spec.js does
// the same with the handshake window).
import { NOTICE_KEY } from "../../web/shell.js";
async function openApp(page) {
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await page.locator("#grid").focus();
}
test.describe("every field the render model projects reaches the screen", () => {
// The four widgets a user reported as "not working" — the tile bar, the view
// list, the formula panel and the category sidebar ignoring its flag — were
// ONE failure at this boundary: a projected field with no element painting
// it. This is that contract, and it is what catches the fifth.
test("the widgets the demo document fills are drawn, in the state that fills them", async ({
page,
}) => {
await openApp(page);
// `rows`, `col_levels`, `row_levels`: the grid itself. web/demo.improv is
// Product on row, _Measure on column (gotcha 46).
await expect(page.locator("#grid thead th", { hasText: "Revenue" })).toBeVisible();
await expect(page.locator("#grid tbody th").first()).toHaveText("Espresso");
await expect(page.locator("#grid tbody td").first()).not.toHaveText("");
// `doc_name`, `doc_stored_as`: the title bar. improvise-z754 projected
// these and nothing painted them for a wave, which `render_surface`'s
// substring search cannot see — only a loaded page can (improvise-5jna).
// The dirty marker's own spelling is `web/tests/shell.test.js`: this
// deployment persists on every accepted batch, so `[+]` is transient.
await expect(page.locator("#doc-name")).toHaveText(
"Corner Coffee — 2025 (demo.improv)",
);
// `mode`, `hint`, `view_name`, `status`: the header's four facts.
await expect(page.locator("#mode")).toHaveText("NORMAL");
await expect(page.locator("#hint")).not.toHaveText("");
await expect(page.locator("#view-name")).toHaveText("Plan");
await expect(page.locator("#status")).toContainText("in-page worker");
// `tiles`: drawn on every frame, as the TUI draws them. The axis label is
// `Axis::plain_label` here; the three-column one is the TUI's (771e).
await expect(page.locator("#tiles .tile")).toHaveCount(5);
await expect(page.locator("#tiles .tile").filter({ hasText: "Product" })).toHaveText(
"Product Row",
);
// `categories` + `category_panel_open`: the flag is the client's answer,
// and the sidebar was drawn always until it read it (improvise-wsaw).
await expect(page.locator("#categories")).toBeVisible();
await expect(page.locator("#categories li").first()).toBeVisible();
// Open at launch by the shared default, so the first press only FOCUSES:
// `toggle-panel-and-focus` closes only when open AND focused.
await page.keyboard.press("Shift+C");
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
await expect(page.locator("#categories")).toBeVisible();
// This is the assertion that catches wsaw — a sidebar drawn unconditionally
// passes the launch one. Visible-at-launch is the ruling, not the guard.
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeHidden();
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeVisible();
await page.keyboard.press("Escape");
// `views`: empty unless open, so the page asks no question about the mode.
await expect(page.locator("#views")).toBeHidden();
await page.keyboard.press("Shift+V");
await expect(page.locator("#views li")).toHaveCount(2);
await expect(page.locator("#views li.active")).toContainText("Plan");
// The axis line is the shared `view_axis_summary`; its absence would mean
// a second derivation had crept in.
await expect(page.locator("#views li").nth(0).locator(".axes")).toHaveText(
"R:Product P:Quarter",
);
await page.keyboard.press("Escape");
// `formulas`: the third of `Panel::ALL`, on the same terms.
await page.keyboard.press("Shift+F");
await expect(page.locator("#formulas li")).toHaveText(["Profit = Revenue - Cost"]);
await page.keyboard.press("Escape");
// `help`, `help_page`, `help_titles`: the rows and their keys are
// `improvise_protocol::help`'s, the same ones the TUI draws. Asserted by
// TEXT, because an empty `dt` is a visible `dt` and a page drawing the
// right number of blank rows is the failure this row is here to catch
// (improvise-igxp). What each page holds is `help.spec.js`.
await expect(page.locator("#help")).toBeHidden();
await page.keyboard.press("?");
await expect(page.locator(".help-tab.active")).toHaveText("Welcome");
await expect(page.locator(".help-body dt.help-banner")).toHaveText("improvise");
await page.keyboard.press("l");
await expect(page.locator(".help-tab.active")).toHaveText("Navigation");
await expect(page.locator(".help-body dt.help-keys").first()).toHaveText(
"h j k l ← ↑ → ↓",
);
await page.keyboard.press("Escape");
await expect(page.locator("#help")).toBeHidden();
// `editing`: what has been typed, drawn into the selected cell with a
// caret. The decision that an editor is open is made in Rust.
await page.keyboard.press("i");
await expect(page.locator("#grid td.typing")).toHaveCount(1);
// The class alone would pass against a cell drawn blank: what is on the
// screen is the buffer AND the caret.
await expect(page.locator("#grid td.typing")).toContainText("▏");
await page.keyboard.press("Escape");
await expect(page.locator("#grid td.typing")).toHaveCount(0);
});
// The other half of the same contract: a change the client resolved must
// reach the pixels, not merely the cache. One instance is enough — what
// each key MEANS is `improvise_protocol::resolve`'s, and tested there.
test("a resolved change repaints the widget it moved and the grid with it", async ({
page,
}) => {
await openApp(page);
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
const rowHeaders = page.locator("#grid tbody th");
await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
await page.keyboard.press("Shift+T");
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight");
await expect(product).toHaveClass(/selected/);
await page.keyboard.press("c");
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
// The label alone would be the bar reporting an axis the document has not
// got: the grid must have moved with it.
await expect(
page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(),
).toBeVisible();
await expect(rowHeaders).not.toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]);
});
// improvise-p7lh. `page_coords` and `totals` were projected to every session
// and folded correctly, and NO front end read either: the shipped front page
// showed a grid filtered to Q1 with nothing on screen saying so, and `]`
// changed every number in silence.
test("the page bar names the quarter, and both it and the totals follow `]`", async ({
page,
}) => {
await openApp(page);
// `web/demo.improv` is `Quarter: page, Q1` — the fact this bead is about.
await expect(page.locator("#pages")).toBeVisible();
await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q1 \(1\/4\)/);
// Page is navigable where Filter is not (gotcha 27), and that is what
// says `[`/`]` will move it.
await expect(page.locator("#pages .page-axis")).toHaveClass(/navigable/);
// The totals strip. Q1 Revenue is 18400+24600+9200+11500 — a sum over
// EVERY row, which is why it is projected and not added up here.
await expect(page.locator("#grid tfoot th")).toHaveText("Total");
await expect(page.locator("#grid tfoot td").first()).toHaveText("63,700.00");
// A total is not a cell anyone may type into.
await expect(page.locator("#grid tfoot td.editable")).toHaveCount(0);
await page.keyboard.press("]");
// BOTH move, and the numbers are Q2's: a bar that showed a stale quarter
// over fresh numbers is the exact lie this bead names.
await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q2 \(2\/4\)/, {
timeout: 10_000,
});
await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400.00");
});
});
test.describe("a pointer event reaches the client and its answer is painted", () => {
// improvise-7ybz. What a click MEANS is resolved in Rust and tested in
// `improvise_protocol::resolve`; what this pins is the wiring — the handler
// is on the element, it passes THAT element's index, and the result repaints.
test("each clickable surface is wired to its own index", async ({ page }) => {
await openApp(page);
// A grid cell: the coordinate is the data row, which group headers do not
// consume — `gridPlan` counts it and `web/tests/grid_plan.test.js` pins it.
const cells = page.locator("#grid tbody td");
await expect(cells.nth(0)).toHaveClass(/selected/);
await cells.nth(4).click();
await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
// The cursor a click leaves is the one the keyboard moves from: one
// cursor, two input paths.
await page.keyboard.press("ArrowLeft");
await expect(cells.nth(3)).toHaveClass(/selected/);
// A tile: the first click SELECTS and does not touch the document; the
// second, on the same tile, cycles. The user's ruling — clients.md.
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
await product.click();
await expect(page.locator("#mode")).toHaveText("TILES");
await expect(product).toHaveClass(/selected/);
// Waited out, not asserted instantly: a cycle would arrive asynchronously.
await page.waitForTimeout(500);
await expect(product).toHaveText("Product Row");
await product.click();
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
await page.keyboard.press("Escape");
// A category row: focuses the panel, moves its cursor and activates the
// row — three things from one click, and the index must be the row's own.
await expect(page.locator("#categories")).toBeVisible();
const productRow = page.locator("#categories li").filter({ hasText: "Product" });
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
await productRow.click();
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
await expect(page.locator("#categories li.selected")).toHaveText(/Product/);
await expect(page.locator("#categories li.item")).toHaveText([
"Espresso",
"Latte",
"Cold Brew",
"Pastry",
]);
// The OTHER arm of the same click, which PR #61 dropped with
// `pointer.spec.js` and nothing replaced (found by the test-name diff
// improvise-78v6 asks for): on an ITEM row the rule is "do what Enter
// does here", which is filter — not expand. Only the status line says
// which arm ran, so a click wired to the category arm passes everything
// above and fails here.
await page.locator("#categories li.item").filter({ hasText: "Latte" }).click();
await expect(page.locator("#status")).toContainText("Filter: Product = Latte");
await expect(page.locator("#categories li.selected")).toHaveText("Latte");
await page.keyboard.press("Escape");
// A view row (improvise-iioi). The activation must read the CLICKED index,
// not `view_panel_cursor` — so the cursor must not already be on the row
// clicked, or both readings give the same answer. `Plan` is index 0 and
// holds the cursor by default, so move to it and click `Season`.
await page.keyboard.press("Shift+V");
await expect(page.locator("#views li.selected")).toContainText("Plan");
// Switch to Plan by KEYBOARD, leaving the cursor on it. The click then
// names a different row than the cursor does, which is the whole point.
await page.keyboard.press("Enter");
await expect(page.locator("#view-name")).toHaveText("Plan");
const season = page.locator("#views li").filter({ hasText: "Season" }).first();
await season.click();
// Reading the cursor instead of the index re-selects Plan and fails.
await expect(page.locator("#view-name")).toHaveText("Season");
await expect(page.locator("#views li.active")).toContainText("Season");
await expect(page.locator("#views li.selected")).toContainText("Season");
// A formula row: navigation only, because FormulaPanel binds no Enter and
// a click mirrors Enter. The Rust guard is on the KEYMAP — clients.md.
// `#grid input` toHaveCount(0) belongs here and is deliberately absent:
// it cannot fail, measured by injection (improvise-qf57, clients.md).
await page.keyboard.press("Shift+F");
await expect(page.locator("#formulas li").first()).toBeVisible();
await page.keyboard.press("Escape");
await page.locator("#formulas li").first().click();
await expect(page.locator("#mode")).toHaveText("FORMULAS");
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
});
// improvise-0x9u. The neighbouring state the assertion above cannot see: it
// presses Escape first, so it only ever clicks from a NON-editing page.
test("a click moves the selection from an editing state too", async ({ page }) => {
await openApp(page);
const cells = page.locator("#grid tbody td");
await cells.nth(0).click();
await expect(cells.nth(0).locator("input")).toHaveCount(1);
// No Escape between the two clicks — that is the whole test. The blur used
// to repaint and `replaceChildren` destroyed the <td> the mousedown landed
// on, so no click event was ever dispatched and the pointer was dead.
await cells.nth(4).click();
await expect(cells.nth(4).locator("input")).toHaveCount(1);
await expect(cells.nth(0).locator("input")).toHaveCount(0);
// The third edit of the same repair: `close` restores the DRAWN text, not
// the seed. `repaint` early-returns while `editing` is set, so a `18400`
// put back here stays on screen (improvise-0x9u).
await expect(cells.nth(0)).toHaveText("18,400.00");
await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
// improvise-b8ot moved these five surfaces onto one handler per stable root.
// The three tests below are the properties a naive delegation loses, each of
// which is silent: the page still paints, and only the pointer stops working.
// The fourth — closing a live editor FIRST — is the test above, which asserts
// the old editor is gone and the new one is open with no Escape between.
test("a mousedown alone reaches the grid, because click comes too late", async ({
page,
}) => {
await openApp(page);
const cells = page.locator("#grid tbody td");
// MOUSEDOWN ONLY, dispatched with no click behind it. A handler bound to
// `click` does nothing here, which is the whole of improvise-0x9u: from an
// editing state the blur repaints and the <td> is gone before click fires.
await cells.nth(4).dispatchEvent("mousedown");
await expect(cells.nth(4).locator("input")).toHaveCount(1);
// And the repaint is SKIPPED on that branch: a repaint would take the
// <input> away, so its survival is the assertion.
await page.waitForTimeout(300);
await expect(page.locator("#grid input")).toHaveCount(1);
});
test("the grid prevents its own default, and only over a cell", async ({ page }) => {
await openApp(page);
// `window` is last in the bubble path, so it sees what #grid's handler did.
await page.evaluate(() => {
window.__defaultPrevented = null;
window.addEventListener("mousedown", (e) => {
window.__defaultPrevented = e.defaultPrevented;
});
});
await page.locator("#grid tbody td").nth(1).dispatchEvent("mousedown");
expect(await page.evaluate(() => window.__defaultPrevented)).toBe(true);
// A header carried no listener before delegation, so preventing ITS
// default would be new behaviour — the check must be on the cell, not the
// root. Without this the first assertion passes for a blanket handler.
await page.evaluate(() => {
window.__defaultPrevented = null;
});
await page.locator("#grid thead th").last().dispatchEvent("mousedown");
expect(await page.evaluate(() => window.__defaultPrevented)).toBe(false);
});
test("a click's coordinates are read off the node, not closed over", async ({
page,
}) => {
await openApp(page);
const cells = page.locator("#grid tbody td");
await expect(cells.nth(0)).toHaveClass(/selected/);
// Rewriting the attribute must change where the click lands. A per-node
// closure over `gridPlan`'s answer ignores it and this fails — which is
// what makes the delegated handler a LOOKUP rather than a second
// derivation of which data row was clicked (improvise-b8ot).
await cells.nth(0).evaluate((td) => {
td.dataset.row = "2";
});
await cells.nth(0).dispatchEvent("mousedown");
await page.keyboard.press("Escape");
const thirdRow = page.locator("#grid tbody tr").nth(2).locator("td").first();
await expect(thirdRow).toHaveClass(/selected/);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
});
test.describe("the selection is on screen", () => {
// improvise-jq8l: `web/app.js` called `scrollIntoView` NOWHERE, so a selection
// moved past the bottom of `#grid` simply stayed there, invisible, with no way
// back but scrolling by hand. Only a browser can see this: it is a fact about
// `#grid`'s own scroll box, not about the projection, which had already sent
// the row — the page renders more rows than fit, for the reason below.
test("a selection driven below the fold scrolls itself into view", async ({ page }) => {
// `harness/fixtures/tall.improv`: 30 rows in three groups, which is more
// than the box can show because `ROW_PX` (26) under-estimates the real
// 28.6px row and inline group rows are deliberately not charged for.
await page.goto("/app/index.html?doc=tall-scroll.improv&seed=tall.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#view-name")).toHaveText("Tall");
await expect(page.locator("#grid tbody th[scope=row]").first()).toHaveText("R01");
await page.locator("#grid").focus();
// Not vacuous by construction: if `#grid` does not overflow there is
// nothing to scroll and every assertion below passes on a broken page.
const overflow = await page
.locator("#grid")
.evaluate((g) => g.scrollHeight - g.clientHeight);
expect(overflow).toBeGreaterThan(0);
for (let i = 0; i < 25; i++) await page.keyboard.press("ArrowDown");
// The selected cell must be inside the box the user can see. Measured
// against `#grid`'s own rect rather than the window's, because `#grid` is
// the scroll container — the page itself does not scroll.
await expect
.poll(async () =>
page.locator("#grid").evaluate((g) => {
const sel = g.querySelector("td.selected");
if (!sel) return "no selected cell";
const cell = sel.getBoundingClientRect();
const box = g.getBoundingClientRect();
return cell.bottom <= box.bottom + 1 && cell.top >= box.top - 1;
}),
)
.toBe(true);
// And it really did have to move: a page that never scrolls leaves this 0.
expect(await page.locator("#grid").evaluate((g) => g.scrollTop)).toBeGreaterThan(0);
});
});
// The page's own chrome: what a visitor lands on, and where the prompt is.
// Only a browser can answer either — both are facts about layout and about
// storage that survives a reload. What these CANNOT see is the rest of the
// styling; that residue is named in context/code/clients.md.
test.describe("the shell puts the application first", () => {
const notice = (page) => page.locator("#backend-notice");
const stored = (page) => page.evaluate((k) => localStorage.getItem(k), NOTICE_KEY);
// The deployment notice is ten lines of configuration reference — ?doc=,
// ?seed=, ?ws=, wss:// and reverse proxies — and it was above the fold on
// every visit. See context/test-evidence/clients.md.
test("the deployment notice starts collapsed and remembers being opened", async ({
page,
}) => {
await page.goto("/app/index.html?doc=notice-memory.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
// The one sentence a visitor needs is never collapsed; the reference is.
await expect(page.locator("#backend-notice .notice-lede")).toBeVisible();
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
// BOTH directions, because a page that ignores storage passes the first
// half and a page that always opens passes the second (design-principles
// §7: assert the direction the default cannot supply).
await notice(page).locator("summary").click();
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
// `toggle` is dispatched as a task, not synchronously with the click, so
// the answer must be OBSERVED as stored before a reload can be said to
// have read it back. Without this the reload races the write.
await expect.poll(() => stored(page)).toBe("open");
await page.reload();
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
await notice(page).locator("summary").click();
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
await expect.poll(() => stored(page)).toBe("closed");
await page.reload();
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
});
// The `<details>` is the browser's control, and the page's own keydown
// handler claims Enter. Without the guard a keyboard visitor could focus
// the summary and not open it. See context/test-evidence/clients.md.
test("the notice opens from the keyboard, which the grid's own handler must not claim", async ({
page,
}) => {
await page.goto("/app/index.html?doc=notice-keyboard.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
await notice(page).locator("summary").focus();
await page.keyboard.press("Enter");
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
});
// The prompt was a small line in the page's bottom-left corner, 800px from
// the cell being typed into. See context/test-evidence/clients.md.
test("the minibuffer is raised over the grid rather than filed under it", async ({
page,
}) => {
await page.goto("/app/index.html?doc=minibuffer-place.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await page.locator("#grid").focus();
await expect(page.locator("#minibuffer")).toBeHidden();
// `a` is the one text-entry room this client can both enter and commit in
// (`keys.spec.js` drives the formula one). The precondition is that it
// really opened, or every measurement below is of a hidden element.
await page.keyboard.press("a");
await expect(page.locator("#mode")).toHaveText("INSERT");
await expect(page.locator("#minibuffer")).toBeVisible();
const boxes = await page.evaluate(() => {
const rect = (id) => {
const { top, bottom, left, right } = document
.getElementById(id)
.getBoundingClientRect();
return { top, bottom, left, right };
};
return {
mb: rect("minibuffer"),
grid: rect("grid"),
viewport: { height: window.innerHeight, width: window.innerWidth },
mbFont: parseFloat(getComputedStyle(document.getElementById("minibuffer")).fontSize),
bodyFont: parseFloat(getComputedStyle(document.body).fontSize),
};
});
// On screen without scrolling: the property a bottom-of-document line
// does not have once the page is taller than the window.
expect(boxes.mb.bottom).toBeLessThanOrEqual(boxes.viewport.height);
expect(boxes.mb.top).toBeGreaterThanOrEqual(0);
// And OVER the grid, not in a strip beneath it. This is what separates
// "raised" from "moved": a static bottom bar cannot overlap #grid at all.
expect(boxes.mb.top).toBeLessThan(boxes.grid.bottom);
expect(boxes.mb.bottom).toBeGreaterThan(boxes.grid.top);
// The one prominence claim a browser can check. It was body-sized before.
expect(boxes.mbFont).toBeGreaterThan(boxes.bodyFont);
await page.keyboard.press("Escape");
await expect(page.locator("#minibuffer")).toBeHidden();
});
});
// The stylesheet's own cascade, which no other test here can reach: an
// error is a colour now, where it used to be a colour AND an opacity, so
// the order of two equally-specific rules decides it. See
// context/test-evidence/clients.md.
test.describe("the grid's semantic colours survive the cascade", () => {
test("an error cell stays an error cell when it is also dimmed or selected", async ({
page,
}) => {
await page.goto("/app/index.html?doc=error-colour.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
// The question is about the STYLESHEET, so the combinations are put to it
// directly: the document that produces each of them is a separate matter,
// and a fixture per combination would test the fixture.
const colours = await page.evaluate(() => {
const row = document.querySelector("#grid tbody tr");
const read = (classes) => {
const td = document.createElement("td");
td.className = classes;
td.textContent = "#err";
row.appendChild(td);
const colour = getComputedStyle(td).color;
td.remove();
return colour;
};
return {
// An aggregate error, which is the common case: `editable` is false
// for every cell that is not a stored record.
dimmed: read("error"),
selected: read("error selected"),
typing: read("error typing"),
// What "dimmed" must NOT be, read off a plain unsettable cell.
dim: read(""),
// …and what it must be, read off an error the dimming cannot reach.
error: read("error editable"),
};
});
expect(colours.error).not.toBe(colours.dim);
expect(colours.dimmed).toBe(colours.error);
expect(colours.selected).toBe(colours.error);
expect(colours.typing).toBe(colours.error);
});
});