A trigger->replacement library (core/shortcuts.py, a close port of v4's apply_prompt_shortcuts/resolve_variables), a Settings section to manage it, autocomplete on the real prompt textarea (composer.autocomplete.default, so it outranks the Constructor modal it lives inside), and a stateless /api/prompt/expand that combines anchor + shortcuts + $variables + postfix at the moment a run is queued -- scene.text itself stays exactly what was typed. Project gained anchor/postfix/variables editing UI on its own tab. Along the way: a real layout bug in the Properties column (every composer stack can flex-shrink below its own content, and once the Project tab grew past a couple of rows the column started squeezing its own children's content into each other and, at the outer level, into the sibling Generation section below) -- fixed with flex-shrink:0 at each level that needs it, caught by a Playwright regression test.
241 lines
9.4 KiB
JavaScript
241 lines
9.4 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("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");
|
|
});
|