Merge gh/main (e8562f0) into fix/web-panel-clicks

PR #61 landed while this branch was open. It deleted
`harness/tests/pointer.spec.js` and `harness/tests/widgets.spec.js`, split
`web/app.js` into app/shell/serial, and added a Node suite under `web/tests/`.
Two conflicts, both resolved deliberately rather than by reflex.

**`harness/tests/pointer.spec.js` — modify/delete, resolved as DELETE.** The
habitual "keep both sides" would resurrect a file #61 removed on purpose.
`contract.spec.js` states its own scope as the RenderModel→DOM boundary and
the pointer's half of the JS→wasm one, so that is where these assertions
belong. Merged in, rather than restored:

- the category sidebar is open at launch and `C` still hides it;
- a first tile click selects and does NOT touch the document, a second cycles;
- the view row and formula row clicks improvise-iioi added, which
  `contract.spec.js` had no entry for at all.

**`context/test-evidence/clients.md` — content, resolved as BOTH SIDES.** The
two `pointer.spec.js` subsections are retargeted at the file that exists, with
a note saying why restoring the deleted one would have been wrong. The two
older `#### … the file's own scope` sections keep their history and gain a
forward pointer, because a reader following them would otherwise hunt for a
file that is gone.

**`web/tests/bundle/wasm_surface.test.js` now names `click_formula` and
`click_view`.** That test is an exact `deepEqual` over the page's `client.*`
calls and its comment says a new call "has to come and say so". It did: it
failed on the merge, which is the mechanism working. It could not have been
satisfied earlier — it did not exist when this branch was cut.

Two review findings fixed on top, neither a live bug:

- **The tile guarantee was overclaimed.** `T` leaves `tile_cat_idx` at 0, so a
  click on the LEFTMOST tile cycles on the first gesture. "A click never
  mutates on its own" and "a second click cycles" are both false against that
  probe. The behaviour is right — a click does what `Enter` does there — so the
  prose now states the implemented rule, and
  `t_then_a_click_on_the_leftmost_tile_cycles_it_immediately` names the case
  and asserts the neighbouring tile answers the same gesture differently.
- **`switch_view_at`'s `Option` had no guard on the keyboard side.** Removing
  the `unwrap_or_default()` makes `Enter` in an empty view panel `Upstream` —
  gotcha 47's black hole — and passed the whole workspace. That would make
  membership of the host-needed list state-dependent, which is the one property
  that list denies. One test closes it.

