Resolution Master is renamed Latent Size: the old name belongs to another
node pack. The node id AUSBOSS_NODES_Resolution is unchanged, so saved
workflows and API graphs load and run as before. Workflows only store a
title that differs from the default, so they pick up the new name; a saved
title that starts with "Resolution Master 🆎" gets the new name in its
place on load. The example, its thumbnail and the README image follow.
- Latent Size can be resized: wider stretches the chips, fields and size
picks, and taller gives the stage the room. It opens at 340 x 531 as
before and goes down to 320, where the size row tightens so W, H and MP
stay whole.
- LoRA Loader: gear -> Long names, end (the default, as before) or middle,
which keeps the checkpoint number at the end of a name in view. The
picker opens wide enough for long names and shortens the folder first.
- The frontend adds 104 px to a DOM panel's minWidth before using it as
the node's floor (LGraphNode.computeSize), so a node that opened
narrower jumped wider the moment its corner was touched: the LoRA Loader
could not go under 424. fillNodeHeight gains exactMinWidth, which
measures that padding and takes it back off, and holdVueNodeMinWidth
lends the floor to Nodes 2.0, whose corner drag reads the node's inline
min-width instead. Both nodes use them; the other panels keep their old
floors for now.
- README: the workflow-tools image shows Latent Size alone; the Seed card
is gone.
740 lines
30 KiB
JavaScript
740 lines
30 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
BYPASS_MODE,
|
|
DEFAULT_STEP,
|
|
FINE_STEP,
|
|
SCRUB_DEAD_ZONE,
|
|
SCRUB_PIXELS_PER_STEP,
|
|
clampHighlight,
|
|
clampStrength,
|
|
commonFolderPrefix,
|
|
cycleMasterToggle,
|
|
duplicateLoraKeys,
|
|
filterLoras,
|
|
fitLoraLabel,
|
|
formatFileSize,
|
|
groupByFolder,
|
|
highlightedName,
|
|
hoverRowIndex,
|
|
importNeedsSeparateStrengths,
|
|
importSummary,
|
|
isScrubbing,
|
|
loaderLoraEntries,
|
|
mergeImportedRows,
|
|
middleEllipsis,
|
|
moveHighlight,
|
|
moveRow,
|
|
newRow,
|
|
normalizeRows,
|
|
parseRows,
|
|
pickerWidth,
|
|
reorderRows,
|
|
resolveLoraName,
|
|
roundStrength,
|
|
shortLoraName,
|
|
snapshotEnabled,
|
|
strengthBarBackground,
|
|
strengthBarScale,
|
|
strengthBarSpan,
|
|
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"}'), []);
|
|
});
|
|
|
|
// ---------- promoted lab experiments: bars, absorb, resolution, memory ----------
|
|
|
|
const row = (name, strength, extra = {}) => ({
|
|
id: `t-${name}`, name, strength,
|
|
strength_clip: strength, enabled: true, triggers: "", ...extra,
|
|
});
|
|
|
|
// ---------- scale ----------
|
|
|
|
test("scale floors at 1 so the everyday range reads absolutely", () => {
|
|
assert.equal(strengthBarScale([row("a", 0.5), row("b", 0.3)]), 1);
|
|
assert.equal(strengthBarScale([]), 1);
|
|
});
|
|
|
|
test("scale follows the largest magnitude, positive or negative", () => {
|
|
assert.equal(strengthBarScale([row("a", 2), row("b", 0.5)]), 2);
|
|
assert.equal(strengthBarScale([row("a", -1.6), row("b", 0.5)]), 1.6);
|
|
});
|
|
|
|
test("nameless rows do not vote on the scale", () => {
|
|
assert.equal(strengthBarScale([row("", 5), row("a", 0.5)]), 1);
|
|
});
|
|
|
|
test("strengthBarScale follows the enabled rows only", () => {
|
|
const off = { ...row("a", 2), enabled: false };
|
|
assert.equal(strengthBarScale([off, row("b", 0.5)]), 1);
|
|
assert.equal(strengthBarScale([off, row("b", 1.5)]), 1.5);
|
|
assert.equal(strengthBarScale([off, { ...row("b", 1.5), enabled: false }]), 1);
|
|
});
|
|
|
|
// ---------- span ----------
|
|
|
|
test("positive strength grows right of center", () => {
|
|
assert.deepEqual(strengthBarSpan(1, 1), { from: 50, to: 100 });
|
|
assert.deepEqual(strengthBarSpan(0.5, 1), { from: 50, to: 75 });
|
|
});
|
|
|
|
test("negative strength grows left of center", () => {
|
|
assert.deepEqual(strengthBarSpan(-1, 1), { from: 0, to: 50 });
|
|
assert.deepEqual(strengthBarSpan(-0.95, 1), { from: 2.5, to: 50 });
|
|
});
|
|
|
|
test("the -1 among 0.5s example: far edges represent 1.0", () => {
|
|
const rows = [row("a", -1), row("b", 0.5), row("c", 0.5)];
|
|
const scale = strengthBarScale(rows);
|
|
assert.equal(scale, 1);
|
|
assert.deepEqual(strengthBarSpan(-1, scale), { from: 0, to: 50 });
|
|
assert.deepEqual(strengthBarSpan(0.5, scale), { from: 50, to: 75 });
|
|
});
|
|
|
|
test("magnitudes beyond the scale clamp at the field edge", () => {
|
|
assert.deepEqual(strengthBarSpan(3, 2), { from: 50, to: 100 });
|
|
});
|
|
|
|
// ---------- background string ----------
|
|
|
|
test("zero strength paints only the center tick", () => {
|
|
const css = strengthBarBackground(0, 1);
|
|
assert.match(css, /calc\(50% - 0\.5px\)/);
|
|
assert.ok(!css.includes("0.28"), "no positive fill layer");
|
|
assert.ok(!css.includes("0.26"), "no negative fill layer");
|
|
});
|
|
|
|
test("positive bar carries fill, cap at the outer edge, tick, and base", () => {
|
|
const css = strengthBarBackground(0.5, 1);
|
|
assert.match(css, /rgba\(0, 180, 170, 0\.28\) 50%/);
|
|
assert.match(css, /calc\(75% - 2px\)/);
|
|
assert.match(css, /#23272c$/);
|
|
});
|
|
|
|
test("negative bar caps its left edge", () => {
|
|
const css = strengthBarBackground(-0.95, 1);
|
|
assert.match(css, /rgba\(235, 106, 96, 0\.26\)/);
|
|
assert.match(css, /transparent 2\.5%, rgba\(255, 138, 128, 0\.9\) 2\.5%/);
|
|
});
|
|
|
|
// ---------- upstream extraction ----------
|
|
|
|
const widgets = (pairs) => Object.entries(pairs).map(([name, value]) => ({ name, value }));
|
|
|
|
test("core LoraLoader contributes one dual-strength entry", () => {
|
|
const entries = loaderLoraEntries({
|
|
type: "LoraLoader",
|
|
widgets: widgets({ lora_name: "a/b.safetensors", strength_model: 0.8, strength_clip: 0.6 }),
|
|
});
|
|
assert.deepEqual(entries, [{
|
|
name: "a/b.safetensors", strength: 0.8, strength_clip: 0.6,
|
|
enabled: true, triggers: "",
|
|
}]);
|
|
});
|
|
|
|
test("model-only loader imports with clip strength 0", () => {
|
|
const entries = loaderLoraEntries({
|
|
type: "LoraLoaderModelOnly",
|
|
widgets: widgets({ lora_name: "x.safetensors", strength_model: 1 }),
|
|
});
|
|
assert.equal(entries[0].strength_clip, 0);
|
|
});
|
|
|
|
test("rgthree power loader rows keep their toggles and dual strengths", () => {
|
|
const entries = loaderLoraEntries({
|
|
type: "Power Lora Loader (rgthree)",
|
|
widgets: [
|
|
{ name: "lora_1", value: { on: true, lora: "a.safetensors", strength: 0.7 } },
|
|
{ name: "lora_2", value: { on: false, lora: "b.safetensors", strength: 1, strengthTwo: 0.4 } },
|
|
{ name: "lora_3", value: { on: true, lora: "None", strength: 1 } },
|
|
{ name: "unrelated", value: 3 },
|
|
],
|
|
});
|
|
assert.equal(entries.length, 2);
|
|
assert.equal(entries[0].strength, 0.7);
|
|
assert.equal(entries[1].enabled, false);
|
|
assert.equal(entries[1].strength_clip, 0.4);
|
|
});
|
|
|
|
test("an AusBoss loader upstream hands over its whole stack, triggers included", () => {
|
|
const stack = JSON.stringify([
|
|
{ name: "a.safetensors", strength: 0.9, enabled: true, triggers: "glow" },
|
|
{ name: "", strength: 1 },
|
|
]);
|
|
const entries = loaderLoraEntries({
|
|
type: "AUSBOSS_NODES_LoraLoader",
|
|
widgets: widgets({ loras: stack }),
|
|
});
|
|
assert.equal(entries.length, 1);
|
|
assert.equal(entries[0].triggers, "glow");
|
|
});
|
|
|
|
test("unknown node types stop the walk with null", () => {
|
|
assert.equal(loaderLoraEntries({ type: "KSampler", widgets: [] }), null);
|
|
assert.equal(BYPASS_MODE, 4);
|
|
});
|
|
|
|
// ---------- name resolution ----------
|
|
|
|
const AVAILABLE = [
|
|
"krea2/Beauty.safetensors",
|
|
"styles/Beauty.safetensors",
|
|
"klein/outpaint_v1.safetensors",
|
|
];
|
|
|
|
test("exact names pass through", () => {
|
|
assert.deepEqual(resolveLoraName("klein/outpaint_v1.safetensors", AVAILABLE),
|
|
{ name: "klein/outpaint_v1.safetensors", status: "exact" });
|
|
});
|
|
|
|
test("a foreign folder layout resolves by unique basename", () => {
|
|
assert.deepEqual(resolveLoraName("old\\path\\outpaint_v1.safetensors", AVAILABLE),
|
|
{ name: "klein/outpaint_v1.safetensors", status: "remapped" });
|
|
});
|
|
|
|
test("an ambiguous basename stays untouched and is reported", () => {
|
|
assert.equal(resolveLoraName("elsewhere/Beauty.safetensors", AVAILABLE).status,
|
|
"ambiguous");
|
|
});
|
|
|
|
test("a name nowhere in the install is reported missing", () => {
|
|
assert.equal(resolveLoraName("gone.safetensors", AVAILABLE).status, "missing");
|
|
});
|
|
|
|
// ---------- merge ----------
|
|
|
|
test("imported rows land above the existing stack in chain order", () => {
|
|
const existing = [row("mine.safetensors", 1)];
|
|
const imported = [
|
|
{ name: "first.safetensors", strength: 1, strength_clip: 1, enabled: true, triggers: "" },
|
|
{ name: "second.safetensors", strength: 0.5, strength_clip: 0.5, enabled: true, triggers: "" },
|
|
];
|
|
const { rows, added, skipped } = mergeImportedRows(existing, imported);
|
|
assert.equal(added, 2);
|
|
assert.equal(skipped, 0);
|
|
assert.deepEqual(rows.map((r) => r.name),
|
|
["first.safetensors", "second.safetensors", "mine.safetensors"]);
|
|
assert.ok(rows[0].id && rows[0].id !== rows[1].id, "fresh row ids");
|
|
});
|
|
|
|
test("a lora already in the stack is skipped, case-insensitively", () => {
|
|
const existing = [row("A.safetensors", 1)];
|
|
const { added, skipped, rows } = mergeImportedRows(existing, [
|
|
{ name: "a.safetensors", strength: 0.5, strength_clip: 0.5, enabled: true, triggers: "" },
|
|
]);
|
|
assert.equal(added, 0);
|
|
assert.equal(skipped, 1);
|
|
assert.equal(rows.length, 1);
|
|
});
|
|
|
|
test("separate-strength detection catches model-only imports", () => {
|
|
assert.equal(importNeedsSeparateStrengths([
|
|
{ strength: 1, strength_clip: 1 },
|
|
]), false);
|
|
assert.equal(importNeedsSeparateStrengths([
|
|
{ strength: 1, strength_clip: 0 },
|
|
]), true);
|
|
});
|
|
|
|
test("summary reads as one sentence", () => {
|
|
assert.equal(importSummary({}), "No LoRA loaders found in the model chain.");
|
|
assert.match(importSummary({ added: 3, bypassed: 2, remapped: 1 }),
|
|
/Imported 3 LoRAs; bypassed 2 loader nodes; 1 path remapped/);
|
|
});
|
|
|
|
// ---------- Pixaroma extraction ----------
|
|
|
|
test("a Pixaroma loader hands over its rows with toggles and dual strengths", () => {
|
|
const entries = loaderLoraEntries({
|
|
type: "PixaromaLoraLoader",
|
|
widgets: [{
|
|
name: "loras_ui",
|
|
value: {
|
|
version: 1,
|
|
loras: [
|
|
{ id: "x1", name: "krea2/lenovo.safetensors", on: true, sm: 0.8, sc: 0.6 },
|
|
{ id: "x2", name: "styles/off.safetensors", on: false, sm: 1, sc: 1 },
|
|
{ id: "x3", name: "None", on: true, sm: 1, sc: 1 },
|
|
],
|
|
},
|
|
}],
|
|
});
|
|
assert.equal(entries.length, 2);
|
|
assert.deepEqual(entries[0], {
|
|
name: "krea2/lenovo.safetensors", strength: 0.8, strength_clip: 0.6,
|
|
enabled: true, triggers: "",
|
|
});
|
|
assert.equal(entries[1].enabled, false);
|
|
});
|
|
|
|
test("a Pixaroma loader with junk panel state contributes nothing", () => {
|
|
assert.deepEqual(loaderLoraEntries({ type: "PixaromaLoraLoader", widgets: [] }), []);
|
|
assert.deepEqual(loaderLoraEntries({
|
|
type: "PixaromaLoraLoader",
|
|
widgets: [{ name: "loras_ui", value: "not an object" }],
|
|
}), []);
|
|
});
|
|
|
|
// ---------- downstream merge position ----------
|
|
|
|
test("downstream rows append below the stack and share the dedupe", () => {
|
|
const existing = [row("mine.safetensors", 1)];
|
|
const { rows, added, skipped } = mergeImportedRows(existing, [
|
|
{ name: "after.safetensors", strength: 0.5, strength_clip: 0.5, enabled: true, triggers: "" },
|
|
{ name: "MINE.safetensors", strength: 1, strength_clip: 1, enabled: true, triggers: "" },
|
|
], { position: "after" });
|
|
assert.equal(added, 1);
|
|
assert.equal(skipped, 1);
|
|
assert.deepEqual(rows.map((r) => r.name), ["mine.safetensors", "after.safetensors"]);
|
|
});
|
|
|
|
// ---------- display names ----------
|
|
|
|
test("shortLoraName strips folders and extension independently", () => {
|
|
assert.equal(shortLoraName("krea2/dr0smorgan_krea2_v1.safetensors"), "dr0smorgan_krea2_v1");
|
|
assert.equal(shortLoraName("a\\b\\c.ckpt"), "c");
|
|
assert.equal(shortLoraName("krea2/x.safetensors", { hideFolders: false }), "krea2/x");
|
|
assert.equal(shortLoraName("krea2/x.safetensors", { hideExtension: false }), "x.safetensors");
|
|
assert.equal(shortLoraName("plain_name"), "plain_name");
|
|
});
|
|
|
|
// One unit per character: widths below read as character counts.
|
|
const chars = (text) => text.length;
|
|
|
|
test("a long name is cut in the middle and keeps the larger share of its end", () => {
|
|
const name = "ausboss_aesthetic_krea2_raw_r12_v1_000004000";
|
|
assert.equal(middleEllipsis(name, 100, chars), name, "fits: untouched");
|
|
assert.equal(middleEllipsis(name, 15, chars), "ausbo\u2026000004000");
|
|
assert.equal(middleEllipsis(name, 30, chars), "ausboss_aes\u2026w_r12_v1_000004000");
|
|
// Checkpoints of one run stay tellable apart at a narrow width.
|
|
assert.equal(middleEllipsis("minimax_h3_fl2v_turbo_4step_v1.0_7600", 15, chars), "minim\u2026v1.0_7600");
|
|
assert.equal(middleEllipsis("minimax_h3_fl2v_turbo_4step_v1.2_7600", 15, chars), "minim\u2026v1.2_7600");
|
|
for (const width of [1, 5, 12, 20, 43]) {
|
|
assert.ok(middleEllipsis(name, width, chars).length <= width, `fits in ${width}`);
|
|
}
|
|
assert.equal(middleEllipsis(name, 0, chars), "\u2026", "no room: just the mark");
|
|
assert.equal(middleEllipsis("", 10, chars), "");
|
|
});
|
|
|
|
test("a path shortens its folder before it touches the file name", () => {
|
|
const label = "minimaxh3/MiniMax-H3-Turbo-Lora-ComfyUI/minimax_h3_fl2v_turbo_4step_v1.2_7600";
|
|
assert.deepEqual(fitLoraLabel(label, 200, chars), {
|
|
folder: "minimaxh3/MiniMax-H3-Turbo-Lora-ComfyUI/",
|
|
name: "minimax_h3_fl2v_turbo_4step_v1.2_7600",
|
|
});
|
|
assert.deepEqual(fitLoraLabel(label, 50, chars), {
|
|
folder: "minimaxh3/M\u2026/",
|
|
name: "minimax_h3_fl2v_turbo_4step_v1.2_7600",
|
|
});
|
|
// No room left for any of the folder: it goes, and the name is cut.
|
|
assert.deepEqual(fitLoraLabel(label, 20, chars), { folder: "", name: "minimax\u2026ep_v1.2_7600" });
|
|
assert.deepEqual(fitLoraLabel("styles\\ink_wash_v3", 30, chars), { folder: "styles\\", name: "ink_wash_v3" });
|
|
assert.deepEqual(fitLoraLabel("AltGirlKrea", 30, chars), { folder: "", name: "AltGirlKrea" });
|
|
assert.deepEqual(fitLoraLabel(undefined, 30, chars), { folder: "", name: "" });
|
|
});
|
|
|
|
test("the picker widens for long names, within the cap and the window", () => {
|
|
// Short names: the old width (name field + 80, 260..380).
|
|
assert.equal(pickerWidth(100, 150, 1920), 260);
|
|
assert.equal(pickerWidth(100, 280, 1920), 360);
|
|
assert.equal(pickerWidth(100, 900, 1920), 380);
|
|
// A long name widens it to fit (label + 48 px of padding and scrollbar)...
|
|
assert.equal(pickerWidth(400, 150, 1920), 448);
|
|
// ...up to the cap, and never past the window's edge.
|
|
assert.equal(pickerWidth(900, 150, 1920), 560);
|
|
assert.equal(pickerWidth(900, 150, 400), 384);
|
|
assert.equal(pickerWidth(900, 150, 200), 184);
|
|
});
|
|
|
|
|
|
// ---------- duplicates ----------
|
|
|
|
test("duplicate detection is by full name, case-insensitive, blanks ignored", () => {
|
|
const keys = duplicateLoraKeys([
|
|
row("krea2/a.safetensors", 1),
|
|
{ ...row("KREA2/A.safetensors", 0.5), id: "t2" },
|
|
row("styles/a.safetensors", 1),
|
|
row("", 1),
|
|
]);
|
|
assert.deepEqual([...keys], ["krea2/a.safetensors"]);
|
|
});
|
|
|
|
// ---------- master-toggle memory ----------
|
|
|
|
test("the master pill cycles mixed -> all on -> all off -> restored", () => {
|
|
const mixed = [
|
|
row("a.safetensors", 1),
|
|
{ ...row("b.safetensors", 1), id: "t-b", enabled: false },
|
|
row("c.safetensors", 1),
|
|
];
|
|
const first = cycleMasterToggle(mixed, undefined);
|
|
assert.ok(first.rows.every((r) => r.enabled), "mixed turns all on");
|
|
assert.deepEqual(first.snapshot, { "t-a.safetensors": true, "t-b": false, "t-c.safetensors": true });
|
|
|
|
const second = cycleMasterToggle(first.rows, first.snapshot);
|
|
assert.ok(second.rows.every((r) => !r.enabled), "on turns all off");
|
|
|
|
const third = cycleMasterToggle(second.rows, second.snapshot);
|
|
assert.deepEqual(third.rows.map((r) => r.enabled), [true, false, true], "off restores the setup");
|
|
});
|
|
|
|
test("all off with no memory is a plain binary toggle", () => {
|
|
const off = [
|
|
{ ...row("a.safetensors", 1), enabled: false },
|
|
{ ...row("b.safetensors", 1), id: "t-b2", enabled: false },
|
|
];
|
|
const { rows } = cycleMasterToggle(off, undefined);
|
|
assert.ok(rows.every((r) => r.enabled));
|
|
});
|
|
|
|
test("a stale snapshot that lights nothing falls back to all on", () => {
|
|
const off = [{ ...row("a.safetensors", 1), enabled: false }];
|
|
const { rows } = cycleMasterToggle(off, { "gone-id": true });
|
|
assert.ok(rows.every((r) => r.enabled));
|
|
});
|
|
|
|
test("snapshotEnabled records named rows only", () => {
|
|
assert.deepEqual(
|
|
snapshotEnabled([row("a.safetensors", 1), row("", 1)]),
|
|
{ "t-a.safetensors": true },
|
|
);
|
|
});
|
|
|
|
test("rgthree stack imports numbered rows in order with shared strengths", () => {
|
|
const rows = loaderLoraEntries({type: "Lora Loader Stack (rgthree)", widgets: widgets({
|
|
lora_03: "c.safetensors", strength_03: -0.25,
|
|
lora_01: "a.safetensors", strength_01: 0.8,
|
|
lora_02: "None", strength_02: 1,
|
|
})});
|
|
assert.deepEqual(rows.map(r => [r.name, r.strength, r.strength_clip]), [
|
|
["a.safetensors", 0.8, 0.8], ["c.safetensors", -0.25, -0.25],
|
|
]);
|
|
});
|
|
|
|
test("JPS loader retains its Off switch instead of enabling the LoRA", () => {
|
|
for (const setting of ["Off", "On"]) {
|
|
const rows = loaderLoraEntries({type: "Lora Loader (JPS)", widgets: widgets({
|
|
switch: setting, lora_name: "a.safetensors", strength_model: 0.5, strength_clip: 0.2,
|
|
})});
|
|
assert.equal(rows[0].enabled, setting === "On");
|
|
assert.equal(rows[0].strength_clip, 0.2);
|
|
}
|
|
});
|
|
|
|
test("rgthree null strengthTwo means linked strengths, not zero CLIP", () => {
|
|
const rows = loaderLoraEntries({type: "Power Lora Loader (rgthree)", widgets: [
|
|
{name: "lora_1", value: {on: true, lora: "a.safetensors", strength: 0.8, strengthTwo: null}},
|
|
]});
|
|
assert.equal(rows[0].strength_clip, 0.8);
|
|
});
|
|
|
|
|
|
test("absorb walks a mixed stack in both directions regardless of canvas position", async () => {
|
|
const { collectUpstreamLoaders, collectDownstreamLoaders } = await import("../js/shared/lora_stack.mjs");
|
|
const graph = { links: {}, getNodeById: id => nodes.find(n => n.id === id) };
|
|
const types = ["Lora Loader (JPS)", "Power Lora Loader (rgthree)", "Lora Loader Stack (rgthree)", "AUSBOSS_NODES_LoraLoader"];
|
|
const nodes = types.map((type, id) => ({ id, type, graph, widgets: [], pos: [500 - id * 100, id * 30],
|
|
inputs: [{ name: "model", type: "MODEL", link: id ? id : null }],
|
|
outputs: [{ name: "MODEL", type: "MODEL", links: id < 3 ? [id + 1] : [] }],
|
|
}));
|
|
for (let id = 1; id < 4; id++) graph.links[id] = { origin_id: id - 1, target_id: id };
|
|
assert.deepEqual(collectUpstreamLoaders(nodes[3]).map(n => n.id), [2, 1, 0]);
|
|
// Put AusBoss before the foreign loaders in the same connected chain.
|
|
nodes[0].type = "AUSBOSS_NODES_LoraLoader";
|
|
nodes[3].type = "Lora Loader (JPS)";
|
|
assert.deepEqual(collectDownstreamLoaders(nodes[0]).map(n => n.id), [1, 2, 3]);
|
|
nodes[1].type = "Reroute";
|
|
assert.deepEqual(collectDownstreamLoaders(nodes[0]).map(n => n.id), [2, 3]);
|
|
nodes[1].outputs[0].links.push(99);
|
|
assert.deepEqual(collectDownstreamLoaders(nodes[0]), []);
|
|
});
|
|
|
|
test("absorption keeps repeated applications in upstream, current, downstream order", () => {
|
|
const existing = [row("same.safetensors", 0.4)];
|
|
const before = mergeImportedRows(existing, [row("same.safetensors", 0.2)], {position: "before", deduplicate: false});
|
|
const after = mergeImportedRows(before.rows, [row("same.safetensors", 0.7)], {position: "after", deduplicate: false});
|
|
assert.deepEqual(after.rows.map(r => r.strength), [0.2, 0.4, 0.7]);
|
|
assert.equal(after.skipped, 0);
|
|
});
|
|
|
|
test("absorption refuses shared model paths, mismatched CLIP paths and used trigger outputs", async () => {
|
|
const {absorbChainIssue} = await import("../js/shared/lora_stack.mjs");
|
|
const nodes = [];
|
|
const graph = {links: {}, getNodeById: id => nodes[id]};
|
|
const make = id => nodes[id] = {id, type: "LoraLoader", graph,
|
|
widgets: widgets({lora_name:"a.safetensors",strength_model:1,strength_clip:1}),
|
|
inputs:[{type:"MODEL",link:900},{type:"CLIP",link:901}],
|
|
outputs:[{type:"MODEL",links:[]},{type:"CLIP",links:[]}]};
|
|
const source=make(0), target=make(1);
|
|
const wire=(from,out,to,input,id)=>{graph.links[id]={origin_id:from.id,origin_slot:out,target_id:to.id,target_slot:input};from.outputs[out].links.push(id);to.inputs[input].link=id};
|
|
wire(source,0,target,0,1);wire(source,1,target,1,2);
|
|
assert.equal(absorbChainIssue(target,[source],[]),null);
|
|
target.mode = 4;
|
|
assert.match(absorbChainIssue(target,[source],[]),/Enable this/);
|
|
target.mode = 0; target.inputs.push({name:"loras",type:"STRING",link:9});
|
|
assert.match(absorbChainIssue(target,[source],[]),/connected input/);
|
|
target.inputs.pop();
|
|
source.outputs[0].links.push(3);
|
|
assert.match(absorbChainIssue(target,[source],[]),/model chain branches/);
|
|
source.outputs[0].links.pop();source.outputs[1].links.push(3);
|
|
assert.match(absorbChainIssue(target,[source],[]),/CLIP/);
|
|
source.outputs[1].links.pop();target.inputs[1].link=null;
|
|
assert.match(absorbChainIssue(target,[source],[]),/CLIP/);
|
|
target.inputs[1].link=2;
|
|
source.outputs.push({type:"STRING",links:[3]});
|
|
assert.match(absorbChainIssue(target,[source],[]),/trigger words/);
|
|
source.outputs.pop();
|
|
// A reroute before the target must not hide a shared branch.
|
|
const reroute=make(2);reroute.type="Reroute";
|
|
source.outputs[0].links=[];wire(source,0,reroute,0,4);wire(reroute,0,target,0,5);
|
|
assert.equal(absorbChainIssue(target,[source],[]),null);
|
|
reroute.outputs[0].links.push(6);
|
|
assert.match(absorbChainIssue(target,[source],[]),/model chain branches/);
|
|
});
|