Files
olivv-cs-ComfyUI-FunPack/app/shell/tests/autocomplete.test.js
T
digital-garbage 2e384c48b2 core, app: shortcuts, autocomplete, and prompt-craft expansion
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.
2026-09-08 11:49:49 +03:00

129 lines
4.3 KiB
JavaScript

// Shortcut autocomplete on a prompt textarea, over a mocked shortcuts.js.
//
// The matching algorithm itself (word-run under the caret, longest trigger
// first) is exercised through real typing/caret positions here rather than
// as isolated unit tests -- attach() is the only export, by design (see its
// own file), so the behaviour worth pinning is what a user actually sees.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let attach;
const fetched = [];
test.before(async () => {
setupDom();
await import("../../composer/composer.js");
// shortcuts.js's fetchAll() hits fetch() itself; mocked once for the whole
// file since every test wants the same small library.
globalThis.fetch = async () => {
fetched.push(1);
return {
ok: true,
json: async () => ({ shortcuts: [
{ name: "Fox", triggers: ["golden hour", "golden"], replacements: ["warm light"], enabled: true, category: "", sub_category: "" },
{ name: "Off", triggers: ["off"], replacements: ["x"], enabled: false, category: "", sub_category: "" },
] }),
};
};
({ attach } = await import("../autocomplete.js"));
});
test.after(() => teardownDom());
function field(value = "") {
// A fresh document each time: a popover from the PREVIOUS test's field is
// portal-mounted onto document.body too, and without this a later test's
// querySelector(".cx-autocomplete") can find that leftover menu instead of
// (or as well as) its own, since nothing here ever closes one.
document.body.replaceChildren();
const ta = document.createElement("textarea");
ta.value = value;
document.body.appendChild(ta);
return ta;
}
const typeAt = (ta, value, caret = value.length) => {
ta.value = value;
ta.selectionStart = ta.selectionEnd = caret;
ta.dispatchEvent(new window.Event("input", { bubbles: true }));
};
const wait = () => new Promise((r) => setTimeout(r, 0));
test("attach is a no-op the second time on the same field", () => {
const ta = field();
attach(ta);
attach(ta);
assert.equal(ta._acAttached, true);
});
test("typing a matching trigger's tail shows it as a suggestion", async () => {
const ta = field();
attach(ta);
await wait(); // let the warm-up fetch land
typeAt(ta, "a gold");
await wait();
const menu = document.querySelector(".cx-autocomplete");
assert.ok(menu, "no suggestion menu appeared");
assert.match(menu.textContent, /golden/);
});
test("the longer trigger is offered ahead of the shorter one it contains", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "golden hou");
await wait();
const labels = [...document.querySelectorAll(".cx-autocomplete .cx-menu-label")].map((n) => n.textContent);
assert.equal(labels[0], "golden hour");
});
test("a disabled shortcut's trigger is never suggested", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "of");
await wait();
assert.equal(document.querySelector(".cx-autocomplete"), null);
});
test("accepting a suggestion replaces only the matched word run", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "a gold");
await wait();
// "gold" ties "golden" and "golden hour" on rank/position, and the shorter
// trigger sorts first on that tie (matchTriggers' own final ordering) --
// so the top suggestion is "golden", not the longer one.
const item = document.querySelector(".cx-autocomplete .cx-menu-item");
assert.equal(item.querySelector(".cx-menu-label").textContent, "golden");
item.click();
await wait();
assert.equal(ta.value, "a golden ");
});
test("accepting before an existing delimiter does not add a second space", async () => {
const ta = field();
attach(ta);
await wait();
ta.value = "a gold, cool";
ta.selectionStart = ta.selectionEnd = "a gold".length;
ta.dispatchEvent(new window.Event("input", { bubbles: true }));
await wait();
const item = document.querySelector(".cx-autocomplete .cx-menu-item");
item.click();
await wait();
assert.equal(ta.value, "a golden, cool");
});
test("a query shorter than two characters suggests nothing", async () => {
const ta = field();
attach(ta);
await wait();
typeAt(ta, "a g");
await wait();
assert.equal(document.querySelector(".cx-autocomplete"), null);
});