T1: heritage corridor_pool excluded from the ordinary phase-1 lottery and coverage repair (D-232 reserves the heritage sub-pool for the remoteness dial); reachable via hero pin, necessity swerve, and the T-1003 heritage pool only. 3 new tests. T2+H4: coverage repair no longer grows trait_selection past K when all slots are pinned (phase-2 necessity swerve serves the type instead); runtime warn when authored pins exceed K; V-TT-05 importer guardrail bounds pins per body at 5 (max ComplexityTier K). 2 new tests + 2 python tests. H1: body-level dispatch aggregation extracted to pure aggregate_body_dispatch_inputs + tested directly (union mix, MAX prosperity/K); threading test asserts identical vocabulary/pools across co-body settlements with per-settlement swerve rates. 2 new tests. H2: tooling/economy-db/test_traits.py — 14 stdlib unittest cases over V-TT-03/04/05 failure branches, wired into make test-tooling. H3: hard-gate JSON parsers now tracing::warn on malformed blobs (silent gate-widening) matching the sibling map parsers. H5: catalog read memoized (OnceLock) — SQL+parse once per server run, bias stays per-body. 1 new test. T3: TraitDistrict seed-domain doc aligned with the two-level derive chain. T4: D-225 misattribution dropped from the reader module doc. systems.db regenerated + stamped (traits.py is a stamped source). Gates: full cargo test 1638 green (goldens intact), clippy -D warnings, ruff, make test-tooling (now incl. the traits units). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
314 lines
10 KiB
Python
314 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Unit tests for economy_import.traits validation (T-995, PR #173 review H2).
|
|
|
|
Covers the failure branches of the ObjectTag registry loader/validator
|
|
(V-TT-03 existence/axis, V-TT-04 fallback-graph) and the V-TT-05 pin bound —
|
|
the `make test-tooling` dry-run only exercises the happy path against the
|
|
committed, already-valid registry.
|
|
|
|
Stdlib only (unittest) — run directly or via `make test-tooling`:
|
|
python3 tooling/economy-db/test_traits.py
|
|
"""
|
|
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from economy_import import traits # noqa: E402
|
|
from economy_import.errors import ImportAborted # noqa: E402
|
|
|
|
|
|
def write_registry(tmp: Path, body: str) -> Path:
|
|
path = tmp / "object_tag_vocabulary.toml"
|
|
path.write_text(body, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
VALID_REGISTRY = """
|
|
[tags.wall.generic_wall]
|
|
description = "generic wall placeholder"
|
|
generic = true
|
|
|
|
[tags.wall.brick_wall]
|
|
description = "fired brick"
|
|
fallback = "generic_wall"
|
|
|
|
[tags.roof.generic_roof]
|
|
description = "generic roof placeholder"
|
|
generic = true
|
|
|
|
[tags.roof.flat_roof]
|
|
description = "flat roof"
|
|
fallback = "generic_roof"
|
|
|
|
[tags.facade.generic_facade]
|
|
description = "generic facade placeholder"
|
|
generic = true
|
|
|
|
[tags.street.generic_street]
|
|
description = "generic street placeholder"
|
|
generic = true
|
|
"""
|
|
|
|
|
|
class RegistryValidationTests(unittest.TestCase):
|
|
"""_load_object_tag_vocabulary: V-TT-03 shape + V-TT-04 fallback graph."""
|
|
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.tmp = Path(self._tmp.name)
|
|
self._orig = traits.OBJECT_TAG_VOCABULARY_TOML
|
|
|
|
def tearDown(self):
|
|
traits.OBJECT_TAG_VOCABULARY_TOML = self._orig
|
|
self._tmp.cleanup()
|
|
|
|
def load(self, registry_toml: str | None):
|
|
if registry_toml is None:
|
|
traits.OBJECT_TAG_VOCABULARY_TOML = self.tmp / "missing.toml"
|
|
else:
|
|
traits.OBJECT_TAG_VOCABULARY_TOML = write_registry(self.tmp, registry_toml)
|
|
errors: list[str] = []
|
|
registry = traits._load_object_tag_vocabulary(errors)
|
|
return registry, errors
|
|
|
|
def test_valid_registry_loads_without_errors(self):
|
|
registry, errors = self.load(VALID_REGISTRY)
|
|
self.assertEqual(errors, [])
|
|
self.assertEqual(registry["brick_wall"]["axis"], "wall")
|
|
self.assertEqual(registry["brick_wall"]["fallback"], "generic_wall")
|
|
self.assertTrue(registry["generic_wall"]["generic"])
|
|
|
|
def test_missing_registry_is_a_vtt03_error(self):
|
|
registry, errors = self.load(None)
|
|
self.assertEqual(registry, {})
|
|
self.assertTrue(any("V-TT-03" in e and "not found" in e for e in errors))
|
|
|
|
def test_unknown_axis_is_flagged(self):
|
|
_, errors = self.load(
|
|
VALID_REGISTRY
|
|
+ """
|
|
[tags.chimney.smoke_stack]
|
|
description = "not a real axis"
|
|
fallback = "generic_wall"
|
|
"""
|
|
)
|
|
self.assertTrue(any("V-TT-03" in e and "axis 'chimney'" in e for e in errors))
|
|
|
|
def test_duplicate_tag_across_axes_is_flagged(self):
|
|
_, errors = self.load(
|
|
VALID_REGISTRY
|
|
+ """
|
|
[tags.roof.brick_wall]
|
|
description = "duplicate of a wall tag"
|
|
fallback = "generic_roof"
|
|
"""
|
|
)
|
|
self.assertTrue(any("V-TT-03" in e and "declared in both" in e for e in errors))
|
|
|
|
def test_missing_description_is_flagged(self):
|
|
_, errors = self.load(
|
|
VALID_REGISTRY
|
|
+ """
|
|
[tags.wall.bare_wall]
|
|
fallback = "generic_wall"
|
|
"""
|
|
)
|
|
self.assertTrue(any("V-TT-03" in e and "missing 'description'" in e for e in errors))
|
|
|
|
def test_generic_with_fallback_is_flagged(self):
|
|
_, errors = self.load(
|
|
"""
|
|
[tags.wall.generic_wall]
|
|
description = "generic wall"
|
|
generic = true
|
|
fallback = "generic_wall"
|
|
"""
|
|
)
|
|
self.assertTrue(any("V-TT-04" in e and "fallback-terminal" in e for e in errors))
|
|
|
|
def test_non_generic_without_fallback_is_flagged(self):
|
|
_, errors = self.load(
|
|
VALID_REGISTRY
|
|
+ """
|
|
[tags.wall.orphan_wall]
|
|
description = "no parent"
|
|
"""
|
|
)
|
|
self.assertTrue(any("V-TT-04" in e and "must declare" in e for e in errors))
|
|
|
|
def test_fallback_to_unknown_tag_is_flagged(self):
|
|
_, errors = self.load(
|
|
VALID_REGISTRY
|
|
+ """
|
|
[tags.wall.floating_wall]
|
|
description = "points nowhere"
|
|
fallback = "no_such_tag"
|
|
"""
|
|
)
|
|
self.assertTrue(any("V-TT-04" in e and "unknown tag 'no_such_tag'" in e for e in errors))
|
|
|
|
def test_fallback_cycle_is_flagged(self):
|
|
_, errors = self.load(
|
|
VALID_REGISTRY
|
|
+ """
|
|
[tags.wall.wall_a]
|
|
description = "cycles to b"
|
|
fallback = "wall_b"
|
|
|
|
[tags.wall.wall_b]
|
|
description = "cycles to a"
|
|
fallback = "wall_a"
|
|
"""
|
|
)
|
|
self.assertTrue(any("V-TT-04" in e and "cycles" in e for e in errors))
|
|
|
|
def test_wrong_generic_set_is_flagged(self):
|
|
# Drop generic_street entirely — the 4-placeholder contract breaks.
|
|
registry_toml = VALID_REGISTRY.replace(
|
|
"""
|
|
[tags.street.generic_street]
|
|
description = "generic street placeholder"
|
|
generic = true
|
|
""",
|
|
"",
|
|
)
|
|
_, errors = self.load(registry_toml)
|
|
self.assertTrue(any("V-TT-04" in e and "generic placeholders" in e for e in errors))
|
|
|
|
|
|
CATALOG_WITH_BAD_TAG = """
|
|
[templates.test_template]
|
|
label = "Test Template"
|
|
corridor_pool = "baseline"
|
|
base_weight = 10000
|
|
allow_tags = ["no_such_tag"]
|
|
zone_affinity = { MixedUse = 10000 }
|
|
"""
|
|
|
|
CATALOG_WITH_AXIS_MISMATCH = """
|
|
[templates.test_template]
|
|
label = "Test Template"
|
|
corridor_pool = "baseline"
|
|
base_weight = 10000
|
|
zone_affinity = { MixedUse = 10000 }
|
|
|
|
[templates.test_template.visual_bundle]
|
|
wall = ["flat_roof"]
|
|
"""
|
|
|
|
|
|
class CatalogCrossCheckTests(unittest.TestCase):
|
|
"""populate_trait_templates: V-TT-03 catalog→registry cross-checks."""
|
|
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.tmp = Path(self._tmp.name)
|
|
self._orig_registry = traits.OBJECT_TAG_VOCABULARY_TOML
|
|
self._orig_catalog = traits.ARCHITECTURE_TRAIT_CATALOG_TOML
|
|
traits.OBJECT_TAG_VOCABULARY_TOML = write_registry(self.tmp, VALID_REGISTRY)
|
|
self.conn = sqlite3.connect(":memory:")
|
|
self.conn.execute(
|
|
"""CREATE TABLE trait_templates (
|
|
tag TEXT PRIMARY KEY, label TEXT NOT NULL, cultural_description TEXT,
|
|
corridor_pool TEXT NOT NULL DEFAULT 'baseline', geographic_sector TEXT,
|
|
bulk_class_gate TEXT, production_ubiquity_gate TEXT,
|
|
min_prosperity_bps INTEGER NOT NULL DEFAULT 0,
|
|
base_weight INTEGER NOT NULL DEFAULT 10000,
|
|
weight_mods TEXT, zone_affinity TEXT,
|
|
allow_tags TEXT, block_tags TEXT, era_scope TEXT, visual_bundle TEXT)"""
|
|
)
|
|
|
|
def tearDown(self):
|
|
traits.OBJECT_TAG_VOCABULARY_TOML = self._orig_registry
|
|
traits.ARCHITECTURE_TRAIT_CATALOG_TOML = self._orig_catalog
|
|
self.conn.close()
|
|
self._tmp.cleanup()
|
|
|
|
def populate_errors(self, catalog_toml: str) -> str:
|
|
"""Run populate_trait_templates; return its printed error report.
|
|
|
|
A tiny test catalog also trips the V-TT-01 pool-size guardrail, so the
|
|
abort alone proves nothing — assertions must target the specific
|
|
V-TT-03 line in the captured output.
|
|
"""
|
|
import contextlib
|
|
import io
|
|
|
|
catalog = self.tmp / "architecture_trait_catalog.toml"
|
|
catalog.write_text(catalog_toml, encoding="utf-8")
|
|
traits.ARCHITECTURE_TRAIT_CATALOG_TOML = catalog
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
with self.assertRaises(ImportAborted):
|
|
traits.populate_trait_templates(self.conn, dry_run=True)
|
|
return out.getvalue()
|
|
|
|
def test_unknown_allow_tag_reports_vtt03(self):
|
|
out = self.populate_errors(CATALOG_WITH_BAD_TAG)
|
|
self.assertIn("V-TT-03", out)
|
|
self.assertIn("no_such_tag", out)
|
|
|
|
def test_axis_mismatch_in_visual_bundle_reports_vtt03(self):
|
|
# flat_roof is a valid tag, but registered under 'roof', used as 'wall'.
|
|
out = self.populate_errors(CATALOG_WITH_AXIS_MISMATCH)
|
|
self.assertIn("V-TT-03", out)
|
|
self.assertIn("axis 'roof'", out)
|
|
|
|
|
|
class PinBoundTests(unittest.TestCase):
|
|
"""populate_atlas_body_trait_bias: V-TT-05 pins-per-body bound (H4)."""
|
|
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.tmp = Path(self._tmp.name)
|
|
self._orig_bias = traits.ARCHITECTURE_TRAIT_BIAS_TOML
|
|
self.conn = sqlite3.connect(":memory:")
|
|
self.conn.execute("CREATE TABLE bodies (body_id TEXT PRIMARY KEY)")
|
|
self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY)")
|
|
self.conn.execute(
|
|
"""CREATE TABLE atlas_body_trait_bias (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, body_id TEXT NOT NULL,
|
|
template_tag TEXT NOT NULL, bias_kind TEXT NOT NULL,
|
|
weight_multiplier_bps INTEGER, note TEXT)"""
|
|
)
|
|
self.conn.execute("INSERT INTO bodies VALUES ('HeroBody')")
|
|
for i in range(6):
|
|
self.conn.execute("INSERT INTO trait_templates VALUES (?)", (f"tmpl_{i}",))
|
|
|
|
def tearDown(self):
|
|
traits.ARCHITECTURE_TRAIT_BIAS_TOML = self._orig_bias
|
|
self.conn.close()
|
|
self._tmp.cleanup()
|
|
|
|
def bias_toml(self, pin_count: int) -> str:
|
|
blocks = []
|
|
for i in range(pin_count):
|
|
blocks.append(
|
|
f'[[bias]]\nbody_id = "HeroBody"\ntemplate_tag = "tmpl_{i}"\nbias_kind = "pin"\n'
|
|
)
|
|
return "\n".join(blocks)
|
|
|
|
def populate(self, body: str):
|
|
path = self.tmp / "architecture_trait_bias.toml"
|
|
path.write_text(body, encoding="utf-8")
|
|
traits.ARCHITECTURE_TRAIT_BIAS_TOML = path
|
|
return traits.populate_atlas_body_trait_bias(self.conn, dry_run=True)
|
|
|
|
def test_five_pins_on_one_body_pass(self):
|
|
self.assertEqual(self.populate(self.bias_toml(5)), 5)
|
|
|
|
def test_six_pins_on_one_body_abort_with_vtt05(self):
|
|
with self.assertRaises(ImportAborted):
|
|
self.populate(self.bias_toml(6))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|