22bb621dd8
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
296 lines
15 KiB
JavaScript
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);
|
|
});
|
|
});
|