304 lines
12 KiB
JavaScript
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: [],
|
|
});
|
|
}
|
|
});
|