Result gains a record field with a default. Nothing existing moves and the report dict is untouched, so all 123 oracle cases are unmoved at 506 passed. A rejected profile still raises before this point and gets no record, which is correct because there is no model to preserve. The record learns the stock and fit from the Geo the build actually used, so it describes what was built rather than what was asked for. Those agree today and the record will keep saying so if they ever stop. code_revision resolves once per process from MECHCOMP_REVISION, then git rev-parse, then unknown. Never a guess: unknown tells you the part may not be reproducible, while a plausible wrong sha would send someone to the wrong commit. A dirty tree is reported as such, since a model built from uncommitted edits cannot be regenerated from a revision alone. Caching matters because assemble runs 123 times in the suite and would otherwise shell out to git twice per build. The end to end test builds a part, renders its record, parses it back from nothing but the text, rebuilds from the parsed parameters, and asserts the report is identical key for key across five profiles. If that fails the record is a description rather than a recipe. Method correction. The mutation harness had been letting pytest write bytecode, and a write landing inside one mtime tick could be masked by a stale pyc. That made one real defect appear to survive and, more importantly, means every mutation result reported earlier in this work was optimistic by an unknown amount. Rerun with bytecode disabled, all nine mutations caught. The earlier sets are worth rerunning under the corrected method.
426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""
|
|
The design record has to survive being the only thing left.
|
|
|
|
A part on the bench, a text file beside it, and five years. If the record cannot
|
|
regenerate the design from that, it is decoration. These tests are mostly about
|
|
exactness and about what must NOT change an identity.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from mechcomp.design_record import (
|
|
FORMAT_VERSION,
|
|
VERIFY_KEYS,
|
|
DesignRecord,
|
|
canonical_params,
|
|
canonical_value,
|
|
parse_value,
|
|
record_for,
|
|
resolve,
|
|
toolchain_versions,
|
|
)
|
|
from mechcomp.profiles.three_x import FAMILY as THREE_X
|
|
|
|
|
|
def _record(**over):
|
|
base = dict(
|
|
family="3x", profile="Y",
|
|
defaults=THREE_X.defaults,
|
|
params={"fit_clearance_mm": 0.25},
|
|
code_revision="abc1234",
|
|
generator_revision="8.0.0",
|
|
)
|
|
base.update(over)
|
|
return record_for(**base)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exactness -- the record is a recipe, not a description
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_floats_round_trip_exactly():
|
|
"""
|
|
Every float must survive render -> parse unchanged, bit for bit.
|
|
|
|
This is the property the whole record rests on. ``%g`` at six significant
|
|
figures -- what the report emits -- would lose bits here and turn the record
|
|
into a description of the part rather than a means of rebuilding it.
|
|
"""
|
|
hostile = [0.1, 0.2, 0.30000000000000004, 1 / 3, 15.875, 0.508, 13.4, 0.79,
|
|
1e-9, 1.7976931348623157e308, 5e-324, 2.2250738585072014e-308,
|
|
123456.789012345, 0.1 + 0.2]
|
|
for value in hostile:
|
|
assert float(canonical_value(value)) == value, value
|
|
assert parse_value(canonical_value(value)) == value, value
|
|
|
|
rec = _record(params={"fit_clearance_mm": 0.1 + 0.2, "strap_width_mm": 1 / 3})
|
|
back = DesignRecord.parse(rec.render())
|
|
assert back.params["fit_clearance_mm"] == 0.1 + 0.2
|
|
assert back.params["strap_width_mm"] == 1 / 3
|
|
|
|
|
|
def test_render_parse_round_trip_preserves_identity():
|
|
"""A record written out and read back is the same design."""
|
|
rec = _record()
|
|
back = DesignRecord.parse(rec.render())
|
|
assert back.family == rec.family
|
|
assert back.profile == rec.profile
|
|
assert back.params == rec.params
|
|
assert back.input_id == rec.input_id
|
|
assert back.code_revision == rec.code_revision
|
|
assert back.generator_revision == rec.generator_revision
|
|
assert back.toolchain == rec.toolchain
|
|
assert back.build_id == rec.build_id
|
|
|
|
|
|
def test_resolve_matches_the_assemble_merge():
|
|
"""
|
|
``resolve`` reproduces ``_common.assemble``'s ``{**defaults, **params}``.
|
|
|
|
Duplicated deliberately, to keep this module off the build path. Duplication
|
|
is only safe while it is checked, so it is checked.
|
|
"""
|
|
params = {"fit_clearance_mm": 0.4, "y_junction_web_mm": 2.0}
|
|
got = resolve(THREE_X.defaults, params)
|
|
want = {**THREE_X.defaults, **params}
|
|
assert got == want
|
|
assert got["fit_clearance_mm"] == 0.4
|
|
assert got["strap_width_mm"] == THREE_X.defaults["strap_width_mm"]
|
|
assert set(got) == set(THREE_X.defaults)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Identity -- what must and must not change it
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_identical_designs_have_identical_ids():
|
|
assert _record().input_id == _record().input_id
|
|
assert _record().build_id == _record().build_id
|
|
|
|
|
|
def test_parameter_order_does_not_change_identity():
|
|
"""Dict ordering is an accident of how a caller built the mapping."""
|
|
a = record_for(family="3x", profile="Y", defaults={},
|
|
params={"a": 1.0, "b": 2.0, "c": 3.0})
|
|
b = record_for(family="3x", profile="Y", defaults={},
|
|
params={"c": 3.0, "b": 2.0, "a": 1.0})
|
|
assert a.input_id == b.input_id
|
|
assert canonical_params(a.params) == canonical_params(b.params)
|
|
|
|
|
|
def test_any_parameter_change_changes_the_input_id():
|
|
"""The tuning loop must never silently reuse an identity."""
|
|
base = _record()
|
|
seen = {base.input_id}
|
|
for clearance in (0.15, 0.2, 0.24, 0.2500001, 0.26, 0.0, -0.05):
|
|
other = _record(params={"fit_clearance_mm": clearance})
|
|
assert other.input_id != base.input_id, clearance
|
|
assert other.input_id not in seen, "collision at %r" % clearance
|
|
seen.add(other.input_id)
|
|
|
|
assert _record(profile="T").input_id != base.input_id
|
|
assert _record(family="4x").input_id != base.input_id
|
|
|
|
|
|
def test_code_revision_changes_build_id_but_not_input_id():
|
|
"""
|
|
The distinction that makes the pair useful: same intent, different bytes
|
|
possible. That is the moment to check whether the geometry moved, and the
|
|
record is what tells you to look.
|
|
"""
|
|
a = _record(code_revision="aaaaaaa")
|
|
b = _record(code_revision="bbbbbbb")
|
|
assert a.input_id == b.input_id
|
|
assert a.build_id != b.build_id
|
|
|
|
c = _record(generator_revision="8.0.1")
|
|
assert c.input_id == a.input_id
|
|
assert c.build_id != a.build_id
|
|
|
|
|
|
def test_timestamp_and_note_are_excluded_from_both_ids():
|
|
"""
|
|
A design regenerated tomorrow is the same design. If the clock entered the
|
|
identity, nothing would ever reproduce.
|
|
"""
|
|
a = _record(generated="2026-08-23T10:00:00Z", note="first attempt")
|
|
b = _record(generated="2027-01-01T00:00:00Z", note="reprint for Dave")
|
|
assert a.input_id == b.input_id
|
|
assert a.build_id == b.build_id
|
|
assert "2026-08-23" in a.render()
|
|
assert "first attempt" in a.render()
|
|
|
|
|
|
def test_verification_values_do_not_affect_identity():
|
|
"""
|
|
Reported values are outputs. If they entered the hash, a part would be
|
|
identified by what it turned out to be rather than by what was asked for --
|
|
and two runs of the same design on different GEOS builds could then claim to
|
|
be different designs.
|
|
"""
|
|
report = {k: 1.0 for k in VERIFY_KEYS}
|
|
other = {k: 2.0 for k in VERIFY_KEYS}
|
|
a = _record(report=report)
|
|
b = _record(report=other)
|
|
c = _record()
|
|
assert a.input_id == b.input_id == c.input_id
|
|
assert a.build_id == b.build_id == c.build_id
|
|
assert a.verification != b.verification
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The rendered artifact
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_render_is_deterministic():
|
|
assert _record().render() == _record().render()
|
|
|
|
|
|
def test_render_is_readable_without_this_software():
|
|
"""
|
|
Plain text, one fact per line, no framing that needs a parser. A record that
|
|
only this program can open is not a record.
|
|
"""
|
|
rec = _record(report={k: 12.3456 for k in VERIFY_KEYS},
|
|
stock=["PET strap 15.875 x 0.508 -- unverified: supplied at design time"],
|
|
fit=["clearance 0.2500 mm per face"],
|
|
generated="2026-08-23T10:00:00Z")
|
|
text = rec.render()
|
|
|
|
assert text.startswith("MECHCOMP DESIGN RECORD %s" % FORMAT_VERSION)
|
|
assert rec.input_id in text and rec.build_id in text
|
|
assert "3x" in text and "Y" in text
|
|
assert "PET strap" in text and "unverified" in text
|
|
assert "clearance 0.2500 mm per face" in text
|
|
assert "fit_clearance_mm=0.25" in text
|
|
assert "code_revision=abc1234" in text
|
|
assert "generator_revision=8.0.0" in text
|
|
assert "ENVELOPE_X_MM" in text
|
|
assert text.endswith("\n")
|
|
|
|
# Every resolved parameter reaches the file. A record that carries only the
|
|
# overrides cannot rebuild anything, because the defaults it silently relied
|
|
# on will have moved by then.
|
|
for key in THREE_X.defaults:
|
|
assert key in text, "parameter %s missing from the record" % key
|
|
|
|
|
|
def test_record_carries_every_resolved_parameter_not_just_overrides():
|
|
rec = _record(params={"fit_clearance_mm": 0.25})
|
|
assert set(rec.params) == set(THREE_X.defaults)
|
|
assert len(rec.params) > 20
|
|
|
|
|
|
def test_unrecorded_stock_and_fit_say_so_rather_than_being_blank():
|
|
text = _record().render()
|
|
assert "(none recorded)" in text
|
|
|
|
|
|
def test_toolchain_is_captured_and_reaches_the_build_id():
|
|
"""
|
|
GEOS can change boolean results on near-degenerate geometry -- the F-034
|
|
mechanism exactly. A record omitting it cannot explain why the same
|
|
parameters produced a different part, so its absence must be a failure and
|
|
not a quiet blank.
|
|
"""
|
|
shapely = pytest.importorskip(
|
|
"shapely",
|
|
reason="the toolchain assertion is about capturing the real GEOS "
|
|
"version; without shapely there is nothing to capture")
|
|
|
|
tools = toolchain_versions()
|
|
assert "shapely" in tools, "shapely version not captured"
|
|
assert tools["shapely"] == shapely.__version__
|
|
assert tools["shapely"] != "unavailable", "shapely version lookup failed"
|
|
|
|
rec = _record()
|
|
assert rec.toolchain == tools
|
|
assert rec.toolchain.get("shapely")
|
|
|
|
text = rec.render()
|
|
assert "shapely=%s" % tools["shapely"] in text
|
|
|
|
# A different toolchain is a different build, even at identical intent.
|
|
same_intent = DesignRecord(
|
|
family=rec.family, profile=rec.profile, params=dict(rec.params),
|
|
code_revision=rec.code_revision,
|
|
generator_revision=rec.generator_revision,
|
|
toolchain={**tools, "shapely": "0.0.0-not-real"})
|
|
assert same_intent.input_id == rec.input_id
|
|
assert same_intent.build_id != rec.build_id
|
|
|
|
empty = DesignRecord(family=rec.family, profile=rec.profile,
|
|
params=dict(rec.params),
|
|
code_revision=rec.code_revision,
|
|
generator_revision=rec.generator_revision,
|
|
toolchain={})
|
|
assert empty.build_id != rec.build_id
|
|
|
|
|
|
def test_missing_report_keys_are_omitted_not_invented():
|
|
rec = _record(report={"ENVELOPE_X_MM": 20.5})
|
|
assert rec.verification == {"ENVELOPE_X_MM": 20.5}
|
|
assert "MASS_G" not in rec.verification
|
|
|
|
|
|
def test_build_emits_a_record_that_regenerates_the_same_report():
|
|
"""
|
|
The end-to-end claim, asserted rather than argued.
|
|
|
|
Build something. Render its record. Read the record back with nothing but
|
|
the text. Rebuild from the parsed parameters. The report must be
|
|
identical, key for key. If it is not, the record is a description of a
|
|
part rather than a means of rebuilding it, and preservation is a claim we
|
|
cannot make.
|
|
"""
|
|
build = pytest.importorskip("mechcomp.profiles").build
|
|
|
|
for profile, params in (
|
|
("Y", {}),
|
|
("Y", {"fit_clearance_mm": 0.15}),
|
|
("T", {"t_stem_web_mm": 1.6, "bundle_count": 2}),
|
|
("Three-Fin", {"three_fin_bore_side_mm": 15.0}),
|
|
("A Frame", {"a_frame_leg_angle_deg": 53}),
|
|
):
|
|
first = build(family="3x", profile=profile, params=params)
|
|
assert first.record is not None, "build produced no design record"
|
|
|
|
text = first.record.render()
|
|
recovered = type(first.record).parse(text)
|
|
|
|
assert recovered.input_id == first.record.input_id
|
|
assert recovered.family == "3x"
|
|
assert recovered.profile == profile
|
|
|
|
# Rebuild using ONLY what came out of the text.
|
|
again = build(family=recovered.family, profile=recovered.profile,
|
|
params=recovered.params)
|
|
|
|
assert again.report == first.report, (
|
|
"regenerating %s from its own design record produced a different "
|
|
"report" % profile)
|
|
assert again.record.input_id == first.record.input_id
|
|
assert again.record.build_id == first.record.build_id
|
|
|
|
|
|
def test_tuning_a_parameter_changes_the_identity_of_the_part():
|
|
"""
|
|
The failure this whole module exists to prevent: two prints at different
|
|
settings that cannot be told apart afterwards.
|
|
"""
|
|
build = pytest.importorskip("mechcomp.profiles").build
|
|
|
|
seen = {}
|
|
for clearance in (0.15, 0.2, 0.25, 0.3):
|
|
r = build(family="3x", profile="Y",
|
|
params={"fit_clearance_mm": clearance})
|
|
ident = r.record.input_id
|
|
assert ident not in seen, (
|
|
"clearance %s and %s produced the same input_id -- two different "
|
|
"parts would be indistinguishable" % (clearance, seen.get(ident)))
|
|
seen[ident] = clearance
|
|
assert "fit_clearance_mm=%r" % clearance in r.record.render()
|
|
|
|
|
|
def test_record_describes_the_stock_and_fit_actually_used():
|
|
build = pytest.importorskip("mechcomp.profiles").build
|
|
|
|
r = build(family="3x", profile="Y",
|
|
params={"strap_width_mm": 13.4, "fit_clearance_mm": 0.15,
|
|
"bundle_count": 2})
|
|
text = r.record.render()
|
|
|
|
# Target the stock line itself. Asserting "13.4" appears somewhere
|
|
# in the record is not enough -- it also appears as strap_width_mm in
|
|
# the parameter block, so a designation hardcoded to the family
|
|
# default would pass unnoticed.
|
|
stock_lines = [l.strip() for l in text.splitlines()
|
|
if l.strip().startswith("stock ")]
|
|
assert stock_lines, "no stock line in the record"
|
|
assert stock_lines[0].startswith("stock 13.4 x 0.508 mm x2, stack 1.016 mm"), (
|
|
stock_lines)
|
|
|
|
assert "clearance 0.1500 mm per face" in text
|
|
assert "build parameters" in text
|
|
|
|
|
|
def test_a_rejected_profile_produces_no_model_and_no_record():
|
|
"""There is nothing to preserve, and the rejection is unchanged."""
|
|
profiles = pytest.importorskip("mechcomp.profiles")
|
|
|
|
with pytest.raises(profiles.ProfileRejected):
|
|
profiles.build(family="3x", profile="Y",
|
|
params={"min_wall_mm": 0})
|
|
|
|
|
|
def test_code_revision_is_resolved_or_honestly_unknown():
|
|
"""
|
|
Never a guess. "unknown" tells you the part may not be reproducible; a
|
|
plausible wrong revision would send someone to the wrong commit.
|
|
"""
|
|
from mechcomp.design_record import code_revision
|
|
|
|
rev = code_revision()
|
|
assert rev
|
|
assert rev == code_revision(), "code_revision is not stable per process"
|
|
if rev != "unknown":
|
|
head = rev[:-len("-dirty")] if rev.endswith("-dirty") else rev
|
|
assert len(head) >= 7
|
|
assert all(c in "0123456789abcdef" for c in head), rev
|
|
|
|
|
|
def test_code_revision_honours_an_explicit_revision(monkeypatch):
|
|
import mechcomp.design_record as dr
|
|
|
|
monkeypatch.setattr(dr, "_CODE_REVISION", None)
|
|
monkeypatch.setenv("MECHCOMP_REVISION", "deadbee")
|
|
assert dr.code_revision() == "deadbee"
|
|
|
|
|
|
def test_code_revision_is_cached_not_recomputed(monkeypatch):
|
|
# Caching is not a micro-optimisation. Without it every build shells
|
|
# out to git twice, and assemble runs 123 times in the acceptance
|
|
# suite alone -- a subprocess in a geometry compilers build path.
|
|
import mechcomp.design_record as dr
|
|
|
|
monkeypatch.setattr(dr, "_CODE_REVISION", None)
|
|
monkeypatch.setenv("MECHCOMP_REVISION", "first00")
|
|
assert dr.code_revision() == "first00"
|
|
|
|
monkeypatch.setenv("MECHCOMP_REVISION", "second0")
|
|
assert dr.code_revision() == "first00", (
|
|
"code_revision recomputed instead of using its cache")
|
|
|
|
|
|
def test_code_revision_falls_back_to_unknown_not_a_fabrication(monkeypatch):
|
|
# When git cannot answer, the record must say so. A plausible wrong
|
|
# sha would send someone to the wrong commit, which is worse than
|
|
# admitting the part may not be reproducible.
|
|
import subprocess
|
|
|
|
import mechcomp.design_record as dr
|
|
|
|
monkeypatch.setattr(dr, "_CODE_REVISION", None)
|
|
monkeypatch.delenv("MECHCOMP_REVISION", raising=False)
|
|
|
|
def refuse(*a, **k):
|
|
raise OSError("git unavailable")
|
|
|
|
monkeypatch.setattr(subprocess, "run", refuse)
|
|
assert dr.code_revision() == "unknown"
|
|
|
|
|
|
def test_bools_survive_the_round_trip():
|
|
"""``length_view`` is a string and some profile extras are bools."""
|
|
assert canonical_value(True) == "true"
|
|
assert parse_value("true") is True
|
|
assert parse_value("false") is False
|
|
rec = record_for(family="3x", profile="Y", defaults={},
|
|
params={"flag": True, "off": False, "name": "Preview"})
|
|
back = DesignRecord.parse(rec.render())
|
|
assert back.params["flag"] is True
|
|
assert back.params["off"] is False
|
|
assert back.params["name"] == "Preview"
|