Gates on the merged tree: 1391 Rust tests, 26 + 3 Node tests, 16 Playwright
(3 WebKit OPFS skips), clippy -D warnings, fmt, harness fmt + wasm32 clippy,
eight wasm crates, comment runs and 513 evidence entries clean. `.beads/`
matches gh/main exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 14:00:19 -07:00
22 changed files with 1347 additions and 629 deletions
+61 -117
View File
@@ -2,30 +2,26 @@
// string calls, no protocol knowledge. See context/code/clients.md.
import init, { Client } from "./pkg/improvise_wasm_client.js";
import {
COL_PX,
ROW_PX,
createShell,
extentOf,
frameCostOf,
gridPlan,
indicator,
socketUrl,
} from "./shell.js";
const params = new URLSearchParams(location.search);
// present => socket, absent => in-page worker. The value is a host:port, and
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
const WS_PEER = params.get("ws");
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
function socketUrl(peer) {
const secure = location.protocol === "https:";
if (/^wss:\/\//i.test(peer)) return peer;
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
return `${secure ? "wss" : "ws"}://${peer}`;
}
const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null;
const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null;
const DOC = params.get("doc") ?? "demo.improv";
const SEED = params.get("seed") ?? "demo.improv";
// Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more.
const ROW_PX = 26;
const COL_PX = 110;
const el = {
grid: document.getElementById("grid"),
categories: document.getElementById("categories"),
@@ -42,64 +38,38 @@ const el = {
await init();
const client = new Client();
// `#status` carries two independent facts, composed rather than overwritten:
// a repaint must not quietly erase a connection error.
let shellStatus = "";
let modelStatus = "";
function paintStatus() {
el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · ");
}
function setShellStatus(text) {
shellStatus = text;
paintStatus();
}
// A peer that never answers the handshake, and the two facts `#status` shows.
// Both are decided in shell.js; this is the wire to the element.
const HANDSHAKE_TIMEOUT_MS = 8000;
const shell = createShell({
onStatus: (text) => {
el.status.textContent = text;
},
timeoutMs: HANDSHAKE_TIMEOUT_MS,
});
// What the grid's own frame costs, in whole cells, as of the last repaint —
// measured off the painted model, never predicted. context/code/clients.md.
let frameCost = { rows: 1, cols: 1 };
/** The extent this window can show, in whole DATA cells. */
function extent() {
const box = el.grid.getBoundingClientRect();
return {
rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows),
cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols),
};
}
function sendViewport() {
const { rows, cols } = extent();
const box = el.grid.getBoundingClientRect();
const metrics = { rowPx: ROW_PX, colPx: COL_PX };
const { rows, cols } = extentOf(box, frameCost, metrics);
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
// frame, but it also RECORDS the window. Why that matters: clients.md.
transport.send(client.set_viewport(rows, cols));
}
// A peer that never answers the handshake. Deliberately about the *first
// frame*, not either peer's own failure modes — context/code/clients.md.
const HANDSHAKE_TIMEOUT_MS = 8000;
let watchdog = null;
function armWatchdog(message) {
clearTimeout(watchdog);
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
}
// A peer that failed *loudly* has already said something more precise, so the
// watchdog must be DISARMED and not merely pre-empted (PR #13 review).
function setShellFailure(text) {
clearTimeout(watchdog);
watchdog = null;
setShellStatus(text);
}
/** One frame, one fold, in arrival order. Never batch or reorder these. */
function onFrame(text) {
clearTimeout(watchdog);
shell.sawFrame();
try {
client.on_frame(text);
} catch (err) {
// `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare
// listener it reached the console alone and the grid just stopped.
setShellStatus("bad frame — the grid has stopped updating (see console)");
shell.setShell("bad frame — the grid has stopped updating (see console)");
console.error("improvise: frame rejected", text, err);
return;
}
@@ -116,13 +86,13 @@ function workerTransport() {
const worker = new Worker(`./worker.js?${query}`, { type: "module" });
worker.addEventListener("message", (event) => onFrame(event.data));
worker.addEventListener("messageerror", () =>
setShellFailure("the worker sent a frame this page could not read"),
shell.fail("the worker sent a frame this page could not read"),
);
worker.addEventListener("error", (event) => {
setShellFailure(`worker failed to start: ${event.message ?? "see console"}`);
shell.fail(`worker failed to start: ${event.message ?? "see console"}`);
console.error("improvise: worker error", event);
});
setShellStatus(`in-page worker · ${DOC}`);
shell.setShell(`in-page worker · ${DOC}`);
// Frames posted before the worker has finished `start()` are held by
// worker.js in arrival order, so the handshake can go out immediately.
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
@@ -138,20 +108,20 @@ function socketTransport() {
const ready = new Promise((resolve) => {
socket.addEventListener("open", () => {
opened = true;
setShellStatus(`connected · ${WS_URL}`);
shell.setShell(`connected · ${WS_URL}`);
resolve();
});
});
socket.addEventListener("message", (event) => onFrame(event.data));
// Without this, a connection that never opens left #status blank and
// "server not running" read exactly like "still connecting".
socket.addEventListener("error", () => setShellFailure(unreachable));
socket.addEventListener("error", () => shell.fail(unreachable));
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
// route through `setShellFailure`, which disarms the watchdog — clients.md.
// route through `shell.fail`, which disarms the watchdog — clients.md.
socket.addEventListener("close", () =>
setShellFailure(opened ? "disconnected" : unreachable),
shell.fail(opened ? "disconnected" : unreachable),
);
setShellStatus(`connecting to ${WS_URL}…`);
shell.setShell(`connecting to ${WS_URL}…`);
return {
send: (text) => {
if (socket.readyState === WebSocket.OPEN) socket.send(text);
@@ -173,7 +143,7 @@ function repaint() {
try {
model = JSON.parse(client.render_model());
} catch (err) {
setShellStatus("cannot render the current state (see console)");
shell.setShell("cannot render the current state (see console)");
console.error("improvise: render failed", err);
return;
}
@@ -185,8 +155,7 @@ function repaint() {
// itself; see `RenderModel::hint`.
el.hint.textContent = model.hint;
paintHelp(model);
modelStatus = model.status;
paintStatus();
shell.setModel(model.status);
el.viewName.textContent = model.view_name;
el.grid.replaceChildren(gridTable(model));
// The flag is the client's answer, not the page's: `C` toggles it and the
@@ -201,10 +170,7 @@ function repaint() {
// What the frame costs, so the NEXT viewport announcement is honest.
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
const next = {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
};
const next = frameCostOf(model);
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
frameCost = next;
if (changed) sendViewport();
@@ -249,32 +215,30 @@ function paintHelp(model) {
el.help.replaceChildren(tabs, body);
}
/** The grid, painted from a model in which every frame decision is already
* made (improvise-anr9). The page decides nothing here: which labels repeat
* and are therefore blank, how many header levels there are, which columns
* share a group and whether it is collapsed all arrive answered, by the same
* `AxisFrame` the TUI widget reads. */
/** The grid, painted from a plan in which every decision is already made
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
* which labels repeat and are therefore blank, how many header levels there
* are, which columns share a group and whether it is collapsed all arrive
* answered, by the same `AxisFrame` the TUI widget reads. */
function gridTable(model) {
const plan = gridPlan(model);
const table = document.createElement("table");
const head = table.createTHead();
const corner = () => {
const th = document.createElement("th");
th.colSpan = model.row_levels;
th.colSpan = plan.cornerSpan;
return th;
};
// The column-group line, when there is one. An empty `col_groups` means
// this grid has no column groups at all -- not that every group is blank.
if (model.col_groups.length > 0) {
// The column-group line, when there is one. An empty `groups` means this
// grid has no column groups at all -- not that every group is blank.
if (plan.groups.length > 0) {
const groups = head.insertRow();
groups.appendChild(corner());
for (const group of model.col_groups) {
for (const group of plan.groups) {
const th = document.createElement("th");
th.colSpan = group.span;
// `label` is null for a run in no group; an empty STRING is a legal group
// name, so this is a null check and not a truthiness test.
th.textContent =
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
th.textContent = group.text;
th.classList.add("group");
groups.appendChild(th);
}
@@ -282,7 +246,7 @@ function gridTable(model) {
// One header line per level. A blank entry is repeat suppression, decided
// in Rust -- never an absent label.
for (const level of model.col_levels) {
for (const level of plan.levels) {
const tr = head.insertRow();
tr.appendChild(corner());
for (const label of level) {
@@ -293,23 +257,16 @@ function gridTable(model) {
}
const body = table.createTBody();
// The cell coordinates a click sends are DATA row indices and a group header
// is not a data row, so this counts rather than trusting `model.rows`.
let r = -1;
for (const row of model.rows) {
for (const row of plan.body) {
const tr = body.insertRow();
if (row.kind === "group") {
const th = document.createElement("th");
th.colSpan = model.row_levels + model.col_levels[0].length;
th.textContent = indicator(row.collapsed) + " " + row.label;
th.colSpan = row.colSpan;
th.textContent = row.text;
th.classList.add("group");
tr.appendChild(th);
continue;
}
r += 1;
// `r` is one binding shared by every closure below, so a click handler
// that captured it would fire with the LAST row's index. Snapshot it.
const dataRow = r;
for (const label of row.labels) {
const header = document.createElement("th");
@@ -318,39 +275,26 @@ function gridTable(model) {
tr.appendChild(header);
}
row.cells.forEach((cell, c) => {
for (const cell of row.cells) {
const td = tr.insertCell();
// A keyboard edit is open when `model.editing` is a string, and it holds
// what has been typed. Both facts are decided in Rust.
const typing = cell.selected && typeof model.editing === "string";
td.textContent = typing ? model.editing + "\u258f" : cell.text;
td.classList.toggle("numeric", cell.numeric && !typing);
td.classList.toggle("error", cell.error);
td.classList.toggle("selected", cell.selected);
td.classList.toggle("typing", typing);
// `editable` is the server's answer relayed through Rust: a stored cell.
// Drill is not on the wire yet, so an aggregate is not offered.
td.classList.toggle("editable", cell.editable);
td.textContent = cell.text;
for (const [name, on] of Object.entries(cell.classes)) {
td.classList.toggle(name, on);
}
// Every cell moves the selection; an editable one also opens its editor,
// which is the pointer's `i`. Not repainted in that branch: `edit` has
// set `editing`, and a repaint would take the <input> away.
td.addEventListener("click", () => {
const frame = client.click_cell(dataRow, c);
const frame = client.click_cell(cell.row, cell.col);
if (frame) transport.send(frame);
if (cell.editable) edit(td, dataRow, c, cell.edit_text);
if (cell.editable) edit(td, cell.row, cell.col, cell.editText);
else repaint();
});
});
}
}
return table;
}
/** The collapse glyph. `collapsed` is the fact, decided by the view and
* carried on the frame; only this spelling is the page's (gotcha 26). */
function indicator(collapsed) {
return collapsed ? "\u25b6" : "\u25bc";
}
/** Open an inline editor. Committing sends a frame; the value that comes
* back is the server's, never the string typed here. */
function edit(td, row, col, seed) {
@@ -495,7 +439,7 @@ window.addEventListener("keydown", (e) => {
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent
armWatchdog(
shell.arm(
WS_URL
? `${WS_URL} accepted the connection but sent no document`
: "the in-page worker did not answer — this browser may not allow it (see console)",
+17 -1
View File
@@ -44,7 +44,10 @@ cp -rf "$root/docs/casts" "$out/casts"
app="$out/app"
mkdir -p "$app"
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/style.css" "$root/web/worker.js" "$app/"
# shell.js and serial.js are imported by app.js and worker.js: leaving either
# out is a page that fails to load its own module graph, not a degraded one.
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/shell.js" \
"$root/web/serial.js" "$root/web/style.css" "$root/web/worker.js" "$app/"
# The document the page opens on a browser that has never seen one.
# web/worker.js fetches it by the ?seed= URL and hands the bytes to Rust,
# which writes them through the VFS — so leaving it out is an empty grid, not
@@ -82,3 +85,16 @@ stamped "$app/app.js" "pkg/improvise_wasm_client.js?v=${token}"
stamped "$app/worker.js" "worker-pkg/improvise_worker_server.js?v=${token}"
stamped "$app/pkg/improvise_wasm_client.js" "improvise_wasm_client_bg.wasm?v=${token}"
stamped "$app/worker-pkg/improvise_worker_server.js" "improvise_worker_server_bg.wasm?v=${token}"
# Every relative import the two entry points make must have been copied. The
# cp list above is a second answer to "what ships", and a missing sibling is a
# 404 at module-load time, which the page cannot report.
for entry in app.js worker.js; do
deps=$(sed -n 's|.*from "\./\([A-Za-z0-9_.-]*\.js\)".*|\1|p' "$app/$entry")
for dep in $deps; do
test -f "$app/$dep" || {
echo "assemble: $entry imports ./$dep, which was not copied" >&2
exit 1
}
done
done
+6
View File
@@ -0,0 +1,6 @@
{
"name": "web",
"private": true,
"type": "module",
"description": "The browser page. `node --test web/tests` runs what needs no browser (improvise-cr3)."
}
+19
View File
@@ -0,0 +1,19 @@
// The one rule `web/worker.js` exists to keep: one frame at a time, in arrival
// order. No DOM, no worker globals — context/code/clients.md.
/** Chain every arrival onto one tail promise, starting from `head`.
*
* An `async` listener does NOT serialize: the event loop is free to start the
* next message while an earlier one is suspended at an `await`, so two
* handler runs interleave and a later frame's replies can be posted before an
* earlier frame's — which the client's fold answers by DROPPING the overtaken
* update, not by replaying it. Chaining is also what makes "frames that
* arrived before `start()` finished" an ordinary case of this rule rather
* than a drain loop with the race in it twice over. */
export function serialize(head, handler, onError) {
let tail = head.catch(() => {});
return (value) => {
tail = tail.then(() => handler(value)).catch((err) => onError(err, value));
return tail;
};
}
+144
View File
@@ -0,0 +1,144 @@
// Everything `web/app.js` decides that is not painting: no DOM, no browser
// API, one `node --test` away. What belongs here: context/code/clients.md.
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
export function socketUrl(peer, pageProtocol) {
const secure = pageProtocol === "https:";
if (/^wss:\/\//i.test(peer)) return peer;
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
return `${secure ? "wss" : "ws"}://${peer}`;
}
/** `#status` carries two independent facts, composed rather than overwritten:
* a repaint must not quietly erase a connection error. */
export function composeStatus(shell, model) {
return [shell, model].filter(Boolean).join(" · ");
}
/** The collapse glyph. `collapsed` is the fact, decided by the view and
* carried on the frame; only this spelling is the page's (gotcha 26). */
export function indicator(collapsed) {
return collapsed ? "▶" : "▼";
}
// Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more.
export const ROW_PX = 26;
export const COL_PX = 110;
/** The extent a box of this size can show, in whole DATA cells: the frame is
* charged for first, and at least one cell is always asked for. */
export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_PX }) {
return {
rows: Math.max(1, Math.floor(box.height / metrics.rowPx) - frameCost.rows),
cols: Math.max(1, Math.floor(box.width / metrics.colPx) - frameCost.cols),
};
}
/** What the grid's own frame costs, in whole cells — measured off the painted
* model, never predicted. Inline group ROWS are deliberately not counted. */
export function frameCostOf(model) {
return {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
};
}
/** The grid, as a description: every decision made, nothing created. The page
* decides nothing here and neither does this — which labels repeat, how many
* header levels there are and which columns share a group all arrive
* answered, by the same `AxisFrame` the TUI widget reads (improvise-anr9). */
export function gridPlan(model) {
const cornerSpan = model.row_levels;
const groups = model.col_groups.map((group) => ({
span: group.span,
// `label` is null for a run in no group; an empty STRING is a legal group
// name, so this is a null check and not a truthiness test.
text: group.label === null ? "" : `${indicator(group.collapsed)} ${group.label}`,
}));
// The cell coordinates a click sends are DATA row indices and a group header
// is not a data row, so this counts rather than trusting the row's position.
let dataRow = -1;
const body = model.rows.map((row) => {
if (row.kind === "group") {
return {
kind: "group",
colSpan: model.row_levels + model.col_levels[0].length,
text: `${indicator(row.collapsed)} ${row.label}`,
};
}
dataRow += 1;
return {
kind: "data",
row: dataRow,
labels: row.labels,
cells: row.cells.map((cell, col) => {
// A keyboard edit is open when `model.editing` is a string, and it
// holds what has been typed. Both facts are decided in Rust.
const typing = cell.selected && typeof model.editing === "string";
return {
row: dataRow,
col,
text: typing ? `${model.editing}▏` : cell.text,
editText: cell.edit_text,
editable: cell.editable,
classes: {
numeric: cell.numeric && !typing,
error: cell.error,
selected: cell.selected,
typing,
// `editable` is the server's answer relayed through Rust: a stored
// cell. Drill is not on the wire, so an aggregate is not offered.
editable: cell.editable,
},
};
}),
};
});
return { cornerSpan, groups, levels: model.col_levels, body };
}
/** The page's two status facts and the handshake watchdog, which share a home
* because the one rule between them is that a *failure disarms the timer*. */
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
let shell = "";
let model = "";
let watchdog = null;
const paint = () => onStatus(composeStatus(shell, model));
return {
setShell(text) {
shell = text;
paint();
},
setModel(text) {
model = text;
paint();
},
/** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */
arm(message) {
clearTimer(watchdog);
watchdog = setTimer(() => {
watchdog = null;
this.setShell(message);
}, timeoutMs);
},
/** A frame arrived, so the peer answered: the watchdog has no question
* left to ask. */
sawFrame() {
clearTimer(watchdog);
watchdog = null;
},
/** A peer that failed *loudly* has already said something more precise, so
* the watchdog must be DISARMED and not merely pre-empted (PR #13). */
fail(text) {
this.sawFrame();
this.setShell(text);
},
armed: () => watchdog !== null,
};
}
+103
View File
@@ -0,0 +1,103 @@
// The JS↔wasm boundary: every call the page makes is declared by the bundle,
// with the arity it declares. Needs `web/build.sh` to have run.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const web = (name) => fileURLToPath(new URL(`../../${name}`, import.meta.url));
/// Declared methods and free functions, as `name -> [min, max]` arity. A
/// missing bundle is a FAILURE and never a skip: this file exists to be run
/// after `web/build.sh`, and a silent pass would report exactly nothing.
function declarations(dts) {
const text = readFileSync(web(dts), "utf8");
const found = new Map();
const decl = /(?:^|\n)\s*(?:export function |static )?([a-z_][A-Za-z0-9_]*)\(([^)]*)\)\s*:/g;
for (const [, name, params] of text.matchAll(decl)) {
const list = params.trim() === "" ? [] : params.split(",");
const required = list.filter((p) => !p.includes("?:")).length;
found.set(name, [required, list.length]);
}
assert.ok(found.size > 0, `${dts} declares nothing — was web/build.sh run?`);
return found;
}
/// Every `<receiver>.<name>(...)` call in a source file, with how many
/// top-level arguments it passes.
function callsOn(file, receiver) {
const text = readFileSync(web(file), "utf8");
const calls = [];
const head = new RegExp(`${receiver}\\.([a-z_][A-Za-z0-9_]*)\\(`, "g");
for (const match of text.matchAll(head)) {
let depth = 1;
let i = match.index + match[0].length;
let commas = 0;
let body = "";
while (depth > 0 && i < text.length) {
const ch = text[i];
if ("([{".includes(ch)) depth += 1;
else if (")]}".includes(ch)) depth -= 1;
else if (ch === "," && depth === 1) commas += 1;
if (depth > 0) body += ch;
i += 1;
}
assert.equal(depth, 0, `unbalanced call to ${match[1]} in ${file}`);
calls.push({ name: match[1], args: body.trim() === "" ? 0 : commas + 1 });
}
return calls;
}
test("every client call web/app.js makes is declared, with that arity", () => {
const declared = declarations("pkg/improvise_wasm_client.d.ts");
const calls = callsOn("app.js", "client");
assert.ok(calls.length >= 6, `found only ${calls.length} client calls`);
for (const call of calls) {
const arity = declared.get(call.name);
assert.ok(arity, `web/app.js calls client.${call.name}, which the bundle does not declare`);
const [min, max] = arity;
assert.ok(
call.args >= min && call.args <= max,
`client.${call.name} takes ${min}..${max} arguments; app.js passes ${call.args}`,
);
}
});
test("the page still reaches the client through a handful of calls", () => {
const names = new Set(callsOn("app.js", "client").map((c) => c.name));
// The named surface, so a call added without an argument fails here and
// has to come and say so. `viewport` is deliberately absent: `set_viewport`
// is the one that records the window (gotcha 47).
assert.deepEqual(
[...names].sort(),
[
"click_category",
"click_cell",
"click_formula",
"click_tile",
"click_view",
"on_frame",
"on_key_event",
"render_model",
"set_viewport",
"typed_cell",
],
);
});
test("every worker call web/worker.js makes is declared, with that arity", () => {
const declared = declarations("worker-pkg/improvise_worker_server.d.ts");
const calls = callsOn("worker.js", "server");
assert.ok(calls.length >= 2, `found only ${calls.length} server calls`);
for (const call of calls) {
const arity = declared.get(call.name);
assert.ok(arity, `web/worker.js calls server.${call.name}, undeclared by the bundle`);
assert.ok(
call.args >= arity[0] && call.args <= arity[1],
`server.${call.name} takes ${arity[0]}..${arity[1]}; worker.js passes ${call.args}`,
);
}
// `start` is a free function rather than a method, and takes the seed.
assert.deepEqual(declared.get("start"), [1, 2]);
});
+110
View File
@@ -0,0 +1,110 @@
// 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");
});
+52
View File
@@ -0,0 +1,52 @@
// 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");
/// 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.
// improvise-z754 put the title bar's facts in one shared cache and taught
// `src/draw.rs::draw_title` to fold it; this page never learned to. That is
// improvise-5jna, open, and this list is what stops it being forgotten.
const UNPAINTED = {
doc_name: "improvise-5jna: the title bar is drawn by the TUI and not here",
doc_dirty: "improvise-5jna",
doc_stored_as: "improvise-5jna",
};
/// 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 = read("../app.js") + read("../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",
);
});
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`);
}
});
+79
View File
@@ -0,0 +1,79 @@
// The worker's one rule: one frame at a time, in arrival order. The bug this
// guards against is an `async` listener, which does not serialize.
import { test } from "node:test";
import assert from "node:assert/strict";
import { serialize } from "../serial.js";
const tick = (n) => new Promise((resolve) => setTimeout(resolve, n));
test("a slow frame does not let a later one overtake it", async () => {
const posted = [];
// The first frame suspends for longer than the second: an `async` listener
// would post B's replies while A is still awaiting.
const handler = async (frame) => {
await tick(frame === "A" ? 20 : 0);
posted.push(frame);
};
const onMessage = serialize(Promise.resolve(), handler, () => {});
onMessage("A");
await onMessage("B");
assert.deepEqual(posted, ["A", "B"]);
});
test("the same interleaving without the chain is what this rules out", async () => {
const posted = [];
const listener = async (frame) => {
await tick(frame === "A" ? 20 : 0);
posted.push(frame);
};
listener("A");
await listener("B");
await tick(30);
assert.deepEqual(posted, ["B", "A"]);
});
test("frames that arrive before startup finishes are held, not dropped", async () => {
const posted = [];
let ready;
const head = new Promise((resolve) => {
ready = resolve;
});
const onMessage = serialize(head, (f) => posted.push(f), () => {});
onMessage("first");
onMessage("second");
assert.deepEqual(posted, [], "nothing runs before startup settles");
ready();
await onMessage("third");
assert.deepEqual(posted, ["first", "second", "third"]);
});
test("a startup that failed still lets the chain run, so the handler can decline", async () => {
const seen = [];
const head = Promise.reject(new Error("no bundle"));
const onMessage = serialize(head, (f) => seen.push(f), () => {});
await onMessage("frame");
assert.deepEqual(seen, ["frame"]);
});
test("one bad frame does not poison the frames after it", async () => {
const posted = [];
const errors = [];
const onMessage = serialize(
Promise.resolve(),
(frame) => {
if (frame === "bad") throw new Error("rejected");
posted.push(frame);
},
(err, frame) => errors.push([frame, err.message]),
);
onMessage("good");
onMessage("bad");
await onMessage("later");
assert.deepEqual(posted, ["good", "later"]);
assert.deepEqual(errors, [["bad", "rejected"]]);
});
+162
View File
@@ -0,0 +1,162 @@
// What the page decides between the boundaries: transport selection, the two
// status facts, the watchdog and the extent arithmetic. No DOM is involved.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
composeStatus,
createShell,
extentOf,
frameCostOf,
indicator,
socketUrl,
} from "../shell.js";
test("a bare peer takes the page's own scheme, and https never falls back", () => {
assert.equal(socketUrl("127.0.0.1:8787", "http:"), "ws://127.0.0.1:8787");
assert.equal(socketUrl("example.test", "https:"), "wss://example.test");
// An explicit `ws://` on an https page is UPGRADED, not honoured: the
// browser would refuse the mixed-content connection either way.
assert.equal(socketUrl("ws://example.test", "https:"), "wss://example.test");
assert.equal(socketUrl("ws://example.test", "http:"), "ws://example.test");
// `wss://` is honoured on both, and the match is case-insensitive.
assert.equal(socketUrl("WSS://example.test", "http:"), "WSS://example.test");
});
test("a peer whose host merely starts with ws is not a scheme", () => {
assert.equal(socketUrl("wsserver.test:9000", "http:"), "ws://wsserver.test:9000");
});
test("the two status facts compose, and neither can erase the other", () => {
assert.equal(composeStatus("connected", "Filter: Product = Latte"), "connected · Filter: Product = Latte");
// An empty fact contributes nothing rather than a stray separator.
assert.equal(composeStatus("connected", ""), "connected");
assert.equal(composeStatus("", "saved"), "saved");
assert.equal(composeStatus("", ""), "");
});
test("a repaint's model status does not overwrite a connection failure", () => {
const seen = [];
const shell = createShell({ onStatus: (t) => seen.push(t), timeoutMs: 10 });
shell.fail("cannot reach 127.0.0.1:8787");
shell.setModel("Filter: Product = Latte");
assert.equal(seen.at(-1), "cannot reach 127.0.0.1:8787 · Filter: Product = Latte");
});
/// A clock the test drives, so the watchdog's arming is asserted rather than
/// waited for. `fire()` runs what is pending, as the event loop would.
function fakeClock() {
let next = 1;
const pending = new Map();
return {
setTimer: (fn) => {
const id = next++;
pending.set(id, fn);
return id;
},
clearTimer: (id) => pending.delete(id),
fire: () => {
const due = [...pending.values()];
pending.clear();
for (const fn of due) fn();
},
count: () => pending.size,
};
}
test("the watchdog speaks when the peer never answers the handshake", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onStatus: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
shell.setShell("connecting…");
shell.arm("sent no document");
assert.equal(shell.armed(), true);
clock.fire();
assert.equal(seen.at(-1), "sent no document");
});
test("a loud failure DISARMS the watchdog rather than being replaced by it", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onStatus: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
shell.arm("sent no document");
shell.fail("disconnected");
assert.equal(shell.armed(), false);
assert.equal(clock.count(), 0);
clock.fire();
// The precise message is what stands, eight seconds later and forever.
assert.equal(seen.at(-1), "disconnected");
});
test("a frame that arrives is the answer the watchdog was waiting for", () => {
const clock = fakeClock();
const seen = [];
const shell = createShell({
onStatus: (t) => seen.push(t),
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
shell.setShell("connected");
shell.arm("sent no document");
shell.sawFrame();
clock.fire();
assert.equal(seen.at(-1), "connected");
});
test("arming twice leaves one timer, not two", () => {
const clock = fakeClock();
const shell = createShell({
onStatus: () => {},
timeoutMs: 8000,
setTimer: clock.setTimer,
clearTimer: clock.clearTimer,
});
shell.arm("first");
shell.arm("second");
assert.equal(clock.count(), 1);
});
test("the frame is charged for before the extent is announced", () => {
// 10 rows and 4 columns fit; one header level and one row-label column are
// not data, so the peer is asked for 9 by 3.
const box = { height: 10 * 26, width: 4 * 110 };
assert.deepEqual(extentOf(box, { rows: 1, cols: 1 }), { rows: 9, cols: 3 });
});
test("a window smaller than its own frame still asks for one cell", () => {
assert.deepEqual(extentOf({ height: 0, width: 0 }, { rows: 3, cols: 2 }), {
rows: 1,
cols: 1,
});
});
test("the frame costs a line per column level, plus one for a group line", () => {
const model = (levels, groups, rowLevels) => ({
col_levels: levels,
col_groups: groups,
row_levels: rowLevels,
});
assert.deepEqual(frameCostOf(model([["a"]], [], 1)), { rows: 1, cols: 1 });
// Two category levels on the column axis, and a group line above them.
assert.deepEqual(frameCostOf(model([["a"], ["b"]], [{ span: 1 }], 2)), {
rows: 3,
cols: 2,
});
});
test("the collapse glyph points right when collapsed", () => {
assert.equal(indicator(true), "▶");
assert.equal(indicator(false), "▼");
});
+16 -15
View File
@@ -2,6 +2,7 @@
// every decision in Rust. The two rules it must not break: context/code/clients.md.
import init, { start } from "./worker-pkg/improvise_worker_server.js";
import { serialize } from "./serial.js";
const params = new URLSearchParams(self.location.search);
const DOC = params.get("doc") || "demo.improv";
@@ -43,21 +44,21 @@ started.catch((err) => {
console.error("improvise worker: failed to start", err);
});
// One frame at a time, in arrival order: the listener is deliberately NOT
// `async` — see context/code/clients.md.
let tail = started.catch(() => {});
// One frame at a time, in arrival order, and the listener is deliberately NOT
// `async`: the chaining is `serial.js`'s and is tested there.
const onFrame = serialize(
started,
async (data) => {
// `start()` failed. There is nothing to answer with, and the main
// thread's handshake watchdog is what tells the user.
if (!server) return;
await handle(data);
},
// An unhandled rejection here reaches the devtools console and nothing
// else. Caught, so one bad frame does not poison every later one.
(err, data) => console.error("improvise worker: frame rejected", data, err),
);
self.onmessage = (event) => {
tail = tail
.then(async () => {
// `start()` failed. There is nothing to answer with, and the main
// thread's handshake watchdog is what tells the user.
if (!server) return;
await handle(event.data);
})
.catch((err) => {
// An unhandled rejection here reaches the devtools console and nothing
// else. Caught, so one bad frame does not poison every later one.
console.error("improvise worker: frame rejected", event.data, err);
});
onFrame(event.data);
};