Merge remote-tracking branch 'gh/main' into feat/doc-site

This commit is contained in:
Edward Langley
2026-09-20 13:50:42 -07:00
20 changed files with 1176 additions and 508 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
@@ -199,10 +168,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();
@@ -247,32 +213,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);
}
@@ -280,7 +244,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) {
@@ -291,23 +255,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");
@@ -316,39 +273,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) {
@@ -489,7 +433,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
@@ -49,7 +49,10 @@ node "$root/scripts/build-doc-site.mjs" "$out/docs" "$token"
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
@@ -87,3 +90,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,
};
}
+101
View File
@@ -0,0 +1,101 @@
// 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_tile",
"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);
};