Checks as values, the five universal checks, metrics, ProfileRejected, and the report block. Completes the shared layer; only the profiles and build() remain. Report numbers are rounded to six significant figures on the way out, matching OpenSCAD echo, which is C %g at default precision. The oracle records what OpenSCAD printed, not full-precision geometry. This is load bearing rather than cosmetic. VOLUME_MM3 ends in _MM3, so test_oracle.py compares it at the lengths tolerance of 1e-4 and not the areas tolerance of 1e-3. Volume is section area times a 100 mm length, so an unrounded port reporting 13557.402 against a recorded 13557.4 fails by twenty times the tolerance while being geometrically correct. Verified against the oracle: across all 113 accepted cases the recorded volume equals the rounded area times length to within 3.6e-12, which holds only if volume is computed unrounded and rounded at print. That is what this layer does. The consequence is that geometry must agree with the reference to better than one part in a million before rounding. Near a rounding boundary a smaller error can still tip the last digit, and that will show up as a single case failing by one unit in the last place rather than as something mysterious. 28 tests. Nine mutations, all caught first pass, including rounding to decimal places instead of significant figures and computing volume from the already-rounded area. Oracle acceptance still skips; 236 unchanged.
316 lines
10 KiB
Python
316 lines
10 KiB
Python
"""
|
|
Unit tests for validation, metrics and report formatting.
|
|
|
|
The rounding tests carry values taken from the committed oracle, so they assert
|
|
against what the reference actually recorded rather than against my reading of
|
|
how OpenSCAD formats numbers.
|
|
|
|
Each universal check is tested by constructing geometry that violates it and
|
|
nothing else. A check that cannot be made to fail is not a check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from mechcomp.geom.join import (
|
|
bore_from_members,
|
|
fit_ring,
|
|
ring_members,
|
|
ring_shell,
|
|
section,
|
|
)
|
|
from mechcomp.geom.records import Geo, Member
|
|
from mechcomp.geom.report import (
|
|
Metrics,
|
|
ProfileRejected,
|
|
Result,
|
|
check,
|
|
echo_num,
|
|
first_failure,
|
|
metrics,
|
|
report,
|
|
require,
|
|
universal_checks,
|
|
)
|
|
|
|
|
|
def geo(**kw) -> Geo:
|
|
base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.2,
|
|
wall_inside=1.0, wall_outside=1.6, wall_edge=1.2, min_wall=0.8)
|
|
base.update(kw)
|
|
return Geo(**base)
|
|
|
|
|
|
def ngon(n, r=20.0, start=90.0):
|
|
return [(r * math.cos(math.radians(start + i * 360.0 / n)),
|
|
r * math.sin(math.radians(start + i * 360.0 / n)))
|
|
for i in range(n)]
|
|
|
|
|
|
TRI = ngon(3)
|
|
|
|
|
|
def ring_case(poly=TRI, web=1.2, g=None):
|
|
g = g or geo()
|
|
fitted = fit_ring(poly, g, web)
|
|
ms = ring_members(fitted, g)
|
|
bore = bore_from_members(ms, g)
|
|
shell = [ring_shell(fitted, g, 0.0)]
|
|
return section(shell, ms, g, bore), shell, ms, g
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Checks
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_check_coerces_to_bool():
|
|
assert check(1, "m") == (True, "m")
|
|
assert check(0, "m") == (False, "m")
|
|
assert check([], "m") == (False, "m")
|
|
|
|
|
|
def test_first_failure_reports_the_earliest():
|
|
"""
|
|
The reference reports the first failure, not an aggregate, so the caller
|
|
sees the most specific message the profile author put first.
|
|
"""
|
|
checks = [(True, "a"), (False, "b"), (False, "c")]
|
|
assert first_failure(checks) == "b"
|
|
|
|
|
|
def test_first_failure_is_none_when_all_pass():
|
|
assert first_failure([(True, "a"), (True, "b")]) is None
|
|
|
|
|
|
def test_require_returns_ok_when_everything_passes():
|
|
assert require([(True, "a")]) == "ok"
|
|
|
|
|
|
def test_require_raises_profile_rejected_with_the_message():
|
|
with pytest.raises(ProfileRejected, match="leg angle"):
|
|
require([(True, "a"), (False, "reduce the leg angle"), (True, "c")])
|
|
|
|
|
|
def test_rejection_carries_a_non_empty_message():
|
|
"""
|
|
test_oracle.py requires a rejection to name the parameter and the limit.
|
|
An empty message fails the contract even though the rejection is correct.
|
|
"""
|
|
with pytest.raises(ProfileRejected) as exc:
|
|
require([(False, "min_wall_mm of 0.8 mm not met: actual 0.41 mm")])
|
|
assert str(exc.value).strip()
|
|
assert "min_wall_mm" in str(exc.value)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# echo_num -- OpenSCAD's print formatting
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_echo_num_rounds_to_six_significant_figures():
|
|
assert echo_num(135.57402118) == 135.574
|
|
assert echo_num(13557.402118) == 13557.4
|
|
assert echo_num(16.81118) == 16.8112
|
|
assert echo_num(33.848812) == 33.8488
|
|
|
|
|
|
def test_echo_num_leaves_short_values_alone():
|
|
assert echo_num(1.2) == 1.2
|
|
assert echo_num(0.508) == 0.508
|
|
assert echo_num(1.0) == 1.0
|
|
|
|
|
|
def test_echo_num_is_significant_figures_not_decimal_places():
|
|
"""Six *significant* figures: the decimal count shifts with magnitude."""
|
|
assert echo_num(0.000123456789) == 0.000123457
|
|
assert echo_num(123456.789) == 123457.0
|
|
|
|
|
|
def test_volume_matches_the_oracles_own_derivation():
|
|
"""
|
|
The oracle's volume equals its rounded area times the length to within
|
|
float noise -- 3.6e-12 across all 113 accepted cases. That holds only if
|
|
volume is computed from the unrounded area and rounded at print, which is
|
|
what this layer does.
|
|
"""
|
|
a = 135.57402118
|
|
assert echo_num(a) == 135.574
|
|
assert echo_num(a * 100.0) == 13557.4
|
|
assert abs(echo_num(a * 100.0) - echo_num(a) * 100.0) < 1e-9
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Metrics
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_metrics_on_a_real_ring_profile():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
assert m.parts == 1
|
|
assert m.slots == 3
|
|
assert m.area > 0
|
|
assert m.size_x > 0 and m.size_y > 0
|
|
assert m.leak == pytest.approx(0.0, abs=1e-9)
|
|
|
|
|
|
def test_metrics_min_wall_is_the_thinnest_surviving_gap():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
assert 0 < m.min_wall < 100
|
|
|
|
|
|
def test_leak_is_positive_when_a_cavity_escapes_the_envelope():
|
|
"""A cavity outside the shell means the straps are not enclosed."""
|
|
g = geo()
|
|
sec, shell, ms, _ = ring_case()
|
|
stray = list(ms) + [Member(500.0, 0.0, 0.0)]
|
|
m = metrics(sec, shell, stray, g)
|
|
assert m.leak > 1.0
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Universal checks -- each made to fail on its own
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def _m(**kw) -> Metrics:
|
|
base = dict(area=100.0, parts=1, slots=3, min_wall=1.2,
|
|
size_x=30.0, size_y=30.0, leak=0.0)
|
|
base.update(kw)
|
|
return Metrics(**base)
|
|
|
|
|
|
def test_all_universal_checks_pass_on_a_good_profile():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
assert first_failure(universal_checks(sec, m, 3, g)) is None
|
|
assert require(universal_checks(sec, m, 3, g)) == "ok"
|
|
|
|
|
|
def test_disconnected_section_is_rejected():
|
|
sec, shell, ms, g = ring_case()
|
|
fail = first_failure(universal_checks(sec, _m(parts=2), 3, g))
|
|
assert fail is not None and "connected solid" in fail
|
|
assert "2 separate pieces" in fail
|
|
|
|
|
|
def test_merged_channels_are_rejected():
|
|
sec, shell, ms, g = ring_case()
|
|
fail = first_failure(universal_checks(sec, _m(slots=2), 3, g))
|
|
assert fail is not None and "strap channels" in fail
|
|
assert "Expected 3" in fail and "found 2" in fail
|
|
|
|
|
|
def test_thin_wall_is_rejected():
|
|
"""
|
|
Connectivity alone is not enough: a knife edge is topologically connected
|
|
and physically useless, and this is the check that catches it.
|
|
"""
|
|
sec, shell, ms, g = ring_case()
|
|
fail = first_failure(universal_checks(sec, _m(min_wall=0.14), 3, g))
|
|
assert fail is not None and "Thinnest PLA+ wall" in fail
|
|
|
|
|
|
def test_wall_check_has_a_tolerance_band():
|
|
"""Exactly at the minimum must pass; float noise must not reject a profile."""
|
|
sec, shell, ms, g = ring_case()
|
|
assert first_failure(universal_checks(sec, _m(min_wall=g.min_wall), 3, g)) is None
|
|
assert first_failure(
|
|
universal_checks(sec, _m(min_wall=g.min_wall - 1e-5), 3, g)) is None
|
|
assert first_failure(
|
|
universal_checks(sec, _m(min_wall=g.min_wall - 1e-2), 3, g)) is not None
|
|
|
|
|
|
def test_self_touching_section_is_rejected():
|
|
"""The manifold precondition: valid in 2D, impossible to extrude."""
|
|
g = geo()
|
|
bowtie = [[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)]]
|
|
fail = first_failure(universal_checks(bowtie, _m(), 3, g))
|
|
assert fail is not None and "cannot be " in fail
|
|
|
|
|
|
def test_escaped_cavity_is_rejected():
|
|
sec, shell, ms, g = ring_case()
|
|
fail = first_failure(universal_checks(sec, _m(leak=5.0), 3, g))
|
|
assert fail is not None and "breaks out of the outer envelope" in fail
|
|
|
|
|
|
def test_checks_are_reported_in_declaration_order():
|
|
"""Two failures at once must surface the connectivity message first."""
|
|
sec, shell, ms, g = ring_case()
|
|
fail = first_failure(universal_checks(sec, _m(parts=3, slots=1), 3, g))
|
|
assert "connected solid" in fail
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Report
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def test_report_carries_every_key_the_oracle_records():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
r = report("3x", "Equilateral Triangle", "ok", g, m, 100.0, 1.24)
|
|
for key in ("STATUS", "FAMILY", "PROFILE", "STRAP_WIDTH_MM",
|
|
"STRAP_THICK_MM", "BUNDLE_COUNT", "BUNDLE_THICK_MM",
|
|
"CLEARANCE_MM", "WALL_INSIDE_MM", "WALL_OUTSIDE_MM",
|
|
"WALL_EDGE_MM", "MIN_WALL_SPEC_MM", "MIN_WALL_ACTUAL_MM",
|
|
"SECTION_AREA_MM2", "SECTION_PARTS", "STRAP_CHANNELS",
|
|
"ENVELOPE_X_MM", "ENVELOPE_Y_MM", "LENGTH_MM", "VOLUME_MM3",
|
|
"MASS_G"):
|
|
assert key in r, key
|
|
|
|
|
|
def test_report_numbers_are_rounded_on_the_way_out():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
r = report("3x", "T", "ok", g, m, 100.0, 1.24)
|
|
for key, value in r.items():
|
|
if isinstance(value, float):
|
|
assert echo_num(value) == value, key
|
|
|
|
|
|
def test_report_volume_is_area_times_length():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
r = report("3x", "T", "ok", g, m, 100.0, 1.24)
|
|
assert r["VOLUME_MM3"] == echo_num(m.area * 100.0)
|
|
|
|
|
|
def test_report_mass_uses_density_in_g_per_cm3():
|
|
"""Volume is mm^3 and density g/cm^3, so the conversion divides by 1000."""
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
r = report("3x", "T", "ok", g, m, 100.0, 1.24)
|
|
assert r["MASS_G"] == echo_num(m.area * 100.0 * 1.24 / 1000.0)
|
|
|
|
|
|
def test_report_merges_and_rounds_extra_keys():
|
|
"""Profile-published keys are part of the recorded report, not an addendum."""
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
r = report("3x", "Y", "ok", g, m, 100.0, 1.24,
|
|
extra={"SPOKE_RADIUS_MM": 9.17133421,
|
|
"NOTE": "inside wall unused: no enclosed bore"})
|
|
assert r["SPOKE_RADIUS_MM"] == 9.17133
|
|
assert r["NOTE"] == "inside wall unused: no enclosed bore"
|
|
|
|
|
|
def test_report_leaves_strings_untouched():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
r = report("3x", "Y", "ok", g, m, 100.0, 1.24)
|
|
assert r["FAMILY"] == "3x"
|
|
assert r["PROFILE"] == "Y"
|
|
assert r["STATUS"] == "ok"
|
|
|
|
|
|
def test_result_holds_the_report_and_the_geometry():
|
|
sec, shell, ms, g = ring_case()
|
|
m = metrics(sec, shell, ms, g)
|
|
res = Result(report=report("3x", "T", "ok", g, m, 100.0, 1.24),
|
|
section=sec, members=list(ms), geo=g)
|
|
assert res.report["SECTION_PARTS"] == 1
|
|
assert len(res.members) == 3
|
|
assert res.geo is g
|