Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
9.9 KiB
JavaScript
238 lines
9.9 KiB
JavaScript
// The bridge: a declaration in, a panel out.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { setupDom, teardownDom, fire } from "./_dom.js";
|
|
|
|
let renderPanel, defaultsOf, visible, rendererFor, DEFAULT_UI;
|
|
test.before(async () => {
|
|
setupDom();
|
|
await import("../composer.js"); // elements register themselves
|
|
({ renderPanel, defaultsOf, visible, rendererFor, DEFAULT_UI } = await import("../panel.js"));
|
|
});
|
|
test.after(() => teardownDom());
|
|
|
|
// Panels are built detached and then mounted; jsdom only runs a control's
|
|
// activation behaviour once it is in the document, so anything that clicks has
|
|
// to mount first -- which is what the shell does anyway.
|
|
const mount = (panel) => { document.body.appendChild(panel.node); return panel; };
|
|
|
|
const SPEC = {
|
|
id: "audio_clock",
|
|
title: "Audio clock",
|
|
settings: {
|
|
enabled: { type: "bool", default: true, label: "Sync to audio clock", hint: "Locks frame timing." },
|
|
strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05,
|
|
label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } },
|
|
mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented",
|
|
options: [{ value: "beat", label: "Beat grid" }, { value: "onset", label: "Onset" }],
|
|
when: { enabled: true } },
|
|
},
|
|
};
|
|
|
|
test("defaults come from the declaration, not from the controls", () => {
|
|
assert.deepEqual(defaultsOf(SPEC), { enabled: true, strength: 0.65, mode: "beat" });
|
|
});
|
|
|
|
test("a panel renders one row per visible setting", () => {
|
|
const panel = renderPanel(SPEC);
|
|
assert.equal(panel.node.querySelectorAll(".cx-check-row, .cx-settings-row").length, 3);
|
|
assert.match(panel.node.textContent, /Audio clock/);
|
|
panel.destroy();
|
|
});
|
|
|
|
test("the declared renderer is used, not the default for the type", () => {
|
|
const panel = renderPanel(SPEC);
|
|
assert.ok(panel.node.querySelector(".cx-slider"), "strength asked for a slider");
|
|
assert.ok(panel.node.querySelector(".cx-segmented"), "mode asked for segmented");
|
|
assert.equal(panel.node.querySelector("select"), null, "and not the enum default");
|
|
panel.destroy();
|
|
});
|
|
|
|
test("a type with no declared renderer gets its default", () => {
|
|
const panel = renderPanel({ title: "T", settings: {
|
|
n: { type: "float", default: 1, label: "N" },
|
|
t: { type: "text", default: "x", label: "T" },
|
|
} });
|
|
assert.equal(DEFAULT_UI.float, "number");
|
|
assert.ok(panel.node.querySelector("input[type=number]"));
|
|
assert.ok(panel.node.querySelector("input[type=text]"));
|
|
panel.destroy();
|
|
});
|
|
|
|
test("a self-labelling control is not wrapped in a second label", () => {
|
|
const panel = renderPanel({ title: "T", settings: {
|
|
on: { type: "bool", default: true, label: "Enabled" },
|
|
} });
|
|
const labels = [...panel.node.querySelectorAll("*")]
|
|
.filter((n) => n.textContent.trim() === "Enabled" && !n.children.length);
|
|
assert.equal(labels.length, 1, "the label appears once, not once per wrapper");
|
|
panel.destroy();
|
|
});
|
|
|
|
// --- conditional rows ------------------------------------------------------
|
|
|
|
test("a row whose condition fails is absent, not disabled", () => {
|
|
// Hide, don't warn, one level down: a greyed control invites clicking on
|
|
// something that cannot do anything.
|
|
const panel = renderPanel(SPEC, { values: { enabled: false } });
|
|
assert.match(panel.node.textContent, /Sync to audio clock/);
|
|
assert.doesNotMatch(panel.node.textContent, /Lock strength/);
|
|
assert.equal(panel.node.querySelector("[disabled]"), null);
|
|
panel.destroy();
|
|
});
|
|
|
|
test("changing the key a row depends on brings it back", () => {
|
|
const panel = mount(renderPanel(SPEC, { values: { enabled: false } }));
|
|
assert.doesNotMatch(panel.node.textContent, /Lock strength/);
|
|
panel.node.querySelector("input[type=checkbox]").click();
|
|
assert.match(panel.node.textContent, /Lock strength/);
|
|
panel.destroy();
|
|
});
|
|
|
|
test("visible() takes a list as any-of", () => {
|
|
const setting = { when: { mode: ["beat", "onset"] } };
|
|
assert.equal(visible(setting, { mode: "onset" }), true);
|
|
assert.equal(visible(setting, { mode: "flat" }), false);
|
|
});
|
|
|
|
test("a setting with no condition is always visible", () => {
|
|
assert.equal(visible({}, {}), true);
|
|
});
|
|
|
|
// --- values ----------------------------------------------------------------
|
|
|
|
test("edits are reported with the key and the whole value set", () => {
|
|
const seen = [];
|
|
const panel = mount(renderPanel(SPEC, { onChange: (key, value, all) => seen.push([key, value, all.mode]) }));
|
|
panel.node.querySelector("input[type=checkbox]").click();
|
|
assert.deepEqual(seen[0].slice(0, 2), ["enabled", false]);
|
|
assert.equal(seen[0][2], "beat", "the rest of the values come along");
|
|
panel.destroy();
|
|
});
|
|
|
|
test("the panel's values start from the defaults and track edits", () => {
|
|
const panel = mount(renderPanel(SPEC));
|
|
assert.deepEqual(panel.values, { enabled: true, strength: 0.65, mode: "beat" });
|
|
|
|
const slider = panel.node.querySelector(".cx-slider input");
|
|
slider.value = "0.3";
|
|
fire(slider, "input");
|
|
assert.equal(panel.values.strength, 0.3);
|
|
panel.destroy();
|
|
});
|
|
|
|
test("supplied values override the defaults", () => {
|
|
const panel = renderPanel(SPEC, { values: { strength: 0.2 } });
|
|
assert.equal(panel.values.strength, 0.2);
|
|
assert.equal(panel.values.mode, "beat", "and the rest still come from the declaration");
|
|
panel.destroy();
|
|
});
|
|
|
|
// --- refusing rather than approximating ------------------------------------
|
|
|
|
test("a setting with no default is refused by the panel too", () => {
|
|
// Core catches this first; the panel is the layer that cannot proceed without
|
|
// one, since it would render a control holding undefined.
|
|
assert.throws(() => rendererFor({ type: "float", min: 0, max: 1 }), /no default/);
|
|
});
|
|
|
|
test("an unknown renderer throws instead of falling back", () => {
|
|
// A panel that silently renders a different control is worse than an absent
|
|
// one, because nobody notices it is wrong.
|
|
assert.throws(() => rendererFor({ type: "bool", ui: "hologram" }), TypeError);
|
|
});
|
|
|
|
test("a panel that cannot be built appends nothing", () => {
|
|
const host = document.createElement("div");
|
|
document.body.appendChild(host);
|
|
try {
|
|
const panel = renderPanel({ title: "Broken", settings: {
|
|
ok: { type: "bool", default: true, label: "Fine" },
|
|
bad: { type: "bool", default: true, label: "Bad", ui: "hologram" },
|
|
} });
|
|
host.appendChild(panel.node);
|
|
assert.fail("should have thrown");
|
|
} catch (err) {
|
|
assert.ok(err instanceof TypeError);
|
|
assert.equal(host.childElementCount, 0, "nothing half-built reached the document");
|
|
}
|
|
});
|
|
|
|
test("every renderer named by the contract exists", async () => {
|
|
// The contract and the kit have to agree: a hint the schema accepts but the
|
|
// kit cannot render would make every module using it hide itself.
|
|
const HINTS = {
|
|
bool: ["checkboxRow", "toggle"],
|
|
int: ["number", "slider", "stepper", "macroSlider"],
|
|
float: ["number", "slider", "stepper", "macroSlider"],
|
|
enum: ["select", "segmented", "radioGroup", "filterList", "wheel"],
|
|
text: ["input", "search"],
|
|
multiline: ["textarea", "autoTextarea"],
|
|
path: ["filterList"],
|
|
};
|
|
for (const [type, hints] of Object.entries(HINTS)) {
|
|
for (const ui of hints) {
|
|
assert.doesNotThrow(() => rendererFor({ type, ui, default: null }), `${type} + ${ui}`);
|
|
}
|
|
assert.doesNotThrow(() => rendererFor({ type, default: null }), `${type} default`);
|
|
}
|
|
});
|
|
|
|
// --- a redraw is as safe as the first build --------------------------------
|
|
|
|
test("a row that only appears later cannot empty the panel when it throws", () => {
|
|
// The initial build is protected by a detached fragment, and the redraw was
|
|
// not: it destroyed every control and then built, so a setting revealed by
|
|
// another value -- one the first build never rendered and so never checked --
|
|
// emptied the panel and threw on the way to refilling it. A blank panel with
|
|
// no way back, from a typo in a `ui` name.
|
|
const spec = {
|
|
id: "late", title: "Late",
|
|
settings: {
|
|
mode: { type: "enum", label: "Mode", default: "simple",
|
|
options: [{ value: "simple", label: "Simple" }, { value: "full", label: "Full" }] },
|
|
extra: { type: "int", label: "Extra", default: 1, ui: "noSuchRenderer",
|
|
when: { mode: "full" } },
|
|
},
|
|
};
|
|
|
|
const panel = renderPanel(spec, {});
|
|
document.body.replaceChildren(panel.node);
|
|
// The CONTROLS, not the rows. A control's destroy() takes the control out of
|
|
// its settings row and leaves the row behind, so counting rows sees a panel
|
|
// that is intact and is looking at labels with nothing under them -- the
|
|
// first version of this test did exactly that and passed against the bug.
|
|
const controls = () => panel.node.querySelectorAll("input, select, textarea").length;
|
|
const before = controls();
|
|
assert.ok(before >= 1, "nothing was rendered to begin with");
|
|
|
|
assert.throws(() => panel.setValue("mode", "full"), /noSuchRenderer/);
|
|
assert.equal(controls(), before,
|
|
"a redraw that could not finish stripped the controls out of the panel");
|
|
});
|
|
|
|
test("a redraw that succeeds destroys the controls it replaced", () => {
|
|
// The fix builds before it tears down, which is exactly where a leak would
|
|
// hide: keeping both maps and forgetting to destroy the old one.
|
|
const destroyed = [];
|
|
const spec = {
|
|
id: "swap", title: "Swap",
|
|
settings: {
|
|
mode: { type: "enum", label: "Mode", default: "a",
|
|
options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] },
|
|
only_a: { type: "int", label: "Only A", default: 1, when: { mode: "a" } },
|
|
},
|
|
};
|
|
const panel = renderPanel(spec, {});
|
|
document.body.replaceChildren(panel.node);
|
|
const was = panel.node.querySelector('[aria-label="Only A"]');
|
|
assert.ok(was, "the gated row was not rendered");
|
|
|
|
panel.setValue("mode", "b");
|
|
assert.equal(was.isConnected, false, "the replaced control stayed in the document");
|
|
assert.equal(panel.node.querySelector('[aria-label="Only A"]'), null);
|
|
void destroyed;
|
|
});
|