Add the Scene Validator per MASTER_SPEC §14 and a central message-code registry.
- compiler/common/message_codes.py: single source of truth for message codes
(Appendix B SC0001-SC0014 plus documented extensions SC0015 Unexpected Field
Removed, SC0016 Empty Concept Removed, SC0017 Interaction Dropped) with a
message() factory that pins each code's severity and title.
- compiler/validator/scene_validator.py: validate_scene(scene_json, config,
logger=None) -> CompilerResult.
- Strict schema validation classifies failures: missing required -> SC0009,
wrong type/malformed -> SC0002 (Errors that stop the compilation).
- Recoverable normalization: unexpected fields -> SC0015 warning + removed
(suppressed when config.validator.allow_unknown_fields is true); empty/
whitespace concepts -> SC0016 warning + removed after trimming; interactions
whose participants reference a non-existent character -> SC0017 warning +
dropped.
- Never creates concepts, generates tags, or does KB lookup. Output is a
Scene model wrapped in a CompilerResult; order is deterministic.
New warning codes (SC0015-SC0017) are extensions to be folded into Appendix B.
Tests: valid input, hard errors (SC0009/SC0002) that stop, recoverable warnings
(SC0015/SC0016/SC0017), allow_unknown_fields suppression, determinism, and the
code registry.
Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
5.4 KiB
Python
175 lines
5.4 KiB
Python
"""Tests for the Scene Validator (issue #11, MASTER_SPEC §14)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from compiler.common.config import Config
|
|
from compiler.common.result import Severity
|
|
from compiler.validator.scene_validator import validate_scene
|
|
from schemas.models import Scene
|
|
|
|
|
|
def valid_scene() -> dict:
|
|
return {
|
|
"characters": [
|
|
{
|
|
"id": 0,
|
|
"identity": ["female"],
|
|
"appearance": [],
|
|
"clothing": ["dress"],
|
|
"accessories": [],
|
|
"pose": ["standing"],
|
|
"expression": ["smile"],
|
|
"actions": [],
|
|
}
|
|
],
|
|
"interactions": [],
|
|
"objects": [],
|
|
"environment": ["classroom"],
|
|
"camera": [],
|
|
"lighting": ["sunset"],
|
|
"metadata": {"schema_version": "1.0"},
|
|
}
|
|
|
|
|
|
def default_config() -> Config:
|
|
return Config()
|
|
|
|
|
|
def codes(messages) -> list[str]:
|
|
return [m.code for m in messages]
|
|
|
|
|
|
# --- valid input -----------------------------------------------------------
|
|
|
|
|
|
def test_valid_scene_succeeds() -> None:
|
|
result = validate_scene(valid_scene(), default_config())
|
|
assert result.success
|
|
assert isinstance(result.data, Scene)
|
|
assert result.warnings == ()
|
|
assert result.data.characters[0].identity[0].name == "female"
|
|
|
|
|
|
# --- hard errors stop compilation ------------------------------------------
|
|
|
|
|
|
def test_missing_required_field_is_sc0009_and_stops() -> None:
|
|
scene = valid_scene()
|
|
del scene["metadata"]
|
|
result = validate_scene(scene, default_config())
|
|
assert not result.success
|
|
assert result.data is None
|
|
assert "SC0009" in codes(result.errors)
|
|
|
|
|
|
def test_wrong_type_is_sc0002_and_stops() -> None:
|
|
scene = valid_scene()
|
|
scene["characters"] = "not a list"
|
|
result = validate_scene(scene, default_config())
|
|
assert not result.success
|
|
assert result.data is None
|
|
assert "SC0002" in codes(result.errors)
|
|
|
|
|
|
# --- recoverable warnings continue -----------------------------------------
|
|
|
|
|
|
def test_unexpected_top_level_field_warns_and_continues() -> None:
|
|
scene = valid_scene()
|
|
scene["bogus"] = 1
|
|
result = validate_scene(scene, default_config())
|
|
assert result.success
|
|
assert "SC0015" in codes(result.warnings)
|
|
assert isinstance(result.data, Scene) # field removed, scene still built
|
|
|
|
|
|
def test_unexpected_character_field_warns() -> None:
|
|
scene = valid_scene()
|
|
scene["characters"][0]["nickname"] = "Ann"
|
|
result = validate_scene(scene, default_config())
|
|
assert result.success
|
|
assert "SC0015" in codes(result.warnings)
|
|
|
|
|
|
def test_allow_unknown_fields_suppresses_warning() -> None:
|
|
scene = valid_scene()
|
|
scene["bogus"] = 1
|
|
config = Config.from_json({"validator": {"allow_unknown_fields": True}})
|
|
result = validate_scene(scene, config)
|
|
assert result.success
|
|
assert "SC0015" not in codes(result.warnings)
|
|
|
|
|
|
def test_empty_concept_is_removed_with_warning() -> None:
|
|
scene = valid_scene()
|
|
scene["characters"][0]["identity"] = ["", "female"]
|
|
result = validate_scene(scene, default_config())
|
|
assert result.success
|
|
assert "SC0016" in codes(result.warnings)
|
|
assert [c.name for c in result.data.characters[0].identity] == ["female"]
|
|
|
|
|
|
def test_whitespace_concept_is_trimmed_and_empty_dropped() -> None:
|
|
scene = valid_scene()
|
|
scene["characters"][0]["identity"] = [" female ", " "]
|
|
result = validate_scene(scene, default_config())
|
|
assert result.success
|
|
assert [c.name for c in result.data.characters[0].identity] == ["female"]
|
|
assert "SC0016" in codes(result.warnings)
|
|
|
|
|
|
def test_dangling_interaction_is_dropped_with_warning() -> None:
|
|
scene = valid_scene()
|
|
scene["interactions"] = [{"participants": [0, 5], "concept": "hug"}]
|
|
result = validate_scene(scene, default_config())
|
|
assert result.success
|
|
assert "SC0017" in codes(result.warnings)
|
|
assert result.data.interactions == ()
|
|
|
|
|
|
def test_valid_interaction_is_kept() -> None:
|
|
scene = valid_scene()
|
|
scene["characters"].append(
|
|
{
|
|
"id": 1,
|
|
"identity": ["male"],
|
|
"appearance": [],
|
|
"clothing": [],
|
|
"accessories": [],
|
|
"pose": [],
|
|
"expression": [],
|
|
"actions": [],
|
|
}
|
|
)
|
|
scene["interactions"] = [{"participants": [0, 1], "concept": "hug"}]
|
|
result = validate_scene(scene, default_config())
|
|
assert result.success
|
|
assert len(result.data.interactions) == 1
|
|
assert "SC0017" not in codes(result.warnings)
|
|
|
|
|
|
# --- guarantees ------------------------------------------------------------
|
|
|
|
|
|
def test_does_not_generate_tags_or_lookup_kb() -> None:
|
|
# Concept names must be preserved verbatim (no tag substitution / KB lookup).
|
|
scene = valid_scene()
|
|
result = validate_scene(scene, default_config())
|
|
assert result.data.characters[0].clothing[0].name == "dress" # not "dress" -> tag
|
|
|
|
|
|
def test_output_is_deterministic() -> None:
|
|
scene = valid_scene()
|
|
scene["bogus"] = 1
|
|
scene["characters"][0]["identity"] = ["", "female"]
|
|
a = validate_scene(scene, default_config())
|
|
b = validate_scene(scene, default_config())
|
|
assert codes(a.warnings) == codes(b.warnings)
|
|
|
|
|
|
def test_warnings_have_warning_severity() -> None:
|
|
scene = valid_scene()
|
|
scene["bogus"] = 1
|
|
result = validate_scene(scene, default_config())
|
|
assert all(m.severity is Severity.WARNING for m in result.warnings)
|