a93d668e5e
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
117 lines
5.5 KiB
JavaScript
117 lines
5.5 KiB
JavaScript
// The RenderModel→DOM boundary, statically: a projected field that nothing
|
|
// paints is the bug four widgets shared. See context/code/clients.md.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
|
|
|
|
/// Comments name model fields routinely, so a field mentioned only in one
|
|
/// would read as painted. Strip them before asking what the code touches.
|
|
const code = (rel) =>
|
|
read(rel)
|
|
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
|
|
|
/// Fields the page deliberately does not read, each with the reason. A field
|
|
/// belongs here only with an argument; the list is the debt, written down.
|
|
// Empty since improvise-5jna painted the title bar. Note what this check CANNOT
|
|
// see, and why the harness asserts the painted text too: it is a substring
|
|
// search, so a field named by a function nothing calls reads as painted.
|
|
const UNPAINTED = {};
|
|
|
|
/// The `pub` fields of `RenderModel`, read off the Rust that defines it.
|
|
function renderModelFields() {
|
|
const src = read("../../crates/improvise-wasm-client/src/render.rs");
|
|
const start = src.indexOf("pub struct RenderModel {");
|
|
assert.ok(start > 0, "RenderModel moved — this test reads the struct itself");
|
|
const body = src.slice(start, src.indexOf("\n}", start));
|
|
const fields = [...body.matchAll(/\n {4}pub ([a-z_0-9]+):/g)].map((m) => m[1]);
|
|
assert.ok(fields.length > 10, `found only ${fields.length} fields`);
|
|
return fields;
|
|
}
|
|
|
|
test("every field the render model projects is painted, or named as debt", () => {
|
|
// Both halves of the page: `app.js` paints, `shell.js` plans the grid.
|
|
const page = code("../app.js") + code("../shell.js");
|
|
const unpainted = renderModelFields().filter((field) => !page.includes(`model.${field}`));
|
|
assert.deepEqual(
|
|
unpainted.sort(),
|
|
Object.keys(UNPAINTED).sort(),
|
|
"a projected field reaches no element — paint it, or add it to UNPAINTED with the reason",
|
|
);
|
|
});
|
|
|
|
/// One level up, and the direction that would have caught improvise-p7lh:
|
|
/// the check above is closed under what `RenderModel` chose to expose, so a
|
|
/// cache field that never reaches it is invisible to it. Reason per entry.
|
|
const UNREAD_CACHE = {
|
|
"grid.col_widths":
|
|
"the browser lets the table size its own columns; improvise-pcje is the parity residue",
|
|
};
|
|
|
|
/// `ClientState`'s cache fields, and the `pub` fields of each cache type.
|
|
function cacheFields() {
|
|
const client = read("../../crates/improvise-protocol/src/client.rs");
|
|
const render = read("../../crates/improvise-protocol/src/render.rs");
|
|
const state = client.slice(
|
|
client.indexOf("pub struct ClientState {"),
|
|
client.indexOf("\n}", client.indexOf("pub struct ClientState {")),
|
|
);
|
|
assert.ok(state.length > 0, "ClientState moved — this test reads the struct itself");
|
|
const caches = [...state.matchAll(/\n {4}pub ([a-z_0-9]+): ([A-Za-z]*Cache)\b/g)];
|
|
assert.ok(caches.length >= 5, `found only ${caches.length} widget caches`);
|
|
|
|
return caches.flatMap(([, field, type]) => {
|
|
const start = render.indexOf(`pub struct ${type} {`);
|
|
if (start < 0) return []; // Defined elsewhere (BasisCache): not a drawn widget.
|
|
const body = render.slice(start, render.indexOf("\n}", start));
|
|
return [...body.matchAll(/\n {4}pub ([a-z_0-9]+):/g)].map((m) => `${field}.${m[1]}`);
|
|
});
|
|
}
|
|
|
|
// improvise-p7lh: `GridCache::totals` and `page_coords` were projected, folded
|
|
// and correctly maintained, and no front end read either for months.
|
|
test("every field of every widget cache reaches the browser, or is named as debt", () => {
|
|
// The browser's whole decision surface: what it renders, what it resolves a
|
|
// key against, and the fold. A field none of these names is drawn by nothing.
|
|
// Whitespace-stripped, because rustfmt breaks `state.grid.totals` across
|
|
// three lines and a substring search would then read it as unread.
|
|
const client = [
|
|
"../../crates/improvise-wasm-client/src/render.rs",
|
|
"../../crates/improvise-wasm-client/src/lib.rs",
|
|
"../../crates/improvise-wasm-client/src/socket.rs",
|
|
"../../crates/improvise-protocol/src/resolve.rs",
|
|
]
|
|
.map((rel) => code(rel).replace(/\s+/g, ""))
|
|
.join("\n");
|
|
|
|
const unread = cacheFields().filter((path) => {
|
|
const [cache, field] = path.split(".");
|
|
// Either the field itself is read, or the WHOLE cache is handed to a
|
|
// shared derivation — which is a real hole, named in the evidence file.
|
|
if (client.includes(`state.${cache}.${field}`)) return false;
|
|
if (field === "cells" && client.includes(`state.${cache}.cell(`)) return false;
|
|
// `&state.grid.row_frame` CONTAINS `&state.grid`, so a plain substring
|
|
// test here makes every GridCache field read as handed over. Demonstrated.
|
|
return !new RegExp(`&state\\.${cache}(?![.\\w])`).test(client);
|
|
});
|
|
assert.deepEqual(
|
|
unread.sort(),
|
|
Object.keys(UNREAD_CACHE).sort(),
|
|
"a maintained cache field reaches no front end — read it, or add it to UNREAD_CACHE with the reason",
|
|
);
|
|
});
|
|
|
|
test("every element the page looks up exists in the page", () => {
|
|
const app = read("../app.js");
|
|
const html = read("../index.html");
|
|
const ids = [...app.matchAll(/getElementById\("([^"]+)"\)/g)].map((m) => m[1]);
|
|
assert.ok(ids.length >= 8, `found only ${ids.length} element lookups`);
|
|
for (const id of ids) {
|
|
assert.ok(html.includes(`id="${id}"`), `app.js looks up #${id}, which index.html has not got`);
|
|
}
|
|
});
|