trikona / tests /test_assembler.py
disssid's picture
fix: generate non-regular shapes (pyramids, dipyramids, cones, etc.)
b0364db
Raw
History Blame Contribute Delete
5.69 kB
"""Phase A3 — assembler bridge proof (SPEC_OPTION_A.md §3.3, §10).
The crucial offline gate: each build-spec fixture, run through the real
generator + kernel + repair + assembler, produces output that passes BOTH the
existing frontend contract (`validate_shape`) AND the geometric metric gate.
Also proves model-free regeneration (new vane count -> valid contract).
"""
import importlib.util
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from geometry import assembler, metrics # noqa: E402
# Reuse the contract validator from the Phase 1 schema tests.
_spec = importlib.util.spec_from_file_location("schema_mod", ROOT / "tests" / "test_schema.py")
_schema = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_schema)
validate_shape = _schema.validate_shape
FIX = ROOT / "tests" / "fixtures" / "codegen"
SPECS = {"octahedron": "spec_octahedron.json",
"cube": "spec_cube.json",
"cylindrical_vent": "spec_cylindrical_vent.json"}
def load_spec(name):
return json.loads((FIX / SPECS[name]).read_text())
def _geom(contract):
verts = contract["geometry"]["vertices"]
faces = [f["indices"] for f in contract["geometry"]["faces"]]
return verts, faces
@pytest.mark.parametrize("name", list(SPECS))
def test_assembled_passes_frontend_contract(name):
spec = load_spec(name)
contract, warnings = assembler.build(spec)
errors = validate_shape(contract)
assert errors == [], errors
@pytest.mark.parametrize("name", list(SPECS))
def test_assembled_passes_metric_gate(name):
spec = load_spec(name)
contract, _ = assembler.build(spec)
verts, faces = _geom(contract)
ok, fails = metrics.passes(verts, faces, spec["regularity"])
assert ok, fails
def test_octahedron_decomposed_into_8_components():
contract, _ = assembler.build(load_spec("octahedron"))
assert contract["complexity"]["component_count"] == 8
assert contract["complexity"]["face_count"] == 8
def test_cube_six_components_twelve_triangles():
contract, _ = assembler.build(load_spec("cube"))
assert contract["complexity"]["component_count"] == 6
assert contract["complexity"]["face_count"] == 12
def test_complexity_counts_match_arrays():
for name in SPECS:
contract, _ = assembler.build(load_spec(name))
assert contract["complexity"]["vertex_count"] == len(contract["geometry"]["vertices"])
assert contract["complexity"]["face_count"] == len(contract["geometry"]["faces"])
assert contract["complexity"]["component_count"] == len(contract["components"])
def test_regeneration_more_vanes_is_model_free_and_valid():
# Re-running the cached generator with vanes=12 must yield a valid contract
# with auto-reconciled vane components — no model call involved.
spec = load_spec("cylindrical_vent")
contract, warnings = assembler.build(spec, params={"vanes": 12, "height": 2.0})
assert validate_shape(contract) == []
vane_comps = [c for c in contract["components"] if c["id"].startswith("vane_")]
assert len(vane_comps) == 12
assert any("auto-reconciled" in w for w in warnings) # vane_8..11 were undeclared
def test_regeneration_fewer_vanes_valid():
spec = load_spec("cylindrical_vent")
contract, _ = assembler.build(spec, params={"vanes": 5, "height": 2.0})
assert validate_shape(contract) == []
vane_comps = [c for c in contract["components"] if c["id"].startswith("vane_")]
assert len(vane_comps) == 5
def test_sanitizes_invalid_transform_axis():
# Model emits axis "xy" (only x|y|z is valid) — assembler must coerce so the
# contract validates instead of the frontend rejecting it.
spec = load_spec("octahedron")
spec["params"][0]["transform"] = {"op": "scale_axis", "axis": "xy", "components": "all"}
contract, _ = assembler.build(spec)
assert validate_shape(contract) == []
p = next(p for p in contract["params"] if p["id"] == "scale")
assert p["transform"]["axis"] == "x" # coerced to first valid axis char
def test_sanitizes_unfixable_axis_drops_transform():
spec = load_spec("octahedron")
spec["params"][0]["transform"] = {"op": "rotate", "axis": "diagonal"}
contract, _ = assembler.build(spec)
assert validate_shape(contract) == []
p = next(p for p in contract["params"] if p["id"] == "scale")
assert "transform" not in p # no valid axis -> transform dropped
def test_sanitizes_out_of_range_valid_band_and_dropdown():
spec = load_spec("octahedron")
spec["params"][0]["valid_min"] = spec["params"][0]["min"] - 5 # below min
spec["params"][0]["valid_max"] = spec["params"][0]["max"] + 5 # above max
spec["params"].append({"id": "style", "label": "Style", "type": "dropdown",
"options": ["a", "b"], "default": "zzz"}) # bad default
contract, _ = assembler.build(spec)
assert validate_shape(contract) == []
style = next(p for p in contract["params"] if p["id"] == "style")
assert style["default"] == "a"
def test_constraint_referencing_dropped_param_removed():
spec = load_spec("octahedron")
spec["constraints"] = [{"id": "c1", "params": ["ghost_param"], "rule": "x"}]
contract, _ = assembler.build(spec)
assert validate_shape(contract) == []
assert contract["constraints"] == []
def test_triangle_counts_sum_to_face_count():
for name in SPECS:
contract, _ = assembler.build(load_spec(name))
total = sum(c["triangle_count"] for c in contract["components"])
assert total == contract["complexity"]["face_count"]