Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/ui.test.js
T
digital-garbage 86484bf2d8 app, core: fix Undo silently no-op'ing after a variable edit
Round 2 of adversarial review found a real bug in the fix the previous
round's own review prompted: mutating the live project.variables array (and
its row objects) in place before calling setVariables() meant projects.js's
remember() -- which deep-clones the project for the undo stack BEFORE
applying the new value -- captured the already-edited state as the "before"
snapshot. Undo after any variable edit or removal was a silent no-op.

Fixed by never mutating what project.variables already points at: every
commit now reads project.variables fresh and hands setVariables a brand new
array, which is also what closes the SAME stale-closure race the in-place
version was built to fix in the first place (redraw-mid-edit still reads
whatever the previous commit already saved, since it reads live rather than
from a snapshot taken at render time). Also fixed core/prompt_build.py's
docstring, which claimed the opposite of what resolve-variables-last
actually does and what its own test already pinned.
2026-09-08 12:24:29 +03:00

270 lines
11 KiB
JavaScript

// The parts added to make v5 an app rather than a generate button: the action
// registry and the wheel that shows it, the inspector, and the way in.
//
// One file, because each is small and they are all the same shape: something
// announces, something else draws whatever announced.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom, fire } from "../../composer/tests/_dom.js";
let actions, createWheel, createInspector, openWizard;
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
actions = await import("../actions.js");
({ createWheel } = await import("../wheel.js"));
({ createInspector } = await import("../inspector.js"));
({ open: openWizard } = await import("../wizard.js"));
});
test.after(() => teardownDom());
// --- actions ----------------------------------------------------------------
test("an action is offered by whoever owns it, and taken away with it", () => {
actions._reset();
const ran = [];
const drop = actions.offerAction({ id: "go", label: "Go", run: () => ran.push("go") });
assert.deepEqual(actions.offered().map((a) => a.id), ["go"]);
assert.equal(actions.run("go"), true);
assert.deepEqual(ran, ["go"]);
drop();
assert.deepEqual(actions.offered(), []);
// Not an error: whatever offered it may simply be gone.
assert.equal(actions.run("go"), false);
});
test("one broken action does not take down the list that showed it", () => {
actions._reset();
actions.offerAction({ id: "bad", run: () => { throw new Error("nope"); } });
actions.offerAction({ id: "good", run: () => {} });
assert.equal(actions.run("bad"), false);
assert.equal(actions.run("good"), true, "a neighbour's failure stopped this one");
});
test("offering the same id twice replaces it", () => {
// Two entries that look identical and do different things is worse than one.
actions._reset();
const ran = [];
actions.offerAction({ id: "go", label: "First", run: () => ran.push(1) });
actions.offerAction({ id: "go", label: "Second", run: () => ran.push(2) });
assert.equal(actions.offered().length, 1);
assert.equal(actions.offered()[0].label, "Second");
actions.run("go");
assert.deepEqual(ran, [2]);
});
// --- the wheel --------------------------------------------------------------
const middle = (node, init = {}) => node.dispatchEvent(
new window.MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true, ...init }));
test("the middle button opens the wheel on whatever is offered", () => {
actions._reset();
actions.offerAction({ id: "generate", label: "Generate", run: () => {} });
actions.offerAction({ id: "cancel", label: "Cancel", run: () => {} });
const wheel = createWheel();
middle(document.body);
assert.equal(wheel.isOpen, true);
assert.match(document.body.textContent, /Generate/);
wheel.close();
assert.equal(wheel.isOpen, false);
wheel.destroy();
});
test("a wheel of one is not a wheel", () => {
// Every action comes from a part of the app that loaded, so "only one" is a
// real state -- and there is nothing to aim between. The kit refuses it; no
// wheel is a better answer than an error thrown under the pointer.
actions._reset();
const wheel = createWheel();
middle(document.body);
assert.equal(wheel.isOpen, false, "a wheel opened with nothing in it");
actions.offerAction({ id: "a", run: () => {} });
middle(document.body);
assert.equal(wheel.isOpen, false, "a wheel opened with one item in it");
wheel.destroy();
});
test("the middle button's own behaviour is prevented", () => {
// Otherwise Chrome starts autoscroll under the wheel.
actions._reset();
actions.offerAction({ id: "a", run: () => {} });
actions.offerAction({ id: "b", run: () => {} });
const wheel = createWheel();
const event = new window.MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
document.body.dispatchEvent(event);
assert.equal(event.defaultPrevented, true);
wheel.destroy();
});
test("a wheel that is already open is not opened again", () => {
actions._reset();
actions.offerAction({ id: "a", run: () => {} });
actions.offerAction({ id: "b", run: () => {} });
const wheel = createWheel();
middle(document.body);
const first = document.querySelectorAll(".cx-wheel").length;
middle(document.body);
assert.equal(document.querySelectorAll(".cx-wheel").length, first);
wheel.destroy();
});
// --- the inspector ----------------------------------------------------------
function fakeProject(over = {}) {
const scene = { id: "aaaaaaaaaaaa", text: "", result: null, length: null, rating: null };
const written = [];
return {
written,
project: { id: "p", name: "Untitled" },
scenes: [scene],
selectedId: scene.id,
selected: scene,
video: { length: 97 },
anchor: "", postfix: "", postfixEnabled: true, variables: [],
setScene: (id, key, value) => { written.push([id, key, value]); scene[key] = value; },
rename: (name) => written.push(["project", "name", name]),
setAnchor: (v) => written.push(["project", "anchor", v]),
setPostfix: (v) => written.push(["project", "postfix", v]),
setPostfixEnabled: (v) => written.push(["project", "postfix_enabled", v]),
setVariables: (v) => written.push(["project", "variables", v]),
...over,
};
}
test("the scene tab edits the scene, and says the crop is not what regenerates", () => {
const project = fakeProject();
const inspector = createInspector({ project });
document.body.replaceChildren(inspector.node);
assert.match(inspector.node.textContent, /Regenerating uses the project's length/);
const length = inspector.node.querySelector('input[type="number"]');
assert.equal(length.value, "97", "a scene with no crop of its own shows the project's length");
length.value = "48";
fire(length, "blur");
assert.deepEqual(project.written.at(-1), [project.selected.id, "length", 48]);
});
test("a rating is kept with the scene, and can be taken back", () => {
// Binary, because the sign is all anything downstream reads. And reversible:
// without a way back a mis-click is a permanent opinion, and "no opinion" is
// a real answer nothing should have to guess at.
const project = fakeProject();
const inspector = createInspector({ project });
document.body.replaceChildren(inspector.node);
const press = (label) => fire([...inspector.node.querySelectorAll("button")]
.find((b) => b.textContent === label), "click");
press("Liked");
assert.deepEqual(project.written.at(-1), [project.selected.id, "rating", "liked"]);
press("Disliked");
assert.deepEqual(project.written.at(-1), [project.selected.id, "rating", "disliked"]);
press("Disliked");
assert.deepEqual(project.written.at(-1), [project.selected.id, "rating", null]);
});
test("the project tab renames the project", () => {
const project = fakeProject();
const renamed = [];
const inspector = createInspector({ project, onRename: (n) => renamed.push(n) });
document.body.replaceChildren(inspector.node);
inspector.show("project");
const name = inspector.node.querySelector('input[type="text"]');
assert.equal(name.value, "Untitled");
name.value = "Rooftops";
fire(name, "blur");
assert.deepEqual(renamed, ["Rooftops"]);
});
test("whatever mounted at project.negative shows only on the Project tab", () => {
// The host has to survive a redraw (a scene selection, a rating) without
// losing whatever a module appended into it -- it lives OUTSIDE the part
// of the tree draw() tears down and rebuilds.
const inspector = createInspector({ project: fakeProject() });
document.body.replaceChildren(inspector.node);
const marker = document.createElement("div");
marker.textContent = "Negative prompt field";
inspector.negativeHost.node.appendChild(marker);
assert.equal(inspector.negativeHost.node.hidden, true, "showing on the Scene tab");
inspector.show("project");
assert.equal(inspector.negativeHost.node.hidden, false);
assert.ok(inspector.node.contains(marker), "the field did not survive the tab switch");
inspector.show("scene");
assert.equal(inspector.negativeHost.node.hidden, true);
assert.ok(inspector.node.contains(marker), "the field did not survive switching away and back");
});
test("editing a variable never mutates the array a caller already holds a reference to", () => {
// The real projects.js store's setVariables() calls remember() -- which
// deep-clones `project` for the undo stack -- BEFORE applying the new
// list. If a caller mutated the array project.variables already points at
// and handed that SAME array back, remember()'s clone would capture the
// ALREADY-EDITED values as the "before" state, and Undo would restore
// exactly what is already on screen: a silent no-op. This fixture stands
// in for that hazard by keeping its own reference to the array from
// before the edit and checking it is untouched afterward.
const before = [{ name: "a", value: "1" }];
let current = before;
const project = fakeProject({
variables: before,
setVariables: (list) => { current = list; },
});
Object.defineProperty(project, "variables", { get: () => current });
const inspector = createInspector({ project });
document.body.replaceChildren(inspector.node);
inspector.show("project");
const nameInput = inspector.node.querySelector('input[placeholder="name"]');
nameInput.value = "b";
fire(nameInput, "change");
assert.notEqual(current, before, "the same array was handed back to setVariables");
assert.deepEqual(before, [{ name: "a", value: "1" }],
"a reference taken before the edit changed value out from under it");
});
test("with no scene selected the inspector says so instead of drawing nothing", () => {
const inspector = createInspector({ project: fakeProject({ selected: null, scenes: [] }) });
document.body.replaceChildren(inspector.node);
assert.match(inspector.node.textContent, /No scene/);
});
// --- the way in -------------------------------------------------------------
test("the wizard can be left, and leaving is an answer", () => {
// v4's could not be dismissed: a page refresh was the only way out, which
// makes it a trap rather than a wizard.
const picked = [];
const window_ = openWizard({ onPick: (id) => picked.push(id) });
assert.ok(document.querySelector(".cx-modal"));
window_.close("dismissed");
assert.deepEqual(picked, [null], "dismissing said nothing at all");
assert.equal(document.querySelector(".cx-modal"), null);
});
test("a choice is answered once, however it was closed", () => {
const picked = [];
const window_ = openWizard({ onPick: (id) => picked.push(id) });
const card = [...document.querySelectorAll(".cx-card")]
.find((c) => /A few scenes/.test(c.textContent));
fire(card, "click");
assert.deepEqual(picked, ["scenes"]);
window_.close("again");
assert.deepEqual(picked, ["scenes"], "closing after a choice answered a second time");
});