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.
270 lines
11 KiB
JavaScript
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");
|
|
});
|