Files
ausboss-ComfyUI-AusBoss/tests/widget_kv.test.mjs
T
ausboss 9b14b78d43 Serialize widget values by name on the video and transform nodes
Load Video, Save Video, and both Crop + Rotate + Pad nodes now write
workflow widgets_values as a {widgetName: value} object and restore by
name on configure, so adding or reordering a widget can no longer
scramble saved workflows. Legacy positional arrays still load through
an order-based migration that validates each value against its widget
(number ranges, combo membership, booleans) and repairs misfits to the
creation defaults. An unreadable payload warns once and falls back to
defaults instead of breaking the load. The queue path is untouched:
prompts read widget.value by name and never consult widgets_values.
2026-08-13 09:48:13 -05:00

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.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: [],
});
}
});