Files
improvise/web/tests/bundle/wasm_surface.test.js
T
Edward Langley 57a25807af 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>
2026-09-20 14:00:19 -07:00

104 lines
3.9 KiB
JavaScript

// 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]);
});