Files
improvise/web/tests/grid_plan.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

138 lines
4.7 KiB
JavaScript

// The grid's plan: the decisions `gridTable` used to make while creating
// elements, where a wrong answer showed up as a wrong pixel.
import { test } from "node:test";
import assert from "node:assert/strict";
import { gridPlan } from "../shell.js";
const cell = (text, extra = {}) => ({
text,
edit_text: text,
numeric: true,
error: false,
selected: false,
editable: true,
...extra,
});
/// A grid with a group header between its data rows — the shape the row
/// index has to survive. Two column levels under one group run.
function grouped() {
return {
row_levels: 2,
col_levels: [["Q1", ""], ["Revenue", "Cost"]],
col_groups: [{ span: 2, label: "2026", collapsed: false }],
totals: [],
editing: null,
rows: [
{ kind: "group", label: "Hot", collapsed: false },
{ kind: "data", labels: ["Hot", "Espresso"], cells: [cell("1"), cell("2")] },
{ kind: "data", labels: ["", "Latte"], cells: [cell("3"), cell("4")] },
{ kind: "group", label: "Cold", collapsed: true },
{ kind: "data", labels: ["Cold", "Cold Brew"], cells: [cell("5"), cell("6")] },
],
};
}
test("a group header consumes no data row index", () => {
const plan = gridPlan(grouped());
const data = plan.body.filter((r) => r.kind === "data");
assert.deepEqual(data.map((r) => r.row), [0, 1, 2]);
// And every cell in a row carries that row's index, not its position in
// `rows` — the coordinate a click sends.
assert.deepEqual(
data.at(-1).cells.map((c) => [c.row, c.col]),
[[2, 0], [2, 1]],
);
});
test("a group header spans the labels and the cells together", () => {
const plan = gridPlan(grouped());
const groups = plan.body.filter((r) => r.kind === "group");
assert.deepEqual(groups.map((r) => r.colSpan), [4, 4]);
assert.deepEqual(groups.map((r) => r.text), ["▼ Hot", "▶ Cold"]);
assert.equal(plan.cornerSpan, 2);
});
test("a column run in no group is blank, and an empty name is a name", () => {
const model = grouped();
model.col_groups = [
{ span: 1, label: null, collapsed: false },
{ span: 1, label: "", collapsed: true },
];
const plan = gridPlan(model);
assert.deepEqual(plan.groups, [
{ span: 1, text: "" },
{ span: 1, text: "▶ " },
]);
});
test("the typed text replaces only the selected cell, and carries a caret", () => {
const model = grouped();
model.editing = "12";
model.rows[1].cells[1].selected = true;
const plan = gridPlan(model);
const row = plan.body[1];
assert.equal(row.cells[0].text, "1");
assert.equal(row.cells[1].text, "12▏");
assert.equal(row.cells[1].classes.typing, true);
// A cell being typed into is not drawn as a number: the buffer is text.
assert.equal(row.cells[1].classes.numeric, false);
assert.equal(row.cells[0].classes.numeric, true);
});
test("a selected cell with no editor open is not typing", () => {
const model = grouped();
model.editing = null;
model.rows[1].cells[0].selected = true;
const plan = gridPlan(model);
const first = plan.body[1].cells[0];
assert.equal(first.classes.typing, false);
assert.equal(first.classes.selected, true);
assert.equal(first.text, "1");
});
test("an empty edit buffer still means an editor is open", () => {
const model = grouped();
model.editing = "";
model.rows[1].cells[0].selected = true;
const plan = gridPlan(model);
assert.equal(plan.body[1].cells[0].classes.typing, true);
assert.equal(plan.body[1].cells[0].text, "▏");
});
test("the editor is seeded from the stored value, not the drawn one", () => {
const model = grouped();
model.rows[1].cells[0] = cell("20,000", { edit_text: "20000" });
const plan = gridPlan(model);
assert.equal(plan.body[1].cells[0].text, "20,000");
assert.equal(plan.body[1].cells[0].editText, "20000");
});
test("an empty totals strip is no strip at all, not a row of blanks", () => {
// Gotcha 33: an EMPTY strip means this grid has no totals — records mode,
// or an empty grid — so there is nothing to draw and nothing to pad.
assert.equal(gridPlan(grouped()).totals, null);
});
test("the totals strip is relayed, and its label spans the row headers", () => {
const model = grouped();
model.totals = [
{ text: "9,001", numeric: true, error: false },
{ text: "err", numeric: false, error: true },
];
const plan = gridPlan(model);
assert.equal(plan.totals.span, 2, "the label spans every row-header level");
assert.equal(plan.totals.label, "Total");
assert.deepEqual(
plan.totals.cells.map((c) => [c.text, c.classes.numeric, c.classes.error]),
[
["9,001", true, false],
["err", false, true],
],
);
// And it carries no coordinates: a total is not a cell a click can name.
assert.ok(!("row" in plan.totals.cells[0]));
});