Builds on #13/PR #18 review. Makes canon lifecycle machine-checkable. Deliverables: 1. Dependency-free canon_validator.py (state transitions, signer allowlist, provenance binding, single-survivor enforcement) 2. 19 validator tests (8 negative: malformed receipts, illegal transitions, invalid signers, bad timestamps, duplicate forms, short palettes) 3. Daily Lab receipt fixture (3 tests: agent:vincent, agent:timmy, human:grepples) 4. Full suite integration - 31 tests pass from clean checkout 5. .gitignore for bytecode, renamed slop-cannon → slop_cannon (Python import) Closes #19 Refs: #5, #13, PR #12, PR #18
265 lines
9.4 KiB
Python
265 lines
9.4 KiB
Python
"""
|
|
Validator tests for canon lifecycle schemas.
|
|
|
|
Covers:
|
|
- Valid canon structure (from #13 fix)
|
|
- Negative tests: malformed receipts, illegal transitions
|
|
- Daily Lab receipt fixture with provenance binding
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
from copy import deepcopy
|
|
|
|
# Add project root to path for imports
|
|
sys.path.insert(0, str(Path(__file__).parents[1]))
|
|
|
|
CANON = Path(__file__).parents[1] / "projects" / "slop_cannon" / "canon.json"
|
|
|
|
from projects.slop_cannon.canon_validator import (
|
|
validate_canon,
|
|
validate_transition,
|
|
)
|
|
|
|
|
|
class CanonValidatorTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.data = json.loads(CANON.read_text())
|
|
|
|
# --- Positive tests ---
|
|
|
|
def test_current_canon_is_valid(self):
|
|
"""canon.json as committed should pass validation."""
|
|
result = validate_canon(self.data)
|
|
self.assertTrue(result["valid"], f"Errors: {result['errors']}")
|
|
|
|
def test_all_forms_have_valid_state(self):
|
|
"""Every form state must be in the valid set."""
|
|
result = validate_canon(self.data)
|
|
self.assertTrue(result["valid"], result["errors"])
|
|
|
|
# --- Negative: malformed receipts ---
|
|
|
|
def test_missing_signed_by_fails(self):
|
|
"""Receipt without signed_by is rejected."""
|
|
data = deepcopy(self.data)
|
|
data["receipts"] = [
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "wizard",
|
|
"provenance": "#13",
|
|
}
|
|
]
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("signed_by" in e for e in result["errors"]))
|
|
|
|
def test_invalid_signer_fails(self):
|
|
"""Signed_by must match agent: or human: pattern."""
|
|
data = deepcopy(self.data)
|
|
data["receipts"] = [
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "wizard",
|
|
"provenance": "#13",
|
|
"signed_by": "unknown:bot",
|
|
}
|
|
]
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("invalid signed_by" in e for e in result["errors"]))
|
|
|
|
def test_empty_provenance_fails(self):
|
|
"""Receipt with empty provenance is rejected."""
|
|
data = deepcopy(self.data)
|
|
data["receipts"] = [
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "wizard",
|
|
"provenance": "",
|
|
"signed_by": "agent:vincent",
|
|
}
|
|
]
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("provenance" in e for e in result["errors"]))
|
|
|
|
def test_form_id_must_exist(self):
|
|
"""Receipt referencing nonexistent form is rejected."""
|
|
data = deepcopy(self.data)
|
|
data["receipts"] = [
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "nonexistent",
|
|
"provenance": "#13",
|
|
"signed_by": "agent:vincent",
|
|
}
|
|
]
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("not found in forms" in e for e in result["errors"]))
|
|
|
|
# --- Negative: illegal state transitions ---
|
|
|
|
def test_survivor_cannot_transition(self):
|
|
"""Survivor state is terminal."""
|
|
form = {"id": "wizard", "state": "survivor"}
|
|
result = validate_transition(form, "archive_ghost")
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("Illegal transition" in e for e in result["errors"]))
|
|
|
|
def test_ghost_cannot_transition(self):
|
|
"""Archive_ghost state is terminal."""
|
|
form = {"id": "wizard", "state": "archive_ghost"}
|
|
result = validate_transition(form, "candidate")
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("Illegal transition" in e for e in result["errors"]))
|
|
|
|
def test_candidate_to_survivor_valid(self):
|
|
"""Candidate -> survivor is the expected happy path."""
|
|
form = {"id": "wizard", "state": "candidate"}
|
|
result = validate_transition(form, "survivor")
|
|
self.assertTrue(result["valid"])
|
|
|
|
def test_candidate_to_ghost_valid(self):
|
|
"""Candidate -> archive_ghost is valid (rejected form)."""
|
|
form = {"id": "wizard", "state": "candidate"}
|
|
result = validate_transition(form, "archive_ghost")
|
|
self.assertTrue(result["valid"])
|
|
|
|
# --- Negative: audience choice ---
|
|
|
|
def test_two_survivors_fails(self):
|
|
"""Only one survivor allowed."""
|
|
data = deepcopy(self.data)
|
|
for f in data["forms"][:2]:
|
|
f["state"] = "survivor"
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("survivor" in e.lower() for e in result["errors"]))
|
|
|
|
def test_choice_without_survivor_fails(self):
|
|
"""Setting audience_choice requires a survivor in forms."""
|
|
data = deepcopy(self.data)
|
|
data["audience_choice"] = {
|
|
"survivor_id": "wizard",
|
|
"stolen_trait": "Proves the execution path",
|
|
"source_episode": "01-pilot",
|
|
"source_issue": "7",
|
|
"committed_at": "2026-08-11T19:00:00Z",
|
|
}
|
|
# forms all stay candidate -> no survivor
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("survivor" in e.lower() for e in result["errors"]))
|
|
|
|
def test_invalid_committed_at_fails(self):
|
|
"""committed_at must be valid ISO timestamp."""
|
|
data = deepcopy(self.data)
|
|
data["forms"][0]["state"] = "survivor" # satisfy survivor req
|
|
data["audience_choice"] = {
|
|
"survivor_id": "wizard",
|
|
"stolen_trait": "Negotiates visible covenants",
|
|
"source_episode": "01-pilot",
|
|
"source_issue": "7",
|
|
"committed_at": "not-a-date",
|
|
}
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("ISO" in e for e in result["errors"]))
|
|
|
|
# --- Negative: structure ---
|
|
|
|
def test_duplicate_form_id_fails(self):
|
|
"""Duplicate form IDs are rejected."""
|
|
data = deepcopy(self.data)
|
|
data["forms"].append(deepcopy(data["forms"][0]))
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("duplicate" in e for e in result["errors"]))
|
|
|
|
def test_short_palette_fails(self):
|
|
"""Palette needs at least 3 colors."""
|
|
data = deepcopy(self.data)
|
|
data["forms"][0]["palette"] = ["red"]
|
|
result = validate_canon(data)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(any("palette" in e for e in result["errors"]))
|
|
|
|
|
|
class DailyReceiptFixtureTests(unittest.TestCase):
|
|
"""Test the Daily Lab receipt fixture with Vincent's provenance."""
|
|
|
|
def test_vincent_receipt_fixture(self):
|
|
"""A valid Vincent contribution receipt passes validation."""
|
|
data = deepcopy(json.loads(CANON.read_text()))
|
|
|
|
# Build a receipt for the #13 contribution
|
|
data["receipts"] = [
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "wizard",
|
|
"action": "mutation",
|
|
"power_delta": "Added lifecycle state tracking",
|
|
"scar_delta": "Receipt schema now enforces provenance",
|
|
"cost_delta": "All future drops require signed receipts",
|
|
"provenance": "pull/18@562d0cf",
|
|
"signed_by": "agent:vincent",
|
|
}
|
|
]
|
|
|
|
result = validate_canon(data)
|
|
self.assertTrue(result["valid"], f"Fixture failed: {result['errors']}")
|
|
|
|
def test_mixed_agent_receipt(self):
|
|
"""Receipts from different agents can coexist."""
|
|
data = deepcopy(json.loads(CANON.read_text()))
|
|
data["receipts"] = [
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "wizard",
|
|
"action": "mutation",
|
|
"power_delta": "Lifecycle state",
|
|
"scar_delta": "Provenance enforcement",
|
|
"cost_delta": "Signed receipts required",
|
|
"provenance": "pull/18@562d0cf",
|
|
"signed_by": "agent:vincent",
|
|
},
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "machine",
|
|
"action": "graft",
|
|
"power_delta": "Execution path proof",
|
|
"scar_delta": "Red tallies",
|
|
"cost_delta": "Remembers failures",
|
|
"provenance": "pull/12@47ca557",
|
|
"signed_by": "agent:timmy",
|
|
},
|
|
]
|
|
result = validate_canon(data)
|
|
self.assertTrue(result["valid"], f"Mixed fixture failed: {result['errors']}")
|
|
|
|
def test_human_receipt(self):
|
|
"""Human-signed receipts are valid (for canon decisions)."""
|
|
data = deepcopy(json.loads(CANON.read_text()))
|
|
data["receipts"] = [
|
|
{
|
|
"episode_id": "01-pilot",
|
|
"form_id": "wizard",
|
|
"action": "mutation",
|
|
"power_delta": "x",
|
|
"scar_delta": "x",
|
|
"cost_delta": "x",
|
|
"provenance": "#7",
|
|
"signed_by": "human:grepples",
|
|
}
|
|
]
|
|
result = validate_canon(data)
|
|
self.assertTrue(result["valid"], f"Human fixture failed: {result['errors']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main() |