初始化
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 == "流程分支/旧版"
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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*""/);
|
||||
});
|
||||
Reference in New Issue
Block a user