// 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 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"]); }); }); 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