Files
improvise/harness/tests/contract.spec.js
T
Edward Langley 36ade35240 test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests
assume those contracts and test between them (user, 2026-09-20). The browser
half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS —
and everything between them needed no browser at all.

`web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A
DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every
question `gridTable` used to answer while creating elements — the data-row
index across group headers, what a group header spans, a null group label
against an empty one, an empty edit buffer against no editor — and `app.js` is
now a loop that builds elements from it. Painting stays untested by Node; what
to paint stopped being untestable.

26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle,
no npm install), plus three in `web/tests/bundle/` that read the generated
.d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to
16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone,
replaced by `contract.spec.js`, which asserts that EVERY field the render model
projects reaches the screen. The four widgets those specs were written for were
one failure at one boundary, and one assertion catches the fifth.

It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element
(improvise-5jna, already open). They are named in `UNPAINTED` with the ticket,
so the list is the debt written down rather than an exemption.

Thirteen faults injected into the Node suite and eight into the served page,
each watched to fail the test it names; the last of those was strengthened
because the first version passed. `web/assemble.sh` grew an import-completeness
guard whose own first version was blind to the multi-line import it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:33:49 -07:00

162 lines
7.8 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("");
// `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")).toBeHidden();
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeVisible();
await expect(page.locator("#categories li").first()).toBeVisible();
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeHidden();
// `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.
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").first()).toBeVisible();
await page.keyboard.press("l");
await expect(page.locator(".help-tab.active")).toHaveText("Navigation");
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: selects and cycles, because a click is the whole interaction.
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
await product.click();
await expect(page.locator("#mode")).toHaveText("TILES");
await expect(product).toHaveClass(/selected/);
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 page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeVisible();
await page.keyboard.press("Escape");
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",
]);
});
});