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.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// Name-keyed widget serialization: pure mapping and migration logic.
|
||||
//
|
||||
// Workflows historically store widgets_values as a positional array, which
|
||||
// breaks whenever a widget is added or reordered. These helpers build a
|
||||
// {widgetName: value} object for onSerialize and plan the restore for
|
||||
// onConfigure, migrating legacy arrays by widget order with per-widget
|
||||
// validation. The queue/API path is untouched: graphToPrompt reads
|
||||
// widget.value by name and never consults widgets_values.
|
||||
//
|
||||
// Restore timing: the frontend's base LGraphNode.configure() runs first and
|
||||
// blindly assigns widgets_values[i] to every widget whose `serialize` is not
|
||||
// false — with a dict that means every such widget briefly holds undefined.
|
||||
// The plan produced here repairs that: each widget gets its stored value when
|
||||
// it fits, or its captured creation default when it does not.
|
||||
|
||||
function jsonClone(value) {
|
||||
if (value === undefined) return null;
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Widgets the frontend's base configure() assigns stored values to.
|
||||
export function isRestorableWidget(widget) {
|
||||
return !!widget
|
||||
&& typeof widget.name === "string"
|
||||
&& widget.name.length > 0
|
||||
&& widget.serialize !== false;
|
||||
}
|
||||
|
||||
// Widgets worth persisting by name: restorable, not opted out through
|
||||
// options, and not a button (buttons carry UI labels, not state).
|
||||
export function isPersistedWidget(widget) {
|
||||
return isRestorableWidget(widget)
|
||||
&& widget.options?.serialize !== false
|
||||
&& widget.type !== "button";
|
||||
}
|
||||
|
||||
// {name: value} snapshot for onSerialize. Object values are JSON-cloned so
|
||||
// undo snapshots never alias live widget state.
|
||||
export function widgetsToDict(widgets) {
|
||||
const out = {};
|
||||
for (const widget of widgets ?? []) {
|
||||
if (!isPersistedWidget(widget)) continue;
|
||||
out[widget.name] = jsonClone(widget.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Creation-time defaults, captured before configure() can overwrite them.
|
||||
export function captureWidgetDefaults(widgets) {
|
||||
const out = {};
|
||||
for (const widget of widgets ?? []) {
|
||||
if (!isRestorableWidget(widget)) continue;
|
||||
out[widget.name] = jsonClone(widget.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function comboValues(widget) {
|
||||
const values = widget.options?.values;
|
||||
return Array.isArray(values) ? values : null;
|
||||
}
|
||||
|
||||
// Does a stored value fit this widget?
|
||||
// "apply" - value is valid for the widget.
|
||||
// "keep" - plausible but unverifiable (e.g. a combo string missing from
|
||||
// a dynamic file list); positional migration leaves it alone,
|
||||
// a name match applies it.
|
||||
// "reject" - value cannot belong to this widget.
|
||||
export function widgetValueVerdict(widget, value) {
|
||||
if (value === undefined || value === null) return "reject";
|
||||
switch (widget?.type) {
|
||||
case "number":
|
||||
case "slider": {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "reject";
|
||||
const { min, max } = widget.options ?? {};
|
||||
if (typeof min === "number" && value < min) return "reject";
|
||||
if (typeof max === "number" && value > max) return "reject";
|
||||
return "apply";
|
||||
}
|
||||
case "toggle":
|
||||
return typeof value === "boolean" ? "apply" : "reject";
|
||||
case "combo": {
|
||||
if (typeof value !== "string" && typeof value !== "number") return "reject";
|
||||
const values = comboValues(widget);
|
||||
if (!values || values.length === 0) return typeof value === "string" ? "apply" : "keep";
|
||||
if (values.includes(value)) return "apply";
|
||||
// A non-member matching the list's element type stays a "keep": file
|
||||
// lists are point-in-time snapshots, so an absent name is not proof
|
||||
// the value is wrong. A type mismatch is.
|
||||
return values.some((entry) => typeof entry === typeof value) ? "keep" : "reject";
|
||||
}
|
||||
case "text":
|
||||
case "string":
|
||||
case "customtext":
|
||||
return typeof value === "string" ? "apply" : "reject";
|
||||
default:
|
||||
return "keep";
|
||||
}
|
||||
}
|
||||
|
||||
function defaultAssignment(widget, defaults) {
|
||||
if (!defaults || !(widget.name in defaults)) return null;
|
||||
return { name: widget.name, value: jsonClone(defaults[widget.name]) };
|
||||
}
|
||||
|
||||
function planFromLegacyArray(widgets, stored, defaults) {
|
||||
const assignments = [];
|
||||
let index = 0;
|
||||
for (const widget of widgets) {
|
||||
if (!isRestorableWidget(widget)) continue;
|
||||
// Base configure() stops at the array end, leaving later widgets on
|
||||
// their defaults — mirror that by planning nothing for them. Trailing
|
||||
// array entries beyond the widget list are ignored the same way.
|
||||
if (index >= stored.length) break;
|
||||
const value = stored[index++];
|
||||
const verdict = widgetValueVerdict(widget, value);
|
||||
if (verdict === "apply") {
|
||||
assignments.push({ name: widget.name, value: jsonClone(value) });
|
||||
} else if (verdict === "reject") {
|
||||
// The blind positional assignment already landed; undo it.
|
||||
const fallback = defaultAssignment(widget, defaults);
|
||||
if (fallback) assignments.push(fallback);
|
||||
}
|
||||
// "keep": leave whatever the positional pass applied.
|
||||
}
|
||||
return { mode: "legacy", assignments };
|
||||
}
|
||||
|
||||
function planFromDict(widgets, stored, defaults) {
|
||||
const assignments = [];
|
||||
for (const widget of widgets) {
|
||||
if (!isRestorableWidget(widget)) continue;
|
||||
if (widget.name in stored) {
|
||||
const value = stored[widget.name];
|
||||
// A name match is strong evidence, so "keep" applies here too.
|
||||
if (widgetValueVerdict(widget, value) !== "reject") {
|
||||
assignments.push({ name: widget.name, value: jsonClone(value) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Missing or unusable: repair the undefined left by base configure().
|
||||
const fallback = defaultAssignment(widget, defaults);
|
||||
if (fallback) assignments.push(fallback);
|
||||
}
|
||||
return { mode: "dict", assignments };
|
||||
}
|
||||
|
||||
function planAllDefaults(widgets, defaults) {
|
||||
const assignments = [];
|
||||
for (const widget of widgets) {
|
||||
if (!isRestorableWidget(widget)) continue;
|
||||
const fallback = defaultAssignment(widget, defaults);
|
||||
if (fallback) assignments.push(fallback);
|
||||
}
|
||||
return { mode: "invalid", assignments };
|
||||
}
|
||||
|
||||
// Restore plan for onConfigure. `stored` is info.widgets_values in either
|
||||
// format; `defaults` comes from captureWidgetDefaults at creation time.
|
||||
// Never throws: an unreadable payload yields mode "invalid" with a
|
||||
// defaults-only plan so the caller can warn once and keep loading.
|
||||
export function planWidgetRestore(widgets, stored, defaults) {
|
||||
const list = Array.isArray(widgets) ? widgets : [];
|
||||
if (stored === undefined || stored === null) return { mode: "none", assignments: [] };
|
||||
if (Array.isArray(stored)) return planFromLegacyArray(list, stored, defaults);
|
||||
if (typeof stored === "object") return planFromDict(list, stored, defaults);
|
||||
return planAllDefaults(list, defaults);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Name-keyed widgets_values for AusBoss nodes.
|
||||
//
|
||||
// Positional widgets_values arrays break the moment a widget is added or
|
||||
// reordered, so these nodes serialize {widgetName: value} objects instead
|
||||
// and restore by name on configure. Legacy positional arrays — every
|
||||
// workflow saved before this change — keep loading through the order-based
|
||||
// migration in shared/widget_kv.mjs. The queue/API path is untouched:
|
||||
// graphToPrompt reads widget.value directly and never sees widgets_values.
|
||||
//
|
||||
// The LoRA loader is deliberately absent: its single JSON widget already
|
||||
// survives reordering.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { chainCallback } from "../shared/index.mjs";
|
||||
import {
|
||||
captureWidgetDefaults,
|
||||
planWidgetRestore,
|
||||
widgetsToDict,
|
||||
} from "../shared/widget_kv.mjs";
|
||||
|
||||
const NODE_TYPES = new Set([
|
||||
"AUSBOSS_NODES_ImageCropRotatePad",
|
||||
"AUSBOSS_NODES_VideoCropRotatePad",
|
||||
"AUSBOSS_NODES_LoadVideo",
|
||||
"AUSBOSS_NODES_SaveVideo",
|
||||
]);
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.widget_kv",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
const nodeName = nodeData?.name;
|
||||
if (!NODE_TYPES.has(nodeName)) return;
|
||||
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
// Snapshot creation defaults before configure() can overwrite them;
|
||||
// the restore plan falls back to these for values that do not fit.
|
||||
this.__ausbossWidgetDefaults ??= captureWidgetDefaults(this.widgets);
|
||||
});
|
||||
|
||||
chainCallback(nodeType.prototype, "onSerialize", function (info) {
|
||||
if (!info || !Array.isArray(this.widgets)) return;
|
||||
try {
|
||||
info.widgets_values = widgetsToDict(this.widgets);
|
||||
} catch (_error) {
|
||||
// Keep the positional array the base serializer already wrote.
|
||||
}
|
||||
});
|
||||
|
||||
chainCallback(nodeType.prototype, "onConfigure", function (info) {
|
||||
// Never throw during workflow load: a bad payload degrades to
|
||||
// defaults with a single warning instead of killing the graph.
|
||||
try {
|
||||
const plan = planWidgetRestore(
|
||||
this.widgets,
|
||||
info?.widgets_values,
|
||||
this.__ausbossWidgetDefaults,
|
||||
);
|
||||
if (plan.mode === "invalid") {
|
||||
console.warn(`[AusBoss] ${nodeName}: stored widget values were unreadable; using defaults.`);
|
||||
}
|
||||
for (const { name, value } of plan.assignments) {
|
||||
const widget = this.widgets?.find((item) => item.name === name);
|
||||
if (widget) widget.value = value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[AusBoss] ${nodeName}: widget restore failed (${error}); keeping current values.`);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
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: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user