#!/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), the V-TT-05 pin bound, the V-TT-06 zone-bias token-existence check, and the V-TT-07 color-register band shape/coverage checks (T-988) — the `make test-tooling` dry-run only exercises the happy path against the committed, already-valid content. Stdlib only (unittest) — run directly or via `make test-tooling`: python3 tooling/economy-db/test_traits.py """ import json 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)) class ZoneBiasValidationTests(unittest.TestCase): """populate_architecture_zone_bias: V-TT-06 token-existence check (T-988).""" def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.tmp = Path(self._tmp.name) self._orig_zone_bias = traits.ARCHITECTURE_ZONE_BIAS_TOML self.conn = sqlite3.connect(":memory:") self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)") self.conn.execute( """CREATE TABLE architecture_zone_bias ( template_tag TEXT NOT NULL, zone_type_id TEXT NOT NULL, bias TEXT NOT NULL, PRIMARY KEY (template_tag, zone_type_id))""" ) self.conn.execute( "INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)", ( "test_template", json.dumps({ "wall": ["brick_wall", "rendered_wall"], "roof": ["flat_roof"], "facade": ["regular_facade"], "street": ["paved"], "color_register": "neutral_grey", }), ), ) def tearDown(self): traits.ARCHITECTURE_ZONE_BIAS_TOML = self._orig_zone_bias self.conn.close() self._tmp.cleanup() def populate(self, body: str): path = self.tmp / "architecture_zone_bias.toml" path.write_text(body, encoding="utf-8") traits.ARCHITECTURE_ZONE_BIAS_TOML = path return traits.populate_architecture_zone_bias(self.conn, dry_run=True) def test_valid_bias_within_visual_bundle_passes(self): n = self.populate( '[bias.test_template.commercial_market]\n' 'wall = { brick_wall = 15000 }\n' 'street = { paved = 13000 }\n' ) self.assertEqual(n, 1) def test_token_outside_visual_bundle_is_rejected(self): # steel_frame is a real ObjectTag, but never appears in test_template's # own visual_bundle.wall above — V-TT-06 must reject it. import contextlib import io out = io.StringIO() with contextlib.redirect_stdout(out): with self.assertRaises(ImportAborted): self.populate( '[bias.test_template.industrial_freight]\n' 'wall = { steel_frame = 16000 }\n' ) self.assertIn("V-TT-06", out.getvalue()) self.assertIn("steel_frame", out.getvalue()) def test_unknown_template_tag_is_rejected(self): with self.assertRaises(ImportAborted): self.populate( '[bias.no_such_template.commercial_market]\n' 'wall = { brick_wall = 15000 }\n' ) def test_non_positive_weight_is_rejected(self): with self.assertRaises(ImportAborted): self.populate( '[bias.test_template.commercial_market]\n' 'wall = { brick_wall = 0 }\n' ) def test_non_dict_axis_value_reports_vtt06_not_a_traceback(self): # A bare scalar or the array shape (a plausible copy-paste from the # sibling catalog's `visual_bundle.wall = [...]`) must produce a clean # V-TT-06 error, not an AttributeError crash on `.items()`. import contextlib import io for bad in ("wall = 15000\n", 'wall = ["brick_wall"]\n'): out = io.StringIO() with contextlib.redirect_stdout(out): with self.assertRaises(ImportAborted): self.populate(f"[bias.test_template.commercial_market]\n{bad}") self.assertIn("V-TT-06", out.getvalue()) self.assertIn("must be a", out.getvalue()) def test_bool_weight_is_rejected(self): # bool is an int subclass; `true` must not slip through as weight 1. with self.assertRaises(ImportAborted): self.populate( '[bias.test_template.commercial_market]\n' 'wall = { brick_wall = true }\n' ) def test_missing_source_yields_zero_rows(self): traits.ARCHITECTURE_ZONE_BIAS_TOML = self.tmp / "missing.toml" self.assertEqual(traits.populate_architecture_zone_bias(self.conn, dry_run=True), 0) class ColorRegisterBandTests(unittest.TestCase): """populate_color_register_bands: V-TT-07 shape + coverage checks (T-988).""" def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.tmp = Path(self._tmp.name) self._orig_bands = traits.COLOR_REGISTER_BANDS_TOML self.conn = sqlite3.connect(":memory:") self.conn.execute("CREATE TABLE trait_templates (tag TEXT PRIMARY KEY, visual_bundle TEXT)") self.conn.execute( """CREATE TABLE color_register_bands ( color_register TEXT PRIMARY KEY, hue_min INTEGER NOT NULL, hue_max INTEGER NOT NULL, sat_min INTEGER NOT NULL, sat_max INTEGER NOT NULL, val_min INTEGER NOT NULL, val_max INTEGER NOT NULL)""" ) self.conn.execute( "INSERT INTO trait_templates (tag, visual_bundle) VALUES (?, ?)", ("test_template", json.dumps({"color_register": "neutral_grey"})), ) def tearDown(self): traits.COLOR_REGISTER_BANDS_TOML = self._orig_bands self.conn.close() self._tmp.cleanup() def populate(self, body: str): path = self.tmp / "color_register_bands.toml" path.write_text(body, encoding="utf-8") traits.COLOR_REGISTER_BANDS_TOML = path return traits.populate_color_register_bands(self.conn, dry_run=True) def test_valid_band_covering_the_referenced_register_passes(self): n = self.populate( '[register.neutral_grey]\n' 'hue = [20000, 21200]\n' 'sat = [400, 1200]\n' 'val = [4800, 6400]\n' ) self.assertEqual(n, 1) def test_missing_coverage_for_referenced_register_is_rejected(self): import contextlib import io out = io.StringIO() with contextlib.redirect_stdout(out): with self.assertRaises(ImportAborted): # A band for a DIFFERENT register — neutral_grey (referenced by # test_template above) has no band at all. self.populate( '[register.some_other_register]\n' 'hue = [0, 100]\n' 'sat = [0, 100]\n' 'val = [0, 100]\n' ) self.assertIn("V-TT-07", out.getvalue()) self.assertIn("neutral_grey", out.getvalue()) def test_hue_out_of_bounds_is_rejected(self): with self.assertRaises(ImportAborted): self.populate( '[register.neutral_grey]\n' 'hue = [30000, 40000]\n' # 40000 > 36000 bound 'sat = [400, 1200]\n' 'val = [4800, 6400]\n' ) def test_min_not_less_than_max_is_rejected(self): with self.assertRaises(ImportAborted): self.populate( '[register.neutral_grey]\n' 'hue = [20000, 20000]\n' # min == max, not min < max 'sat = [400, 1200]\n' 'val = [4800, 6400]\n' ) def test_missing_source_yields_zero_rows(self): traits.COLOR_REGISTER_BANDS_TOML = self.tmp / "missing.toml" self.assertEqual(traits.populate_color_register_bands(self.conn, dry_run=True), 0) if __name__ == "__main__": unittest.main(verbosity=2)