Files
mobcat40-ComfyUI-PromptChain/tests/test_region_binding.py
T
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

201 lines
7.3 KiB
Python

"""Standalone tests for compiler.region_figure_indices / region_figure_count.
compiler.py imports yaml at module level; the binder is pure (stdlib json/re),
so lift just those functions out of the source via AST and exec them in a clean
namespace — same pattern as test_compact_history.py. Runs the real source text.
Run directly: python tests/test_region_binding.py
(also collectable by pytest if available).
"""
import ast
import json
from pathlib import Path
_SRC = Path(__file__).resolve().parents[1] / "core" / "compiler.py"
_WANT_FUNCS = {"region_figure_indices", "region_figure_count", "region_orphans",
"_pose_entity_index", "_region_fallback_index"}
def _load_pure_namespace() -> dict:
tree = ast.parse(_SRC.read_text(encoding="utf-8"))
picked = [n for n in tree.body
if isinstance(n, ast.FunctionDef) and n.name in _WANT_FUNCS]
missing = _WANT_FUNCS - {n.name for n in picked}
if missing:
raise AssertionError(f"functions not found in source: {sorted(missing)}")
module = ast.Module(body=picked, type_ignores=[])
import re
ns: dict = {"json": json, "re": re}
exec(compile(module, str(_SRC), "exec"), ns)
return ns
_NS = _load_pure_namespace()
indices = _NS["region_figure_indices"]
fig_count = _NS["region_figure_count"]
orphans = _NS["region_orphans"]
def _regions(*names):
"""Region list the compiler would emit for $name{} blocks in order:
id = the name's trailing integer, else 1-based block order."""
import re as _re
return [{"id": int(m.group(1)) if (m := _re.search(r"(\d+)$", name)) else n + 1,
"name": name, "text": "x"}
for n, name in enumerate(names)]
def _pose_v2(*fig_names):
return json.dumps({"version": 2,
"figures": [({"name": n} if n else {}) for n in fig_names]})
def _pose_v3(entities, fig_names=None):
pose = {"version": 3,
"regionEntities": [{"kind": k, "name": n} for k, n in entities]}
if fig_names is not None:
pose["figures"] = [({"name": n} if n else {}) for n in fig_names]
return json.dumps(pose)
# ── legacy (no regionEntities) — must be byte-identical to the old behavior ──
def test_legacy_name_match():
assert indices(_regions("alice", "bob"), _pose_v2("bob", "alice")) == [1, 0]
def test_legacy_default_mannequin_names():
assert indices(_regions("mannequin2", "mannequin1"), _pose_v2(None, None)) == [1, 0]
def test_legacy_trailing_int_no_pose():
assert indices(_regions("mannequin2"), "") == [1]
def test_legacy_block_order_fallback():
# Unmatched, no trailing int -> id was assigned block-order 1-based.
assert indices([{"id": 1, "name": "ghost", "text": "x"}], _pose_v2("alice")) == [0]
def test_legacy_unclamped():
# No entity list -> fallbacks stay UNclamped (callers clamp) — old contract.
assert indices(_regions("mannequin7"), _pose_v2("alice")) == [6]
# ── entity mode (regionEntities present) ─────────────────────────────────────
ENTS_2F_1P = [("figure", "alice"), ("figure", "bob"), ("prop", "sword")]
def test_entity_name_match_full_list():
assert indices(_regions("sword", "alice", "bob"), _pose_v3(ENTS_2F_1P)) == [2, 0, 1]
def test_entity_case_insensitive():
assert indices(_regions("SWORD"), _pose_v3(ENTS_2F_1P)) == [2]
def test_entity_unmatched_block_order_clamps_to_figures():
# $ghost at block position 3 (id 3) must NOT land on the prop row (2):
# positional fallbacks are figure-space only.
regs = _regions("alice", "bob") + [{"id": 3, "name": "ghost", "text": "x"}]
assert indices(regs, _pose_v3(ENTS_2F_1P)) == [0, 1, 1]
def test_entity_unmatched_trailing_int_clamps_to_figures():
assert indices(_regions("mannequin7"), _pose_v3(ENTS_2F_1P)) == [1]
def test_entity_one_fig_one_prop():
ents = [("figure", "mannequin1"), ("prop", "desk")]
assert indices(_regions("desk", "mannequin1"), _pose_v3(ents)) == [1, 0]
# Unmatched clamps to the single figure row.
assert indices([{"id": 5, "name": "ghost5", "text": "x"}], _pose_v3(ents)) == [0]
def test_entity_zero_figures_degenerate():
# All-prop scene: unmatched positional blocks clamp to row 0 (best effort).
ents = [("prop", "desk"), ("prop", "lamp")]
assert indices(_regions("lamp"), _pose_v3(ents)) == [1]
assert indices([{"id": 4, "name": "ghost4", "text": "x"}], _pose_v3(ents)) == [0]
def test_entity_empty_list_falls_back_to_figures():
pose = json.dumps({"version": 3, "regionEntities": [],
"figures": [{"name": "alice"}]})
assert indices(_regions("alice"), pose) == [0]
# ── region_orphans (drop $blocks whose mannequin was deleted) ────────────────
def test_orphan_deleted_mannequin():
# The reported bug: $black + $guest in the prompt, only the black figure
# remains. $guest (id 2 -> idx 1) has no figure -> orphan; $black binds.
ents = [("figure", "black")]
regs = _regions("black", "guest")
assert orphans(regs, _pose_v3(ents)) == [False, True]
def test_orphan_unmatched_trailing_int():
# $mannequin7 with 2 figures: clamped binding was the bleed bug -> orphan.
assert orphans(_regions("mannequin7"), _pose_v3(ENTS_2F_1P)) == [True]
def test_orphan_unmatched_block_order():
regs = _regions("alice", "bob") + [{"id": 3, "name": "ghost", "text": "x"}]
assert orphans(regs, _pose_v3(ENTS_2F_1P)) == [False, False, True]
def test_orphan_prop_name_match_is_not_orphan():
# A region bound to a named prop by NAME still paints its prop mask.
assert orphans(_regions("sword", "alice"), _pose_v3(ENTS_2F_1P)) == [False, False]
def test_orphan_positional_within_figures_kept():
# Unmatched but in figure range (e.g. a seeded $mannequin1 before rename)
# is a real positional bind, NOT an orphan.
ents = [("figure", "black")]
assert orphans(_regions("mannequin1"), _pose_v3(ents)) == [False]
def test_orphan_legacy_pose_never_orphans():
# No entity list -> can't tell stray from positional -> never orphan.
assert orphans(_regions("mannequin7"), _pose_v2("alice")) == [False]
assert orphans(_regions("guest"), "") == [False]
def test_orphan_all_prop_scene():
ents = [("prop", "desk"), ("prop", "lamp")]
assert orphans(_regions("lamp"), _pose_v3(ents)) == [False] # prop match
assert orphans([{"id": 4, "name": "ghost4", "text": "x"}],
_pose_v3(ents)) == [True] # no figure
# ── region_figure_count ──────────────────────────────────────────────────────
def test_count_v3():
assert fig_count(_pose_v3(ENTS_2F_1P)) == 2
def test_count_v2_figures():
assert fig_count(_pose_v2("a", None, "c")) == 3
def test_count_absent_or_garbage():
assert fig_count("") is None
assert fig_count("not json") is None
assert fig_count(json.dumps({"version": 3})) is None
if __name__ == "__main__":
fails = 0
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
try:
fn()
print(f"PASS {name}")
except AssertionError as e:
fails += 1
print(f"FAIL {name}: {e}")
raise SystemExit(1 if fails else 0)