852e3f8659
improvise-jq8l. `web/app.js` called `scrollIntoView` NOWHERE, so a selection moved past the bottom of `#grid` stayed there, invisible, with no way back but scrolling by hand. What the ticket did not predict: this is NOT a projection bug and NOT a `row_offset` bug, and the acceptance criteria's talk of reconciling the absolute selection against the announced window describes work `render.rs` already does. The row had been sent. `#grid` has `overflow: auto`, `ROW_PX`/`COL_PX` UNDER-estimate (a real row measures 28.6px against the assumed 26) and inline group rows are deliberately not charged for at all, so the page renders more rows than its own box can show and the overflow is the DOM's. `block: "nearest"` is the whole option: it scrolls only when the element is out of view, and it scrolls the nearest container rather than the document. The expensive half was the fixture. Neither shipped document can reach the state — `web/demo.improv` is four rows, `harness/fixtures/grouped.improv` is eight, and a probe measured BOTH at `scrollHeight === clientHeight` in a 484px box. The assertion would have passed against a page that never scrolls, which is why the spec asserts the overflow exists before it presses a key. `harness/fixtures/tall.improv` is thirty rows in three groups; measured against the unfixed page it gives 105px of overflow with the selected cell 96px below the visible bottom and `scrollTop` still 0. Tests: 25 chromium harness (was 24), Rust and node unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
290 lines
15 KiB
JavaScript
290 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.
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|