8ad5c0806d
Three things the user reported after looking at the deployed page, in the order they cost a visitor something. The deployment notice was ten dense lines of configuration reference — ?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the category tree and the grid on every visit: about 230px of an 820px window. It is now a <details>, so the collapsing is the BROWSER's (keyboard operable, and correct with app.js absent or still loading) and the only thing the page adds is remembering the answer. It starts COLLAPSED, and collapsing withholds nothing: the one fact a first visitor needs — the document lives in this tab, is saved to this browser's private storage and never leaves the machine — is the <summary>, which never collapses. What a stored value means is a decision and lives in shell.js; touching localStorage is a browser API and stays in app.js, wrapped, with every value that is not one of the two spellings read as "unanswered". The minibuffer was a 14px line in the page's bottom-left corner, up to 800px from the cell being typed into. It is now a raised bar over the grid. The CONTENT is untouched — RenderMinibuffer is built from `state.view.mode.minibuffer()`, the same semantic value the TUI draws — so this is position, weight and contrast, and there is no second derivation of what a prompt says. MinibufColor still resolves here exactly as draw.rs::minibuf_fg resolves it for the terminal. style.css stops being "legibility only": layered surfaces, one accent, a filled badge for the mode, chips for the two axis strips, one selection treatment, tabular numerals in the grid, and a focus ring on the one keyboard surface the page has. The palette is the landing page's, so the two read as one product. Asserted (harness/tests/contract.spec.js, Chromium): the notice starts collapsed and remembers, in BOTH directions across a reload; it opens from the keyboard, which the page's own keydown handler must not claim; the minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static bottom bar cannot satisfy — and is larger than body text. Each was proved discriminating by injecting the fault and watching it fail. NOT asserted, and named as such in context/code/clients.md: colour, contrast, spacing, weight, the grouped and multi-level header DOM, either theme, and the localStorage-throws path. Those are argued and read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
299 lines
11 KiB
JavaScript
299 lines
11 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,
|
|
NOTICE_OPEN_BY_DEFAULT,
|
|
noticeOpen,
|
|
noticeState,
|
|
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)}`,
|
|
);
|
|
});
|
|
|
|
// The notice's remembered state. The value crosses `localStorage`, which is
|
|
// allowed to forget and allowed to throw, so the interesting inputs are the
|
|
// ones that are NOT either spelling.
|
|
test("a notice state this page did not write is unanswered, not a third state", () => {
|
|
assert.equal(noticeOpen("open"), true);
|
|
assert.equal(noticeOpen("closed"), false);
|
|
// Everything storage can hand back when it has forgotten, been cleared,
|
|
// been blocked, or been written by an older page.
|
|
for (const stored of [null, undefined, "", "true", "false", "1", "0", "OPEN", {}]) {
|
|
assert.equal(
|
|
noticeOpen(stored),
|
|
NOTICE_OPEN_BY_DEFAULT,
|
|
`${JSON.stringify(stored)} was read as an answer`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// A first-time visitor lands on the application, not on its configuration
|
|
// reference. The argument is in context/code/clients.md; this pins the call so
|
|
// that changing it is a change to a stated decision rather than a stray edit.
|
|
test("a visitor who has never answered gets the notice collapsed", () => {
|
|
assert.equal(NOTICE_OPEN_BY_DEFAULT, false);
|
|
assert.equal(noticeOpen(null), false);
|
|
});
|
|
|
|
test("the two spellings round-trip, in both directions", () => {
|
|
for (const open of [true, false]) {
|
|
assert.equal(noticeOpen(noticeState(open)), open);
|
|
}
|
|
// And `noticeState` produces only spellings `noticeOpen` recognises — the
|
|
// half that would break silently, since an unrecognised value reads as the
|
|
// default and a visitor's answer would simply stop being kept.
|
|
assert.deepEqual([noticeState(true), noticeState(false)].sort(), ["closed", "open"]);
|
|
});
|