// The app bar holds still (improvise-n6t8). The rule is that nothing a user // expects to be fixed may move, and the CSS reserve is only half of it: the // other half is this file, because nothing else checks that the reserve still // fits. See context/code/clients.md. import { test, expect } from "@playwright/test"; /// The shipped demo, focused, with a grid on screen. `#grid` must have focus /// or the page's keydown listener never sees a key and every mode below stays /// NORMAL — which would make every assertion here pass for the wrong reason. async function theDemo(page) { await page.goto("/app/index.html"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await page.locator("#grid").focus(); await expect(page.locator("#grid td.editable").first()).toBeVisible(); } /// Every child of the bar, by id, with the bar itself. Measured rather than /// predicted: a reserve that has stopped fitting shows up here as a box that /// moved, whatever the stylesheet says it should be. async function headerBoxes(page) { return page.evaluate(() => { const box = (node) => { const { x, y, width, height } = node.getBoundingClientRect(); return { x, y, width, height }; }; const header = document.querySelector("header"); const children = {}; for (const child of header.children) children[child.id || child.tagName] = box(child); return { header: box(header), children }; }); } /// The keys that reach each mode from NORMAL, and the label each must show. /// NINE of the seventeen `mode_label` variants: `SavePrompt`, `ExportPrompt` /// and `CommandMode` are refused by `resolve::stranding`, `RecordsNormal` and /// `RecordsEditing` decline out loud, `ImportWizard` has no browser entry and /// `Quit` is not a screen. Named individually because "every mode" over a /// four-mode walk is the overstatement this ticket asked not to make. /// `exit` is spelled out because the two families leave differently: a panel /// is closed by its own toggle key (Escape drops focus but leaves it open, so /// the mode does not change), and everything else answers Escape. const MODES = [ { keys: [], label: "NORMAL", exit: [] }, { keys: ["C"], label: "CATEGORIES", exit: ["C"] }, { keys: ["F"], label: "FORMULAS", exit: ["F"] }, { keys: ["V"], label: "VIEWS", exit: ["V"] }, { keys: ["T"], label: "TILES", exit: ["Escape"] }, { keys: ["?"], label: "HELP", exit: ["Escape"] }, { keys: ["a"], label: "INSERT", exit: ["Escape"] }, // Escape leaves a text-entry mode for its EXIT mode, not for Normal // (gotcha 11), and both of these exit into the panel that owns them. { keys: ["N"], label: "NEW CATEGORY", exit: ["Escape", "C"] }, { keys: ["F", "n"], label: "FORMULA", exit: ["Escape", "F"] }, ]; test.describe("the app bar is reserved, not fitted", () => { // The pill is sized by `help::mode_label`, and the labels differ in length. // Before the reserve, every mode change resized it and shifted #hint, // #status and the whole right-hand end of the bar with it. test("no element of the bar moves as the mode changes", async ({ page }) => { await theDemo(page); const first = await headerBoxes(page); const visited = []; for (const { keys, label, exit } of MODES) { for (const key of keys) await page.keyboard.press(key); // The precondition, and the thing that makes the walk nine rather than // one: a key that did not reach its mode would leave NORMAL's boxes and // every comparison below would hold vacuously. await expect(page.locator("#mode")).toHaveText(label); visited.push(label); expect(await headerBoxes(page), `the bar moved in ${label}`).toEqual(first); for (const key of exit) await page.keyboard.press(key); await expect(page.locator("#mode")).toHaveText("NORMAL"); } expect(visited).toEqual(MODES.map((m) => m.label)); }); // The check-the-checker. A reserve sized by eye regresses silently the day // a longer `AppMode` label is added, so the measurement above has to be // able to SEE one — which a `max-width` or a fixed `width` on the pill // would prevent, by absorbing the overflow instead of growing. test("the measurement sees a label longer than the reserve, and not one that fits", async ({ page, }) => { await theDemo(page); const first = await headerBoxes(page); // Sixteen characters: what the reserve claims to hold. The longest label // the browser can reach is NEW CATEGORY at twelve, and the longest in the // enum is RECORDS INSERT at fourteen, so this is the claim under test. await page.evaluate(() => { document.getElementById("mode").textContent = "ABCDEFGHIJKLMNOP"; }); expect(await headerBoxes(page), "the reserve does not hold sixteen characters").toEqual( first, ); // And the fault: a label the reserve cannot hold. If this passed, the // walk above would be measuring something that cannot fail. await page.evaluate(() => { document.getElementById("mode").textContent = "A".repeat(40); }); expect(await headerBoxes(page)).not.toEqual(first); }); // `status_msg` reached #status, which is in the bar, so every refusal the // client makes reflowed the header. It is a toast now, out of flow. test("a status message changes nothing in the bar and overlaps the grid", async ({ page, }) => { await theDemo(page); const before = await headerBoxes(page); const grid = await page.locator("#grid").boundingBox(); // A key this client declines, which is the message the user reported. await page.keyboard.press(":"); await expect(page.locator("#toast")).toContainText( "the command line is not available in the browser yet", ); await expect(page.locator("#toast")).toBeVisible(); expect(await headerBoxes(page)).toEqual(before); expect(await page.locator("#grid").boundingBox()).toEqual(grid); // Out of flow means OVER, not beside: a toast in a strip of its own would // have taken its height from the grid and the two boxes above would move. const toast = await page.locator("#toast").boundingBox(); expect(toast.y).toBeLessThan(grid.y + grid.height); expect(toast.y + toast.height).toBeGreaterThan(grid.y); }); // The trap the ticket names: `status_msg` is STATE, so `repaint()` offers // the same string on every keydown. A toast on a timer alone comes back the // moment the user touches the keyboard, which is worse than one that stays. test("a dismissed message does not come back on the next keypress", async ({ page }) => { await page.clock.install(); await theDemo(page); await page.keyboard.press(":"); await expect(page.locator("#toast")).toBeVisible(); await page.clock.runFor(10_000); await expect(page.locator("#toast")).toBeHidden(); // A cursor key: it changes the revision, so `repaint()` runs in full and // re-offers the identical `status_msg` — nothing in Rust cleared it. await page.keyboard.press("ArrowDown"); await expect(page.locator("#grid td.selected")).toHaveCount(1); await expect(page.locator("#toast")).toBeHidden(); // And the element still holds what was last said, so the assertion above // is about visibility rather than about an emptied node. await expect(page.locator("#toast")).toContainText("command line"); }); // What happens when both are present. The dock is anchored at its bottom // and stacks upward, so the prompt is neither covered nor displaced. The // toast is forced rather than provoked: a host message arriving DURING text // entry is reachable but has no keyboard route, and the rule under test is // the stylesheet's, not the route's. test("a toast does not cover or displace an open minibuffer", async ({ page }) => { await theDemo(page); await page.keyboard.press("a"); await expect(page.locator("#mode")).toHaveText("INSERT"); await expect(page.locator("#minibuffer")).toBeVisible(); const alone = await page.locator("#minibuffer").boundingBox(); await page.evaluate(() => { const toast = document.getElementById("toast"); toast.textContent = "a message that arrived while you were typing"; toast.hidden = false; }); await expect(page.locator("#toast")).toBeVisible(); expect(await page.locator("#minibuffer").boundingBox()).toEqual(alone); const toast = await page.locator("#toast").boundingBox(); expect(toast.y + toast.height).toBeLessThanOrEqual(alone.y); }); // improvise-1x9h. Whether a message stands is Rust's answer; this is the // wire from the render model to the toast's timer, which node cannot see. test("a message written beside an open minibuffer outlives the timer", async ({ page }) => { await page.addInitScript(() => { const Real = window.Worker; window.Worker = class extends Real { constructor(...args) { super(...args); window.__worker = this; } }; }); await page.clock.install(); await theDemo(page); await page.keyboard.press("F"); await page.keyboard.press("n"); await expect(page.locator("#mode")).toHaveText("FORMULA"); // A host message arriving during text entry has no keyboard route in the // demo, so the frame is delivered as the worker would deliver it. await page.evaluate(() => { const frame = JSON.stringify({ Commands: [{ SetStatus: "Formula error" }] }); window.__worker.dispatchEvent(new MessageEvent("message", { data: frame })); }); await expect(page.locator("#toast")).toContainText("Formula error"); await page.clock.runFor(10_000); await expect(page.locator("#toast")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.locator("#mode")).toHaveText("FORMULAS"); await expect(page.locator("#toast")).toBeHidden(); }); });