Files
improvise/harness/tests/pointer.spec.js
T
Edward Langley 50e4a37214 fix(web): a click is all of its intents, never mutates, and the panel opens
Four user rulings and three review findings, all in the pointer's one
resolver.

improvise-hkr8 — a click is all of its intents or none of them. Each
`click_*` now returns `Option<Vec<Command>>` and `click` does the single
`unwrap_or_default`. The `None` that matters is `BasisCache::claim`
answering that no basis has been published: the click used to keep its
navigation prefix, drop its activation and report success, while the
keyboard handled the identical `None` correctly as `KeyOutcome::Upstream`.

USER RULING — a tile click SELECTS; a second click on the already-selected
tile CYCLES. Every click in the UI is now non-mutating by default. The
argument that earns the two keystrokes is not tidiness: a cycle is
`LayoutGeometry`, so it bumps `cursor_epoch` and resets EVERY connected
client's cursor (gotcha 24) — the easiest input to trigger by accident was
wired to the one class of change that reaches other people. The divergence
from `Enter`, which still cycles in one keystroke, is chosen and recorded.
"Already selected" is mode AND index, because `tile_cat_idx` is 0 before
anything selects anything and tile 0 would otherwise cycle on first click.

USER RULING — the category panel is open at launch, as the shared
`ViewState::default()` rather than a browser special case. A browser-only
default is "the page overrides the flag", which is the same defect as the
"the page ignores the flag" bug improvise-wsaw fixed. This changes the TUI
too: `src/draw.rs` draws the panel off the same field.

Three review findings, all "a guard that would not catch the drift it is
named for":

- The formula-row `assert!` could never fail — the `assert_eq!` above it
  pins the same vector and aborts first. Replaced by an assertion aimed at
  the keymap itself, which is where the fact lives; the reviewer had shown
  that adding `Enter` to FormulaPanel left 1381 tests green while the click
  and the keyboard disagreed.
- `click_view_row` re-spelled `SwitchView + ChangeMode` inline instead of
  sharing a helper, so a change forced into the keyboard arm by lockstep
  left the click behind silently. Both now call `switch_view_at`.
- `context/test-evidence/clients.md` documented the behaviour PR #59 ships
  as one of the injected faults that disproved it, and had no entry for
  #59's new test. Both fixed; the checker's one-directional blind spot that
  hid it is filed as improvise-lyyc.

Every change is proved by injecting the fault: six injections, six distinct
failure signatures, none passing for another's reason.

Gates: 1389 workspace tests (was 1381), clippy -D warnings, fmt, harness
fmt, all eight wasm crates, 27/27 Chromium harness, 189 cited ticket IDs
resolve, comment runs and evidence headings clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 13:45:31 -07:00

234 lines
9.8 KiB
JavaScript

