Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/ui.test.js
T
digital-garbage 739a720adc app, core: Settings matches v4 -- retractable rail, fixed frame, real About
Three things the unified Settings window was still missing next to v4's
own, all found by looking at it next to v4 rather than in isolation:

- The sidebar is a 52px icon rail now, expanding to a full list on
  hover/focus and overlaying the content instead of a splitPane's
  permanently-docked two-pane shape -- v4's own sidebar, not an
  approximation of it. Fixing this surfaced two real CSS bugs along the
  way: filterList's own min-width:220px silently defeated the collapse
  (clipping the search box to "Sear" instead of showing an icon), and
  .cx-filter-list's unset overflow-x (computed to auto once overflow-y
  was set) put a horizontal scrollbar under it. Centering the icon in
  the collapsed row needed the label's flex-basis AND the row's own gap
  to both go to zero -- an invisible label still claims its share of
  both otherwise, and the icon renders off-centre.
- The modal now has a real height (v4's own 660px) rather than sizing to
  whichever section is showing, so switching between a short section
  (About) and a long one (Node packs) doesn't visibly resize the whole
  window. That exposed a THIRD latent bug: .cx-modal-body never had
  flex:1 1 auto, invisible until a modal had a real height to fill --
  every prior modal shrink-wrapped its content, so head+body+foot always
  summed to exactly the card's height by accident. Fixed at the shared
  rule, not just for Settings.
- About now shows what v4's does: commit and dirty state, a codename
  when the checkout has one, and the machine's real hardware/software
  facts (chip, memory, GPU or MPS, disk, OS, ComfyUI/Python/Torch/
  attention-backend versions, hostname) -- a new core/sysinfo.py
  (ported from v4's own, individually guarded so a missing probe never
  takes the panel down) behind a new GET /funpack/api/system, injected
  into settings_window.js the same way every other network call here is.

Also: a nav row's tone (the coloured icon chip) is now validated against
its closed set the same way button.js already validates its own tone --
an unknown one used to render silently wrong instead of failing loudly.
2026-09-07 13:47:18 +03:00

236 lines
9.1 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 },
setScene: (id, key, value) => { written.push([id, key, value]); scene[key] = value; },
rename: (name) => written.push(["project", "name", name]),
...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("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");
});