初始化

This commit is contained in:
2026-07-30 06:39:46 +08:00
commit 1a460f3f1d
23 changed files with 3025 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("flow_branch_nodes", PLUGIN_ROOT / "nodes.py")
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
+386
View File
@@ -0,0 +1,386 @@
import assert from "node:assert/strict";
import test from "node:test";
import { compileFlowPrompt, generatedStageNodeId } from "../web/compiler.mjs";
import { branchInputName } from "../web/pipeline_config.mjs";
function node(classType, inputs) {
return { class_type: classType, inputs: { ...inputs } };
}
function stage(id, name, branches = [], selected = null, enabled = true, autoSelect = false) {
return { id, name, enabled, autoSelect, selected, branches };
}
function branch(id, name) {
return { id, name };
}
function config(stages) {
return JSON.stringify({ version: 2, stages });
}
test("an unlimited pipeline compiles into real ordered dependencies", () => {
const face = branch("face", "修脸");
const upscale = branch("upscale", "高清放大");
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "原始图像", value: ["100", 0] }),
"10": node("FlowBranchGet", { channel: "原始图像" }),
"20": node("FaceDetailer", { image: ["10", 0] }),
"11": node("FlowBranchGet", { channel: "修脸后" }),
"21": node("Upscaler", { image: ["11", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "原始图像",
output_channel: "最终图像",
pipeline_config: config([
stage("face-stage", "修脸后", [face], face.id),
stage("upscale-stage", "放大后", [upscale], upscale.id),
]),
[branchInputName(face.id)]: ["20", 0],
[branchInputName(upscale.id)]: ["21", 0],
}),
"3": node("FlowBranchGet", { channel: "最终图像" }),
} };
const diagnostics = compileFlowPrompt(prompt);
const firstStage = generatedStageNodeId("2", 0);
const secondStage = generatedStageNodeId("2", 1);
assert.deepEqual(prompt.output["10"].inputs.source, ["1", 0]);
assert.deepEqual(prompt.output[firstStage].inputs.source, ["1", 0]);
assert.deepEqual(prompt.output[firstStage].inputs.selected_value, ["20", 0]);
assert.deepEqual(prompt.output["11"].inputs.source, [firstStage, 0]);
assert.deepEqual(prompt.output[secondStage].inputs.source, [firstStage, 0]);
assert.deepEqual(prompt.output[secondStage].inputs.selected_value, ["21", 0]);
assert.deepEqual(prompt.output["2"].inputs.pipeline_result, [secondStage, 0]);
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
assert.equal(prompt.output["2"].inputs[branchInputName(face.id)], undefined);
assert.equal(diagnostics.some((item) => item.level === "error"), false);
});
test("one stage accepts more than three branches", () => {
const branches = Array.from({ length: 12 }, (_, index) => branch(`b${index + 1}`, `方案 ${index + 1}`));
const selected = branches.at(-1);
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入图像", value: ["100", 0] }),
"10": node("FlowBranchGet", { channel: "输入图像" }),
"20": node("SelectedProcessor", { image: ["10", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "输入图像",
output_channel: "输出图像",
pipeline_config: config([stage("s1", "处理后", branches, selected.id)]),
[branchInputName(selected.id)]: ["20", 0],
}),
} };
compileFlowPrompt(prompt);
assert.deepEqual(
prompt.output[generatedStageNodeId("2", 0)].inputs.selected_value,
["20", 0],
);
});
test("automatic selection picks the first branch whose named result survived bypass", () => {
const basic = branch("basic", "基础放大");
const sd = branch("sd", "SD 放大");
const gpu = branch("gpu", "GPU 放大");
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "修脸结果", value: ["100", 0] }),
"10": node("FlowBranchGet", { channel: "基础放大" }),
"11": node("FlowBranchGet", { channel: "SD 放大" }),
"12": node("FlowBranchGet", { channel: "GPU 放大" }),
"20": node("FlowBranchGet", { channel: "修脸结果" }),
"21": node("GpuUpscaler", { image: ["20", 0] }),
"22": node("FlowBranchPublish", { channel: "GPU 放大", value: ["21", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "修脸结果",
output_channel: "最终图像",
pipeline_config: config([
stage("upscale", "放大结果", [basic, sd, gpu], null, true, true),
]),
[branchInputName(basic.id)]: ["10", 0],
[branchInputName(sd.id)]: ["11", 0],
[branchInputName(gpu.id)]: ["12", 0],
}),
} };
compileFlowPrompt(prompt);
const generated = prompt.output[generatedStageNodeId("2", 0)];
assert.deepEqual(generated.inputs.selected_value, ["12", 0]);
assert.equal(generated.inputs.selected_name, "GPU 放大");
assert.deepEqual(prompt.output["12"].inputs.source, ["22", 0]);
assert.equal(prompt.output["10"].inputs.source, undefined);
assert.equal(prompt.output["11"].inputs.source, undefined);
});
test("automatic selection ignores a bypassed direct branch that became the previous result", () => {
const bypassed = branch("bypassed", "已旁路方案");
const active = branch("active", "有效方案");
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
"10": node("FlowBranchGet", { channel: "输入" }),
"20": node("ActiveProcessor", { image: ["10", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "输入",
output_channel: "输出",
pipeline_config: config([
stage("auto", "处理结果", [bypassed, active], null, true, true),
]),
[branchInputName(bypassed.id)]: ["10", 0],
[branchInputName(active.id)]: ["20", 0],
}),
} };
compileFlowPrompt(prompt);
const generated = prompt.output[generatedStageNodeId("2", 0)];
assert.deepEqual(generated.inputs.selected_value, ["20", 0]);
assert.equal(generated.inputs.selected_name, "有效方案");
});
test("one pipeline accepts far more than three stages", () => {
const stages = Array.from({ length: 20 }, (_, index) => (
stage(`s${index}`, `阶段 ${index + 1}`, [], null, false)
));
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "输入",
output_channel: "输出",
pipeline_config: config(stages),
}),
} };
compileFlowPrompt(prompt);
for (let index = 0; index < stages.length; index += 1) {
const generatedId = generatedStageNodeId("2", index);
assert.ok(prompt.output[generatedId]);
const expectedSource = index === 0 ? ["1", 0] : [generatedStageNodeId("2", index - 1), 0];
assert.deepEqual(prompt.output[generatedId].inputs.source, expectedSource);
}
assert.deepEqual(prompt.output["2"].inputs.pipeline_result, [generatedStageNodeId("2", 19), 0]);
});
test("a selected branch must read the immediately previous result", () => {
const selected = branch("wrong", "错误方案");
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "原始图像", value: ["100", 0] }),
"10": node("FlowBranchGet", { channel: "另一个结果" }),
"20": node("Processor", { image: ["10", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "原始图像",
output_channel: "最终图像",
pipeline_config: config([stage("s1", "处理后", [selected], selected.id)]),
[branchInputName(selected.id)]: ["20", 0],
}),
} };
compileFlowPrompt(prompt);
const generated = prompt.output[generatedStageNodeId("2", 0)];
assert.match(generated.inputs.compile_error, /必须读取上一阶段.*原始图像/);
assert.equal(generated.inputs.selected_value, undefined);
});
test("disabled and unconnected stages bypass to the previous result", () => {
const ignored = branch("ignored", "不会执行");
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "输入",
output_channel: "输出",
pipeline_config: config([
stage("disabled", "阶段一", [ignored], ignored.id, false),
stage("empty", "阶段二", [], null, true),
]),
[branchInputName(ignored.id)]: ["99", 0],
}),
} };
compileFlowPrompt(prompt);
const firstStage = prompt.output[generatedStageNodeId("2", 0)];
const secondStage = prompt.output[generatedStageNodeId("2", 1)];
assert.equal(firstStage.inputs.selected_value, undefined);
assert.deepEqual(firstStage.inputs.source, ["1", 0]);
assert.equal(secondStage.inputs.selected_value, undefined);
assert.deepEqual(secondStage.inputs.source, [generatedStageNodeId("2", 0), 0]);
});
test("a pipeline whose entire input chain was bypassed compiles as an empty branch", () => {
const missing = branch("missing", "已绕过方案");
const prompt = { output: {
"10": node("FlowBranchGet", { channel: "已绕过方案" }),
"2": node("FlowBranchPipeline", {
input_channel: "已绕过起点",
output_channel: "最终结果",
pipeline_config: config([
stage("s1", "处理结果", [missing], null, true, true),
]),
[branchInputName(missing.id)]: ["10", 0],
}),
"3": node("FlowBranchGet", { channel: "最终结果" }),
"4": node("OptionalImageConsumer", {
text: "仍然执行",
optional_image: ["3", 0],
}),
} };
const diagnostics = compileFlowPrompt(prompt);
const generatedId = generatedStageNodeId("2", 0);
assert.equal(prompt.output[generatedId].inputs.source, undefined);
assert.equal(prompt.output[generatedId].inputs.selected_value, undefined);
assert.equal(prompt.output[generatedId].inputs.compile_error, "");
assert.equal(prompt.output["2"].inputs.compile_error, "");
assert.equal(prompt.output["2"].inputs.pipeline_result, undefined);
assert.equal(prompt.output["3"].inputs.source, undefined);
assert.equal(prompt.output["4"].inputs.optional_image, undefined);
assert.equal(prompt.output["4"].inputs.text, "仍然执行");
assert.equal(diagnostics.some((item) => item.level === "error"), false);
});
test("a missing named result is removed exactly like an unconnected optional input", () => {
const prompt = { output: {
"1": node("FlowBranchGet", { channel: "参考图像" }),
"274": node("TextEncodeQwenImageEditPlus", {
prompt: "保留这个输入",
reference_image: ["1", 0],
}),
} };
compileFlowPrompt(prompt);
assert.equal(prompt.output["274"].inputs.reference_image, undefined);
assert.equal(prompt.output["274"].inputs.prompt, "保留这个输入");
});
test("a missing named source activates a connected fallback instead of pruning the reader", () => {
const prompt = { output: {
"9": node("FallbackImage", { value: "fallback" }),
"1": node("FlowBranchGet", { channel: "参考图像", fallback: ["9", 0] }),
"274": node("OptionalImageConsumer", { image: ["1", 0] }),
} };
compileFlowPrompt(prompt);
assert.deepEqual(prompt.output["1"].inputs.fallback, ["9", 0]);
assert.deepEqual(prompt.output["274"].inputs.image, ["1", 0]);
});
test("an empty sender connected directly to an optional input is pruned", () => {
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "空结果" }),
"2": node("OptionalConsumer", { optional_value: ["1", 0], keep: 42 }),
} };
compileFlowPrompt(prompt);
assert.equal(prompt.output["2"].inputs.optional_value, undefined);
assert.equal(prompt.output["2"].inputs.keep, 42);
});
test("a pipeline with no stages is a named passthrough", () => {
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "输入",
output_channel: "输出",
pipeline_config: config([]),
}),
"3": node("FlowBranchGet", { channel: "输出" }),
} };
compileFlowPrompt(prompt);
assert.deepEqual(prompt.output["2"].inputs.pipeline_result, ["1", 0]);
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
});
test("duplicate stage result names become clear blockers", () => {
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "输入",
output_channel: "输出",
pipeline_config: config([
stage("s1", "重复名称"),
stage("s2", "重复名称"),
]),
}),
"3": node("FlowBranchGet", { channel: "输出" }),
"4": node("OptionalConsumer", { value: ["3", 0] }),
} };
compileFlowPrompt(prompt);
assert.match(prompt.output["2"].inputs.compile_error, /阶段结果名称.*重复/);
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
assert.deepEqual(prompt.output["4"].inputs.value, ["3", 0]);
});
test("an empty sender is unavailable and compilation does not throw", () => {
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入" }),
"2": node("FlowBranchGet", { channel: "输入" }),
} };
assert.doesNotThrow(() => compileFlowPrompt(prompt));
assert.equal(prompt.output["2"].inputs.source, undefined);
assert.equal(prompt.output["2"].inputs.compile_error, "");
});
test("compilation never mutates unrelated nodes or random seed widgets", () => {
const samplerInputs = {
noise_seed: -1,
control_after_generate: "randomize",
steps: 20,
};
const prompt = { output: {
"10": node("KSamplerAdvEfficient", samplerInputs),
"11": node("FlowBranchGet", { channel: "missing" }),
} };
const before = structuredClone(prompt.output["10"]);
compileFlowPrompt(prompt);
assert.deepEqual(prompt.output["10"], before);
});
test("compiling the same queued prompt twice does not duplicate generated stages", () => {
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "输入", value: ["100", 0] }),
"2": node("FlowBranchPipeline", {
input_channel: "输入",
output_channel: "输出",
pipeline_config: config([stage("s1", "处理后")]),
}),
} };
compileFlowPrompt(prompt);
const firstIds = Object.keys(prompt.output).filter((id) => id.startsWith("__flowbranch_stage__"));
compileFlowPrompt(prompt);
const secondIds = Object.keys(prompt.output).filter((id) => id.startsWith("__flowbranch_stage__"));
assert.deepEqual(secondIds, firstIds);
});
test("saved workflows using legacy stage nodes still compile", () => {
const prompt = { output: {
"1": node("FlowBranchPublish", { channel: "原始图像", value: ["100", 0] }),
"2": node("FlowBranchStage", {
input_channel: "原始图像",
output_channel: "旧版阶段结果",
enabled: false,
}),
"3": node("FlowBranchGet", { channel: "旧版阶段结果" }),
} };
compileFlowPrompt(prompt);
assert.deepEqual(prompt.output["2"].inputs.source, ["1", 0]);
assert.deepEqual(prompt.output["3"].inputs.source, ["2", 0]);
});
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
nextAvailablePairedPosition,
pairedNodePosition,
uniquePublisherNode,
} from "../web/node_actions.mjs";
test("paired reader is placed to the right without mutating its source", () => {
const source = { pos: [100, 80], size: [250, 120] };
assert.deepEqual(pairedNodePosition(source), [390, 80]);
assert.deepEqual(source, { pos: [100, 80], size: [250, 120] });
});
test("additional paired readers avoid occupied node rectangles", () => {
const source = { pos: [100, 80], size: [250, 120] };
const reader = { size: [250, 80] };
const occupied = { pos: [390, 80], size: [250, 80] };
assert.deepEqual(
nextAvailablePairedPosition(source, reader, [source, occupied]),
[390, 178],
);
});
test("navigation accepts repeated entries from the same pipeline node", () => {
const pipeline = { id: 7 };
assert.equal(uniquePublisherNode([
{ node: pipeline, key: "stage" },
{ node: pipeline, key: "final" },
]), pipeline);
});
test("navigation refuses ambiguous publisher nodes", () => {
assert.equal(uniquePublisherNode([
{ node: { id: 1 } },
{ node: { id: 2 } },
]), null);
assert.equal(uniquePublisherNode([]), null);
});
+157
View File
@@ -0,0 +1,157 @@
from comfy_execution.graph_utils import ExecutionBlocker
from flow_branch_nodes import (
MISSING,
FlowGet,
FlowIf,
FlowPipeline,
FlowPublish,
FlowRoute,
FlowStage,
)
def blocker_message(result):
assert isinstance(result[0], ExecutionBlocker)
return result[0].message
def test_publish_without_input_is_a_clear_safety_error_if_compilation_was_skipped():
assert "没有输入数据" in blocker_message(FlowPublish.publish("g1"))
def test_get_uses_source_then_fallback():
assert FlowGet.get("g1", fallback="old", source="new") == ("new",)
assert FlowGet.get("g1", fallback="old") == ("old",)
assert "找不到发布位置" in blocker_message(FlowGet.get("g1"))
def test_stage_disabled_only_requests_source():
needed = FlowStage.check_lazy_status("g1", "g2", False, processed=None, source=None)
assert needed == ["source"]
assert FlowStage.select("g1", "g2", False, processed="face", source="base") == ("base",)
def test_stage_enabled_requests_processed_and_falls_back_when_unconnected():
assert FlowStage.check_lazy_status("g1", "g2", True, processed=None, source=None) == ["processed"]
assert FlowStage.select("g1", "g2", True, processed="face", source="base") == ("face",)
assert FlowStage.select("g1", "g2", True, processed=MISSING, source="base") == ("base",)
def test_route_only_requests_selected_option():
needed = FlowRoute.check_lazy_status(
"g2", "g3", "方案 2", option_1=None, option_2=None, option_3=None, source=None,
)
assert needed == ["option_2"]
assert FlowRoute.select(
"g2", "g3", "方案 2", option_1="a", option_2="b", option_3="c", source="base",
) == ("b",)
def test_route_bypass_and_unconnected_option_use_source():
assert FlowRoute.check_lazy_status("g2", "g3", "旁路", source=None) == ["source"]
assert FlowRoute.select("g2", "g3", "旁路", source="base") == ("base",)
assert FlowRoute.select("g2", "g3", "方案 1", source="base") == ("base",)
def test_if_is_lazy_and_allows_one_missing_branch():
assert FlowIf.check_lazy_status(True, on_true=None, on_false=None) == ["on_true"]
assert FlowIf.select(True, on_false="fallback") == ("fallback",)
assert "两个分支都没有连接" in blocker_message(FlowIf.select(True))
def test_legacy_missing_inputs_remain_clear_safety_errors_if_compilation_was_skipped():
assert "没有可用数据" in blocker_message(FlowStage.select("g1", "g2", True))
assert "没有可回退的数据" in blocker_message(FlowRoute.select("g1", "g2", "方案 1"))
def test_compile_error_blocks_without_requesting_expensive_inputs():
message = "通道存在循环依赖"
assert FlowStage.check_lazy_status("g1", "g2", True, processed=None, source=None, compile_error=message) == []
assert message in blocker_message(
FlowStage.select("g1", "g2", True, processed=MISSING, source=MISSING, compile_error=message)
)
def test_pipeline_visible_node_requests_only_the_compiled_final_result():
config = FlowPipeline.EMPTY_CONFIG
assert FlowPipeline.check_lazy_status(
"原始图像", "最终图像", config, pipeline_result=None, source=None,
) == ["pipeline_result"]
assert FlowPipeline.select(
"原始图像", "最终图像", config, pipeline_result="finished", source="original",
) == ("finished",)
def test_pipeline_internal_stage_is_lazy_and_bypasses_when_no_plan_is_selected():
internal = {"__stage_internal": True, "stage_name": "修脸后"}
assert FlowPipeline.check_lazy_status(
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
selected_value=None, source=None, **internal,
) == ["selected_value"]
assert FlowPipeline.select(
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
selected_value="repaired", source="original", **internal,
) == ("repaired",)
assert FlowPipeline.select(
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
source="original", **internal,
) == ("original",)
def test_pipeline_without_compiled_inputs_remains_a_clear_safety_error():
assert "找不到起点结果" in blocker_message(FlowPipeline.select(
"原始图像", "最终图像", FlowPipeline.EMPTY_CONFIG,
))
assert "找不到上一阶段结果" in blocker_message(FlowPipeline.select(
"原始图像", "修脸后", FlowPipeline.EMPTY_CONFIG,
**{"__stage_internal": True, "stage_name": "修脸后"},
))
def test_pipeline_accepts_unlimited_frontend_defined_lazy_inputs():
optional = FlowPipeline.INPUT_TYPES()["optional"]
dynamic = optional["branch_any_stable_id"]
assert "branch_any_stable_id" in optional
assert dynamic[0] == "*"
assert dynamic[1]["lazy"] is True
assert dynamic[1]["forceInput"] is True
def test_pipeline_compile_error_blocks_before_selected_plan_runs():
message = "方案没有读取上一阶段"
assert FlowPipeline.check_lazy_status(
"原始图像", "最终图像", FlowPipeline.EMPTY_CONFIG,
selected_value=None, source=None, compile_error=message,
**{"__stage_internal": True},
) == []
assert message in blocker_message(
FlowPipeline.select(
"原始图像", "最终图像", FlowPipeline.EMPTY_CONFIG,
compile_error=message, **{"__stage_internal": True},
)
)
def test_public_defaults_use_readable_names_instead_of_g_numbers():
publish_default = FlowPublish.INPUT_TYPES()["required"]["channel"][1]["default"]
get_default = FlowGet.INPUT_TYPES()["required"]["channel"][1]["default"]
pipeline_required = FlowPipeline.INPUT_TYPES()["required"]
assert publish_default == "原始图像"
assert get_default == "原始图像"
assert pipeline_required["input_channel"][1]["default"] == "原始图像"
assert pipeline_required["output_channel"][1]["default"] == "最终图像"
def test_fixed_legacy_nodes_are_hidden_from_the_new_node_menu():
for node_class in (FlowStage, FlowRoute, FlowIf):
assert node_class.DEPRECATED is True
assert node_class.CATEGORY == "流程分支/旧版"
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
branchInputName,
createBranch,
createStage,
normalizePipelineConfig,
parsePipelineConfig,
serializePipelineConfig,
} from "../web/pipeline_config.mjs";
test("configuration keeps an unlimited number of stages and branches", () => {
const stages = Array.from({ length: 20 }, (_, stageIndex) => ({
id: `s${stageIndex}`,
name: `阶段 ${stageIndex + 1}`,
enabled: true,
autoSelect: stageIndex % 2 === 0,
selected: `s${stageIndex}_b29`,
branches: Array.from({ length: 30 }, (_, branchIndex) => ({
id: `s${stageIndex}_b${branchIndex}`,
name: `方案 ${branchIndex + 1}`,
})),
}));
const normalized = normalizePipelineConfig({ version: 2, stages });
assert.equal(normalized.stages.length, 20);
assert.equal(normalized.stages[19].branches.length, 30);
assert.equal(normalized.stages[19].selected, "s19_b29");
assert.equal(normalized.stages[18].autoSelect, true);
assert.equal(normalized.stages[19].autoSelect, false);
});
test("malformed configuration becomes a safe empty pipeline", () => {
assert.deepEqual(parsePipelineConfig("not json").stages, []);
assert.deepEqual(parsePipelineConfig(null).stages, []);
});
test("new rows use stable ids and readable names", () => {
const first = createStage(1);
const second = createStage(2);
const option = createBranch(4);
assert.notEqual(first.id, second.id);
assert.equal(first.name, "阶段 1 结果");
assert.equal(first.autoSelect, false);
assert.equal(option.name, "方案 4");
assert.match(branchInputName(option.id), /^branch_[A-Za-z0-9_-]+$/);
});
test("an explicitly cleared stage name stays empty for validation", () => {
const normalized = normalizePipelineConfig({
stages: [{ id: "s1", name: "", enabled: true, selected: null, branches: [] }],
});
assert.equal(normalized.stages[0].name, "");
});
test("copy and workflow reload preserve ids order switches and selection", () => {
const original = {
stages: [
{
id: "face-stage",
name: "修脸后",
enabled: false,
autoSelect: false,
selected: "codeformer",
branches: [
{ id: "facedetailer", name: "FaceDetailer" },
{ id: "codeformer", name: "CodeFormer" },
],
},
{
id: "upscale-stage",
name: "放大后",
enabled: true,
autoSelect: true,
selected: null,
branches: [],
},
],
};
const restored = parsePipelineConfig(serializePipelineConfig(original));
assert.deepEqual(restored, { version: 2, ...original });
});
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
calculateSlotCanvasY,
calculateStackContentHeight,
} from "../web/pipeline_editor.js";
test("pipeline panel height is based only on children, gaps and padding", () => {
const height = calculateStackContentHeight([28, 116, 28], 8, 5, 9);
assert.equal(height, 202);
});
test("pipeline layout never feeds the allocated container height back into sizing", async () => {
const source = await readFile(new URL("../web/pipeline_editor.js", import.meta.url), "utf8");
assert.doesNotMatch(source, /root\.scrollHeight/);
});
test("custom branch slot positions cannot push the DOM widget down every frame", async () => {
const source = await readFile(new URL("../web/pipeline_editor.js", import.meta.url), "utf8");
assert.match(source, /node\.widgets_start_y\s*=\s*PIPELINE_WIDGET_START_Y/);
assert.doesNotMatch(source, /node\.computeSize\?\.\(\)/);
});
test("branch sockets include the DOM widget margin when aligning to rows", () => {
assert.equal(calculateSlotCanvasY(4, 120, 10), 134);
});
test("dynamic socket labels do not expose internal branch ids", async () => {
const source = await readFile(new URL("../web/pipeline_editor.js", import.meta.url), "utf8");
assert.match(source, /input\.label\s*=\s*" "/);
assert.doesNotMatch(source, /input\.label\s*=\s*""/);
});