9eb83538fe
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
220 lines
9.7 KiB
JavaScript
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();
|
|
});
|
|
});
|