Files
ComfyAssets-ComfyUI_PromptM…/tests/test_inspection_outcomes.py
T
Vito Sansevero baf788b863 feat(inspect): port legacy parser rules the new resolver missed (A1111)
R3: an accumulating batch saver repeats the A1111 parameters record once
per batch item, identical except "Batch index: N". The bounded PNG reader
now keeps the last copy (Pillow's rule, which #165 relied on) instead of
calling the file unreadable. Any other difference, including a missing or
second batch index, is still a conflict, and decoding the copies draws from
the duplicate role budget.

R4: parameters text that starts with "Negative prompt:" has an empty
positive (#165), instead of showing the delimiter line as the positive.

R5: the settings footer may be followed by sd-dynamic-prompts' one-line
"Template:" and "Negative Template:" records, and may be "Steps: N" alone
when there is no full footer.

R10: commas inside A1111's quoted settings values no longer split them, so
an "ADetailer prompt" with a comma keeps the other facts known.

The facts' A1111 reader mirrors R4, R5 and R10; the default (ingestion)
profile keeps the historical split. Also from the Task 2.7 review: a guard
test fails on any uncoded unresolved reason without a user line (M1), and
the encoder bounds role lines to the client's 512 code units (M4).
2026-10-19 15:00:33 -07:00

487 lines
19 KiB
Python

"""Non-resolved prompt outcomes carry one short, truthful line per role."""
import ast
import json
import unittest
from pathlib import Path
from dataclasses import replace
from unittest import mock
from utils import prompt_pair_resolver
from utils.inspection_outcomes import (
_UNCODED_LINES,
CLASS_NAME_LENGTH,
REASON_CODES,
outcome_message,
project_resolution,
)
from utils.inspection_response import encode_inspection_response
from utils.prompt_inspector import inspect_prompt_evidence
from utils.prompt_pair_resolver import (
EMPTY_POSITIVE_REASON,
INSPECTION_PROFILE,
MULTISTAGE_UNATTRIBUTED_REASON,
SEMANTICS_UNAVAILABLE_REASON,
)
LIMITS = "Prompt exceeds inspection limits."
def _encode(text: str) -> dict:
return {"class_type": "CLIPTextEncode", "inputs": {"text": text, "clip": ["9", 0]}}
def _latent() -> dict:
return {
"class_type": "EmptyLatentImage",
"inputs": {"width": 8, "height": 8, "batch_size": 1},
}
def _ksampler(positive: str = "7") -> dict:
return {
"class_type": "KSampler",
"inputs": {
"positive": [positive, 0],
"negative": ["6", 0],
"latent_image": ["4", 0],
"model": ["9", 0],
"seed": 1,
"steps": 2,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
},
}
def _dual_cfg() -> dict:
return {
"1": _encode("positive"),
"6": _encode("negative"),
"2": {
"class_type": "DualCFGGuider",
"inputs": {
"cond1": ["1", 0],
"cond2": ["1", 0],
"negative": ["6", 0],
"model": ["9", 0],
},
},
"3": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"guider": ["2", 0],
"noise": ["9", 0],
"sampler": ["9", 0],
"sigmas": ["9", 0],
"latent_image": ["4", 0],
},
},
"4": _latent(),
}
def _runtime() -> dict:
return {
"1": {"class_type": "PromptSearchList", "inputs": {"search": "cat"}},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["1", 0], "clip": ["9", 0]},
},
"6": _encode("negative"),
"3": _ksampler(),
"4": _latent(),
}
def _composite() -> dict:
graph = _runtime()
graph["7"] = {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["6", 0], "conditioning_2": ["8", 0]},
}
graph["8"] = _encode("other")
return graph
def _messages(evidence: dict) -> dict:
return {role: side.get("message") for role, side in evidence["prompts"].items()}
def _statuses(evidence: dict) -> dict:
return {role: side["status"] for role, side in evidence["prompts"].items()}
class TestOutcomeMessages(unittest.TestCase):
def assert_unresolved_with(self, evidence: dict, message: str) -> None:
self.assertEqual(
_statuses(evidence), {"positive": "unresolved", "negative": "unresolved"}
)
self.assertEqual(
_messages(evidence), {"positive": message, "negative": message}
)
def test_unsupported_node_status_names_the_class(self):
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
self.assertEqual(evidence["resolution"]["reason_code"], "unsupported-node")
self.assert_unresolved_with(
evidence,
"Prompt uses a node the inspector does not support (DualCFGGuider).",
)
def test_runtime_and_composite_statuses(self):
self.assert_unresolved_with(
inspect_prompt_evidence({"prompt": _runtime()}),
"Prompt is chosen at run time (PromptSearchList).",
)
self.assert_unresolved_with(
inspect_prompt_evidence({"prompt": _composite()}),
"Prompt combines several texts (ConditioningCombine).",
)
def test_limit_status(self):
tight = replace(INSPECTION_PROFILE, per_candidate_visits=1, total_visits=1)
evidence = inspect_prompt_evidence({"prompt": _runtime()}, profile=tight)
self.assertEqual(evidence["resolution"]["reason_code"], "work-limit")
self.assert_unresolved_with(evidence, LIMITS)
for code in ("work-limit", "depth-limit", "text-limit", "graph-ceiling"):
self.assertEqual(
outcome_message({"state": "unresolved", "reason_code": code}), LIMITS
)
def test_every_closed_code_and_known_uncoded_reason_has_a_line(self):
for code in REASON_CODES:
message = outcome_message({"state": "unresolved", "reason_code": code})
self.assertIsInstance(message, str, code)
self.assertTrue(message.endswith("."), code)
self.assertNotIn("could not be resolved", message)
for reason, expected in (
(EMPTY_POSITIVE_REASON, "Positive prompt is empty."),
(
SEMANTICS_UNAVAILABLE_REASON,
"The inspector's node table is unavailable.",
),
(
MULTISTAGE_UNATTRIBUTED_REASON,
"Several sampler stages were found; their prompts were not compared.",
),
(
"Media metadata could not be read",
"Metadata in this file could not be read.",
),
):
resolution = {"state": "unresolved", "reason": reason, "reason_code": None}
self.assertEqual(outcome_message(resolution), expected)
def test_named_codes_without_a_class_omit_the_name(self):
for code, line in (
("unsupported-node", "Prompt uses a node the inspector does not support."),
("runtime-value", "Prompt is chosen at run time."),
("composite", "Prompt combines several texts."),
):
for node in (None, {"id": "7", "class_type": None}):
resolution = {"state": "unresolved", "reason_code": code, "node": node}
self.assertEqual(outcome_message(resolution), line)
def test_class_name_is_bounded(self):
resolution = {
"state": "unresolved",
"reason_code": "unsupported-node",
"node": {"id": "7", "class_type": "N" * 500},
}
self.assertEqual(
outcome_message(resolution),
"Prompt uses a node the inspector does not support "
f"({'N' * CLASS_NAME_LENGTH}).",
)
self.assertEqual(CLASS_NAME_LENGTH, 200)
def test_resolved_ambiguous_absent_and_unknown_outcomes_carry_no_line(self):
for resolution in (
{"state": "resolved", "reason_code": None},
{"state": "ambiguous", "reason_code": "multistage-differs"},
{"state": "ambiguous", "reason_code": None, "reason": "Multiple"},
{"state": "absent", "reason_code": None, "reason": "No parsed"},
{"state": "unresolved", "reason_code": "guess"},
{"state": "unresolved", "reason_code": None, "reason": "Anything else"},
None,
):
self.assertIsNone(outcome_message(resolution), resolution)
def test_inspector_lines_for_no_sampler_malformed_and_empty_positive(self):
evidence = inspect_prompt_evidence({"prompt": {"1": _encode("x")}})
self.assert_unresolved_with(evidence, "No supported sampler was found.")
evidence = inspect_prompt_evidence({"prompt": "not-json"})
self.assert_unresolved_with(
evidence, "Prompt metadata in this file is malformed."
)
graph = _runtime()
graph["7"] = _encode(" ")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["reason"], EMPTY_POSITIVE_REASON)
self.assert_unresolved_with(evidence, "Positive prompt is empty.")
def test_a_resolved_pair_with_an_empty_positive_explains_only_that_role(self):
side = {"text": " ", "provenance": {"text_source": "graph-inferred"}}
negative = {"text": "kept", "provenance": {"text_source": "graph-inferred"}}
resolved = {
"state": "resolved",
"positive": side,
"negative": negative,
"sampler_id": "3",
"reason": "",
"reason_code": None,
"node": None,
}
with mock.patch(
"utils.prompt_inspector.resolve_prompt_pair", return_value=resolved
):
evidence = inspect_prompt_evidence({"prompt": {}})
self.assertEqual(
evidence["prompts"]["positive"],
{
"status": "unresolved",
"text": None,
"source": None,
"message": "Positive prompt is empty.",
},
)
self.assertEqual(evidence["prompts"]["negative"]["text"], "kept")
self.assertNotIn("message", evidence["prompts"]["negative"])
def test_candidates_and_absence_keep_their_status_lines(self):
graph = _runtime()
graph["7"] = _encode("positive")
graph["5"] = _ksampler()
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "ambiguous")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
evidence = inspect_prompt_evidence({})
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
graph = _runtime()
graph["7"] = _encode("positive")
evidence = inspect_prompt_evidence({"prompt": graph})
self.assertEqual(evidence["resolution"]["state"], "resolved")
self.assertEqual(_messages(evidence), {"positive": None, "negative": None})
class TestResolutionProjection(unittest.TestCase):
def test_closed_codes_and_named_nodes_pass_unchanged(self):
for code in REASON_CODES:
resolution = {
"state": "unresolved",
"sampler_id": "3",
"reason": "r",
"reason_code": code,
"node": None,
}
self.assertEqual(project_resolution(resolution), resolution)
named = {
"state": "unresolved",
"sampler_id": None,
"reason": "r",
"reason_code": "composite",
"node": {"id": "7", "class_type": None},
}
self.assertEqual(project_resolution(named), named)
def test_unknown_codes_and_unbounded_nodes_fail_closed(self):
base = {"state": "unresolved", "sampler_id": None, "reason": "r"}
for code, node in (
("guess", None),
(1, None),
("malformed", {"id": "7", "class_type": "X"}),
(None, {"id": "7", "class_type": "X"}),
("unsupported-node", {"id": 7, "class_type": "X"}),
("unsupported-node", {"id": "", "class_type": "X"}),
("unsupported-node", {"id": "7"}),
("unsupported-node", {"id": "7", "class_type": 7}),
("unsupported-node", {"id": "7", "class_type": "X", "extra": 1}),
("unsupported-node", "X"),
):
projected = project_resolution({**base, "reason_code": code, "node": node})
self.assertIsNone(projected["node"], (code, node))
if code not in REASON_CODES:
self.assertIsNone(projected["reason_code"], code)
for state in ("resolved", "absent"):
projected = project_resolution(
{**base, "state": state, "reason_code": "work-limit", "node": None}
)
self.assertIsNone(projected["reason_code"])
long_name = project_resolution(
{
**base,
"reason_code": "runtime-value",
"node": {"id": "7" * 300, "class_type": "N" * 300},
}
)["node"]
self.assertEqual(
long_name, {"id": "7" * CLASS_NAME_LENGTH, "class_type": "N" * 200}
)
unnamed = project_resolution(
{
**base,
"reason_code": "composite",
"node": {"id": "7", "class_type": ""},
}
)["node"]
self.assertEqual(unnamed, {"id": "7", "class_type": None})
def test_wire_carries_role_lines_and_projected_resolution(self):
evidence = inspect_prompt_evidence({"prompt": _dual_cfg()})
resolution = dict(evidence["resolution"], reason_code="guess")
body = json.loads(
encode_inspection_response(
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
root_scope="a" * 64,
file={
"observation": "current-file",
"source_label": "output",
"media_type": "image",
"extension": ".png",
"size": 1,
"modified_time": 1,
},
prompts=evidence["prompts"],
resolution=resolution,
inspection=None,
library={"status": "none", "tags": []},
)
)
self.assertEqual(body["prompts"], evidence["prompts"])
self.assertIsNone(body["resolution"]["reason_code"])
self.assertIsNone(body["resolution"]["node"])
# Uncoded unresolved reasons only the default (ingestion) profile emits; the
# inspector never shows them, so they need no line.
_DEFAULT_PROFILE_ONLY = frozenset(
{"Cyclic or excessive latent ancestry", "Resolution limit reached"}
)
def _uncoded_unresolved_reasons() -> set:
"""Every literal reason the resolver can return as unresolved without a code.
Reads the source: ``_Unresolved(reason)`` without a code, and
``_result("unresolved", ..., reason=...)`` without ``reason_code``. A reason
is a string literal or a module constant naming one.
"""
source = Path(prompt_pair_resolver.__file__).read_text(encoding="utf-8")
tree = ast.parse(source)
def literal(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
value = getattr(prompt_pair_resolver, node.id, None)
return value if isinstance(value, str) else None
return None
found = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
continue
keywords = {item.arg: item.value for item in node.keywords}
if node.func.id == "_Unresolved":
if len(node.args) < 2 and "code" not in keywords and node.args:
reason = literal(node.args[0])
if reason is None:
raise AssertionError(f"line {node.lineno}: unreadable reason")
found.add(reason)
elif node.func.id == "_result" and node.args:
state = literal(node.args[0])
coded = "reason_code" in keywords or len(node.args) > 6
reason = keywords.get(
"reason", node.args[4] if len(node.args) > 4 else None
)
if state == "unresolved" and not coded and reason is not None:
text = literal(reason)
if text is None:
raise AssertionError(f"line {node.lineno}: unreadable reason")
found.add(text)
return found
class TestEveryUncodedOutcomeHasALine(unittest.TestCase):
"""Task 2.7 review M1: a new uncoded unresolved reason fails here."""
def test_resolver_reasons_without_a_code_have_a_line(self):
reasons = _uncoded_unresolved_reasons()
self.assertIn(EMPTY_POSITIVE_REASON, reasons, "the scan sees the source")
missing = reasons - set(_UNCODED_LINES) - _DEFAULT_PROFILE_ONLY
self.assertEqual(missing, set(), "add a code or a line in inspection_outcomes")
# The allowance names only reasons the source still emits.
self.assertLessEqual(_DEFAULT_PROFILE_ONLY, reasons)
def test_default_profile_only_reasons_are_coded_in_inspection(self):
# The limit outcome is uncoded only on the default profile.
source = {"prompt": {"1": {"class_type": "KSampler", "inputs": {}}}}
for limited in ({"max_depth": 0}, {"max_text_length": 0}):
with self.subTest(**limited):
default = prompt_pair_resolver.resolve_prompt_pair(source, **limited)
inspected = prompt_pair_resolver.resolve_prompt_pair(
source, profile=INSPECTION_PROFILE, **limited
)
self.assertEqual(default["reason"], "Resolution limit reached")
self.assertIsNone(default["reason_code"])
self.assertEqual(inspected["reason"], "Resolution limit reached")
self.assertIsNotNone(inspected["reason_code"])
class TestRoleMessageBound(unittest.TestCase):
"""Task 2.7 review M4: the encoder bounds role lines as the client does."""
def encode(self, prompts: dict) -> dict:
return json.loads(
encode_inspection_response(
image={"root_index": 0, "path": "a.png", "filename": "a.png"},
root_scope="a" * 64,
file={
"observation": "current-file",
"source_label": "output",
"media_type": "image",
"extension": ".png",
"size": 1,
"modified_time": 1,
},
prompts=prompts,
resolution={"state": "unresolved", "reason": "x"},
inspection=None,
library={"status": "none", "tags": []},
)
)["prompts"]
def test_role_messages_are_bounded_text_on_unresolved_roles(self):
side = {"status": "unresolved", "text": None, "source": None}
prompts = self.encode(
{
"positive": {**side, "message": "x" * 600},
"negative": {**side, "message": "\U0001f600" * 300},
}
)
self.assertEqual(prompts["positive"]["message"], "x" * 512)
# The client counts UTF-16 code units.
self.assertLessEqual(
len(prompts["negative"]["message"].encode("utf-16-le")), 1024
)
self.assertEqual(prompts["negative"]["message"], "\U0001f600" * 256)
for message in ("", None, 7):
with self.subTest(message=message):
prompts = self.encode(
{"positive": {**side, "message": message}, "negative": side}
)
self.assertNotIn("message", prompts["positive"])
resolved = {"status": "resolved", "text": "a", "source": "graph-inferred"}
prompts = self.encode(
{"positive": {**resolved, "message": "line"}, "negative": side}
)
self.assertNotIn("message", prompts["positive"])
self.assertEqual(prompts["negative"], side)
if __name__ == "__main__":
unittest.main()