8ad5c0806d
Three things the user reported after looking at the deployed page, in the order they cost a visitor something. The deployment notice was ten dense lines of configuration reference — ?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the category tree and the grid on every visit: about 230px of an 820px window. It is now a <details>, so the collapsing is the BROWSER's (keyboard operable, and correct with app.js absent or still loading) and the only thing the page adds is remembering the answer. It starts COLLAPSED, and collapsing withholds nothing: the one fact a first visitor needs — the document lives in this tab, is saved to this browser's private storage and never leaves the machine — is the <summary>, which never collapses. What a stored value means is a decision and lives in shell.js; touching localStorage is a browser API and stays in app.js, wrapped, with every value that is not one of the two spellings read as "unanswered". The minibuffer was a 14px line in the page's bottom-left corner, up to 800px from the cell being typed into. It is now a raised bar over the grid. The CONTENT is untouched — RenderMinibuffer is built from `state.view.mode.minibuffer()`, the same semantic value the TUI draws — so this is position, weight and contrast, and there is no second derivation of what a prompt says. MinibufColor still resolves here exactly as draw.rs::minibuf_fg resolves it for the terminal. style.css stops being "legibility only": layered surfaces, one accent, a filled badge for the mode, chips for the two axis strips, one selection treatment, tabular numerals in the grid, and a focus ring on the one keyboard surface the page has. The palette is the landing page's, so the two read as one product. Asserted (harness/tests/contract.spec.js, Chromium): the notice starts collapsed and remembers, in BOTH directions across a reload; it opens from the keyboard, which the page's own keydown handler must not claim; the minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static bottom bar cannot satisfy — and is larger than body text. Each was proved discriminating by injecting the fault and watching it fail. NOT asserted, and named as such in context/code/clients.md: colour, contrast, spacing, weight, the grouped and multi-level header DOM, either theme, and the localStorage-throws path. Those are argued and read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
508 lines
25 KiB
JavaScript
508 lines
25 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";
|
|
|
|
// The page's own key, imported rather than spelled: a spec with its own
|
|
// copy would pass while reading a key nothing writes (shell.spec.js does
|
|
// the same with the handshake window).
|
|
import { NOTICE_KEY } from "../../web/shell.js";
|
|
|
|
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"]);
|
|
});
|
|
|
|
// improvise-p7lh. `page_coords` and `totals` were projected to every session
|
|
// and folded correctly, and NO front end read either: the shipped front page
|
|
// showed a grid filtered to Q1 with nothing on screen saying so, and `]`
|
|
// changed every number in silence.
|
|
test("the page bar names the quarter, and both it and the totals follow `]`", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page);
|
|
|
|
// `web/demo.improv` is `Quarter: page, Q1` — the fact this bead is about.
|
|
await expect(page.locator("#pages")).toBeVisible();
|
|
await expect(page.locator("#pages .page-axis")).toHaveText("Quarter = Q1 (1/4)");
|
|
// Page is navigable where Filter is not (gotcha 27), and that is what
|
|
// says `[`/`]` will move it.
|
|
await expect(page.locator("#pages .page-axis")).toHaveClass(/navigable/);
|
|
|
|
// The totals strip. Q1 Revenue is 18400+24600+9200+11500 — a sum over
|
|
// EVERY row, which is why it is projected and not added up here.
|
|
await expect(page.locator("#grid tfoot th")).toHaveText("Total");
|
|
await expect(page.locator("#grid tfoot td").first()).toHaveText("63,700");
|
|
// A total is not a cell anyone may type into.
|
|
await expect(page.locator("#grid tfoot td.editable")).toHaveCount(0);
|
|
|
|
await page.keyboard.press("]");
|
|
|
|
// BOTH move, and the numbers are Q2's: a bar that showed a stale quarter
|
|
// over fresh numbers is the exact lie this bead names.
|
|
await expect(page.locator("#pages .page-axis")).toHaveText("Quarter = Q2 (2/4)", {
|
|
timeout: 10_000,
|
|
});
|
|
await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400");
|
|
});
|
|
});
|
|
|
|
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 <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);
|
|
});
|
|
|
|
// improvise-b8ot moved these five surfaces onto one handler per stable root.
|
|
// The three tests below are the properties a naive delegation loses, each of
|
|
// which is silent: the page still paints, and only the pointer stops working.
|
|
// The fourth — closing a live editor FIRST — is the test above, which asserts
|
|
// the old editor is gone and the new one is open with no Escape between.
|
|
test("a mousedown alone reaches the grid, because click comes too late", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page);
|
|
const cells = page.locator("#grid tbody td");
|
|
|
|
// MOUSEDOWN ONLY, dispatched with no click behind it. A handler bound to
|
|
// `click` does nothing here, which is the whole of improvise-0x9u: from an
|
|
// editing state the blur repaints and the <td> is gone before click fires.
|
|
await cells.nth(4).dispatchEvent("mousedown");
|
|
await expect(cells.nth(4).locator("input")).toHaveCount(1);
|
|
|
|
// And the repaint is SKIPPED on that branch: a repaint would take the
|
|
// <input> away, so its survival is the assertion.
|
|
await page.waitForTimeout(300);
|
|
await expect(page.locator("#grid input")).toHaveCount(1);
|
|
});
|
|
|
|
test("the grid prevents its own default, and only over a cell", async ({ page }) => {
|
|
await openApp(page);
|
|
// `window` is last in the bubble path, so it sees what #grid's handler did.
|
|
await page.evaluate(() => {
|
|
window.__defaultPrevented = null;
|
|
window.addEventListener("mousedown", (e) => {
|
|
window.__defaultPrevented = e.defaultPrevented;
|
|
});
|
|
});
|
|
|
|
await page.locator("#grid tbody td").nth(1).dispatchEvent("mousedown");
|
|
expect(await page.evaluate(() => window.__defaultPrevented)).toBe(true);
|
|
|
|
// A header carried no listener before delegation, so preventing ITS
|
|
// default would be new behaviour — the check must be on the cell, not the
|
|
// root. Without this the first assertion passes for a blanket handler.
|
|
await page.evaluate(() => {
|
|
window.__defaultPrevented = null;
|
|
});
|
|
await page.locator("#grid thead th").last().dispatchEvent("mousedown");
|
|
expect(await page.evaluate(() => window.__defaultPrevented)).toBe(false);
|
|
});
|
|
|
|
test("a click's coordinates are read off the node, not closed over", async ({
|
|
page,
|
|
}) => {
|
|
await openApp(page);
|
|
const cells = page.locator("#grid tbody td");
|
|
await expect(cells.nth(0)).toHaveClass(/selected/);
|
|
|
|
// Rewriting the attribute must change where the click lands. A per-node
|
|
// closure over `gridPlan`'s answer ignores it and this fails — which is
|
|
// what makes the delegated handler a LOOKUP rather than a second
|
|
// derivation of which data row was clicked (improvise-b8ot).
|
|
await cells.nth(0).evaluate((td) => {
|
|
td.dataset.row = "2";
|
|
});
|
|
await cells.nth(0).dispatchEvent("mousedown");
|
|
await page.keyboard.press("Escape");
|
|
|
|
const thirdRow = page.locator("#grid tbody tr").nth(2).locator("td").first();
|
|
await expect(thirdRow).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);
|
|
});
|
|
});
|
|
|
|
// The page's own chrome: what a visitor lands on, and where the prompt is.
|
|
// Only a browser can answer either — both are facts about layout and about
|
|
// storage that survives a reload. What these CANNOT see is the rest of the
|
|
// styling; that residue is named in context/code/clients.md.
|
|
test.describe("the shell puts the application first", () => {
|
|
const notice = (page) => page.locator("#backend-notice");
|
|
const stored = (page) => page.evaluate((k) => localStorage.getItem(k), NOTICE_KEY);
|
|
|
|
// The deployment notice is ten lines of configuration reference — ?doc=,
|
|
// ?seed=, ?ws=, wss:// and reverse proxies — and it was above the fold on
|
|
// every visit. See context/test-evidence/clients.md.
|
|
test("the deployment notice starts collapsed and remembers being opened", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/app/index.html?doc=notice-memory.improv");
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
|
|
// The one sentence a visitor needs is never collapsed; the reference is.
|
|
await expect(page.locator("#backend-notice .notice-lede")).toBeVisible();
|
|
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
|
|
|
// BOTH directions, because a page that ignores storage passes the first
|
|
// half and a page that always opens passes the second (design-principles
|
|
// §7: assert the direction the default cannot supply).
|
|
await notice(page).locator("summary").click();
|
|
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
|
|
// `toggle` is dispatched as a task, not synchronously with the click, so
|
|
// the answer must be OBSERVED as stored before a reload can be said to
|
|
// have read it back. Without this the reload races the write.
|
|
await expect.poll(() => stored(page)).toBe("open");
|
|
await page.reload();
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
|
|
|
|
await notice(page).locator("summary").click();
|
|
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
|
await expect.poll(() => stored(page)).toBe("closed");
|
|
await page.reload();
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
|
});
|
|
|
|
// The `<details>` is the browser's control, and the page's own keydown
|
|
// handler claims Enter. Without the guard a keyboard visitor could focus
|
|
// the summary and not open it. See context/test-evidence/clients.md.
|
|
test("the notice opens from the keyboard, which the grid's own handler must not claim", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/app/index.html?doc=notice-keyboard.improv");
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
await expect(page.locator("#backend-notice .notice-body")).toBeHidden();
|
|
|
|
await notice(page).locator("summary").focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.locator("#backend-notice .notice-body")).toBeVisible();
|
|
});
|
|
|
|
// The prompt was a small line in the page's bottom-left corner, 800px from
|
|
// the cell being typed into. See context/test-evidence/clients.md.
|
|
test("the minibuffer is raised over the grid rather than filed under it", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/app/index.html?doc=minibuffer-place.improv");
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
await page.locator("#grid").focus();
|
|
await expect(page.locator("#minibuffer")).toBeHidden();
|
|
|
|
// `a` is the one text-entry room this client can both enter and commit in
|
|
// (`keys.spec.js` drives the formula one). The precondition is that it
|
|
// really opened, or every measurement below is of a hidden element.
|
|
await page.keyboard.press("a");
|
|
await expect(page.locator("#mode")).toHaveText("INSERT");
|
|
await expect(page.locator("#minibuffer")).toBeVisible();
|
|
|
|
const boxes = await page.evaluate(() => {
|
|
const rect = (id) => {
|
|
const { top, bottom, left, right } = document
|
|
.getElementById(id)
|
|
.getBoundingClientRect();
|
|
return { top, bottom, left, right };
|
|
};
|
|
return {
|
|
mb: rect("minibuffer"),
|
|
grid: rect("grid"),
|
|
viewport: { height: window.innerHeight, width: window.innerWidth },
|
|
mbFont: parseFloat(getComputedStyle(document.getElementById("minibuffer")).fontSize),
|
|
bodyFont: parseFloat(getComputedStyle(document.body).fontSize),
|
|
};
|
|
});
|
|
|
|
// On screen without scrolling: the property a bottom-of-document line
|
|
// does not have once the page is taller than the window.
|
|
expect(boxes.mb.bottom).toBeLessThanOrEqual(boxes.viewport.height);
|
|
expect(boxes.mb.top).toBeGreaterThanOrEqual(0);
|
|
// And OVER the grid, not in a strip beneath it. This is what separates
|
|
// "raised" from "moved": a static bottom bar cannot overlap #grid at all.
|
|
expect(boxes.mb.top).toBeLessThan(boxes.grid.bottom);
|
|
expect(boxes.mb.bottom).toBeGreaterThan(boxes.grid.top);
|
|
// The one prominence claim a browser can check. It was body-sized before.
|
|
expect(boxes.mbFont).toBeGreaterThan(boxes.bodyFont);
|
|
|
|
await page.keyboard.press("Escape");
|
|
await expect(page.locator("#minibuffer")).toBeHidden();
|
|
});
|
|
});
|