UI: tests for app.dom, stage, cover, look, tiles; feature rule fixtures

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155S2Ye8BRUakV7GHAsmYWu
This commit is contained in:
Claude
2026-10-17 14:25:18 +00:00
parent 908feac364
commit 4916af2bcd
11 changed files with 284 additions and 10 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ const link = linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if
const frame = build(document.getElementById("app"));
const selection = createSelection({ project });
selection.on(() => say("select"));
const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), dom: createDom(), peek: createPeek(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
const app = { editorSettings: [], timelineLanes: [], timelineView: {}, monitor: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), dom: createDom(), peek: createPeek(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
// Which backend modules the server has (loaded, even if this model cannot use them). Unknown (server down) = assume all, so nothing is hidden by a failed fetch.
const installed = await Promise.race([api.modules(), new Promise((_, no) => setTimeout(() => no(new Error("slow")), 4000))]) // a stalled server must not leave the page blank
.then((m) => (Array.isArray(m.modules) ? new Set([...m.modules, ...(m.incompatible || [])].map((x) => x.id)) : null)).catch(() => null);
+1 -1
View File
@@ -163,4 +163,4 @@
.cx-viewer-cover[hidden] { display: none; }
.cx-viewer-cover-media { max-inline-size: 100%; max-block-size: 100%; object-fit: contain; }
.cx-viewer-cover-audio { inline-size: 80%; }
.cx-viewer-cover > .cx-btn { position: absolute; inset-block-start: var(--sp-3); inset-inline-end: var(--sp-3); }
.cx-viewer-cover > button { position: absolute; inset-block-start: var(--sp-3); inset-inline-end: var(--sp-3); }
+3 -3
View File
@@ -99,9 +99,9 @@ body { -webkit-font-smoothing: antialiased; letter-spacing: -.003em; }
.cx-viewer-media { border-radius: var(--r-3); }
/* inspector: related fields share one rounded card */
.cx-card { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); }
.cx-card .cx-input, .cx-card .cx-textarea, .cx-card .cx-select { background: var(--elev-3); }
.cx-card .cx-segmented { background: var(--elev-3); }
.cx-fieldcard { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); }
.cx-fieldcard .cx-input, .cx-fieldcard .cx-textarea, .cx-fieldcard .cx-select { background: var(--elev-3); }
.cx-fieldcard .cx-segmented { background: var(--elev-3); }
.cx-panel-body > .cx-eyebrow, .cx-stack > .cx-eyebrow { margin-block-start: var(--sp-4); }
/* list views read as a grouped card, like a settings list */
+2 -2
View File
@@ -2,12 +2,12 @@
.cx-grid-pair { grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-block-start: var(--sp-5); }
.cx-grid-cards { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
.cx-tile { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-5) var(--sp-6); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
.cx-tile { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-1); padding: var(--sp-5) var(--sp-6); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
.cx-tile:hover { background: var(--hover); }
.cx-tile-title { font-size: var(--fs-4); font-weight: var(--fw-medium); }
.cx-tile-sub { font-size: var(--fs-6); color: var(--muted); }
.cx-tile-node { min-block-size: 84px; padding: var(--sp-6) var(--sp-6) var(--sp-5); }
.cx-tile-node { gap: var(--sp-2); min-block-size: 84px; padding: var(--sp-6) var(--sp-6) var(--sp-5); }
.cx-tile-node .cx-tile-title { font-weight: var(--fw-semibold); padding-inline-end: var(--sp-6); }
.cx-tile-node .cx-tile-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-inline-size: 100%; }
.cx-tile-state { position: absolute; inset-block-start: var(--sp-6); inset-inline-end: var(--sp-6); inline-size: 8px; block-size: 8px; border-radius: var(--r-pill); background: var(--good); }
+1 -1
View File
@@ -6,7 +6,7 @@ import { setText } from "../internals/text.js";
/** Related fields on one rounded card. Takes handles, like a stack. */
define("card", "default", ({ children = [], gap = "md" } = {}) => {
const stack = lookup("region", "stack")({ children, gap });
stack.node.classList.add("cx-card");
stack.node.classList.add("cx-fieldcard");
return stack;
});
@@ -76,3 +76,23 @@ test("a url containing a hash is not a colour", () => {
assert.deepEqual(scanSource('export const a = "#abc";', MODULE_RULES).map((v) => v.rule),
["hardcoded-colour"], "a real colour is caught");
});
test("the feature rules catch the page, the frame's classes and styling, and let prose and imports through", () => {
const bad = `
const a = document.createElement("div");
window.addEventListener("resize", f);
node.classList.add("x"); node.style.left = "1px";
q.closest(".cx-panel-head"); el.className = "fp-card";
box.innerHTML = "x"; const c = "#ff0066";
`;
const caught = new Set(scanSource(bad, FEATURE_RULES).map((v) => v.rule));
for (const rule of ["document", "reaching-for-globals", "class-manipulation", "inline-style", "class-name", "markup", "hardcoded-colour", "hardcoded-size"]) {
assert.ok(caught.has(rule), `not caught: ${rule}`);
}
const fine = `
import { open } from "./window.js";
const text = "Check the Log window. Wrong look? Open the document, then close it.";
localStorage.setItem("k", "1"); app.dom.onPage("focusin", f);
`;
assert.deepEqual(scanSource(fine, FEATURE_RULES), []);
});
+163
View File
@@ -0,0 +1,163 @@
// The stage drawn over a picture, the cover over a monitor, the picture's look, and the other pieces added for features.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "./_dom.js";
let c;
test.before(async () => { setupDom(); ({ composer: c } = await import("../composer.js")); });
test.after(() => teardownDom());
const pointer = (node, type, { x = 0, y = 0 } = {}) =>
node.dispatchEvent(new window.PointerEvent(type, { clientX: x, clientY: y, pointerId: 1, button: 0, bubbles: true, cancelable: true }));
const rect = (node, r) => { node.getBoundingClientRect = () => ({ left: 0, top: 0, right: r.width, bottom: r.height, ...r }); };
// A parent holding the picture area (400 x 200 on screen, a 4:2 picture) and the stage.
function monitor(handlers = {}) {
const parent = document.createElement("div"), area = document.createElement("div");
document.body.append(parent);
parent.append(area);
rect(parent, { width: 400, height: 200 }); rect(area, { width: 400, height: 200 });
const stage = c.stage.overlay(handlers);
parent.append(stage.node);
return { stage, parent, area };
}
const text = (over = {}) => ({ id: "t1", kind: "text", text: " Hi ", fontSize: 20, x: 0.5, y: 0.5, ...over });
test("the stage fits the picture's aspect into the room it has and draws sizes at that scale", () => {
const { stage } = monitor();
assert.equal(stage.fit(200, 100), true);
assert.deepEqual(stage.size, { w: 400, h: 200 });
stage.setItems([text()]);
const item = stage.node.querySelector(".cx-stage-item");
assert.equal(item.textContent, "Hi", "text is trimmed, as the render trims it");
assert.equal(item.style.fontSize, "40px", "20 picture pixels at a scale of 2");
stage.destroy();
});
test("with no visible area there is nothing to fit, so the stage says so", () => {
const parent = document.createElement("div");
document.body.append(parent);
const stage = c.stage.overlay();
parent.append(stage.node);
assert.equal(stage.fit(200, 100), false);
});
test("dragging an item reports its new place as fractions of the picture, clamped, and busy is true meanwhile", () => {
const moved = [];
const { stage } = monitor({ onMove: (id, to) => moved.push([id, to]) });
stage.fit(200, 100);
stage.setItems([text()]);
const item = stage.node.querySelector(".cx-stage-item");
pointer(item, "pointerdown", { x: 100, y: 100 });
assert.equal(stage.busy, true);
pointer(item, "pointermove", { x: 200, y: 100 }); // 100 px over a 400 px wide picture: a quarter of it
assert.equal(item.style.left, "75%", "shown live while dragging");
pointer(item, "pointermove", { x: 5000, y: 100 });
pointer(item, "pointerup", { x: 5000, y: 100 });
assert.equal(stage.busy, false);
assert.deepEqual(moved, [["t1", { x: 1, y: 0.5 }]], "far past the edge stops at the edge");
});
test("the corner handle reports a growth factor, not a style; a cancelled gesture reports nothing but onCancel", () => {
const grown = [], cancelled = [];
const { stage } = monitor({ onResize: (id, f) => grown.push([id, f]), onCancel: () => cancelled.push(1) });
stage.fit(200, 100);
stage.setItems([text()]);
const item = stage.node.querySelector(".cx-stage-item"), grip = item.querySelector(".cx-stage-grip");
rect(item, { width: 100, height: 40 });
pointer(grip, "pointerdown", { x: 0, y: 0 });
pointer(grip, "pointermove", { x: 50, y: 0 }); // both sides grow: 100 -> 200 wide
assert.equal(item.style.fontSize, "80px", "shown live while dragging");
pointer(grip, "pointerup", { x: 50, y: 0 });
assert.deepEqual(grown, [["t1", 2]]);
pointer(grip, "pointerdown", { x: 0, y: 0 });
pointer(grip, "pointermove", { x: 10, y: 0 });
pointer(grip, "pointercancel", { x: 10, y: 0 });
assert.equal(grown.length, 1);
assert.equal(cancelled.length, 1);
});
test("a picture item has its width in picture pixels and keeps its aspect unless a height is given", () => {
const { stage } = monitor();
stage.fit(200, 100);
stage.setItems([{ id: "i", kind: "image", src: "/x.png", widthPx: 50, heightPx: null }, { id: "j", kind: "image", src: "/y.png", widthPx: 50, heightPx: 20 }]);
const [a, b] = [...stage.node.querySelectorAll("img")];
assert.equal(a.style.width, "100px"); assert.equal(a.style.height, "");
assert.equal(b.style.height, "40px");
});
test("the cover shows a file over its parent, closes on ✕ or close(), and says so once", () => {
let closed = 0;
const cover = c.viewer.cover({ onClose: () => { closed += 1; } });
document.body.append(cover.node);
assert.equal(cover.isOpen, false);
cover.show({ src: "/a.mp4", kind: "video" });
assert.equal(cover.isOpen, true);
assert.equal(cover.node.querySelector("video").hasAttribute("controls"), true);
cover.node.querySelector("button").click();
assert.equal(cover.isOpen, false); assert.equal(closed, 1);
cover.close();
assert.equal(closed, 1, "closing what is shut is not a close");
cover.show({ src: "/s.wav", kind: "audio" });
assert.ok(cover.node.querySelector("audio"));
cover.show({});
assert.equal(cover.isOpen, false, "nothing to show, nothing shown");
});
test("the viewer keeps its look across a change of source", () => {
const v = c.viewer.media({ kind: "video" });
v.setSource("/a.mp4", "video");
v.setLook({ transform: "scale(1.5)", filter: "blur(2px)", fit: "cover", opacity: 0.5 });
const style = () => v.element.style;
assert.deepEqual([style().transform, style().filter, style().objectFit, style().opacity], ["scale(1.5)", "blur(2px)", "cover", "0.5"]);
v.setSource("/b.mp4", "video");
assert.equal(style().transform, "scale(1.5)", "the new picture is drawn the same way");
v.setLook(null);
assert.equal(style().transform, "");
});
test("a slider whose top changes draws its fill against the new top", () => {
const s = c.slider.sm({ label: "Position", min: 0, max: 1, step: 0.01 });
s.setMax(10); s.setValue(5);
assert.equal(s.input.style.getPropertyValue("--fill"), "50%");
});
test("a corner button follows its box, and goes (once) when the box does", () => {
const box = document.createElement("textarea");
document.body.append(box);
rect(box, { left: 100, right: 400, top: 200, bottom: 300, width: 300, height: 100 });
let gone = 0;
const corner = c.floating.corner({ anchor: box, label: "💡", onGone: () => { gone += 1; } });
rect(corner.node, { width: 24, height: 20 });
return new Promise((resolve) => setTimeout(() => {
assert.deepEqual([corner.node.style.left, corner.node.style.top], ["360px", "276px"]);
box.remove();
setTimeout(() => {
assert.equal(gone, 1);
assert.equal(corner.node.isConnected, false);
resolve();
}, 60);
}, 60));
});
test("tiles carry a state, refuse an unknown one, and say what the dot means", () => {
const ready = c.tile.node({ title: "Load", sub: "m.safetensors" });
assert.ok(ready.node.querySelector(".cx-tile-state"));
assert.equal(ready.node.querySelector(".cx-tile-state").title, "Ready");
const needs = c.tile.node({ title: "Load", sub: "", state: "needs" });
assert.equal(needs.node.querySelector(".cx-tile-state").title, "Needs attention");
assert.throws(() => c.tile.node({ title: "x", state: "bogus" }), /Unknown tile state/);
let hit = 0;
c.tile.action({ title: "Go", sub: "now", onClick: () => { hit += 1; } }).node.click();
assert.equal(hit, 1);
});
test("a hero, an inline progress and a settings row's hint can be changed after the fact", () => {
const row = c.settingsRow.default({ label: "Update", hint: "Checking…", control: c.button.sm({ label: "Update" }) });
row.setHint("Up to date");
assert.match(row.node.textContent, /Up to date/);
const bar = c.progress.inline({ label: "Generating" });
bar.setText("Step 1/2"); bar.setValue(50); bar.setHidden(true);
assert.equal(bar.node.hidden, true); assert.match(bar.node.textContent, /Step 1\/2/);
assert.match(c.hero.default({ title: "FunPack 5", hint: "Cutting Room" }).node.textContent, /FunPack 5.*Cutting Room/);
});
+1
View File
@@ -37,6 +37,7 @@ export default {
const p = app.project, head = app.playhead;
function draw() {
const open = p.project;
if (!app.monitor.look) return; // no monitor on screen: nothing to draw on
const seg = open && segments(open).find((s) => s.kind === "scene" && head.at >= s.start - 1e-6 && head.at < s.start + s.dur - 1e-6);
const st = fxStyle(seg ? seg.scene.effects : {}, seg ? head.at - seg.start : 0, seg ? seg.dur : 0, seg ? num(seg.scene.fps_mode === "project" ? open.frame_rate : seg.scene.fps, 25) || 25 : 25);
app.monitor.look({ transform: st.transform, filter: st.filter, fit: st.objectFit, opacity: st.opacity === "" ? null : st.opacity });
+2 -2
View File
@@ -128,7 +128,7 @@ export default {
if (s && !(head.at >= s.start && head.at < s.start + s.dur)) head.set(s.start); else sync();
};
sync();
app.monitor = { look: (look) => viewer.setLook(look) }; // the effects feature draws on the picture through this
app.monitor.look = (look) => viewer.setLook(look); // the effects feature draws on the picture through this
const off = [head.on(sync), app.on((what) => {
if (what === "select") onSelect();
else if (what === "play.toggle") (playing ? pause() : start());
@@ -138,6 +138,6 @@ export default {
else if (what === "open") { pause(); head.set(0); } // another project: nothing carries over
else sync();
})];
return () => { clearInterval(walk); off.forEach((f) => f()); delete app.monitor; viewer.destroy(); };
return () => { clearInterval(walk); off.forEach((f) => f()); delete app.monitor.look; viewer.destroy(); };
},
};
+11
View File
@@ -18,3 +18,14 @@ test("a bin tile dropped on a matching target reaches the handler; files and oth
off();
assert.deepEqual(Object.keys(doc.l), []);
});
test("a target can be named, and the handler is told which clip is under the drop and which row the target is", () => {
const lanes = [{}, {}, {}], clip = { dataset: { id: "s3" } };
const doc = { ...fake(), querySelectorAll: () => lanes };
const got = [];
onMediaDrop("lane:video", (item, el, e, where) => got.push([el === lanes[1], where.clipId, where.row]), doc);
const e = ev({ id: "m", kind: "image" }, lanes[1]);
e.target.closest = (sel) => (sel.includes("cx-nle-clip") ? clip : lanes[1]);
doc.l.drop(e);
assert.deepEqual(got, [[true, "s3", 1]]);
});
+79
View File
@@ -0,0 +1,79 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let createDom;
test.before(async () => { setupDom(); ({ createDom } = await import("../dom.js")); });
test.after(() => teardownDom());
test("typingIn is true only for a text field inside the node that has the caret", () => {
document.body.innerHTML = `<div id="a"><input id="i"><button id="b"></button></div><textarea id="t"></textarea>`;
const dom = createDom(), a = document.getElementById("a");
assert.equal(dom.typingIn(a), false);
document.getElementById("i").focus();
assert.equal(dom.typingIn(a), true);
assert.equal(dom.typingIn(a, "TEXTAREA"), false, "narrowed to one tag");
document.getElementById("t").focus();
assert.equal(dom.typingIn(a), false, "a field outside is not inside");
assert.equal(dom.focusOn(document.getElementById("t")), true);
document.getElementById("b").focus();
assert.equal(dom.holdsFocus(a), true);
dom.blur();
assert.equal(dom.holdsFocus(a), false);
});
test("listeners come back with their own off switch", () => {
const dom = createDom(); let n = 0;
const off = dom.onPage("pointerdown", () => { n += 1; });
document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true }));
off();
document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true }));
assert.equal(n, 1);
});
test("a dialog on screen is reported, and a landmark is found by name", () => {
document.body.innerHTML = `<header class="cx-frame-head"></header><main class="cx-frame-main"><section class="cx-zone"></section><section class="cx-zone"></section><button> ▶ Generate </button></main>`;
const dom = createDom();
assert.equal(dom.dialogOpen(), false);
document.body.insertAdjacentHTML("beforeend", `<div role="dialog"></div>`);
assert.equal(dom.dialogOpen(), true);
assert.equal(dom.landmark("menubar").className, "cx-frame-head");
assert.equal(dom.landmark("zone:1"), document.querySelectorAll("section.cx-zone")[1]);
assert.equal(dom.landmark("button:▶ Generate").tagName, "BUTTON");
assert.equal(dom.landmark("zone:9"), null);
assert.equal(dom.landmark("nonsense"), null);
});
test("a download is a click on a link with the file's name", () => {
const dom = createDom(), seen = [];
const click = window.HTMLAnchorElement.prototype.click;
window.HTMLAnchorElement.prototype.click = function () { seen.push([this.getAttribute("href"), this.download]); };
try { dom.download("/funpack/api/x", "x.json"); } finally { window.HTMLAnchorElement.prototype.click = click; }
assert.deepEqual(seen, [["/funpack/api/x", "x.json"]]);
});
test("a file chooser that is cancelled gives null, and one that is answered gives the file", async () => {
const dom = createDom();
const click = window.HTMLInputElement.prototype.click;
let input;
window.HTMLInputElement.prototype.click = function () { input = this; };
try {
const cancelled = dom.pickFile(".json"); input.oncancel();
assert.equal(await cancelled, null);
assert.equal(input.accept, ".json");
const picked = dom.pickFile(".json");
Object.defineProperty(input, "files", { value: [{ name: "a.json" }] });
input.onchange();
assert.deepEqual(await picked, { name: "a.json" });
} finally { window.HTMLInputElement.prototype.click = click; }
});
test("selectingIn is true only while text inside the node is selected", () => {
document.body.innerHTML = `<pre id="p">some log text</pre><p id="q">elsewhere</p>`;
const dom = createDom(), p = document.getElementById("p");
assert.equal(dom.selectingIn(p), false);
const range = document.createRange(); range.selectNodeContents(p.firstChild);
const sel = document.getSelection(); sel.removeAllRanges(); sel.addRange(range);
assert.equal(dom.selectingIn(p), true);
assert.equal(dom.selectingIn(document.getElementById("q")), false);
});