diff --git a/docs/nodes.md b/docs/nodes.md index 4bfa233..25fd875 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -8,6 +8,12 @@ as typed objects; the inter-stage types are `SCENE`, `RESOLVED_TAGS`, Nodes are thin adapters: all behaviour lives in the `compiler` package, which never imports ComfyUI. +Every stage node also exposes a **`raw`** string output — a JSON dump of that +node's data output — for inspecting the pipeline (wire it into a text/preview +node). The Knowledge Base Loader's `raw` summarizes the loaded entries. A +relative Knowledge Base `path` is resolved against the installed package, so the +default `knowledge_base/` works regardless of ComfyUI's working directory. + ## Pipeline nodes | Node | Inputs | Outputs | diff --git a/nodes/adapters.py b/nodes/adapters.py index 2634699..212fc51 100644 --- a/nodes/adapters.py +++ b/nodes/adapters.py @@ -21,6 +21,26 @@ def format_messages(messages: Iterable[Message]) -> str: return "\n".join(f"{message.code}: {message.description}" for message in messages) +def to_raw_json(data: Any) -> str: + """Serialize a stage's data output to a pretty JSON string for inspection. + + Handles models (via their ``to_json``), tuples/lists of models, plain values, + and ``None`` (returns an empty string). Used for the debug ``raw`` outputs. + """ + if data is None: + return "" + try: + if hasattr(data, "to_json"): + payload: Any = data.to_json() + elif isinstance(data, list | tuple): + payload = [item.to_json() if hasattr(item, "to_json") else item for item in data] + else: + payload = data + return json.dumps(payload, indent=2, ensure_ascii=False) + except (TypeError, ValueError): + return repr(data) + + def upstream_failure_message(what: str) -> str: """A stage's error string when a required input is missing (upstream failed).""" return ( diff --git a/nodes/category_splitter_node.py b/nodes/category_splitter_node.py index 9482a27..c8a7c43 100644 --- a/nodes/category_splitter_node.py +++ b/nodes/category_splitter_node.py @@ -15,7 +15,7 @@ from typing import Any from compiler.splitter.category_splitter import split_into_categories -from .adapters import format_messages, upstream_failure_message +from .adapters import format_messages, to_raw_json, upstream_failure_message class CategorySplitterNode: @@ -23,15 +23,20 @@ class CategorySplitterNode: CATEGORY = "Scene Compiler" FUNCTION = "run" - RETURN_TYPES = ("CATEGORY_MAP", "STRING", "STRING") - RETURN_NAMES = ("category_map", "warnings", "errors") + RETURN_TYPES = ("CATEGORY_MAP", "STRING", "STRING", "STRING") + RETURN_NAMES = ("category_map", "warnings", "errors", "raw") @classmethod def INPUT_TYPES(cls) -> dict[str, Any]: return {"required": {"resolved_tags": ("RESOLVED_TAGS",)}} - def run(self, resolved_tags: Any) -> tuple[Any, str, str]: + def run(self, resolved_tags: Any) -> tuple[Any, str, str, str]: if resolved_tags is None: - return (None, "", upstream_failure_message("resolved tags")) + return (None, "", upstream_failure_message("resolved tags"), "") result = split_into_categories(tuple(resolved_tags)) - return (result.data, format_messages(result.warnings), format_messages(result.errors)) + return ( + result.data, + format_messages(result.warnings), + format_messages(result.errors), + to_raw_json(result.data), + ) diff --git a/nodes/knowledge_base_loader_node.py b/nodes/knowledge_base_loader_node.py index b167d4a..cca97b6 100644 --- a/nodes/knowledge_base_loader_node.py +++ b/nodes/knowledge_base_loader_node.py @@ -3,26 +3,38 @@ A thin interface: it loads a Knowledge Base directory via the loader module and exposes it for the Resolver node. No compiler logic; it never imports ComfyUI. -Reloading is explicit: bumping the ``reload`` input changes the node's inputs so -ComfyUI re-executes it (there is no automatic file watching). +A relative ``path`` is resolved against this package (where the shipped +``knowledge_base/`` lives), not ComfyUI's working directory, so the default works +regardless of where ComfyUI is launched from. Reloading is explicit: bumping the +``reload`` input re-executes the node (there is no automatic file watching). """ from __future__ import annotations +from pathlib import Path from typing import Any from compiler.common.knowledge_base import KnowledgeBaseError, load_knowledge_base from .adapters import format_messages +# Package root: the repository directory that ships knowledge_base/, prompts/, ... +_PACKAGE_ROOT = Path(__file__).resolve().parent.parent + + +def _resolve_path(path: str) -> Path: + """Resolve a relative Knowledge Base path against the package root.""" + candidate = Path(path) + return candidate if candidate.is_absolute() else _PACKAGE_ROOT / candidate + class KnowledgeBaseLoaderNode: """Loads the Knowledge Base and exposes it for the Resolver.""" CATEGORY = "Scene Compiler" FUNCTION = "run" - RETURN_TYPES = ("KNOWLEDGE_BASE", "STRING", "STRING") - RETURN_NAMES = ("knowledge_base", "warnings", "errors") + RETURN_TYPES = ("KNOWLEDGE_BASE", "STRING", "STRING", "STRING") + RETURN_NAMES = ("knowledge_base", "warnings", "errors", "raw") @classmethod def INPUT_TYPES(cls) -> dict[str, Any]: @@ -31,9 +43,21 @@ class KnowledgeBaseLoaderNode: "optional": {"reload": ("INT", {"default": 0, "min": 0})}, } - def run(self, path: str, reload: int = 0) -> tuple[Any, str, str]: + def run(self, path: str, reload: int = 0) -> tuple[Any, str, str, str]: + resolved = _resolve_path(path) try: - knowledge_base = load_knowledge_base(path) + knowledge_base = load_knowledge_base(resolved) except KnowledgeBaseError as error: - return (None, "", format_messages([error.message, *error.findings])) - return (knowledge_base, "", "") + errors = format_messages([error.message, *error.findings]) + return (None, "", errors, f"Failed to load Knowledge Base from: {resolved}") + + count = len(knowledge_base) + warnings = "" + if count == 0: + warnings = ( + f"Knowledge Base at '{resolved}' is empty (0 entries); " + "check the path. Every concept will be reported as unknown." + ) + sample = ", ".join(sorted(knowledge_base.by_id)[:20]) + raw = f"Loaded {count} entries from {resolved}\nFirst entries: {sample}" + return (knowledge_base, warnings, "", raw) diff --git a/nodes/prompt_builder_node.py b/nodes/prompt_builder_node.py index c209056..37548dc 100644 --- a/nodes/prompt_builder_node.py +++ b/nodes/prompt_builder_node.py @@ -8,15 +8,17 @@ ComfyUI. from __future__ import annotations +import json from typing import Any from compiler.builder.prompt_builder import RESERVED_OUTPUTS, build_prompts from compiler.common.categories import CANONICAL_CATEGORIES from compiler.common.config import Config -# The stable set of node outputs: one per canonical category, then the reserved -# outputs. Matches the Prompt Outputs produced by build_prompts. +# One output per canonical category, then the reserved outputs (matching the +# Prompt Outputs produced by build_prompts), then a debug `raw` dump. _OUTPUT_NAMES: tuple[str, ...] = (*CANONICAL_CATEGORIES, *RESERVED_OUTPUTS) +_RETURN_NAMES: tuple[str, ...] = (*_OUTPUT_NAMES, "raw") class PromptBuilderNode: @@ -24,8 +26,8 @@ class PromptBuilderNode: CATEGORY = "Scene Compiler" FUNCTION = "run" - RETURN_TYPES = ("STRING",) * len(_OUTPUT_NAMES) - RETURN_NAMES = _OUTPUT_NAMES + RETURN_TYPES = ("STRING",) * len(_RETURN_NAMES) + RETURN_NAMES = _RETURN_NAMES @classmethod def INPUT_TYPES(cls) -> dict[str, Any]: @@ -37,7 +39,9 @@ class PromptBuilderNode: def run(self, category_map: Any, config: Config | None = None) -> tuple[str, ...]: if category_map is None: # Upstream produced no Category Map; emit empty strings rather than crash. - return tuple("" for _ in _OUTPUT_NAMES) + return tuple("" for _ in _RETURN_NAMES) result = build_prompts(category_map, config or Config()) by_name = {output.name: output.value for output in result.data} - return tuple(by_name[name] for name in _OUTPUT_NAMES) + values = tuple(by_name[name] for name in _OUTPUT_NAMES) + raw = json.dumps(by_name, indent=2, ensure_ascii=False) + return (*values, raw) diff --git a/nodes/resolver_node.py b/nodes/resolver_node.py index dea2cc6..8408aa0 100644 --- a/nodes/resolver_node.py +++ b/nodes/resolver_node.py @@ -12,7 +12,7 @@ from typing import Any from compiler.common.config import Config from compiler.resolver.illustrious_resolver import resolve_scene -from .adapters import format_messages, upstream_failure_message +from .adapters import format_messages, to_raw_json, upstream_failure_message class ResolverNode: @@ -20,8 +20,8 @@ class ResolverNode: CATEGORY = "Scene Compiler" FUNCTION = "run" - RETURN_TYPES = ("RESOLVED_TAGS", "STRING", "STRING") - RETURN_NAMES = ("resolved_tags", "warnings", "errors") + RETURN_TYPES = ("RESOLVED_TAGS", "STRING", "STRING", "STRING") + RETURN_NAMES = ("resolved_tags", "warnings", "errors", "raw") @classmethod def INPUT_TYPES(cls) -> dict[str, Any]: @@ -38,8 +38,13 @@ class ResolverNode: scene: Any, knowledge_base: Any, config: Config | None = None, - ) -> tuple[Any, str, str]: + ) -> tuple[Any, str, str, str]: if scene is None or knowledge_base is None: - return (None, "", upstream_failure_message("scene or knowledge base")) + return (None, "", upstream_failure_message("scene or knowledge base"), "") result = resolve_scene(scene, knowledge_base, config or Config()) - return (result.data, format_messages(result.warnings), format_messages(result.errors)) + return ( + result.data, + format_messages(result.warnings), + format_messages(result.errors), + to_raw_json(result.data), + ) diff --git a/nodes/scene_analyzer_node.py b/nodes/scene_analyzer_node.py index b568234..a9bb4e3 100644 --- a/nodes/scene_analyzer_node.py +++ b/nodes/scene_analyzer_node.py @@ -13,7 +13,7 @@ from compiler.analyzer.backend import OllamaBackend from compiler.analyzer.scene_analyzer import analyze from compiler.common.config import Config -from .adapters import format_messages +from .adapters import format_messages, to_raw_json class SceneAnalyzerNode: @@ -21,8 +21,8 @@ class SceneAnalyzerNode: CATEGORY = "Scene Compiler" FUNCTION = "run" - RETURN_TYPES = ("SCENE", "STRING", "STRING") - RETURN_NAMES = ("scene", "warnings", "errors") + RETURN_TYPES = ("SCENE", "STRING", "STRING", "STRING") + RETURN_NAMES = ("scene", "warnings", "errors", "raw") @classmethod def INPUT_TYPES(cls) -> dict[str, Any]: @@ -57,4 +57,9 @@ class SceneAnalyzerNode: config = Config.from_json({"analyzer": analyzer}) backend = OllamaBackend.from_config(config) result = analyze(natural_language, backend, config) - return (result.data, format_messages(result.warnings), format_messages(result.errors)) + return ( + result.data, + format_messages(result.warnings), + format_messages(result.errors), + to_raw_json(result.data), + ) diff --git a/nodes/scene_validator_node.py b/nodes/scene_validator_node.py index 7dfa9e8..86d3d61 100644 --- a/nodes/scene_validator_node.py +++ b/nodes/scene_validator_node.py @@ -12,7 +12,7 @@ from typing import Any from compiler.common.config import Config from compiler.validator.scene_validator import validate_scene -from .adapters import format_messages, upstream_failure_message +from .adapters import format_messages, to_raw_json, upstream_failure_message class SceneValidatorNode: @@ -20,8 +20,8 @@ class SceneValidatorNode: CATEGORY = "Scene Compiler" FUNCTION = "run" - RETURN_TYPES = ("SCENE", "STRING", "STRING") - RETURN_NAMES = ("scene", "warnings", "errors") + RETURN_TYPES = ("SCENE", "STRING", "STRING", "STRING") + RETURN_NAMES = ("scene", "warnings", "errors", "raw") @classmethod def INPUT_TYPES(cls) -> dict[str, Any]: @@ -30,8 +30,13 @@ class SceneValidatorNode: "optional": {"config": ("COMPILER_CONFIG",)}, } - def run(self, scene: Any, config: Config | None = None) -> tuple[Any, str, str]: + def run(self, scene: Any, config: Config | None = None) -> tuple[Any, str, str, str]: if scene is None: - return (None, "", upstream_failure_message("scene")) + return (None, "", upstream_failure_message("scene"), "") result = validate_scene(scene.to_json(), config or Config()) - return (result.data, format_messages(result.warnings), format_messages(result.errors)) + return ( + result.data, + format_messages(result.warnings), + format_messages(result.errors), + to_raw_json(result.data), + ) diff --git a/tests/resolver/test_resolver_node.py b/tests/resolver/test_resolver_node.py index c2857de..edf1c4c 100644 --- a/tests/resolver/test_resolver_node.py +++ b/tests/resolver/test_resolver_node.py @@ -49,15 +49,15 @@ def scene() -> Scene: def test_node_metadata() -> None: - assert ResolverNode.RETURN_TYPES == ("RESOLVED_TAGS", "STRING", "STRING") - assert ResolverNode.RETURN_NAMES == ("resolved_tags", "warnings", "errors") + assert ResolverNode.RETURN_TYPES == ("RESOLVED_TAGS", "STRING", "STRING", "STRING") + assert ResolverNode.RETURN_NAMES == ("resolved_tags", "warnings", "errors", "raw") inputs = ResolverNode.INPUT_TYPES() assert set(inputs["required"]) == {"scene", "knowledge_base"} assert "config" in inputs["optional"] def test_resolves_tags_with_real_resolver() -> None: - resolved, warnings, errors = ResolverNode().run(scene(), kb()) + resolved, warnings, errors, *_ = ResolverNode().run(scene(), kb()) assert [t.tag for t in resolved] == ["1girl", "long hair"] assert errors == "" @@ -67,7 +67,7 @@ def test_delegates_and_surfaces_messages(monkeypatch) -> None: return CompilerResult(data=()).add_warning(message("SC0001", "unknown 'x'")) monkeypatch.setattr(node_module, "resolve_scene", fake_resolve) - resolved, warnings, errors = ResolverNode().run(scene(), kb()) + resolved, warnings, errors, *_ = ResolverNode().run(scene(), kb()) assert resolved == () assert "SC0001: unknown 'x'" in warnings diff --git a/tests/test_category_splitter_node.py b/tests/test_category_splitter_node.py index 5916268..f2c3040 100644 --- a/tests/test_category_splitter_node.py +++ b/tests/test_category_splitter_node.py @@ -15,14 +15,14 @@ def rtag(name: str, category: str) -> ResolvedTag: def test_node_metadata() -> None: - assert CategorySplitterNode.RETURN_TYPES == ("CATEGORY_MAP", "STRING", "STRING") - assert CategorySplitterNode.RETURN_NAMES == ("category_map", "warnings", "errors") + assert CategorySplitterNode.RETURN_TYPES == ("CATEGORY_MAP", "STRING", "STRING", "STRING") + assert CategorySplitterNode.RETURN_NAMES == ("category_map", "warnings", "errors", "raw") assert "resolved_tags" in CategorySplitterNode.INPUT_TYPES()["required"] def test_splits_with_real_module() -> None: tags = (rtag("1girl", "character"), rtag("long hair", "hair")) - category_map, warnings, errors = CategorySplitterNode().run(tags) + category_map, warnings, errors, *_ = CategorySplitterNode().run(tags) assert isinstance(category_map, CategoryMap) assert [t.tag for t in category_map.tags_for("character")] == ["1girl"] assert [t.tag for t in category_map.tags_for("hair")] == ["long hair"] @@ -35,7 +35,7 @@ def test_delegates_and_surfaces_errors(monkeypatch) -> None: "split_into_categories", lambda tags: CompilerResult().add_error(message("SC0008", "bad category")), ) - category_map, warnings, errors = CategorySplitterNode().run(()) + category_map, warnings, errors, *_ = CategorySplitterNode().run(()) assert category_map is None assert "SC0008: bad category" in errors diff --git a/tests/test_knowledge_base_loader_node.py b/tests/test_knowledge_base_loader_node.py index 4eec5b3..fa20d7e 100644 --- a/tests/test_knowledge_base_loader_node.py +++ b/tests/test_knowledge_base_loader_node.py @@ -15,8 +15,8 @@ def write_kb(directory, entries: list[dict]) -> str: def test_node_metadata() -> None: - assert KnowledgeBaseLoaderNode.RETURN_TYPES == ("KNOWLEDGE_BASE", "STRING", "STRING") - assert KnowledgeBaseLoaderNode.RETURN_NAMES == ("knowledge_base", "warnings", "errors") + assert KnowledgeBaseLoaderNode.RETURN_TYPES == ("KNOWLEDGE_BASE", "STRING", "STRING", "STRING") + assert KnowledgeBaseLoaderNode.RETURN_NAMES == ("knowledge_base", "warnings", "errors", "raw") inputs = KnowledgeBaseLoaderNode.INPUT_TYPES() assert "path" in inputs["required"] assert "reload" in inputs["optional"] @@ -24,7 +24,7 @@ def test_node_metadata() -> None: def test_loads_knowledge_base(tmp_path) -> None: path = write_kb(tmp_path, [{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]) - kb, warnings, errors = KnowledgeBaseLoaderNode().run(path) + kb, warnings, errors, *_ = KnowledgeBaseLoaderNode().run(path) assert isinstance(kb, KnowledgeBase) assert kb.get("long_hair") is not None assert (warnings, errors) == ("", "") @@ -32,14 +32,31 @@ def test_loads_knowledge_base(tmp_path) -> None: def test_invalid_knowledge_base_surfaces_sc0004(tmp_path) -> None: path = write_kb(tmp_path, [{"id": "x", "tags": ["x"], "category": "nope"}]) - kb, warnings, errors = KnowledgeBaseLoaderNode().run(path) + kb, warnings, errors, *_ = KnowledgeBaseLoaderNode().run(path) assert kb is None assert "SC0004" in errors +def test_relative_path_resolves_against_package(monkeypatch, tmp_path) -> None: + # Even with an unrelated working directory (as under ComfyUI), the default + # relative path must load the shipped Knowledge Base, not an empty one. + monkeypatch.chdir(tmp_path) + kb, warnings, errors, raw = KnowledgeBaseLoaderNode().run("knowledge_base/") + assert isinstance(kb, KnowledgeBase) + assert len(kb) > 100 + assert (warnings, errors) == ("", "") + assert "entries" in raw + + +def test_empty_knowledge_base_warns(tmp_path) -> None: + kb, warnings, errors, raw = KnowledgeBaseLoaderNode().run(str(tmp_path)) + assert len(kb) == 0 + assert "empty" in warnings.lower() + + def test_reload_input_is_accepted(tmp_path) -> None: path = write_kb(tmp_path, [{"id": "long_hair", "tags": ["long hair"], "category": "hair"}]) - kb, _, _ = KnowledgeBaseLoaderNode().run(path, reload=5) + kb, _, _, _ = KnowledgeBaseLoaderNode().run(path, reload=5) assert isinstance(kb, KnowledgeBase) diff --git a/tests/test_node_none_guards.py b/tests/test_node_none_guards.py index 885498b..9d773c2 100644 --- a/tests/test_node_none_guards.py +++ b/tests/test_node_none_guards.py @@ -14,25 +14,25 @@ from nodes.scene_validator_node import SceneValidatorNode def test_validator_handles_none_scene() -> None: - scene, warnings, errors = SceneValidatorNode().run(None) + scene, warnings, errors, *_ = SceneValidatorNode().run(None) assert scene is None assert "no output" in errors.lower() def test_resolver_handles_none_scene() -> None: - resolved, _, errors = ResolverNode().run(None, knowledge_base=object()) + resolved, _, errors, *_ = ResolverNode().run(None, knowledge_base=object()) assert resolved is None assert errors def test_resolver_handles_none_knowledge_base() -> None: - resolved, _, errors = ResolverNode().run(scene=object(), knowledge_base=None) + resolved, _, errors, *_ = ResolverNode().run(scene=object(), knowledge_base=None) assert resolved is None assert errors def test_category_splitter_handles_none_tags() -> None: - category_map, _, errors = CategorySplitterNode().run(None) + category_map, _, errors, *_ = CategorySplitterNode().run(None) assert category_map is None assert errors diff --git a/tests/test_prompt_builder_node.py b/tests/test_prompt_builder_node.py index ec755e8..ee9ae50 100644 --- a/tests/test_prompt_builder_node.py +++ b/tests/test_prompt_builder_node.py @@ -24,8 +24,8 @@ def outputs(result: tuple[str, ...]) -> dict[str, str]: def test_emits_one_output_per_category_plus_reserved() -> None: - assert PromptBuilderNode.RETURN_NAMES == (*CANONICAL_CATEGORIES, "negative", "scene") - assert PromptBuilderNode.RETURN_TYPES == ("STRING",) * 21 + assert PromptBuilderNode.RETURN_NAMES == (*CANONICAL_CATEGORIES, "negative", "scene", "raw") + assert PromptBuilderNode.RETURN_TYPES == ("STRING",) * 22 def test_formats_and_orders_category_strings() -> None: diff --git a/tests/test_scene_analyzer_node.py b/tests/test_scene_analyzer_node.py index 063224d..8b4df83 100644 --- a/tests/test_scene_analyzer_node.py +++ b/tests/test_scene_analyzer_node.py @@ -21,8 +21,8 @@ def test_input_types_structure() -> None: def test_node_metadata() -> None: - assert SceneAnalyzerNode.RETURN_TYPES == ("SCENE", "STRING", "STRING") - assert SceneAnalyzerNode.RETURN_NAMES == ("scene", "warnings", "errors") + assert SceneAnalyzerNode.RETURN_TYPES == ("SCENE", "STRING", "STRING", "STRING") + assert SceneAnalyzerNode.RETURN_NAMES == ("scene", "warnings", "errors", "raw") assert SceneAnalyzerNode.FUNCTION == "run" assert SceneAnalyzerNode.CATEGORY == "Scene Compiler" @@ -41,7 +41,7 @@ def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None: monkeypatch.setattr(node_module, "analyze", fake_analyze) - scene, warnings, errors = SceneAnalyzerNode().run( + scene, warnings, errors, *_ = SceneAnalyzerNode().run( natural_language="A girl.", model_name="mistral", temperature=0.0, @@ -56,7 +56,7 @@ def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None: def test_no_messages_yield_empty_strings(monkeypatch) -> None: monkeypatch.setattr(node_module, "analyze", lambda d, b, c: CompilerResult(data="S")) - scene, warnings, errors = SceneAnalyzerNode().run("x", "llama3", 0.0, 60) + scene, warnings, errors, *_ = SceneAnalyzerNode().run("x", "llama3", 0.0, 60) assert (warnings, errors) == ("", "") diff --git a/tests/validator/test_scene_validator_node.py b/tests/validator/test_scene_validator_node.py index dff390e..8b9d8ea 100644 --- a/tests/validator/test_scene_validator_node.py +++ b/tests/validator/test_scene_validator_node.py @@ -37,8 +37,8 @@ def sample_scene() -> Scene: def test_node_metadata() -> None: - assert SceneValidatorNode.RETURN_TYPES == ("SCENE", "STRING", "STRING") - assert SceneValidatorNode.RETURN_NAMES == ("scene", "warnings", "errors") + assert SceneValidatorNode.RETURN_TYPES == ("SCENE", "STRING", "STRING", "STRING") + assert SceneValidatorNode.RETURN_NAMES == ("scene", "warnings", "errors", "raw") assert SceneValidatorNode.CATEGORY == "Scene Compiler" inputs = SceneValidatorNode.INPUT_TYPES() assert "scene" in inputs["required"] @@ -46,11 +46,17 @@ def test_node_metadata() -> None: def test_valid_scene_passes_through() -> None: - scene, warnings, errors = SceneValidatorNode().run(sample_scene()) + scene, warnings, errors, *_ = SceneValidatorNode().run(sample_scene()) assert isinstance(scene, Scene) assert (warnings, errors) == ("", "") +def test_raw_output_contains_scene_json() -> None: + _, _, _, raw = SceneValidatorNode().run(sample_scene()) + assert '"characters"' in raw # the debug raw output is the scene as JSON + assert "female" in raw + + def test_delegates_and_surfaces_messages(monkeypatch) -> None: captured = {} @@ -64,7 +70,7 @@ def test_delegates_and_surfaces_messages(monkeypatch) -> None: monkeypatch.setattr(node_module, "validate_scene", fake_validate) config = Config.from_json({"validator": {"allow_unknown_fields": True}}) - scene, warnings, errors = SceneValidatorNode().run(sample_scene(), config) + scene, warnings, errors, *_ = SceneValidatorNode().run(sample_scene(), config) assert scene == "VALIDATED" assert captured["allow_unknown"] is True assert "SC0015: unexpected field removed" in warnings