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.
113 lines
4.5 KiB
JavaScript
113 lines
4.5 KiB
JavaScript
// The shortcut library UI, over a mocked /funpack/api/shortcuts.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
|
|
|
|
let mount;
|
|
test.before(async () => {
|
|
setupDom();
|
|
await import("../../composer/composer.js");
|
|
({ mount } = await import("../shortcuts.js"));
|
|
});
|
|
test.after(() => teardownDom());
|
|
|
|
const sc = (name, over = {}) => ({ name, triggers: [name.toLowerCase()],
|
|
replacements: [`replacement for ${name}`], enabled: true, category: "", sub_category: "", ...over });
|
|
|
|
function mockFetch(handlers) {
|
|
globalThis.fetch = async (url, opts) => {
|
|
const method = (opts && opts.method) || "GET";
|
|
const key = `${method} ${new URL(url, "http://localhost/").pathname}`;
|
|
const h = handlers[key] || handlers[method + " *"];
|
|
if (!h) throw new Error(`unhandled: ${key}`);
|
|
return h(opts);
|
|
};
|
|
}
|
|
|
|
/** setFooter's actions are handed to whoever hosts this (the modal chrome
|
|
* in production) rather than living inside body.node -- a footer that
|
|
* did nothing with them would make "+ Add shortcut" and "Close" untestable
|
|
* and unclickable, so this stands in for that host: a footer strip actually
|
|
* appended beside the body, exactly like packs.js's own open() does. */
|
|
function open(opts = {}) {
|
|
let closed = false;
|
|
const footer = document.createElement("div");
|
|
const body = mount({
|
|
...opts,
|
|
setFooter: (f) => { footer.replaceChildren(...(f.actions || []).map((a) => a.node)); },
|
|
close: () => { closed = true; if (opts.close) opts.close(); },
|
|
});
|
|
const root = document.createElement("div");
|
|
root.append(body.node, footer);
|
|
document.body.replaceChildren(root);
|
|
return { root, get closed() { return closed; } };
|
|
}
|
|
|
|
const find = (root, text) => [...root.querySelectorAll("button")].find((b) => b.textContent.trim() === text);
|
|
|
|
test("the list loads on mount", async () => {
|
|
mockFetch({ "GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [sc("Fox")] }) }) });
|
|
const { root } = open();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.match(root.textContent, /Fox/);
|
|
});
|
|
|
|
test("adding a shortcut with no trigger is refused before the request is sent", async () => {
|
|
let posted = false;
|
|
mockFetch({
|
|
"GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [] }) }),
|
|
"POST /funpack/api/shortcuts": async () => { posted = true; return { ok: true, json: async () => ({ shortcuts: [] }) }; },
|
|
});
|
|
const { root } = open();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
find(root, "+ Add shortcut").click();
|
|
find(root, "Save").click();
|
|
assert.equal(posted, false, "an empty trigger list reached the server");
|
|
assert.match(root.textContent, /at least one trigger/i);
|
|
});
|
|
|
|
test("delete calls the route for that name and redraws from the response", async () => {
|
|
const calls = [];
|
|
mockFetch({
|
|
"GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [sc("Fox"), sc("Owl")] }) }),
|
|
"DELETE /funpack/api/shortcuts/Fox": async () => { calls.push("Fox"); return { ok: true, json: async () => ({ shortcuts: [sc("Owl")] }) }; },
|
|
});
|
|
const { root } = open();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
find(root, "Delete").click();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.deepEqual(calls, ["Fox"]);
|
|
assert.doesNotMatch(root.textContent, /Fox/);
|
|
assert.match(root.textContent, /Owl/);
|
|
});
|
|
|
|
test("a server refusal on save is shown rather than silently dropped", async () => {
|
|
mockFetch({
|
|
"GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [] }) }),
|
|
"POST /funpack/api/shortcuts": async () => ({ ok: false, status: 400, json: async () => ({ problems: ["a shortcut needs at least one trigger"] }) }),
|
|
});
|
|
const { root } = open();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
find(root, "+ Add shortcut").click();
|
|
const triggers = [...root.querySelectorAll("textarea")][0];
|
|
triggers.value = "fox";
|
|
triggers.dispatchEvent(new window.Event("change", { bubbles: true }));
|
|
find(root, "Save").click();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
assert.match(root.textContent, /a shortcut needs at least one trigger/);
|
|
});
|
|
|
|
test("the footer's Close action calls the close handler it was given", async () => {
|
|
mockFetch({ "GET /funpack/api/shortcuts": async () => ({ ok: true, json: async () => ({ shortcuts: [] }) }) });
|
|
const session = open();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
find(session.root, "Close").click();
|
|
assert.equal(session.closed, true);
|
|
});
|