v4's Simple mode is the Editor with about ten `display: none !important` rules over it, which is not a smaller app but the same one wearing a mask. It broke in the way that arrangement always breaks: a collapsed column was marked [hidden], nothing can slide a display:none element into view, and the panel button did nothing at all and said nothing. So the same shape is built the other way round -- a region that does not exist yet is ABSENT, and the toggles live in rails that are siblings of the panels, never inside what they hide. Two kit elements, because both are layout primitives rather than app code: frame.app establishes the one definite height everything else fills, and workspace.docked is the centre-shrinks-between-two-panels arrangement. Three faults found by looking at it rather than by reasoning about it: * The prompt was in the DOM with no pixels. A vertical split inside a parent whose height came from its content resolves its second pane to zero, and nothing anywhere reports that. * The whole app rendered as a strip at the top of an empty window: `100%` of a `min-height` is `auto`. * Two fixed-width panels plus their rails are wider than a 480px window, and the page scrolled sideways -- taking the transport bar with it. The collapse is deliberately not animated: a remembered-closed panel would animate shut on every load, which reads as the app doing something rather than as a preference being restored. Also: jsdom gives a document with no url an OPAQUE ORIGIN, where reading window.localStorage throws on the property access itself. Everything that remembers a preference was silently taking its fallback path in every jsdom test, so "it is remembered" could not be tested at the cheap layer and looked like it worked.
422 lines
16 KiB
JavaScript
422 lines
16 KiB
JavaScript
// Behaviour the registry-wide rule tests cannot see: what each element does
|
|
// when it is actually used.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { setupDom, teardownDom, fire } from "./_dom.js";
|
|
|
|
let composer;
|
|
test.before(async () => {
|
|
setupDom();
|
|
({ composer } = await import("../composer.js"));
|
|
});
|
|
test.after(() => teardownDom());
|
|
|
|
const mount = (handle) => { document.body.appendChild(handle.node); return handle; };
|
|
|
|
// --- buttons ---------------------------------------------------------------
|
|
|
|
test("a button reports clicks and can be made busy", () => {
|
|
let clicks = 0;
|
|
const b = mount(composer.button.md({ label: "Go", onClick: () => { clicks += 1; } }));
|
|
b.node.click();
|
|
assert.equal(clicks, 1);
|
|
|
|
b.setBusy(true);
|
|
assert.equal(b.node.disabled, true);
|
|
assert.equal(b.node.getAttribute("aria-busy"), "true");
|
|
b.node.click();
|
|
assert.equal(clicks, 1, "a busy button does not fire again");
|
|
|
|
b.setBusy(false);
|
|
assert.equal(b.node.disabled, false);
|
|
});
|
|
|
|
test("an unknown tone is refused rather than rendered plain", () => {
|
|
assert.throws(() => composer.button.md({ label: "x", tone: "sparkly" }), RangeError);
|
|
});
|
|
|
|
test("an icon button without a label is refused", () => {
|
|
// Otherwise a toolbar of glyphs is unusable by screen reader and unlabelled
|
|
// on hover, and nobody notices until someone needs it.
|
|
assert.throws(() => composer.iconButton.md({ icon: "x" }), TypeError);
|
|
});
|
|
|
|
test("buttonGroup picks one, or several when multi", () => {
|
|
const seen = [];
|
|
const g = mount(composer.buttonGroup.md({
|
|
value: "a", onChange: (v) => seen.push(v),
|
|
items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
|
|
}));
|
|
g.node.querySelectorAll("button")[1].click();
|
|
assert.equal(g.value, "b");
|
|
assert.deepEqual(seen, ["b"]);
|
|
assert.equal(g.node.querySelectorAll("button")[1].getAttribute("aria-checked"), "true");
|
|
|
|
const m = mount(composer.buttonGroup.md({
|
|
multi: true, value: ["a"],
|
|
items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
|
|
}));
|
|
m.node.querySelectorAll("button")[1].click();
|
|
assert.deepEqual(m.value.sort(), ["a", "b"]);
|
|
m.node.querySelectorAll("button")[0].click();
|
|
assert.deepEqual(m.value, ["b"], "clicking an active one turns it off");
|
|
});
|
|
|
|
// --- choice ----------------------------------------------------------------
|
|
|
|
test("checkbox reads and writes its state", () => {
|
|
let last = null;
|
|
const c = mount(composer.checkbox.default({ label: "On", checked: false, onChange: (v) => { last = v; } }));
|
|
const input = c.node.querySelector("input");
|
|
input.click();
|
|
assert.equal(c.value, true);
|
|
assert.equal(last, true);
|
|
c.setValue(false);
|
|
assert.equal(input.checked, false);
|
|
});
|
|
|
|
test("a bare checkbox carries its label as the accessible name", () => {
|
|
const c = composer.checkbox.default({ label: "Enabled" });
|
|
assert.equal(c.node.querySelector("input").getAttribute("aria-label"), "Enabled");
|
|
});
|
|
|
|
test("a checkbox row is labelled by its visible text, not by aria-label", () => {
|
|
// Duplicating it would override what the user can see with a copy that can
|
|
// drift from it.
|
|
const c = composer.checkboxRow.default({ label: "Sync", hint: "Locks timing" });
|
|
const input = c.node.querySelector("input");
|
|
assert.equal(input.getAttribute("aria-label"), null);
|
|
const face = c.node.querySelector("label.cx-check-face");
|
|
assert.equal(face.getAttribute("for"), input.id,
|
|
"the label points at the input rather than wrapping it");
|
|
assert.equal(face.contains(input), false);
|
|
assert.ok(input.getAttribute("aria-describedby"), "the hint is wired as the description");
|
|
});
|
|
|
|
test("checklist collects several values", () => {
|
|
const c = mount(composer.checklist.default({
|
|
values: ["a"], items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
|
|
}));
|
|
c.node.querySelectorAll("input")[1].click();
|
|
assert.deepEqual(c.value.sort(), ["a", "b"]);
|
|
c.setValue(["b"]);
|
|
assert.deepEqual(c.value, ["b"]);
|
|
});
|
|
|
|
test("radioGroup keeps exactly one", () => {
|
|
const r = mount(composer.radioGroup.default({
|
|
value: "a", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
|
|
}));
|
|
const inputs = r.node.querySelectorAll("input");
|
|
inputs[1].click();
|
|
assert.equal(r.value, "b");
|
|
assert.equal(inputs[0].checked, false);
|
|
});
|
|
|
|
test("segmented selects and reflects aria-checked", () => {
|
|
const s = mount(composer.segmented.md({
|
|
value: "t2v", options: [{ value: "t2v", label: "T" }, { value: "i2v", label: "I" }],
|
|
}));
|
|
const buttons = s.node.querySelectorAll("button");
|
|
buttons[1].click();
|
|
assert.equal(s.value, "i2v");
|
|
assert.equal(buttons[1].getAttribute("aria-checked"), "true");
|
|
assert.equal(buttons[0].getAttribute("aria-checked"), "false");
|
|
});
|
|
|
|
test("select round-trips its value", () => {
|
|
const s = mount(composer.select.md({
|
|
value: "b", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
|
|
}));
|
|
assert.equal(s.value, "b");
|
|
s.setValue("a");
|
|
assert.equal(s.value, "a");
|
|
});
|
|
|
|
test("toggle is a switch, not a checkbox, to assistive tech", () => {
|
|
const t = mount(composer.toggle.default({ label: "Second pass", checked: true }));
|
|
assert.equal(t.node.querySelector("input").getAttribute("role"), "switch");
|
|
assert.equal(t.value, true);
|
|
});
|
|
|
|
// --- input -----------------------------------------------------------------
|
|
|
|
test("onCommit fires on Enter and blur, not on every keystroke", () => {
|
|
// A settings value that updates per keystroke writes nine intermediate values
|
|
// while you type "0.65".
|
|
const commits = [];
|
|
const inputs = [];
|
|
const i = mount(composer.input.md({
|
|
onInput: (v) => inputs.push(v),
|
|
onCommit: (v) => commits.push(v),
|
|
}));
|
|
i.node.value = "roof";
|
|
fire(i.node, "input");
|
|
assert.deepEqual(inputs, ["roof"]);
|
|
assert.deepEqual(commits, []);
|
|
|
|
i.node.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
assert.deepEqual(commits, ["roof"]);
|
|
|
|
fire(i.node, "blur");
|
|
assert.deepEqual(commits, ["roof"], "an unchanged value does not commit twice");
|
|
});
|
|
|
|
test("number clamps on commit, not while typing", () => {
|
|
// Clamping mid-keystroke makes a leading "-" or "0." impossible to type.
|
|
let last = null;
|
|
const n = mount(composer.number.md({ value: 3, min: 1, max: 10, onChange: (v) => { last = v; } }));
|
|
const input = n.node.querySelector("input") || n.node;
|
|
input.value = "99";
|
|
fire(input, "input");
|
|
assert.equal(input.value, "99", "not clamped yet");
|
|
fire(input, "blur");
|
|
assert.equal(last, 10);
|
|
});
|
|
|
|
test("number applies its precision", () => {
|
|
let last = null;
|
|
const n = mount(composer.number.md({ value: 1, min: 0, max: 10, precision: 1, onChange: (v) => { last = v; } }));
|
|
const input = n.node.querySelector("input") || n.node;
|
|
input.value = "3.14159";
|
|
fire(input, "blur");
|
|
assert.equal(last, 3.1);
|
|
});
|
|
|
|
test("stepper steps without float drift", () => {
|
|
const s = mount(composer.stepper.md({ value: 0.1, min: 0, max: 1, step: 0.2 }));
|
|
s.node.querySelectorAll("button")[1].click();
|
|
assert.equal(s.value, 0.3, "0.1 + 0.2 must not surface as 0.30000000000000004");
|
|
});
|
|
|
|
test("stepper respects its bounds", () => {
|
|
const s = mount(composer.stepper.md({ value: 1, min: 0, max: 1, step: 1 }));
|
|
s.node.querySelectorAll("button")[1].click();
|
|
assert.equal(s.value, 1);
|
|
});
|
|
|
|
test("filterList filters on label and hint, and reports a pick", () => {
|
|
let picked = null;
|
|
const f = mount(composer.filterList.md({
|
|
onChange: (id) => { picked = id; },
|
|
items: [
|
|
{ id: "a", label: "bong_tangent", hint: "validated" },
|
|
{ id: "b", label: "karras", hint: "2.3 era" },
|
|
],
|
|
}));
|
|
const search = f.node.querySelector("input");
|
|
search.value = "valid";
|
|
fire(search, "input");
|
|
assert.equal(f.node.querySelectorAll(".cx-filter-row").length, 1, "matches on hint too");
|
|
|
|
f.node.querySelector(".cx-filter-row").click();
|
|
assert.equal(picked, "a");
|
|
});
|
|
|
|
test("filterList says so when nothing matches", () => {
|
|
const f = mount(composer.filterList.md({ items: [{ id: "a", label: "one" }] }));
|
|
const search = f.node.querySelector("input");
|
|
search.value = "zzz";
|
|
fire(search, "input");
|
|
assert.equal(f.node.querySelectorAll(".cx-filter-row").length, 0);
|
|
assert.ok(f.node.querySelector(".cx-filter-empty"), "an empty list must say it is empty");
|
|
});
|
|
|
|
// --- slider ----------------------------------------------------------------
|
|
|
|
test("slider reports as it moves and commits on release", () => {
|
|
const moves = [];
|
|
const commits = [];
|
|
const s = mount(composer.slider.md({
|
|
value: 0.5, min: 0, max: 1, step: 0.1,
|
|
onChange: (v) => moves.push(v), onCommit: (v) => commits.push(v),
|
|
}));
|
|
const input = s.node.querySelector("input");
|
|
input.value = "0.8";
|
|
fire(input, "input");
|
|
fire(input, "change");
|
|
assert.deepEqual(moves, [0.8]);
|
|
assert.deepEqual(commits, [0.8]);
|
|
});
|
|
|
|
test("the slider paints its fill from the value", () => {
|
|
const s = mount(composer.slider.md({ value: 0.25, min: 0, max: 1, step: 0.05 }));
|
|
assert.equal(s.node.querySelector("input").style.getPropertyValue("--fill"), "25%");
|
|
});
|
|
|
|
test("slider.readout shows the value and its unit", () => {
|
|
const s = mount(composer.slider.readout({ value: 0.65, min: 0, max: 1, step: 0.05, unit: "x", precision: 2 }));
|
|
assert.equal(s.node.querySelector("output").textContent, "0.65x");
|
|
s.setValue(0.3);
|
|
assert.equal(s.node.querySelector("output").textContent, "0.30x");
|
|
});
|
|
|
|
test("slider.macro jumps to a preset", () => {
|
|
const s = mount(composer.slider.macro({
|
|
value: 0.2, min: 0, max: 1, step: 0.05,
|
|
presets: [{ label: "Chill", value: 0.2 }, { label: "Chaos", value: 0.9 }],
|
|
}));
|
|
s.node.querySelectorAll(".cx-macro-presets button")[1].click();
|
|
assert.equal(s.value, 0.9);
|
|
});
|
|
|
|
test("range thumbs cannot pass each other", () => {
|
|
// A range whose start is after its end is a state every consumer would then
|
|
// have to defend against.
|
|
const r = mount(composer.range.md({ from: 0.3, to: 0.7, min: 0, max: 1, step: 0.05 }));
|
|
const [lo, hi] = r.node.querySelectorAll("input");
|
|
lo.value = "0.9";
|
|
fire(lo, "input");
|
|
const { from, to } = r.value;
|
|
assert.ok(from <= to, `from ${from} must not exceed to ${to}`);
|
|
});
|
|
|
|
// --- regressions found by the adversarial pass ------------------------------
|
|
|
|
test("finishing a busy spell does not re-enable a button that was disabled", () => {
|
|
// busy and disabled are independent reasons a button cannot be clicked.
|
|
// Deriving the attribute from the constructor's value meant the later call
|
|
// silently lost to the earlier one.
|
|
const b = mount(composer.button.md({ label: "Save" }));
|
|
b.setDisabled(true);
|
|
b.setBusy(true);
|
|
b.setBusy(false);
|
|
assert.equal(b.node.disabled, true);
|
|
b.setDisabled(false);
|
|
assert.equal(b.node.disabled, false);
|
|
});
|
|
|
|
test("a busy button stays disabled even if setDisabled(false) is called", () => {
|
|
const b = mount(composer.button.md({ label: "Save" }));
|
|
b.setBusy(true);
|
|
b.setDisabled(false);
|
|
assert.equal(b.node.disabled, true);
|
|
});
|
|
|
|
test("clearing a number field keeps the last committed value", () => {
|
|
// A browser normalises invalid text in a number input to "", and Number("")
|
|
// is 0 -- finite, so it sails past an isFinite guard and lands on min,
|
|
// discarding what the user had actually entered.
|
|
const seen = [];
|
|
const n = mount(composer.number.md({ value: 3, min: 1, max: 10, onChange: (v) => seen.push(v) }));
|
|
const input = n.node.querySelector("input") || n.node;
|
|
|
|
input.value = "7";
|
|
fire(input, "blur");
|
|
assert.equal(n.value, 7);
|
|
|
|
input.value = "";
|
|
fire(input, "blur");
|
|
assert.equal(n.value, 7, "an emptied field is not a zero");
|
|
assert.equal(input.value, "7", "and the field shows what is actually held");
|
|
assert.deepEqual(seen, [7, 7]);
|
|
});
|
|
|
|
test("a number field ignores unparseable text on commit", () => {
|
|
const n = mount(composer.number.md({ value: 5, min: 0, max: 10 }));
|
|
const input = n.node.querySelector("input") || n.node;
|
|
input.value = "abc";
|
|
fire(input, "blur");
|
|
assert.equal(n.value, 5);
|
|
});
|
|
|
|
test("a list row carries a real control, separate from its reorder arrows", () => {
|
|
// A bare number printed beside up/down arrows reads as a stepper for that
|
|
// number. The weight is edited in a control; the arrows only move the row.
|
|
const weight = composer.number.md({ value: 0.8, min: -2, max: 2, step: 0.05, label: "Weight" });
|
|
const l = mount(composer.list.rows({
|
|
reorder: true,
|
|
items: [{ label: "detail_v3", control: weight }, { label: "motion_lift" }],
|
|
}));
|
|
assert.ok(l.node.querySelector(".cx-list-control input"), "the row has an editable control");
|
|
|
|
const moves = [...l.node.querySelectorAll(".cx-list-move")];
|
|
assert.ok(moves.length >= 2);
|
|
for (const m of moves) {
|
|
assert.match(m.getAttribute("aria-label"), /^Move .* (up|down)$/,
|
|
"reorder buttons say they reorder");
|
|
}
|
|
});
|
|
|
|
test("reordering a list reports the new order", () => {
|
|
let order = null;
|
|
const l = mount(composer.list.rows({
|
|
reorder: true, onReorder: (items) => { order = items.map((i) => i.label); },
|
|
items: [{ label: "a" }, { label: "b" }],
|
|
}));
|
|
// the second row's "up" button
|
|
l.node.querySelectorAll(".cx-list-row")[1].querySelector(".cx-list-move").click();
|
|
assert.deepEqual(order, ["b", "a"]);
|
|
});
|
|
|
|
// --- workspace -------------------------------------------------------------
|
|
|
|
const workspace = (props = {}) => mount(composer.workspace.docked({
|
|
id: `ws-${Math.random().toString(36).slice(2)}`,
|
|
left: composer.hint.default({ text: "assets" }),
|
|
centre: composer.hint.default({ text: "preview" }),
|
|
right: composer.hint.default({ text: "properties" }),
|
|
...props,
|
|
}));
|
|
|
|
test("a docked panel's toggle is not inside the panel it collapses", () => {
|
|
// The v4 fault, as a test. A collapsed column was marked [hidden], which is
|
|
// display:none, and the control that would have brought it back was inside
|
|
// it -- so the button did nothing at all and said nothing.
|
|
const ws = workspace();
|
|
for (const side of ["left", "right"]) {
|
|
const panel = side === "left" ? ws.left : ws.right;
|
|
const toggle = ws.node.querySelector(`.cx-workspace-rail-${side} button`);
|
|
assert.ok(toggle, `${side} has no toggle`);
|
|
assert.ok(!panel.contains(toggle), `the ${side} toggle is inside the panel it hides`);
|
|
}
|
|
});
|
|
|
|
test("collapsing keeps the panel in the document rather than removing it", () => {
|
|
const ws = workspace();
|
|
ws.close("left");
|
|
assert.equal(ws.isOpen("left"), false);
|
|
assert.ok(ws.node.contains(ws.left), "the panel was removed rather than collapsed");
|
|
assert.ok(!ws.left.hasAttribute("hidden"),
|
|
"[hidden] is display:none, which is what made this unrecoverable in v4");
|
|
assert.ok(ws.left.classList.contains("cx-collapsed"));
|
|
});
|
|
|
|
test("a collapsed panel can be reopened from its toggle", () => {
|
|
const ws = workspace();
|
|
const toggle = ws.node.querySelector(".cx-workspace-rail-right button");
|
|
toggle.click();
|
|
assert.equal(ws.isOpen("right"), false);
|
|
assert.equal(toggle.getAttribute("aria-expanded"), "false");
|
|
toggle.click();
|
|
assert.equal(ws.isOpen("right"), true);
|
|
assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
|
});
|
|
|
|
test("the centre keeps its content and the sides keep theirs", () => {
|
|
const ws = workspace();
|
|
assert.match(ws.centre.textContent, /preview/);
|
|
assert.match(ws.left.textContent, /assets/);
|
|
assert.match(ws.right.textContent, /properties/);
|
|
});
|
|
|
|
test("which panels were open is remembered per workspace id", () => {
|
|
const first = workspace({ id: "remembered" });
|
|
first.close("left");
|
|
first.destroy();
|
|
|
|
const second = workspace({ id: "remembered" });
|
|
assert.equal(second.isOpen("left"), false, "the closed panel came back open");
|
|
assert.equal(second.isOpen("right"), true);
|
|
});
|
|
|
|
test("a workspace with no panels still renders its centre", () => {
|
|
const ws = mount(composer.workspace.docked({
|
|
id: "bare", centre: composer.hint.default({ text: "only the middle" }),
|
|
}));
|
|
assert.match(ws.centre.textContent, /only the middle/);
|
|
assert.ok(ws.left && ws.right, "the regions exist even when nothing was put in them");
|
|
});
|