// The pointer: improvise-7ybz (a click resolves in Rust) and improvise-wsaw
// (the sidebar obeys its flag). History: context/test-evidence/clients.md.
import { test, expect } from "@playwright/test";
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();
}
/// Leave the panel open and the keyboard in Normal — so a later click has a
/// focus change to make. It opens at launch, so `C` here only focuses it.
async function openPanelThenLeave(page) {
await expect(page.locator("#categories")).toBeVisible();
await page.keyboard.press("Shift+C");
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
await page.keyboard.press("Escape");
await expect(page.locator("#mode")).toHaveText("NORMAL");
await expect(page.locator("#categories")).toBeVisible();
}
test.describe("the category sidebar obeys category_panel_open", () => {
// improvise-wsaw. The flag existed from the start and app.js never read it,
// so the sidebar was drawn always and `C` changed only the mode label.
// The launch state is the user's later ruling; the toggle is wsaw's. Both
// are asserted, because either alone passes against the bug wsaw fixed.
test("the sidebar is open at launch, C focuses it, and C again hides it", async ({
page,
}) => {
await openApp(page);
await expect(page.locator("#categories")).toBeVisible();
await expect(page.locator("#categories li").first()).toBeVisible();
await expect(page.locator("#mode")).toHaveText("NORMAL");
// Open but not focused, so the first press focuses rather than closes.
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeVisible();
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
// And now it closes — which is the half that proves the page reads the
// flag rather than drawing the sidebar unconditionally.
await page.keyboard.press("Shift+C");
await expect(page.locator("#categories")).toBeHidden();
await expect(page.locator("#mode")).toHaveText("NORMAL");
});
});
test.describe("clicking the category panel", () => {
// improvise-7ybz. One click means three things — focus the panel, move its
// cursor, activate the row — and all three are resolved in Rust.
test("a click focuses the panel, moves the cursor and expands the row", async ({
page,
}) => {
await openApp(page);
await openPanelThenLeave(page);
const product = page.locator("#categories li").filter({ hasText: "Product" });
await expect(product).toHaveText(/▶ Product \(4\)/);
await expect(page.locator("#categories li.selected")).toHaveText(/_Index/);
await product.click();
// All three, and the third is what the user reported as impossible.
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
await expect(page.locator("#categories li.selected")).toHaveText(/Product/);
await expect(product).toHaveText(/▼ Product \(4\)/);
await expect(page.locator("#categories li.item")).toHaveText([
"Espresso",
"Latte",
"Cold Brew",
"Pastry",
]);
// Clicking it again collapses: the click mirrors Enter, which is a toggle.
await product.click();
await expect(page.locator("#categories li.item")).toHaveCount(0);
});
// The other arm of `filter-to-item`: on an ITEM row the same click filters,
// because the rule is "do what Enter does here" rather than "expand".
test("a click on an item row filters, as Enter does", async ({ page }) => {
await openApp(page);
await openPanelThenLeave(page);
await page.locator("#categories li").filter({ hasText: "Product" }).click();
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");
});
});
test.describe("clicking a tile", () => {
// The user's ruling: a click SELECTS, a second click CYCLES. Every click in
// the UI is non-mutating by default; the cost is two for the common action.
test("the first click selects the tile and changes nothing", async ({ page }) => {
await openApp(page);
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
await expect(product).toHaveText("Product Row");
await product.click();
// The positive half: something did happen, so a green test is not the
// dead-page shape. `Product` is selected and the mode moved.
await expect(page.locator("#mode")).toHaveText("TILES");
await expect(product).toHaveClass(/selected/);
// And the negative half: the document did not move. Waited out rather
// than asserted instantly, since a cycle would arrive asynchronously.
await page.waitForTimeout(500);
await expect(product).toHaveText("Product Row");
await expect(
page.locator("#grid thead th").filter({ hasText: "Espresso" }),
).toHaveCount(0);
});
test("a second click on the selected tile cycles its axis", async ({ page }) => {
await openApp(page);
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
await product.click();
await expect(product).toHaveClass(/selected/);
await product.click();
// Row → Column is `Axis::next`; the page does not spell the rotation.
await expect(product).toHaveText("Product Col", { timeout: 10_000 });
// And the grid moved with it, not just the label.
await expect(
page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(),
).toBeVisible();
});
test("clicking a different tile selects it rather than cycling it", async ({
page,
}) => {
await openApp(page);
const product = page.locator("#tiles .tile").filter({ hasText: "Product" });
const quarter = page.locator("#tiles .tile").filter({ hasText: "Quarter" });
await product.click();
await expect(product).toHaveClass(/selected/);
await quarter.click();
// The guarantee has to hold for every click, not only a session's first.
await expect(quarter).toHaveClass(/selected/);
await page.waitForTimeout(500);
await expect(quarter).toHaveText("Quarter Pag");
await expect(product).toHaveText("Product Row");
});
});
test.describe("clicking a grid cell", () => {
// Before improvise-7ybz a click reached only `edit()`, so the selection —
// which every keyboard command reads — stayed wherever it had been.
test("a click moves the selection, not only the editor", async ({ page }) => {
await openApp(page);
const cells = page.locator("#grid tbody td");
// The cursor starts at the origin, so any other cell is a real move.
await expect(cells.nth(0)).toHaveClass(/selected/);
await cells.nth(4).click();
// demo.improv is all Records, so the click also opened an editor; close it
// without committing and the selection must have followed the pointer.
await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/);
await expect(page.locator("#grid tbody td.selected")).toHaveCount(1);
});
// The selection a click leaves is the one the keyboard then moves from —
// the property that makes the two input paths one cursor rather than two.
test("the keyboard continues from where the pointer left the cursor", async ({
page,
}) => {
await openApp(page);
const cells = page.locator("#grid tbody td");
await cells.nth(4).click();
await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/);
await page.keyboard.press("ArrowLeft");
await expect(cells.nth(3)).toHaveClass(/selected/);
});
});
test.describe("clicking the other two panels", () => {
// improvise-iioi. The category panel got a listener and its two siblings did
// not, so a user who had just learned rows are clickable clicked a view row
// and read the silence as broken — which IS the report behind improvise-7ybz.
test("a click on a view row focuses, moves the cursor and switches", async ({
page,
}) => {
await openApp(page);
await page.keyboard.press("Shift+V");
await expect(page.locator("#views")).toBeVisible();
await expect(page.locator("#view-name")).toHaveText("Plan");
await expect(page.locator("#views li.active")).toContainText("Plan");
// Leave the panel open but unfocused, so the focus third is observable.
await page.keyboard.press("Escape");
await expect(page.locator("#mode")).toHaveText("NORMAL");
await page.locator("#views li").filter({ hasText: "Default" }).click();
// Switching ends in Normal — `switch-view-at-cursor` says so — but the
// cursor and the active marker must both have moved to the clicked row.
await expect(page.locator("#view-name")).toHaveText("Default", {
timeout: 10_000,
});
await expect(page.locator("#views li.active")).toContainText("Default");
await expect(page.locator("#views li.selected")).toContainText("Default");
});
// The formula panel binds NO Enter, so the rule yields navigation and
// nothing else. That is the rule applying, not the fix stopping half way.
test("a click on a formula row moves the cursor and does not edit", async ({
page,
}) => {
await openApp(page);
await page.keyboard.press("Shift+F");
await expect(page.locator("#formulas")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("#mode")).toHaveText("NORMAL");
await page.locator("#formulas li").first().click();
// Focused and selected — and still FORMULAS, because there is nothing to
// activate. A mode change here would mean an activation was invented.
await expect(page.locator("#mode")).toHaveText("FORMULAS");
await expect(page.locator("#formulas li.selected")).toHaveCount(1);
await expect(page.locator("#grid input")).toHaveCount(0);
});
});