// 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, source_image: null, references: [] }; 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]), setSourceImage: (id, mediaId) => { written.push([id, "source_image", mediaId]); scene.source_image = mediaId; }, setReferences: (id, refs) => { written.push([id, "references", refs]); scene.references = refs; }, ...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("picking a resolution source calls back with the scene it is for", () => { const project = fakeProject(); const picked = []; const inspector = createInspector({ project, onPickSourceImage: (id) => picked.push(id) }); document.body.replaceChildren(inspector.node); const pick = [...inspector.node.querySelectorAll("button")].find((b) => b.textContent === "Pick…"); fire(pick, "click"); assert.deepEqual(picked, [project.selected.id]); }); test("a picked resolution source shows Change and can be cleared", () => { const project = fakeProject({ scenes: [{ id: "aaaaaaaaaaaa", text: "", result: null, length: null, rating: null, source_image: "abcdef012345", references: [] }] }); project.selected = project.scenes[0]; const inspector = createInspector({ project }); document.body.replaceChildren(inspector.node); assert.match(inspector.node.textContent, /Change/); const clear = [...inspector.node.querySelectorAll("button")].find((b) => b.textContent === "✕"); fire(clear, "click"); assert.deepEqual(project.written.at(-1), [project.selected.id, "source_image", null]); }); test("adding a reference calls back, and each one can be removed on its own", () => { const project = fakeProject({ scenes: [{ id: "aaaaaaaaaaaa", text: "", result: null, length: null, rating: null, source_image: null, references: ["111111111111", "222222222222"] }] }); project.selected = project.scenes[0]; const picked = []; const inspector = createInspector({ project, onPickReference: (id) => picked.push(id) }); document.body.replaceChildren(inspector.node); assert.match(inspector.node.textContent, /111111111111/); assert.match(inspector.node.textContent, /222222222222/); const add = [...inspector.node.querySelectorAll("button")].find((b) => b.textContent === "+ Add reference"); fire(add, "click"); assert.deepEqual(picked, [project.selected.id]); // The FIRST reference's own ✕ -- removing it must not touch the second, // even though both are plain strings and a value-based removal could not // tell two identical references apart. const removes = [...inspector.node.querySelectorAll("button")].filter((b) => b.textContent === "✕"); fire(removes[0], "click"); assert.deepEqual(project.written.at(-1), [project.selected.id, "references", ["222222222222"]]); }); 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"); });