Files
improvise/harness/tests/contract.spec.js
T
Edward Langley 628f43121d feat(web): the title bar is painted, a click survives an editor, and the twin door is gone
improvise-5jna, improvise-0x9u, improvise-seik — three browser-page defects with
one shape: a thing that looks done from every check except running it.

improvise-5jna. `doc_name`/`doc_dirty`/`doc_stored_as` were projected by z754 and
painted by nothing. `render_surface.test.js` could not see it — it is a SUBSTRING
SEARCH over the page's source, so a field named by a function nothing calls reads
as painted, which is also how `model.minibuffer` shipped dead. So the guard is a
harness assertion that loads the page and reads `#doc-name`, proved by writing it
first (`element(s) not found`). The joining is `shell.js::documentTitle`, the
TUI's own `name (stored_as) [+]` minus the chrome; the `[+]` is a node test, not a
harness one, because this deployment persists on every accepted batch and clears
the flag in the same round trip, so the marker is transient. UNPAINTED is now
empty.

improvise-0x9u. From an EDITING state a cell click did nothing at all: the
mousedown moved focus, the input's blur ran `close`, `close` repainted,
`replaceChildren` destroyed the `<td>` the mousedown landed on, and no `click`
was ever dispatched. The old spec pressed Escape between the two clicks, so it
only ever clicked from a non-editing page — this repo's characteristic defect.

Worth recording: moving to `mousedown` is NECESSARY AND NOT SUFFICIENT, and the
first attempt failed the same assertion with the same symptom. Removing a focused
`<input>` fires `blur` SYNCHRONOUSLY, so `close` rebuilt the table from inside
itself and detached the `<td>` again. `close` therefore no longer repaints, the
`blur` arm checks `closed` first, and the handler `preventDefault()`s so no focus
change happens at all — otherwise the default action focuses `#grid` after
`input.focus()` and blurs the new editor. `close` restores the DRAWN text, since
nothing repaints behind it any more.

improvise-seik. `Client::viewport` was a wasm-bindgen door with no caller, kept
for a reason `web/app.js` falsified in the same wave. It is one letter from
`set_viewport` and fails SILENTLY — identical frame, forgotten window, every
cursor key an upstream black hole. Deleted, along with the exported `init()` free
function that duplicated `new Client()`. The guard is not the deletion:
`wasm_surface.test.js` now reads the generated `.d.ts` and requires every
declared method to be one `app.js` calls or to be named in `UNCALLED` with its
reason. Proved by injecting a spare `static viewport(...)` into the built `.d.ts`
and watching it fail by name.

Tests: 1405 Rust (unchanged), 27 node + 4 bundle (was 26 + 3), 24 chromium
harness (was 23).

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

246 lines
12 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.
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);
await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
});