Files
improvise/harness/tests/header.spec.js
T
Edward Langley 9eb83538fe A message beside its open editor stands in the browser too (improvise-1x9h)
The toast hid every status after six seconds, so a refusal the user was
still fixing vanished with its prompt open: su4u's bug on a timer. The
browser now asks the TUI's question. EditorSession moves into
improvise-protocol, ClientState records which editor a status was
written in, and RenderModel::status_stands tells the toast to hold it
until that editor closes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:08:26 -07:00

220 lines
9.7 KiB
JavaScript

// 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();
});
});