d750b433b8
The rule is the deliverable: nothing a user expects to be fixed may move.
Four children of <header> were sized by their own content and all four
changed on the SAME event, so the bar reflowed on a mode change with no
message at all: #mode (mode_label, 4-14 chars), #hint (hint_line, one
line per mode), #doc-name (gains and loses " [+]") and #view-name, which
had :empty { display: none } and so appeared out of nothing.
Every child that changes with a keystroke now has an allocated width, and
#hint is the single elastic child, so nothing is positioned by it. The
mode pill's reserve is a FLOOR and deliberately not a cap: a cap absorbs
an over-long label inside a box that never grows, which is a reserve that
hides its own failure. AppMode's exhaustive-match methods make the
compiler demand mode_label for a new variant; nothing checked the CSS
still fit it. harness/tests/header.spec.js is that check.
The spec found what the ticket did not predict: the compounding one was
not the pill. #status was flex: 0 1 auto beside a flex: 1 1 auto hint, so
a longer hint took the shrink out of #status, which WRAPPED to two and
three lines and grew the header by a row -- 52.5px to 71.0px. Reserving
the pill alone would have left that untouched.
ViewState::status_msg is now a toast in a position: fixed dock anchored at
its bottom, so it takes part in no layout and stacks ABOVE the minibuffer:
the prompt is neither covered nor displaced. composeStatus is gone -- the
two facts go to two elements, so a repaint reaches onModel and cannot
address onShell at all. That is gotcha 46's rule made structural rather
than kept by a join's convention; its unit test was replaced, not deleted.
Dismissal keys off message IDENTITY, not a timer. status_msg is STATE and
repaint() re-offers it on every revision, so a setTimeout alone resurrects
the toast on the next keypress. Cost stated rather than hidden: two
identical messages in a row show once, because nothing carries a sequence
and the alternative reading of an ambiguous offer is exactly the bug.
Nine of seventeen modes are reachable in a browser and the spec names them
individually; three injected faults are recorded in the evidence file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
553 lines
27 KiB
JavaScript
553 lines
27 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::plain_label` here; the three-column one is the TUI's (771e).
|
|
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("#toast")).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();
|
|
});
|
|
});
|
|
|
|
// The stylesheet's own cascade, which no other test here can reach: an
|
|
// error is a colour now, where it used to be a colour AND an opacity, so
|
|
// the order of two equally-specific rules decides it. See
|
|
// context/test-evidence/clients.md.
|
|
test.describe("the grid's semantic colours survive the cascade", () => {
|
|
test("an error cell stays an error cell when it is also dimmed or selected", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/app/index.html?doc=error-colour.improv");
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
|
|
// The question is about the STYLESHEET, so the combinations are put to it
|
|
// directly: the document that produces each of them is a separate matter,
|
|
// and a fixture per combination would test the fixture.
|
|
const colours = await page.evaluate(() => {
|
|
const row = document.querySelector("#grid tbody tr");
|
|
const read = (classes) => {
|
|
const td = document.createElement("td");
|
|
td.className = classes;
|
|
td.textContent = "#err";
|
|
row.appendChild(td);
|
|
const colour = getComputedStyle(td).color;
|
|
td.remove();
|
|
return colour;
|
|
};
|
|
return {
|
|
// An aggregate error, which is the common case: `editable` is false
|
|
// for every cell that is not a stored record.
|
|
dimmed: read("error"),
|
|
selected: read("error selected"),
|
|
typing: read("error typing"),
|
|
// What "dimmed" must NOT be, read off a plain unsettable cell.
|
|
dim: read(""),
|
|
// …and what it must be, read off an error the dimming cannot reach.
|
|
error: read("error editable"),
|
|
};
|
|
});
|
|
|
|
expect(colours.error).not.toBe(colours.dim);
|
|
expect(colours.dimmed).toBe(colours.error);
|
|
expect(colours.selected).toBe(colours.error);
|
|
expect(colours.typing).toBe(colours.error);
|
|
});
|
|
});
|