Files
settled-reach/tooling/economy-db/test_traits.py
T
jpmschweitzerandClaude Fable 5 b8e0b1b660 fix(db): harden architecture_zone_bias axis-value validation (PR #174 review)
H1: guard that each axis value is a {token=weight} table before .items() —
a scalar (wall = 15000) or the array shape (wall = ["steel_frame"], a
plausible copy-paste from the sibling catalog's visual_bundle) now yields a
clean V-TT-06 error instead of a bare AttributeError. Mirrors the isinstance
guards already on zone_map and axes.
H2: exclude bool from the positive-integer weight check (weight = true is an
int subclass, previously slipped through as 1) — matches the guard
populate_color_register_bands already applies to its own values.
Two new ZoneBiasValidationTests cover both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:22:40 +02:00

502 lines
18 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), 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)