Files
improvise/harness/tests/contract.spec.js
T
Edward Langley 22bb621dd8 test(harness): close the stale-assemble hole, and decide the dropped assertion
improvise-669b. PR #65 single-homed HANDSHAKE_TIMEOUT_MS in web/shell.js, but
the home is single in the TREE: the spec imports web/shell.js and the page
loads harness/serve/app/shell.js. createShell now exposes timeoutMs() beside
timesArmed(), and outlastTheWatchdog requires the running page's value to equal
the one it imported. Injected with the ticket's recipe (8000 -> 100 in source,
no rebuild): 5 passed / 1 failed where it was previously 6/6 green.

Also in scope, the same file's stale line: HANDSHAKE_TIMEOUT_MS restates
nothing and one test outlasts it, not two.

improvise-qf57. The formula-row click's `#grid input` toHaveCount(0) stays
dropped, and now deliberately. The reviewer's argument was that the exact
`#mode` assertion subsumes it; measuring gives a better one. app.js creates a
grid <input> in exactly one place, the grid cell's own mousedown, so no panel
handler can reach it — and an invented formula-row activation opens the
MINIBUFFER, not a grid editor. Injecting ChangeMode(formula_edit()) into
click_formula_row: the toHaveCount(0) probe PASSES and the run fails on the
#mode assertion below it. Restoring it would add an assertion that cannot
fail, which context/test-evidence/clients.md already records as the reason a
sibling assert! was removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:56:52 -07:00

296 lines
15 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";
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::short_label` — "Pag", and "·" for None, an answer not a stub.
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 C: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"]);
});
});
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. `Default` is index 0 and
// holds the cursor by default, so move to it and click `Plan`.
await page.keyboard.press("Shift+V");
await expect(page.locator("#views li.selected")).toContainText("Default");
// Switch to Default 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("Default");
const plan = page.locator("#views li").filter({ hasText: "Plan" }).first();
await plan.click();
// Reading the cursor instead of the index re-selects Default and fails.
await expect(page.locator("#view-name")).toHaveText("Plan");
await expect(page.locator("#views li.active")).toContainText("Plan");
await expect(page.locator("#views li.selected")).toContainText("Plan");
// 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");
await page.keyboard.press("Escape");
await expect(cells.nth(4)).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);
});
});