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