Files
ausboss-ComfyUI-AusBoss/tests/widget_kv.test.mjs

304 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
captureWidgetDefaults,
isPersistedWidget,
planWidgetRestore,
widgetValueVerdict,
widgetsToDict,
} from "../js/shared/widget_kv.mjs";
// --- Fixtures -------------------------------------------------------------
// Widget order is the workflow-compatibility contract for legacy positional
// arrays. Each fixture mirrors the exact INPUT_TYPES order of the Python
// node file at the time this format shipped — if these tests break because
// a widget moved, the migration mapping below must learn the old order, not
// the tests the new one.
function widget(name, type, value, options = {}) {
return { name, type, value, options };
}
const number = (name, value, min, max) => widget(name, "number", value, { min, max });
const combo = (name, value, values) => widget(name, "combo", value, values ? { values } : {});
const text = (name, value) => widget(name, "text", value);
// nodes/_transform_inputs.py transform_inputs()
const ASPECT_RATIOS = ["free", "source", "1:1", "9:16", "16:9", "2:3", "3:2", "3:4", "4:3", "9:21", "21:9"];
const transformWidgets = () => [
number("rotation_degrees", 0.0, -180.0, 180.0),
combo("crop_aspect_ratio", "free", ASPECT_RATIOS),
number("crop_x", 0, 0, 65536),
number("crop_y", 0, 0, 65536),
number("crop_width", 0, 0, 65536),
number("crop_height", 0, 0, 65536),
number("pad_left", 0, 0, 32768),
number("pad_top", 0, 0, 32768),
number("pad_right", 0, 0, 32768),
number("pad_bottom", 0, 0, 32768),
number("feather", 24, 0, 4096),
number("canvas_multiple", 1, 1, 4096),
text("fill_color", "#808080"),
];
// nodes/node_load_video.py — video, start_seconds, end_seconds,
// custom_width, custom_height. The video combo is a dynamic file list.
const loadVideoWidgets = (files = []) => [
combo("video", files[0] ?? "", files),
number("start_seconds", 0.0, 0.0, 86400.0),
number("end_seconds", 0.0, 0.0, 86400.0),
number("custom_width", 0, 0, 16384),
number("custom_height", 0, 0, 16384),
];
// nodes/node_save_video.py — fps, filename_prefix, crf (frames and audio
// are connections, prompt/extra_pnginfo hidden).
const saveVideoWidgets = () => [
number("fps", 16.0, 0.01, 240.0),
text("filename_prefix", "AusBoss/video"),
number("crf", 19, 0, 51),
];
// nodes/node_image_crop_rotate_pad.py — image, then the transform block.
const imageCropWidgets = (files = []) => [
combo("image", files[0] ?? "", files),
...transformWidgets(),
];
// nodes/node_video_crop_rotate_pad.py — video, source_mode, local_path,
// seek_mode, frame_index, frame_time, then the transform block.
const videoCropWidgets = (files = []) => [
combo("video", files[0] ?? "", files),
combo("source_mode", "input folder", ["input folder", "local path"]),
text("local_path", ""),
combo("seek_mode", "frame index", ["frame index", "time seconds"]),
number("frame_index", 0, 0, 100000000),
number("frame_time", 0.0, 0.0, 86400.0),
...transformWidgets(),
];
const names = (widgets) => widgets.map((item) => item.name);
// --- Widget order contracts ----------------------------------------------
test("fixtures capture the exact INPUT_TYPES widget order of each node", () => {
assert.deepEqual(names(loadVideoWidgets()), [
"video", "start_seconds", "end_seconds", "custom_width", "custom_height",
]);
assert.deepEqual(names(saveVideoWidgets()), ["fps", "filename_prefix", "crf"]);
assert.deepEqual(names(imageCropWidgets()), [
"image", "rotation_degrees", "crop_aspect_ratio", "crop_x", "crop_y",
"crop_width", "crop_height", "pad_left", "pad_top", "pad_right",
"pad_bottom", "feather", "canvas_multiple", "fill_color",
]);
assert.deepEqual(names(videoCropWidgets()), [
"video", "source_mode", "local_path", "seek_mode", "frame_index",
"frame_time", "rotation_degrees", "crop_aspect_ratio", "crop_x", "crop_y",
"crop_width", "crop_height", "pad_left", "pad_top", "pad_right",
"pad_bottom", "feather", "canvas_multiple", "fill_color",
]);
});
// --- Dict serialization ---------------------------------------------------
test("widgets serialize to a name-keyed object and round-trip by name", () => {
const widgets = saveVideoWidgets();
widgets[0].value = 24.0;
widgets[1].value = "AusBoss/render";
widgets[2].value = 15;
const dict = widgetsToDict(widgets);
assert.deepEqual(dict, { fps: 24.0, filename_prefix: "AusBoss/render", crf: 15 });
const restored = saveVideoWidgets();
const plan = planWidgetRestore(restored, dict, captureWidgetDefaults(restored));
assert.equal(plan.mode, "dict");
assert.deepEqual(plan.assignments, [
{ name: "fps", value: 24.0 },
{ name: "filename_prefix", value: "AusBoss/render" },
{ name: "crf", value: 15 },
]);
});
test("dict restore survives widget reordering", () => {
const widgets = loadVideoWidgets(["clip.mp4"]);
widgets[1].value = 3.86;
widgets[2].value = 10.11;
const dict = widgetsToDict(widgets);
const reordered = loadVideoWidgets(["clip.mp4"]).reverse();
const plan = planWidgetRestore(reordered, dict, captureWidgetDefaults(reordered));
const byName = Object.fromEntries(plan.assignments.map((item) => [item.name, item.value]));
assert.equal(byName.start_seconds, 3.86);
assert.equal(byName.end_seconds, 10.11);
assert.equal(byName.video, "clip.mp4");
});
test("buttons, opt-outs, and unnamed widgets never enter the dict", () => {
const widgets = [
...saveVideoWidgets(),
widget("upload", "button", "image"),
{ ...widget("ausboss_viewer", "custom", ""), options: { serialize: false } },
{ ...widget("hidden_flag", "toggle", true), serialize: false },
widget("", "text", "nameless"),
];
assert.deepEqual(Object.keys(widgetsToDict(widgets)), ["fps", "filename_prefix", "crf"]);
assert.equal(isPersistedWidget(widgets[3]), false);
assert.equal(isPersistedWidget(widgets[4]), false);
});
test("a dict missing a widget repairs it to the captured default", () => {
// Base configure() leaves such widgets holding undefined; the plan must
// put the creation default back.
const widgets = saveVideoWidgets();
const defaults = captureWidgetDefaults(widgets);
const plan = planWidgetRestore(widgets, { fps: 30.0 }, defaults);
assert.deepEqual(plan.assignments, [
{ name: "fps", value: 30.0 },
{ name: "filename_prefix", value: "AusBoss/video" },
{ name: "crf", value: 19 },
]);
});
test("a dict value that cannot fit its widget falls back to the default", () => {
const widgets = saveVideoWidgets();
const defaults = captureWidgetDefaults(widgets);
const plan = planWidgetRestore(
widgets,
{ fps: "fast", filename_prefix: "kept", crf: 999 },
defaults,
);
assert.deepEqual(plan.assignments, [
{ name: "fps", value: 16.0 },
{ name: "filename_prefix", value: "kept" },
{ name: "crf", value: 19 },
]);
});
test("a combo string absent from a dynamic file list still restores by name", () => {
// File lists are point-in-time snapshots; a name match outranks them.
const widgets = loadVideoWidgets(["other.mp4"]);
const plan = planWidgetRestore(widgets, { video: "missing.mp4" }, captureWidgetDefaults(widgets));
assert.deepEqual(plan.assignments[0], { name: "video", value: "missing.mp4" });
});
// --- Legacy positional arrays --------------------------------------------
test("the shipped Load Video example array migrates by widget order", () => {
// example_workflows/Simple Video Watermark Remover (AusBoss).json — the trailing
// "image" and "" belong to frontend-added upload/preview widgets and must
// be ignored, not force-fed into anything.
const widgets = loadVideoWidgets(["input.mp4"]);
const plan = planWidgetRestore(
widgets,
["input.mp4", 0.0, 0.0, 0, 0, "image", ""],
captureWidgetDefaults(widgets),
);
assert.equal(plan.mode, "legacy");
assert.deepEqual(plan.assignments, [
{ name: "video", value: "input.mp4" },
{ name: "start_seconds", value: 0.0 },
{ name: "end_seconds", value: 0.0 },
{ name: "custom_width", value: 0 },
{ name: "custom_height", value: 0 },
]);
});
test("the shipped Video Crop example array migrates every widget", () => {
// example_workflows/video_crop_rotate_pad.json
const stored = ["", "input folder", "", "frame index", 0, 0, 0, "free", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, "#808080"];
const widgets = videoCropWidgets([""]);
const plan = planWidgetRestore(widgets, stored, captureWidgetDefaults(widgets));
assert.equal(plan.mode, "legacy");
assert.equal(plan.assignments.length, widgets.length);
assert.deepEqual(
plan.assignments.map((item) => item.value),
stored,
);
});
test("legacy values that do not fit are skipped back to defaults", () => {
const widgets = saveVideoWidgets();
widgets[0].value = 999; // base configure already applied the bad array
const plan = planWidgetRestore(
widgets,
["not-a-rate", "AusBoss/render", 500],
captureWidgetDefaults(saveVideoWidgets()),
);
assert.deepEqual(plan.assignments, [
{ name: "fps", value: 16.0 }, // string in a number slot
{ name: "filename_prefix", value: "AusBoss/render" },
{ name: "crf", value: 19 }, // 500 is outside 0..51
]);
});
test("a legacy combo string missing from the file list is left in place", () => {
// Today's positional load keeps a stale filename visible (shown invalid);
// the migration must not silently erase it.
const widgets = loadVideoWidgets(["other.mp4"]);
const plan = planWidgetRestore(
widgets,
["missing.mp4", 1.0, 2.0, 0, 0],
captureWidgetDefaults(widgets),
);
assert.equal(plan.assignments.some((item) => item.name === "video"), false);
assert.equal(plan.assignments.length, 4);
});
test("a short legacy array leaves later widgets on their defaults", () => {
const widgets = imageCropWidgets(["photo.png"]);
const plan = planWidgetRestore(widgets, ["photo.png", 45.0], captureWidgetDefaults(widgets));
assert.deepEqual(plan.assignments, [
{ name: "image", value: "photo.png" },
{ name: "rotation_degrees", value: 45.0 },
]);
});
test("legacy nulls left by unserialized widget slots repair to defaults", () => {
const widgets = saveVideoWidgets();
const plan = planWidgetRestore(widgets, [24.0, null, 15], captureWidgetDefaults(widgets));
assert.deepEqual(plan.assignments, [
{ name: "fps", value: 24.0 },
{ name: "filename_prefix", value: "AusBoss/video" },
{ name: "crf", value: 15 },
]);
});
// --- Verdicts and failure paths ------------------------------------------
test("verdicts enforce number ranges, combo membership, and booleans", () => {
assert.equal(widgetValueVerdict(number("crf", 19, 0, 51), 19), "apply");
assert.equal(widgetValueVerdict(number("crf", 19, 0, 51), -1), "reject");
assert.equal(widgetValueVerdict(number("crf", 19, 0, 51), Number.NaN), "reject");
assert.equal(widgetValueVerdict(combo("mode", "a", ["a", "b"]), "b"), "apply");
assert.equal(widgetValueVerdict(combo("mode", "a", ["a", "b"]), "c"), "keep");
assert.equal(widgetValueVerdict(combo("mode", "a", ["a", "b"]), 3), "reject");
assert.equal(widgetValueVerdict(widget("flag", "toggle", true), false), "apply");
assert.equal(widgetValueVerdict(widget("flag", "toggle", true), "true"), "reject");
assert.equal(widgetValueVerdict(text("label", ""), "hello"), "apply");
assert.equal(widgetValueVerdict(text("label", ""), 5), "reject");
});
test("an unreadable payload degrades to a defaults-only plan, never a throw", () => {
const widgets = saveVideoWidgets();
const defaults = captureWidgetDefaults(widgets);
for (const garbage of ["oops", 42, true]) {
const plan = planWidgetRestore(widgets, garbage, defaults);
assert.equal(plan.mode, "invalid");
assert.deepEqual(plan.assignments, [
{ name: "fps", value: 16.0 },
{ name: "filename_prefix", value: "AusBoss/video" },
{ name: "crf", value: 19 },
]);
}
});
test("absent widgets_values plans nothing", () => {
const widgets = saveVideoWidgets();
for (const stored of [undefined, null]) {
assert.deepEqual(planWidgetRestore(widgets, stored, captureWidgetDefaults(widgets)), {
mode: "none",
assignments: [],
});
}
});