Files
improvise/web/tests/dispatch.test.js
T
Edward Langley a93d668e5e feat(browser): paint the page bar and the totals strip, and delegate the pointer
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected
to every session and folded correctly by every client, and NO front end read
either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` —
drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally
off that same `page_coords`, so a user could page through quarters watching
every number change with no way to tell which one they were looking at.

`RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`.
The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from
`items.is_some()`, which disagrees with it on an empty item list (gotcha 27).
The totals are RELAYED, never summed in the client: the sum runs over every row
including ones a viewport scrolled past (gotcha 44).

`web/tests/render_surface.test.js` gains the direction that would have caught
this when improvise-n10 added the fields. The existing guard is closed under
what `RenderModel` chose to expose, so a cache field that never reaches it is
invisible to it; the new one asks that every `pub` field of every widget cache
is read somewhere the browser runs, or is named as debt with a reason.
`grid.col_widths` is the one entry, pointing at improvise-pcje.

improvise-b8ot: the five clickable surfaces moved from one listener per node,
reallocated inside the render loops on every repaint, to one handler per stable
delegation root — the user's ruling. The operands are carried on the node as
data attributes and read back by a pure `cellAction`/`indexAction` in
`web/shell.js`, so the handler is a LOOKUP rather than a second derivation of
which data row was clicked: `gridPlan` counts data rows because a group header
is not one, and walking the table back up would disagree the moment one appears.
The editor's own keydown/blur stay direct — one transient node, one at a time,
and `blur` does not bubble.

All four documented behaviours a naive delegation loses are preserved and each
is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click
(asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell,
the conditionally skipped repaint (asserted by the editor's survival), and
closing a live editor before composing the click.

Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595);
node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed /
4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:21:57 -07:00

109 lines
4.1 KiB
JavaScript

// What a node in a delegated surface MEANS, and what the page bar reads as.
// Both are decisions, so neither needs a browser — context/code/clients.md.
import { test } from "node:test";
import assert from "node:assert/strict";
import { cellAction, cellAttrs, gridPlan, indexAction, pageEntries } from "../shell.js";
const cell = (text, extra = {}) => ({
text,
edit_text: text,
numeric: true,
error: false,
selected: false,
editable: true,
...extra,
});
/// The shape the row index has to survive: a group header between data rows,
/// which is exactly what walking the table back up would get wrong.
function grouped() {
return {
row_levels: 1,
col_levels: [["Revenue", "Cost"]],
col_groups: [],
totals: [],
editing: null,
rows: [
{ kind: "group", label: "Hot", collapsed: false },
{ kind: "data", labels: ["Espresso"], cells: [cell("1"), cell("2")] },
{ kind: "group", label: "Cold", collapsed: true },
{
kind: "data",
labels: ["Cold Brew"],
cells: [cell("3", { editable: false }), cell("4", { edit_text: "4000" })],
},
],
};
}
test("a cell's attributes carry the plan's answers back to the handler", () => {
const plan = gridPlan(grouped());
const last = plan.body.at(-1).cells[1];
const action = cellAction(cellAttrs(last), last.editable);
assert.deepEqual(action, { row: 1, col: 1, editable: true, editText: "4000" });
});
// improvise-b8ot: the DATA row index is what a click sends, and `gridPlan`
// counts it because a group header is not a data row. A delegated handler
// that inferred the row from the node's position in the table would say 3.
test("the row a delegated click means is the data index, not the table row", () => {
const plan = gridPlan(grouped());
const rows = plan.body
.filter((r) => r.kind === "data")
.map((r) => cellAction(cellAttrs(r.cells[0]), r.cells[0].editable).row);
assert.deepEqual(rows, [0, 1]);
});
test("editable is the class, so an unsettable cell opens no editor", () => {
const plan = gridPlan(grouped());
const aggregate = plan.body.at(-1).cells[0];
assert.equal(aggregate.classes.editable, false);
assert.equal(cellAction(cellAttrs(aggregate), false).editable, false);
});
// Every header, the corner and the totals strip reach the delegated handler
// too. None of them is a cell, and `Number("")` is 0 rather than nothing.
test("a node carrying no coordinates means nothing at all", () => {
assert.equal(cellAction({}, false), null);
assert.equal(cellAction({ row: "0" }, false), null);
assert.equal(cellAction({ row: "", col: "" }, false), null);
assert.equal(cellAction({ row: "x", col: "0" }, false), null);
assert.equal(cellAction({ row: "1.5", col: "0" }, false), null);
});
test("an index surface reads its row back, and nothing else", () => {
assert.equal(indexAction({ index: "0" }), 0);
assert.equal(indexAction({ index: "12" }), 12);
assert.equal(indexAction({}), null);
assert.equal(indexAction({ index: "" }), null);
assert.equal(indexAction({ index: "-1" }), null);
});
// improvise-p7lh: a grid filtered to Q1 said nothing at all, and `[`/`]`
// moved it in silence. The bar is what says which quarter this is.
test("the page bar names the category and the item it is fixed to", () => {
const [entry] = pageEntries({
page_coords: [{ cat: "Quarter", selected: "Q2", cycle: { at: 1, count: 4 } }],
});
assert.equal(entry.text, "Quarter = Q2");
assert.equal(entry.position, "2/4", "one-based, as a user counts");
assert.equal(entry.navigable, true);
});
// Gotcha 27: Page is navigable and Filter is not. A Filter axis offers no
// position, because there is no key that would move it.
test("a filter axis is shown, and is not offered as something to cycle", () => {
const [entry] = pageEntries({
page_coords: [{ cat: "Region", selected: "North", cycle: null }],
});
assert.equal(entry.text, "Region = North");
assert.equal(entry.position, "");
assert.equal(entry.navigable, false);
});
test("no fixed categories is an empty bar, not a blank one", () => {
assert.deepEqual(pageEntries({ page_coords: [] }), []);
});