test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests assume those contracts and test between them (user, 2026-09-20). The browser half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS — and everything between them needed no browser at all. `web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every question `gridTable` used to answer while creating elements — the data-row index across group headers, what a group header spans, a null group label against an empty one, an empty edit buffer against no editor — and `app.js` is now a loop that builds elements from it. Painting stays untested by Node; what to paint stopped being untestable. 26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle, no npm install), plus three in `web/tests/bundle/` that read the generated .d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to 16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone, replaced by `contract.spec.js`, which asserts that EVERY field the render model projects reaches the screen. The four widgets those specs were written for were one failure at one boundary, and one assertion catches the fifth. It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element (improvise-5jna, already open). They are named in `UNPAINTED` with the ticket, so the list is the debt written down rather than an exemption. Thirteen faults injected into the Node suite and eight into the served page, each watched to fail the test it names; the last of those was strengthened because the first version passed. `web/assemble.sh` grew an import-completeness guard whose own first version was blind to the multi-line import it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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), "▼");
|
||||
});
|
||||
Reference in New Issue
Block a user