Unknown custom output nodes are never auto-typed by class-name semantics; they stay pending until the user designates the real output node(s) and media type. Picking a usage alone no longer enables a workflow, reanalyze/ migration/field edits never overwrite a user mapping, generation harvests only the mapped nodes from ComfyUI history, and media-type mismatches or missing node results fail for real instead of faking completion. Co-authored-by: multica-agent <github@multica.ai>
372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""HEL-638: unknown custom output nodes close the loop — no name-based
|
|
auto-typing, feature-less sinks become mappable candidates, a usage choice
|
|
alone never bypasses the output designation, and generation harvests (and
|
|
checks) exactly the nodes the user mapped.
|
|
|
|
Two brand-new class names drive the cases:
|
|
|
|
* ``NebulaGlaxVideoVault`` — screams "video" in its name and carries a
|
|
filename_prefix; before HEL-638 the fuzzy name match auto-typed it video
|
|
and enabled the workflow.
|
|
* ``QuarkMuffinDisplay`` — no save widget, no media link name; before
|
|
HEL-638 such a graph only reported ``no_output`` with no way to map.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from calliope.config import settings
|
|
from calliope.db import get_db, migrate_db
|
|
from tests.test_studio_generation import _real_run, _wait_done
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
|
|
|
|
|
def _vault_workflow() -> dict:
|
|
return {
|
|
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
|
"7": {
|
|
"class_type": "GlaxSamplerNode",
|
|
"inputs": {"prompt": ["1", 0], "length": 121},
|
|
},
|
|
"12": {
|
|
"class_type": "NebulaGlaxVideoVault",
|
|
"inputs": {"filename_prefix": "anim", "frames": ["7", 0]},
|
|
},
|
|
}
|
|
|
|
|
|
def _muffin_workflow() -> dict:
|
|
return {
|
|
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
|
"7": {
|
|
"class_type": "GlaxSamplerNode",
|
|
"inputs": {"prompt": ["1", 0], "length": 121},
|
|
},
|
|
"15": {"class_type": "QuarkMuffinDisplay", "inputs": {"payload": ["7", 0]}},
|
|
}
|
|
|
|
|
|
def _analyze(client, wf: dict) -> dict:
|
|
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Unknown outputs stay pending — names never decide (gap 1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_video_semantic_unknown_node_is_never_auto_typed(client):
|
|
body = _analyze(client, _vault_workflow())
|
|
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "12")
|
|
assert entry["evidence"] == "unknown_node"
|
|
assert entry["kind"] is None # the user picks the media type
|
|
assert entry["suggested_kind"] == "video" # only a form default
|
|
assert body["needs_mapping"] is True
|
|
assert body["usage"] is None and body["usage_ambiguous"] is True
|
|
assert "指定实际输出节点" in body["usage_problem"]
|
|
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
|
assert ("warning", "unknown_output_node") in codes
|
|
assert ("error", "no_output") not in codes
|
|
|
|
|
|
def test_choosing_a_usage_alone_does_not_bypass_output_mapping(client):
|
|
"""create/PATCH with a usage but no output map keeps the row disabled."""
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": "未知·视频语义", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video", "usage_source": "user"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
wf = r.json()
|
|
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
|
assert wf["is_enabled"] is False # usage set, outputs still unconfirmed
|
|
assert wf["needs_mapping"] is True and not wf["output_map"]
|
|
|
|
gen = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
|
)
|
|
assert gen.status_code == 400
|
|
|
|
# forcing the switch on is refused with the mapping reason
|
|
r = client.patch(f"/api/workflows/{wf['id']}", json={"is_enabled": True})
|
|
assert r.status_code == 400
|
|
assert "指定" in r.json()["detail"] and "输出节点" in r.json()["detail"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Feature-less unknown sinks become mappable candidates (gap 2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_featureless_unknown_node_is_a_candidate_not_a_hard_error(client):
|
|
body = _analyze(client, _muffin_workflow())
|
|
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "15")
|
|
assert entry["evidence"] == "unknown_node" and entry["kind"] is None
|
|
assert body["needs_mapping"] is True
|
|
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
|
assert ("warning", "unknown_output_node") in codes
|
|
assert ("error", "no_output") not in codes
|
|
|
|
|
|
def test_truly_outputless_graph_still_reports_the_chinese_error(client):
|
|
wf = {
|
|
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
|
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
|
}
|
|
body = _analyze(client, wf)
|
|
assert body["usage"] is None and body["needs_mapping"] is False
|
|
assert "未在图中找到输出节点" in body["usage_problem"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Manual mapping → enable → scoped generation (fake ComfyUI round trip)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_manual_mapping_enables_and_extracts_from_the_mapped_node(client, fake_comfy):
|
|
fake_comfy.outputs_key = "gifs"
|
|
fake_comfy.out_name = "clip.mp4"
|
|
fake_comfy.forced_output_node = "12" # the mapped node reports results
|
|
with _real_run(client, fake_comfy):
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={
|
|
"name": "映射·视频库",
|
|
"workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"usage_source": "user",
|
|
"output_map": [{"nodeId": "12", "kind": "video"}],
|
|
"output_map_source": "user",
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
wf = r.json()
|
|
assert wf["is_enabled"] is True
|
|
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
|
assert wf["output_map_source"] == "user"
|
|
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wf["id"], "input_values": {"1": "星空延时"},
|
|
"source": "studio-video"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
job = _wait_done(client, r.json()["job"]["id"])
|
|
assert job["status"] == "done", job.get("error")
|
|
assert job["output_paths"][0].endswith(".mp4")
|
|
|
|
|
|
def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
|
|
"""Results that only land on an unmapped node are NOT a completion."""
|
|
fake_comfy.forced_output_node = "7" # something else reports results
|
|
with _real_run(client, fake_comfy):
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={
|
|
"name": "映射·空手而归",
|
|
"workflow_json": _muffin_workflow(),
|
|
"usage": "text2image",
|
|
"usage_source": "user",
|
|
"output_map": [{"nodeId": "15", "kind": "image"}],
|
|
},
|
|
)
|
|
assert r.status_code == 200 and r.json()["is_enabled"] is True
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": r.json()["id"], "input_values": {"1": "x"}},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
job = _wait_done(client, r.json()["job"]["id"])
|
|
assert job["status"] == "failed"
|
|
assert "没有返回任何图片或视频" in job["error"]
|
|
assert "15" in job["error"] # names the node it waited on
|
|
assert job["output_paths"] == []
|
|
|
|
|
|
def test_media_type_mismatch_fails_for_real(client, fake_comfy):
|
|
"""A video mapping that only yields a png is a real failure."""
|
|
fake_comfy.outputs_key = "images"
|
|
fake_comfy.out_name = "still.png"
|
|
fake_comfy.forced_output_node = "12"
|
|
with _real_run(client, fake_comfy):
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={
|
|
"name": "映射·类型不符",
|
|
"workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"usage_source": "user",
|
|
"output_map": [{"nodeId": "12", "kind": "video"}],
|
|
},
|
|
)
|
|
wid = r.json()["id"]
|
|
r = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
|
)
|
|
job = _wait_done(client, r.json()["job"]["id"])
|
|
assert job["status"] == "failed"
|
|
assert "媒体类型与映射不符" in job["error"]
|
|
assert job["output_paths"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Bad / cancelled mappings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_invalid_mappings_are_rejected_in_chinese(client):
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": "坏映射", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"output_map": [{"nodeId": "999", "kind": "video"}]},
|
|
)
|
|
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
|
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"output_map": [{"nodeId": "12", "kind": "audio"}]},
|
|
)
|
|
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
|
|
|
|
# a prompt/parameter node can never be an output
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": "坏映射3", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"output_map": [{"nodeId": "1", "kind": "video"}]},
|
|
)
|
|
assert r.status_code == 422 and "不能作为输出节点" in r.json()["detail"]
|
|
|
|
wid = client.post(
|
|
"/api/workflows",
|
|
json={"name": "先好后坏", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
|
).json()["id"]
|
|
r = client.patch(
|
|
f"/api/workflows/{wid}", json={"output_map": [{"nodeId": "42", "kind": "image"}]}
|
|
)
|
|
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
|
|
|
|
|
def test_clearing_the_mapping_resets_to_pending(client):
|
|
wid = client.post(
|
|
"/api/workflows",
|
|
json={"name": "映射后清空", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
|
).json()["id"]
|
|
wf = client.get(f"/api/workflows/{wid}").json()
|
|
assert wf["is_enabled"] is True
|
|
|
|
r = client.patch(f"/api/workflows/{wid}", json={"output_map": []})
|
|
assert r.status_code == 200
|
|
wf = r.json()
|
|
assert wf["is_enabled"] is False and not wf["output_map"]
|
|
assert wf["usage"] == "text2video" # the usage decision survives
|
|
gen = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
|
)
|
|
assert gen.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Saved mappings survive reanalyze / field edits / migration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_saved_mapping_survives_reanalyze_and_field_edits(client):
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": "重分析·保映射", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video",
|
|
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
|
)
|
|
wid = r.json()["id"]
|
|
|
|
r = client.patch(f"/api/workflows/{wid}", json={"name": "改名不影响映射",
|
|
"description": "编辑其他字段"})
|
|
wf = r.json()
|
|
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
|
assert wf["output_map_source"] == "user" and wf["is_enabled"] is True
|
|
|
|
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
|
assert r.status_code == 200
|
|
wf = r.json()
|
|
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
|
assert wf["output_map_source"] == "user"
|
|
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
|
assert wf["is_enabled"] is True
|
|
|
|
|
|
def test_sql_forced_enabled_row_without_mapping_is_blocked(client):
|
|
"""Backend-direct generation on an unconfirmed row is refused."""
|
|
wid = client.post(
|
|
"/api/workflows",
|
|
json={"name": "强开", "workflow_json": _vault_workflow(),
|
|
"usage": "text2video"},
|
|
).json()["id"]
|
|
conn = get_db(settings.db_path)
|
|
try:
|
|
conn.execute("UPDATE workflows SET is_enabled = 1 WHERE id = ?", (wid,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
gen = client.post(
|
|
"/api/playground/generate",
|
|
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
|
)
|
|
assert gen.status_code == 400
|
|
assert "输出节点" in gen.json()["detail"]
|
|
|
|
|
|
def test_migration_parks_unknown_output_rows_and_keeps_trusted_ones(client):
|
|
import asyncio
|
|
|
|
conn = get_db(settings.db_path)
|
|
try:
|
|
# Pre-HEL-638 shape: user confirmed a usage on an unknown-output
|
|
# graph and the old PATCH re-enabled it.
|
|
conn.execute(
|
|
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
|
" output_schema, is_enabled, usage, usage_source)"
|
|
" VALUES (?, 'video', ?, '[]', '[]', 1, 'text2video', 'user')",
|
|
("存量·未知输出", json.dumps(_vault_workflow())),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
|
|
|
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
|
parked = wfs["存量·未知输出"]
|
|
assert parked["is_enabled"] is False
|
|
assert parked["usage"] == "text2video" # decision preserved
|
|
assert not parked["output_map"] and parked["needs_mapping"] is True
|
|
|
|
# A trusted five-type import keeps working untouched.
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": "存量·可信", "kind": "image",
|
|
"workflow_json": json.loads(
|
|
(FIXTURES / "qwen_image_t2i_API.json").read_text(encoding="utf-8")
|
|
)},
|
|
)
|
|
assert r.status_code == 200
|
|
trusted = next(w for w in client.get("/api/workflows").json() if w["id"] == r.json()["id"])
|
|
assert trusted["is_enabled"] is True
|
|
assert trusted["output_map_source"] == "auto"
|
|
assert any(e["nodeId"] == "60" and e["kind"] == "image" for e in trusted["output_map"])
|