- New nodes: Image Resize plus the Utility group (Text, Integer, Float, Show Text, Math Expression, Select Every Nth, Split Batch, Merge Batches, Free Memory) - the dependency-free gaps that let shared workflows drop heavy packs. - Replace with AusBoss nodes (prototype): preview-then-confirm graph rewrite swapping known third-party nodes for pack equivalents, with a tested translation map, per-node opt-out, and refuse-with-reason for nodes the pack cannot stand in for. - Load Video: single-frame FRAME picker, an honest "what will load" label from the probed source rate, drop-a-video-to-restore. Save Video: format-aware widgets and a file_path output. Stitch Inpaint gained a blend_mask output, Select Frame negative from-the-end indexing, Image Size a batch count - all appended, saved workflows load unchanged. - LoRA Loader: drag-to-reorder rows, hover thumbnails on rows, file size and date in the info card. - Appearance: flagship scheme deepened to slate-teal; the retired bright-teal pair upgrades automatically via LEGACY_SCHEME_PAIRS. Optional queue-completion chime, off by default. - Image Resize invariant: pixels are only invented on an explicit width+height pad request - scale-derived modes resolve divisible_by snaps with a sub-half-step resize, never bars. - Docs refreshed for six stale pages, stale text and metadata fixed across the pack, the panel-height rule codified in AGENTS.md and the brand skill, and ausboss_node_tour.json added - a model-free example exercising 17 node types in one graph. Validated: scripts/validate_nodes.py (24 files), 300 JS tests, all Python suites under the ComfyUI embedded interpreter.
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
DEFAULT_STEP,
|
|
FINE_STEP,
|
|
SCRUB_DEAD_ZONE,
|
|
SCRUB_PIXELS_PER_STEP,
|
|
clampHighlight,
|
|
clampStrength,
|
|
commonFolderPrefix,
|
|
filterLoras,
|
|
formatFileSize,
|
|
groupByFolder,
|
|
highlightedName,
|
|
hoverRowIndex,
|
|
isScrubbing,
|
|
moveHighlight,
|
|
moveRow,
|
|
newRow,
|
|
normalizeRows,
|
|
parseRows,
|
|
reorderRows,
|
|
roundStrength,
|
|
thumbPosition,
|
|
scrubValue,
|
|
serializeRows,
|
|
setStrength,
|
|
strengthOutOfRange,
|
|
summarizeRows,
|
|
toggleAllRows,
|
|
toggleAllState,
|
|
toggleTrigger,
|
|
} from "../js/shared/lora_stack.mjs";
|
|
|
|
test("strengths clamp to the UI range and round to two decimals", () => {
|
|
assert.equal(clampStrength(50), 10);
|
|
assert.equal(clampStrength(-50), -10);
|
|
assert.equal(clampStrength("nonsense"), 0);
|
|
assert.equal(roundStrength(0.12345), 0.12);
|
|
});
|
|
|
|
test("normalizeRows heals partial rows and round-trips through serialize/parse", () => {
|
|
const rows = normalizeRows([
|
|
{ name: "styles/ink.safetensors", strength: 0.8 },
|
|
{ bogus: true },
|
|
"not an object",
|
|
]);
|
|
assert.equal(rows.length, 3);
|
|
assert.equal(rows[0].strength_clip, 0.8, "clip strength defaults to model strength");
|
|
assert.equal(rows[0].enabled, true);
|
|
assert.equal(rows[1].name, "");
|
|
const back = parseRows(serializeRows(rows));
|
|
assert.deepEqual(back, rows);
|
|
assert.deepEqual(parseRows("not json"), []);
|
|
});
|
|
|
|
test("row ids survive normalization so external references stay stable", () => {
|
|
const row = newRow();
|
|
const [kept] = normalizeRows([{ ...row, name: "a.safetensors" }]);
|
|
assert.equal(kept.id, row.id);
|
|
});
|
|
|
|
test("moveRow reorders within bounds and ignores impossible moves", () => {
|
|
const rows = normalizeRows([{ name: "a" }, { name: "b" }, { name: "c" }]);
|
|
const moved = moveRow(rows, 2, -1);
|
|
assert.deepEqual(moved.map((r) => r.name), ["a", "c", "b"]);
|
|
assert.equal(moveRow(rows, 0, -1), rows);
|
|
assert.equal(moveRow(rows, 2, 1), rows);
|
|
});
|
|
|
|
test("reorderRows drops a row into any slot and rejects impossible moves", () => {
|
|
const rows = normalizeRows([{ name: "a" }, { name: "b" }, { name: "c" }, { name: "d" }]);
|
|
assert.deepEqual(reorderRows(rows, 0, 2).map((r) => r.name), ["b", "c", "a", "d"]);
|
|
assert.deepEqual(reorderRows(rows, 3, 0).map((r) => r.name), ["d", "a", "b", "c"]);
|
|
assert.equal(reorderRows(rows, 1, 1), rows, "no-op move returns the same array");
|
|
assert.equal(reorderRows(rows, -1, 2), rows);
|
|
assert.equal(reorderRows(rows, 0, 4), rows);
|
|
const moved = reorderRows(rows, 0, 2);
|
|
assert.equal(moved[2], rows[0], "row objects move untouched, not copied");
|
|
});
|
|
|
|
test("hoverRowIndex maps a pointer y to the visual slot it is over", () => {
|
|
const centers = [15, 45, 75];
|
|
assert.equal(hoverRowIndex(centers, 0), 0, "above everything is the first slot");
|
|
assert.equal(hoverRowIndex(centers, 30), 1);
|
|
assert.equal(hoverRowIndex(centers, 60), 2);
|
|
assert.equal(hoverRowIndex(centers, 500), 2, "past the last center stays on the last slot");
|
|
assert.equal(hoverRowIndex([], 10), -1, "an empty stack has no slot");
|
|
});
|
|
|
|
test("thumbPosition trails the cursor and flips or clamps at the edges", () => {
|
|
assert.deepEqual(thumbPosition(100, 100, 1000, 800), { left: 114, top: 114 });
|
|
const flipped = thumbPosition(950, 100, 1000, 800);
|
|
assert.equal(flipped.left, 950 - 188 - 14, "flips to the cursor's left at the right edge");
|
|
const clamped = thumbPosition(100, 780, 1000, 800);
|
|
assert.equal(clamped.top, 800 - 188, "pins above the bottom edge");
|
|
assert.equal(thumbPosition(2, 2, 100, 100).left, 4, "never goes off-screen");
|
|
});
|
|
|
|
test("formatFileSize picks a sane unit and hides junk", () => {
|
|
assert.equal(formatFileSize(512), "512 B");
|
|
assert.equal(formatFileSize(1536), "1.5 KB");
|
|
assert.equal(formatFileSize(Math.round(143.4 * 1024 * 1024)), "143.4 MB");
|
|
assert.equal(formatFileSize(150 * 1024 * 1024), "150 MB", "a whole value drops the .0");
|
|
assert.equal(formatFileSize(2.5 * 1024 ** 3), "2.5 GB");
|
|
assert.equal(formatFileSize(-1), "");
|
|
assert.equal(formatFileSize("junk"), "");
|
|
});
|
|
|
|
test("setStrength mirrors to clip only while linked", () => {
|
|
const rows = normalizeRows([{ name: "a", strength: 1 }]);
|
|
const linked = setStrength(rows, 0, 0.4, true);
|
|
assert.equal(linked[0].strength_clip, 0.4);
|
|
const split = setStrength(rows, 0, 0.4, false);
|
|
assert.equal(split[0].strength_clip, 1);
|
|
});
|
|
|
|
test("scrubbing is inert inside the dead zone, then steps with distance", () => {
|
|
assert.equal(scrubValue(1, SCRUB_DEAD_ZONE), 1);
|
|
assert.equal(scrubValue(1, -SCRUB_DEAD_ZONE), 1);
|
|
const oneStep = scrubValue(1, SCRUB_DEAD_ZONE + SCRUB_PIXELS_PER_STEP);
|
|
assert.equal(oneStep, roundStrength(1 + DEFAULT_STEP));
|
|
const fine = scrubValue(1, SCRUB_DEAD_ZONE + SCRUB_PIXELS_PER_STEP, true);
|
|
assert.equal(fine, roundStrength(1 + FINE_STEP));
|
|
assert.equal(scrubValue(9.99, 10_000), 10, "scrub clamps at the range edge");
|
|
assert.equal(scrubValue(-9.99, -10_000), -10);
|
|
});
|
|
|
|
test("scrubbing honors a custom coarse step but Shift stays fine", () => {
|
|
const travel = SCRUB_DEAD_ZONE + SCRUB_PIXELS_PER_STEP;
|
|
assert.equal(scrubValue(1, travel, false, 0.25), roundStrength(1.25));
|
|
assert.equal(scrubValue(1, travel, true, 0.25), roundStrength(1 + FINE_STEP));
|
|
assert.equal(scrubValue(1, travel, false, 0), roundStrength(1 + DEFAULT_STEP),
|
|
"junk steps fall back to the default");
|
|
});
|
|
|
|
test("a mostly-vertical drag is not a scrub", () => {
|
|
assert.equal(isScrubbing(10, 4), true);
|
|
assert.equal(isScrubbing(4, 10), false);
|
|
assert.equal(isScrubbing(SCRUB_DEAD_ZONE, 0), false);
|
|
});
|
|
|
|
test("picker filter is a case-insensitive substring over the full list", () => {
|
|
const names = ["styles/InkWash.safetensors", "faces/portrait.safetensors"];
|
|
assert.deepEqual(filterLoras(names, "ink"), ["styles/InkWash.safetensors"]);
|
|
assert.deepEqual(filterLoras(names, " "), names);
|
|
assert.deepEqual(filterLoras(names, "zzz"), []);
|
|
});
|
|
|
|
test("highlight wraps with arrows, clamps when the list shrinks, resolves names", () => {
|
|
assert.equal(moveHighlight(-1, 1, 3), 0, "first ArrowDown lands on the first item");
|
|
assert.equal(moveHighlight(-1, -1, 3), 2, "first ArrowUp lands on the last item");
|
|
assert.equal(moveHighlight(2, 1, 3), 0, "wraps past the end");
|
|
assert.equal(moveHighlight(0, -1, 3), 2, "wraps past the start");
|
|
assert.equal(moveHighlight(0, 1, 0), -1, "empty list has no highlight");
|
|
assert.equal(clampHighlight(5, 3), 2);
|
|
assert.equal(clampHighlight(-1, 3), 0);
|
|
assert.equal(highlightedName(["a", "b"], 1), "b");
|
|
assert.equal(highlightedName(["a", "b"], -1), null);
|
|
});
|
|
|
|
test("trigger chips toggle in and out preserving order", () => {
|
|
let triggers = toggleTrigger("", "ink wash");
|
|
triggers = toggleTrigger(triggers, "brush");
|
|
assert.equal(triggers, "ink wash, brush");
|
|
triggers = toggleTrigger(triggers, "Ink Wash");
|
|
assert.equal(triggers, "brush", "toggle-off matches case-insensitively");
|
|
});
|
|
|
|
test("row summary counts only named rows", () => {
|
|
assert.equal(summarizeRows([]), "no LoRAs");
|
|
const rows = normalizeRows([{ name: "a" }, { name: "b", enabled: false }, { name: "" }]);
|
|
assert.equal(summarizeRows(rows), "1 / 2 on");
|
|
});
|
|
|
|
test("master toggle reports on/off/mixed and blank rows do not vote", () => {
|
|
const mixed = normalizeRows([{ name: "a" }, { name: "b", enabled: false }, { name: "" }]);
|
|
assert.equal(toggleAllState(mixed), "mixed");
|
|
assert.equal(toggleAllState(normalizeRows([{ name: "a" }, { name: "" }])), "on");
|
|
assert.equal(toggleAllState(normalizeRows([{ name: "a", enabled: false }])), "off");
|
|
assert.equal(toggleAllState(normalizeRows([{ name: "" }])), "off");
|
|
});
|
|
|
|
test("master toggle click: mixed and off turn all on, on turns all off", () => {
|
|
const mixed = normalizeRows([{ name: "a" }, { name: "b", enabled: false }]);
|
|
const allOn = toggleAllRows(mixed);
|
|
assert.equal(toggleAllState(allOn), "on");
|
|
const allOff = toggleAllRows(allOn);
|
|
assert.equal(toggleAllState(allOff), "off");
|
|
assert.equal(toggleAllState(toggleAllRows(allOff)), "on");
|
|
});
|
|
|
|
test("common folder prefix strips whole segments only, never a lone file's dir", () => {
|
|
assert.equal(
|
|
commonFolderPrefix(["styles/wan/a.safetensors", "styles/wan/b.safetensors"]),
|
|
"styles/wan/"
|
|
);
|
|
assert.equal(commonFolderPrefix(["styles/a.safetensors", "faces/b.safetensors"]), "");
|
|
assert.equal(commonFolderPrefix(["only/one.safetensors"]), "");
|
|
// The shared segment is also one entry's full remaining path: stop above it.
|
|
assert.equal(commonFolderPrefix(["styles/a.safetensors", "styles/wan/b.safetensors"]), "styles/");
|
|
});
|
|
|
|
test("folder grouping buckets by top folder in first-appearance order", () => {
|
|
const groups = groupByFolder(["root.safetensors", "styles/a", "faces/b", "styles/c"]);
|
|
assert.deepEqual(
|
|
groups.map((group) => [group.folder, group.names.length]),
|
|
[["", 1], ["styles", 2], ["faces", 1]]
|
|
);
|
|
});
|
|
|
|
test("strength range flags only real finite bounds", () => {
|
|
assert.equal(strengthOutOfRange(1.5, { min: 0, max: 1 }), true);
|
|
assert.equal(strengthOutOfRange(-0.5, { min: 0, max: 1 }), true);
|
|
assert.equal(strengthOutOfRange(0.5, { min: 0, max: 1 }), false);
|
|
assert.equal(strengthOutOfRange(5, { min: null, max: null }), false);
|
|
assert.equal(strengthOutOfRange(5, null), false);
|
|
assert.equal(strengthOutOfRange(0.5, { max: 1 }), false);
|
|
});
|
|
|
|
test("templateFromRows snapshots rows without ids and trims the name", async () => {
|
|
const { templateFromRows } = await import("../js/shared/lora_stack.mjs");
|
|
const rows = [
|
|
{ id: "r1", name: "styles/a.safetensors", strength: 0.8, strength_clip: 0.6, enabled: true, triggers: "foo" },
|
|
];
|
|
const template = templateFromRows(" My Set ", rows);
|
|
assert.equal(template.name, "My Set");
|
|
assert.equal(template.rows.length, 1);
|
|
assert.equal(template.rows[0].id, undefined);
|
|
assert.equal(template.rows[0].strength, 0.8);
|
|
assert.equal(templateFromRows(" ", rows), null);
|
|
});
|
|
|
|
test("applyTemplate mints fresh row ids and keeps values", async () => {
|
|
const { applyTemplate, templateFromRows } = await import("../js/shared/lora_stack.mjs");
|
|
const template = templateFromRows("set", [
|
|
{ name: "a", strength: 0.5, strength_clip: 0.4, enabled: false, triggers: "x, y" },
|
|
]);
|
|
const rows = applyTemplate(template);
|
|
assert.equal(rows.length, 1);
|
|
assert.ok(rows[0].id);
|
|
assert.equal(rows[0].strength, 0.5);
|
|
assert.equal(rows[0].enabled, false);
|
|
assert.equal(rows[0].triggers, "x, y");
|
|
assert.deepEqual(applyTemplate(null), []);
|
|
});
|
|
|
|
test("upsertTemplate replaces same names case-insensitively and sorts", async () => {
|
|
const { templateFromRows, upsertTemplate } = await import("../js/shared/lora_stack.mjs");
|
|
const a = templateFromRows("Beta", []);
|
|
const b = templateFromRows("alpha", []);
|
|
let list = upsertTemplate(upsertTemplate([], a), b);
|
|
assert.deepEqual(list.map((t) => t.name), ["alpha", "Beta"]);
|
|
const replacement = templateFromRows("BETA", [{ name: "x" }]);
|
|
list = upsertTemplate(list, replacement);
|
|
assert.deepEqual(list.map((t) => t.name), ["alpha", "BETA"]);
|
|
assert.equal(list[1].rows.length, 1);
|
|
});
|
|
|
|
test("removeTemplate and parseTemplates round-trip storage", async () => {
|
|
const { parseTemplates, removeTemplate, templateFromRows, upsertTemplate } = await import(
|
|
"../js/shared/lora_stack.mjs"
|
|
);
|
|
const list = upsertTemplate([], templateFromRows("keep", [{ name: "a" }]));
|
|
const stored = JSON.stringify(upsertTemplate(list, templateFromRows("drop", [])));
|
|
const loaded = parseTemplates(stored);
|
|
assert.deepEqual(loaded.map((t) => t.name), ["drop", "keep"]);
|
|
assert.deepEqual(removeTemplate(loaded, "DROP").map((t) => t.name), ["keep"]);
|
|
assert.deepEqual(parseTemplates("not json"), []);
|
|
assert.deepEqual(parseTemplates('{"name":"x"}'), []);
|
|
});
|