Files
ausboss-ComfyUI-AusBoss/tests/widget_card.test.mjs
T
ausboss 11b5a6d56a Mask Refine: Max hole size, so fill holes leaves the part you kept
Paint everything except a person or a pet, turn on fill holes, and it
filled the unpainted subject too, so the part meant to stay was repainted.
The new optional max_hole_size input (a percent of the picture, 0 to 100)
sets the biggest hole fill holes fills. The default, 0, fills every hole
exactly as before, so saved workflows and API prompts run the same; at 2
the gaps a quick brush leaves still fill and a subject left unpainted
stays. Areas touching the picture's edge are never holes, as before.

- Hole sizes come from scipy's labels, the same 4-connected holes its
  fill finds; the torch fallback labels the same 8-connected holes as its
  own fill. Each picture in a batch is measured on its own, after expand.
- The card gets a Max hole size row with a % unit, dimmed while Fill holes
  is off, and a resetUnknown fallback of 0 for 2.x saves that hold the
  card's empty value in its slot.
- Tests: a hole above the limit stays, holes up to it fill, 0 matches the
  old fill, edge-touching areas never fill, the torch fallback agrees,
  batches, and older saves opening with 0.
- Node API snapshot, help page and changelog.
2026-09-29 22:38:36 -05:00

270 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET,
cardHeight, commitWidgetValue, holdsUnknownValue, resetUnknownValues, rowHeight, rowKind, rowMuted, rowTops,
scrubSteps, segmentFits, socketWidgetY, visibleRows,
} from "../js/shared/widget_card_math.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
test("visibleRows honors when() and closed groups but keeps the header", () => {
const rows = [
{ widget: "mode" },
{ widget: "width", when: (v) => v.mode === "size" },
{ group: "advanced", label: "More" },
{ widget: "extra", inGroup: "advanced" },
];
assert.deepEqual(visibleRows(rows, { mode: "size" }, { advanced: false }).map((r) => r.widget ?? r.group), ["mode", "width", "advanced"]);
assert.deepEqual(visibleRows(rows, { mode: "other" }, { advanced: true }).map((r) => r.widget ?? r.group), ["mode", "advanced", "extra"]);
});
test("cardHeight sums rows, captions and headers with one gap between each", () => {
assert.equal(cardHeight([]), CARD_PADDING * 2);
assert.equal(cardHeight([{ widget: "a" }]), CARD_PADDING * 2 + ROW_HEIGHT);
assert.equal(
cardHeight([{ section: "Crop" }, { widget: "a" }, { group: "x" }]),
CARD_PADDING * 2 + SECTION_HEIGHT + ROW_HEIGHT + GROUP_HEIGHT + ROW_GAP * 2,
);
});
test("segmentFits takes a few short labels and rejects long lists", () => {
assert.equal(segmentFits(["lab", "rgb", "mkl", "histogram"]), true);
assert.equal(segmentFits(["overwrite", "skip", "error"]), true);
assert.equal(segmentFits(["lanczos", "bicubic", "bilinear", "nearest", "area"]), false);
assert.equal(segmentFits(["width+height", "longest_edge", "shortest_edge"]), false);
assert.equal(segmentFits(["only"]), false);
});
test("scrubSteps: 0..1 floats move by 0.05, wide floats by ten increments, big ints by 8", () => {
assert.deepEqual(scrubSteps({ min: 0, max: 1, step: 0.1, step2: 0.01, precision: 2 }, true), { step: 0.05, fineStep: 0.01, decimals: 2 });
assert.deepEqual(scrubSteps({ min: 0, max: 64, step: 0.1, step2: 0.01, precision: 2 }, true), { step: 0.1, fineStep: 0.01, decimals: 2 });
assert.deepEqual(scrubSteps({ min: 0, max: 16384, step: 10, step2: 1, precision: 0 }, false), { step: 8, fineStep: 1, decimals: 0 });
assert.deepEqual(scrubSteps({ min: 1, max: 128, step: 10, step2: 1, precision: 0 }, false), { step: 1, fineStep: 1, decimals: 0 });
});
test("rowKind follows the widget type unless the row names a kind", () => {
assert.equal(rowKind({ widget: "strength" }, { type: "number" }), "scrub");
assert.equal(rowKind({ widget: "method" }, { type: "combo", options: { values: ["lab", "rgb"] } }), "segment");
assert.equal(rowKind({ widget: "interpolation" }, { type: "combo", options: { values: ["lanczos", "bicubic", "bilinear", "nearest", "area"] } }), "select");
assert.equal(rowKind({ widget: "flag" }, { type: "toggle" }), "switch");
assert.equal(rowKind({ widget: "fill_color" }, { type: "text", value: "#000000" }), "color");
assert.equal(rowKind({ widget: "prefix" }, { type: "text", value: "AusBoss" }), "text");
assert.equal(rowKind({ widget: "caption" }, { type: "customtext" }), "skip");
assert.equal(rowKind({ widget: "method", kind: "select" }, { type: "combo", options: { values: ["a", "b"] } }), "select");
});
test("rowHeight and cardHeight honor a textarea row's own height", () => {
assert.equal(rowHeight({ widget: "prompt", kind: "textarea", height: 96 }), 96);
assert.equal(rowHeight({ widget: "a" }), ROW_HEIGHT);
assert.equal(
cardHeight([{ widget: "prompt", kind: "textarea", height: 96 }, { widget: "flag" }]),
CARD_PADDING * 2 + 96 + ROW_HEIGHT + ROW_GAP,
);
});
test("rowTops walks the visible rows from the card's padding", () => {
const rows = [{ widget: "a" }, { section: "More" }, { widget: "b", kind: "textarea", height: 60 }, { group: "x" }];
assert.deepEqual(rowTops(rows), [
CARD_PADDING,
CARD_PADDING + ROW_HEIGHT + ROW_GAP,
CARD_PADDING + ROW_HEIGHT + ROW_GAP + SECTION_HEIGHT + ROW_GAP,
CARD_PADDING + ROW_HEIGHT + ROW_GAP + SECTION_HEIGHT + ROW_GAP + 60 + ROW_GAP,
]);
});
test("socketWidgetY puts a row's socket on its centre line and a tall row's near its top", () => {
// The frontend draws the socket SLOT_OFFSET below the widget's y.
const cardY = 120;
const row = socketWidgetY(cardY, 10, CARD_PADDING, ROW_HEIGHT);
assert.equal(row + SLOT_OFFSET, cardY + 10 + CARD_PADDING + ROW_HEIGHT / 2);
const tall = socketWidgetY(cardY, 10, CARD_PADDING, 96);
assert.equal(tall + SLOT_OFFSET, cardY + 10 + CARD_PADDING + 13);
});
test("rowKind: an explicit textarea kind wins over the customtext skip", () => {
assert.equal(rowKind({ widget: "prompt", kind: "textarea" }, { type: "customtext" }), "textarea");
assert.equal(rowKind({ widget: "caption" }, { type: "customtext" }), "skip");
});
test("a muted row keeps its place and the card its height", () => {
const rows = [
{ widget: "tone", mute: (v) => v.seam === "blend in" },
{ widget: "halo", mute: (v) => v.seam === "blend in" },
];
assert.equal(rowMuted(rows[0], { seam: "blend in" }), true);
assert.equal(rowMuted(rows[0], { seam: "classic" }), false);
// No predicate, or one that says anything but true, never mutes.
assert.equal(rowMuted({ widget: "plain" }, { seam: "blend in" }), false);
assert.equal(rowMuted({ widget: "odd", mute: () => "yes" }, {}), false);
// Muting is not hiding: the same rows show and the card is as tall.
for (const seam of ["classic", "blend in"]) {
const visible = visibleRows(rows, { seam });
assert.deepEqual(visible.map((row) => row.widget), ["tone", "halo"]);
assert.equal(cardHeight(visible), CARD_PADDING * 2 + ROW_HEIGHT * 2 + ROW_GAP);
assert.deepEqual(rowTops(visible), [CARD_PADDING, CARD_PADDING + ROW_HEIGHT + ROW_GAP]);
}
});
test("commitWidgetValue sets the value, runs the callback, then tells the node", () => {
const calls = [];
const widget = { name: "image", value: "old.png", callback: (value, canvas, node) => calls.push(["callback", value, canvas, node.id]) };
const node = { id: 5, onWidgetChanged: (name, value, previous, changed) => calls.push(["changed", name, value, previous, changed === widget]) };
commitWidgetValue(node, widget, "new.png", "canvas");
assert.equal(widget.value, "new.png");
assert.deepEqual(calls, [
["callback", "new.png", "canvas", 5],
["changed", "image", "new.png", "old.png", true],
]);
});
test("commitWidgetValue copes with a widget that has no callback and a node that has no hook", () => {
const widget = { name: "seed", value: 1 };
commitWidgetValue({}, widget, 2, null);
assert.equal(widget.value, 2);
});
test("a switch holds only on or off, a choice only one of its options, a number only a number", () => {
for (const value of [true, false]) assert.equal(holdsUnknownValue({ type: "toggle", value }), false);
for (const value of ["", null, undefined, 0, "true"]) assert.equal(holdsUnknownValue({ type: "toggle", value }), true);
const seam = (value) => ({ type: "combo", value, options: { values: ["classic", "blend in"] } });
assert.equal(holdsUnknownValue(seam("blend in")), false);
for (const value of ["", null, "blend"]) assert.equal(holdsUnknownValue(seam(value)), true);
// A number holds only a finite number: an old save's "" is not one.
for (const value of [0, 2.5, -3]) assert.equal(holdsUnknownValue({ type: "number", value }), false);
for (const value of ["", "image", null, undefined, Number.NaN, "2"]) assert.equal(holdsUnknownValue({ type: "number", value }), true);
// Other widgets are never judged: an empty text field is a real value.
assert.equal(holdsUnknownValue({ type: "text", value: "" }), false);
});
// ---------- saves from before an input was appended ----------
// The frontend hands saved values out by position, and the card is a widget
// that saves an empty value of its own, so an input appended since a save
// receives the card's "" when that save loads.
function restoreByPosition(names, saved, types = {}) {
return names.map((name, index) => ({ name, type: types[name] ?? "number", value: saved[index] }));
}
// Today's widgets, in order: every input but the sockets, then the card.
function widgetNames(nodeId, sockets) {
const api = JSON.parse(readFileSync(join(ROOT, "tests", "fixtures", "node_api.json"), "utf-8"))[nodeId];
return [...api.required, ...api.optional].filter((name) => !sockets.includes(name)).concat("ausboss_widget_card");
}
// The card's resetUnknown fallbacks, read from js/widget_cards/index.js.
function cardFallbacks(nodeId) {
const source = readFileSync(join(ROOT, "js", "widget_cards", "index.js"), "utf-8");
const start = source.indexOf(`\n ${nodeId}: {`);
assert.ok(start >= 0, `js/widget_cards/index.js has no card for ${nodeId}`);
const next = source.slice(start + 1).search(/\n {2}AUSBOSS_NODES_\w+: \{/);
const entry = next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
const literal = entry.match(/resetUnknown: (\{[^}]*\})/)?.[1];
return literal ? JSON.parse(literal.replace(/(\w+):/g, '"$1":')) : {};
}
// Crop For Inpaint's keep_inside default, read from the Python source: the
// widget default a new node gets, and the argument default an API prompt
// without keep_inside runs with.
function keepInsideDefaults() {
const source = readFileSync(join(ROOT, "nodes", "node_inpaint_crop_stitch.py"), "utf-8");
const asBool = (text) => (text === "True" ? true : text === "False" ? false : undefined);
const widget = source.slice(source.indexOf('"keep_inside": (')).match(/"default": (True|False)/)?.[1];
const argument = source.match(/^\s+keep_inside=(True|False),$/m)?.[1];
return { widget: asBool(widget), argument: asBool(argument) };
}
// Crop For Inpaint as the LaMa Object Removal example saved it before 2.4.0:
// its fifteen settings, then the card's own empty value.
const CROP_BEFORE_KEEP_INSIDE = [2, 16, 8, 0, 0, 0, 0, false, 0, 0, "bilinear", 0, 0, 0, 0, ""];
const CROP_TYPES = { invert_mask: "toggle", keep_inside: "toggle", rescale_algorithm: "combo" };
test("Crop For Inpaint saved before Stay in picture opens with it on, as an API prompt without it runs", () => {
const names = widgetNames("AUSBOSS_NODES_CropForInpaint", ["image", "mask"]);
const widgets = restoreByPosition(names, CROP_BEFORE_KEEP_INSIDE, CROP_TYPES);
const keepInside = widgets.find((widget) => widget.name === "keep_inside");
assert.equal(keepInside.value, "", "the old save hands keep_inside the card's empty value");
assert.deepEqual(resetUnknownValues(widgets, cardFallbacks("AUSBOSS_NODES_CropForInpaint")), ["keep_inside"]);
const defaults = keepInsideDefaults();
assert.equal(keepInside.value, true);
assert.equal(keepInside.value, defaults.widget, "a new node's default");
assert.equal(keepInside.value, defaults.argument, "what an API prompt without keep_inside runs with");
// Every other setting keeps what was saved.
const others = widgets.filter((widget) => widget.name !== "keep_inside" && widget.name !== "ausboss_widget_card");
assert.deepEqual(others.map((widget) => widget.value), CROP_BEFORE_KEEP_INSIDE.slice(0, 15));
});
test("Crop For Inpaint saved since keeps its Stay in picture", () => {
const names = widgetNames("AUSBOSS_NODES_CropForInpaint", ["image", "mask"]);
const fallbacks = cardFallbacks("AUSBOSS_NODES_CropForInpaint");
for (const choice of [true, false]) {
const widgets = restoreByPosition(names, [...CROP_BEFORE_KEEP_INSIDE.slice(0, 15), choice, ""], CROP_TYPES);
assert.deepEqual(resetUnknownValues(widgets, fallbacks), []);
assert.equal(widgets.find((widget) => widget.name === "keep_inside").value, choice);
}
// Saved again on 2.4.0 while it still showed the stale value: on as well.
const resaved = restoreByPosition(names, [...CROP_BEFORE_KEEP_INSIDE.slice(0, 15), "", ""], CROP_TYPES);
resetUnknownValues(resaved, fallbacks);
assert.equal(resaved.find((widget) => widget.name === "keep_inside").value, true);
});
// Mask Refine's max_hole_size default, read from the Python source: the
// widget default a new node gets, and the argument default an API prompt
// without max_hole_size runs with.
function maxHoleSizeDefaults() {
const source = readFileSync(join(ROOT, "nodes", "node_refine_mask.py"), "utf-8");
const widget = source.slice(source.indexOf('"max_hole_size": (')).match(/"default": ([\d.]+)/)?.[1];
const argument = source.match(/^\s+max_hole_size=([\d.]+),$/m)?.[1];
return { widget: Number(widget), argument: Number(argument) };
}
// Mask Refine as the Krea 2 Inpaint Masked example saved it on 2.4.0: its
// eight settings, then the card's and the preview panel's empty values.
const REFINE_BEFORE_MAX_HOLE = [12, 4, true, 0, 0, 1, "off", true, "", ""];
const REFINE_TYPES = { fill_holes: "toggle", edge_refine: "combo", preview: "toggle" };
test("Mask Refine saved before Max hole size opens with no limit, as an API prompt without it runs", () => {
const names = widgetNames("AUSBOSS_NODES_RefineMask", ["mask", "guide_image"]);
const widgets = restoreByPosition(names, REFINE_BEFORE_MAX_HOLE, REFINE_TYPES);
widgets.find((widget) => widget.name === "edge_refine").options = { values: ["off", "guided filter", "matting"] };
const maxHole = widgets.find((widget) => widget.name === "max_hole_size");
assert.equal(maxHole.value, "", "the old save hands max_hole_size the card's empty value");
assert.deepEqual(resetUnknownValues(widgets, cardFallbacks("AUSBOSS_NODES_RefineMask")), ["max_hole_size"]);
const defaults = maxHoleSizeDefaults();
assert.equal(maxHole.value, 0);
assert.equal(maxHole.value, defaults.widget, "a new node's default");
assert.equal(maxHole.value, defaults.argument, "what an API prompt without max_hole_size runs with");
const others = widgets.filter((widget) => widget.name !== "max_hole_size" && widget.name !== "ausboss_widget_card");
assert.deepEqual(others.map((widget) => widget.value), REFINE_BEFORE_MAX_HOLE.slice(0, 8));
});
test("Mask Refine saved since keeps its Max hole size", () => {
const names = widgetNames("AUSBOSS_NODES_RefineMask", ["mask", "guide_image"]);
const fallbacks = cardFallbacks("AUSBOSS_NODES_RefineMask");
for (const size of [0, 2, 0.5]) {
const widgets = restoreByPosition(names, [...REFINE_BEFORE_MAX_HOLE.slice(0, 8), size], REFINE_TYPES);
widgets.find((widget) => widget.name === "edge_refine").options = { values: ["off", "guided filter", "matting"] };
assert.deepEqual(resetUnknownValues(widgets, fallbacks), []);
assert.equal(widgets.find((widget) => widget.name === "max_hole_size").value, size);
}
});
test("Stitch Inpaint saved before Seam still opens classic", () => {
const names = widgetNames("AUSBOSS_NODES_StitchInpaint", ["stitcher", "inpainted"]);
const types = { fix_edge_halo: "toggle", seam: "combo" };
const widgets = restoreByPosition(names, [false, 0.5, ""], types);
widgets.find((widget) => widget.name === "seam").options = { values: ["classic", "blend in"] };
assert.deepEqual(resetUnknownValues(widgets, cardFallbacks("AUSBOSS_NODES_StitchInpaint")), ["seam"]);
assert.deepEqual(widgets.map((widget) => widget.value), [false, 0.5, "classic", undefined]);
// A real choice is left alone.
const chosen = restoreByPosition(names, [false, 0.5, "blend in", ""], types);
chosen.find((widget) => widget.name === "seam").options = { values: ["classic", "blend in"] };
assert.deepEqual(resetUnknownValues(chosen, cardFallbacks("AUSBOSS_NODES_StitchInpaint")), []);
});