36ade35240
E2E and integration tests are rare and pin boundary contracts; unit tests assume those contracts and test between them (user, 2026-09-20). The browser half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS — and everything between them needed no browser at all. `web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every question `gridTable` used to answer while creating elements — the data-row index across group headers, what a group header spans, a null group label against an empty one, an empty edit buffer against no editor — and `app.js` is now a loop that builds elements from it. Painting stays untested by Node; what to paint stopped being untestable. 26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle, no npm install), plus three in `web/tests/bundle/` that read the generated .d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to 16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone, replaced by `contract.spec.js`, which asserts that EVERY field the render model projects reaches the screen. The four widgets those specs were written for were one failure at one boundary, and one assertion catches the fifth. It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element (improvise-5jna, already open). They are named in `UNPAINTED` with the ticket, so the list is the debt written down rather than an exemption. Thirteen faults injected into the Node suite and eight into the served page, each watched to fail the test it names; the last of those was strengthened because the first version passed. `web/assemble.sh` grew an import-completeness guard whose own first version was blind to the multi-line import it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
111 lines
3.7 KiB
JavaScript
111 lines
3.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 }],
|
|
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");
|
|
});
|