Files
improvise/web/tests/shell.test.js
T
Edward Langley a93d668e5e feat(browser): paint the page bar and the totals strip, and delegate the pointer
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected
to every session and folded correctly by every client, and NO front end read
either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` —
drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally
off that same `page_coords`, so a user could page through quarters watching
every number change with no way to tell which one they were looking at.

`RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`.
The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from
`items.is_some()`, which disagrees with it on an empty item list (gotcha 27).
The totals are RELAYED, never summed in the client: the sum runs over every row
including ones a viewport scrolled past (gotcha 44).

`web/tests/render_surface.test.js` gains the direction that would have caught
this when improvise-n10 added the fields. The existing guard is closed under
what `RenderModel` chose to expose, so a cache field that never reaches it is
invisible to it; the new one asks that every `pub` field of every widget cache
is read somewhere the browser runs, or is named as debt with a reason.
`grid.col_widths` is the one entry, pointing at improvise-pcje.

improvise-b8ot: the five clickable surfaces moved from one listener per node,
reallocated inside the render loops on every repaint, to one handler per stable
delegation root — the user's ruling. The operands are carried on the node as
data attributes and read back by a pure `cellAction`/`indexAction` in
`web/shell.js`, so the handler is a LOOKUP rather than a second derivation of
which data row was clicked: `gridPlan` counts data rows because a group header
is not one, and walking the table back up would disagree the moment one appears.
The editor's own keydown/blur stay direct — one transient node, one at a time,
and `blur` does not bubble.

All four documented behaviours a naive delegation loses are preserved and each
is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click
(asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell,
the conditionally skipped repaint (asserted by the editor's survival), and
closing a live editor before composing the click.

Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595);
node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed /
4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 19:21:57 -07:00

261 lines
9.4 KiB
JavaScript

// 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 {
classifyWorkerMessage,
composeStatus,
createShell,
documentTitle,
extentOf,
frameCostOf,
indicator,
isFatal,
noticeText,
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, totals = []) => ({
col_levels: levels,
col_groups: groups,
row_levels: rowLevels,
totals,
});
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,
});
// And the totals strip, which is chrome on the same terms as a header
// level: always there when there are totals, never a data row.
assert.deepEqual(frameCostOf(model([["a"]], [], 1, [{ text: "9" }])), {
rows: 2,
cols: 1,
});
});
test("the collapse glyph points right when collapsed", () => {
assert.equal(indicator(true), "▶");
assert.equal(indicator(false), "▼");
});
test("the title bar spells the document the way the TUI's own title does", () => {
// `src/draw.rs::draw_title`: name, then what it is stored as in parens, then
// the dirty marker. The harness cannot pin `[+]` — the standalone deployment
// persists on every accepted batch, so the marker is transient there.
assert.equal(
documentTitle("Corner Coffee", true, "demo.improv"),
"Corner Coffee (demo.improv) [+]",
);
assert.equal(documentTitle("Corner Coffee", false, "demo.improv"), "Corner Coffee (demo.improv)");
// A host with nowhere to write it: `stored_as` is `None` over the wire, which
// arrives as `null`, and an empty pair of parens would be a lie about storage.
assert.equal(documentTitle("Untitled", true, null), "Untitled [+]");
});
test("a protocol frame is a string and a shell notice is an object", () => {
assert.deepEqual(classifyWorkerMessage('{"Commands":[]}'), {
kind: "frame",
text: '{"Commands":[]}',
});
const notice = { kind: "ReadOnly", document: "a.improv", detail: "held" };
assert.deepEqual(classifyWorkerMessage(notice), { kind: "notice", notice });
// The discriminating case: a notice serialized as a STRING would be fed to
// the fold, which is exactly the confusion the two shapes exist to prevent.
assert.equal(classifyWorkerMessage(JSON.stringify(notice)).kind, "frame");
});
test("a message that is neither is named rather than silently dropped", () => {
for (const junk of [null, undefined, 42, {}, { detail: "no kind" }]) {
assert.equal(
classifyWorkerMessage(junk).kind,
"unknown",
`classified ${JSON.stringify(junk)}`,
);
}
});
test("every notice reads as a reason, and names its document", () => {
const of = (kind) => noticeText({ kind, document: "books.improv", detail: "404" });
// improvise-i0v9: the page must say what is wrong, not blame the browser.
assert.match(of("ReadOnly"), /read-only/);
assert.match(of("ReadOnly"), /another tab/);
// improvise-iobc: an empty document must not pass for an intentional one.
assert.match(of("SeedMissing"), /EMPTY/);
assert.match(of("SeedMissing"), /404/);
// improvise-zre1: "not saved" must not read like "saving".
assert.match(of("SaveFailed"), /NOT SAVED/);
assert.match(of("BootFailed"), /could not start/);
for (const kind of ["ReadOnly", "SeedMissing", "SaveFailed", "BootFailed"]) {
assert.match(of(kind), /books\.improv/, `${kind} did not name the document`);
}
});
test("a notice kind this page has never heard of still carries its reason", () => {
const text = noticeText({ kind: "SomethingNew", document: "a.improv", detail: "because" });
assert.match(text, /SomethingNew/);
assert.match(text, /because/);
});
test("only a failed boot is fatal — read-only, empty and unsaved all still paint", () => {
assert.equal(isFatal({ kind: "BootFailed" }), true);
for (const kind of ["ReadOnly", "SeedMissing", "SaveFailed"]) {
assert.equal(isFatal({ kind }), false, `${kind} was treated as fatal`);
}
});
test("a notice 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,
});
// The order improvise-vep8 puts them in: armed first, then the reason.
shell.arm("the in-page worker did not answer (see console)");
assert.equal(shell.timesArmed(), 1, "the denial below is vacuous if it was never armed");
shell.fail(noticeText({ kind: "ReadOnly", document: "a.improv", detail: "" }));
clock.fire();
assert.match(seen.at(-1), /read-only/);
assert.equal(
seen.some((t) => t.includes("did not answer")),
false,
`the watchdog spoke over a precise reason: ${JSON.stringify(seen)}`,
);
});