Merge remote-tracking branch 'gh/main' into feat/doc-site
This commit is contained in:
+61
-117
@@ -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
@@ -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
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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"]]);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user