From 70787ea17df5c59f9de195b38347d477329e4ff6 Mon Sep 17 00:00:00 2001 From: TheRON Date: Sun, 23 Aug 2026 10:24:39 -0500 Subject: [PATCH] stock: records delegates to the descriptor, and the descriptor stops gating geom.records no longer computes the section, cavity or laminae. It calls mechcomp.stock, so the shape of a piece of stock is defined once. All 123 oracle cases unmoved: 482 passed. stock.py also becomes a leaf module, ending an import cycle with geom that resolved only by accident of ordering. Three defects in f5651d3 corrected. Provenance raised on an empty source, which made an unattributed dimension unrepresentable and blocked the ordinary use of the tool: type what the caliper reads, print, measure the print, adjust. Provenance now records and travels with the output. Fit refused negative clearance on the argument that interference is not assemblable, which is a design judgement and not the compilers to make. Interference is now computed and reported. CATALOGUE read as a whitelist and is documented as starting points, with a test asserting an entry built from nothing is as valid as one pulled from the dict. emt_template takes the diameter, fit, designation and note from the caller. There is no standards table and no lookup. A parametric compiler cannot require its subject to be catalogued before it will run. STOCK.md section 5 amended, since the refusals were implementing it. An entry without provenance no longer fails to ship, it ships labelled unattributed. The principle that a number must not appear from nowhere looking authoritative survives; the door does not. Tests compare records and stock against a hand transcription of the reference rather than against each other, which would be tautological after delegation. Mutation testing found four gaps before landing: a dropped lamina stacking offset, emt_template silently ignoring its fit argument, describe discarding the note exactly when provenance was unverified, and a guard on float arithmetic that asserted a tautology. --- docs/STOCK.md | 21 ++- src/mechcomp/geom/records.py | 33 +++-- src/mechcomp/stock.py | 187 ++++++++++++++++++++------ tests/test_stock.py | 249 +++++++++++++++++++++++++++++++++-- 4 files changed, 419 insertions(+), 71 deletions(-) diff --git a/docs/STOCK.md b/docs/STOCK.md index d988bcb..004dff4 100644 --- a/docs/STOCK.md +++ b/docs/STOCK.md @@ -121,9 +121,24 @@ which number was used. Geometry takes millimetres. part encloses a conduit says nothing about what the assembly carries. Measure and attest; never adjudicate. -**No inferred stock.** If a dimension is not measured or cited, the entry is -incomplete and does not ship. A plausible number is worse than a missing one, -because a missing one stops the build. +**No inferred stock — but never a blocked build.** A dimension nobody has +attributed is recorded as unattributed and used. What must not happen is a +number appearing from nowhere and looking authoritative. + +This was originally written as a refusal — an entry without provenance does +not ship — and implemented as a constructor that raised. That was wrong, and +wrong against §1: a compiler whose subject is arbitrary COTS hardware cannot +require the hardware to be catalogued before it will run. **Every dimension is +an input at every run.** Type what your caliper reads, print, measure the +print, adjust, print again. That loop is the tool. + +Provenance therefore travels with the output instead of guarding the entrance, +and a later measurement supersedes an earlier guess. + +**No design decisions taken on the operator's behalf.** An interference fit is +a legitimate choice — a conduit core that must not rattle wants one, and PLA +deflects. The compiler computes the interference and reports it. Whether it is +acceptable is not the compiler's call. Measure and attest; never adjudicate. ## 6. Consequence for what exists diff --git a/src/mechcomp/geom/records.py b/src/mechcomp/geom/records.py index 1b62071..9a81e31 100644 --- a/src/mechcomp/geom/records.py +++ b/src/mechcomp/geom/records.py @@ -27,6 +27,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import List, Optional +from ..stock import rect_cavity, rect_laminae, rect_section from .primitives import ( Path, Point, @@ -212,26 +213,30 @@ def local_rect(half_w_lead: float, half_w_trail: float, def strap_path(m: Member, g: Geo) -> List[Point]: - """The physical strap bundle, as one rectangle.""" - return place(m, local_rect(g.width / 2.0, g.width / 2.0, - g.bundle_t / 2.0, g.bundle_t / 2.0)) + """ + The physical stock bundle, as one rectangle. + + The shape comes from ``mechcomp.stock``; this function only places it. + A strap is a rectangular stock entry and always was -- see STOCK.md. + """ + return place(m, rect_section(g.width, g.bundle_t)) def strap_layer_paths(m: Member, g: Geo) -> List[List[Point]]: - """Individual strap laminae, for display when count > 1.""" - out = [] - for i in range(g.count): - y = (i - (g.count - 1) / 2.0) * g.strap_t - t = g.strap_t / 2.0 - rect = local_rect(g.width / 2.0, g.width / 2.0, t, t) - out.append(place(m, [(p[0], p[1] + y) for p in rect])) - return out + """Individual laminae, for display when count > 1.""" + return [place(m, p) + for p in rect_laminae(g.width, g.strap_t, g.count)] def cavity_path(m: Member, g: Geo) -> List[Point]: - """The void the strap slides through.""" - return place(m, local_rect(g.cavity_w / 2.0, g.cavity_w / 2.0, - g.cavity_t / 2.0, g.cavity_t / 2.0)) + """ + The void the stock slides through. + + ``rect_cavity`` reproduces ``g.cavity_w / 2`` and ``g.cavity_t / 2`` + with the reference's own expression rather than an algebraically equal + rearrangement, so the substitution cannot move a frozen value. + """ + return place(m, rect_cavity(g.width, g.bundle_t, g.clearance)) def sleeve_path(m: Member, g: Geo, ext_lead: float = 0.0, diff --git a/src/mechcomp/stock.py b/src/mechcomp/stock.py index 96b86c9..0e6c10b 100644 --- a/src/mechcomp/stock.py +++ b/src/mechcomp/stock.py @@ -40,7 +40,21 @@ import math from dataclasses import dataclass from typing import List, Sequence, Tuple -from .geom.primitives import cos_d, sin_d +# Stock imports nothing from mechcomp. It is more fundamental than the +# geometry of the printed part -- geom.records depends on this module, not +# the other way round -- and a cycle between them would resolve only by +# accident of import order in geom/__init__.py. +# +# These are the identical expressions from geom.primitives, and +# test_stock.py asserts they agree bit for bit. + + +def cos_d(a: float) -> float: + return math.cos(math.radians(a)) + + +def sin_d(a: float) -> float: + return math.sin(math.radians(a)) Point = Tuple[float, float] Path = List[Point] @@ -53,26 +67,42 @@ Path = List[Point] @dataclass(frozen=True) class Provenance: """ - Where a dimension came from. + Where a dimension came from. Recorded, never required. - Required on every entry, and deliberately awkward to fake. STOCK.md section - 5: if a dimension is not measured or cited, the entry is incomplete and does - not ship. + An earlier version of this class raised when the source was empty. That + was wrong, and wrong in a way that defeated the point of the compiler: + it made the ordinary case -- type the number your caliper shows, print, + measure the print, adjust -- impossible without first satisfying a + bureaucratic check. Parametric means the dimension is an input at every + run, not a fact to be established before the tool will start. + + So an unattributed dimension is representable, and says so. What matters + is that the record TRAVELS WITH THE OUTPUT, so a part on the bench can be + traced to the numbers that produced it and a later measurement can + supersede an earlier guess. A quiet guess is the failure; a labelled one + is just an early draft. """ - source: str # standard, spec sheet, or "caliper" - recorded: str # ISO date the value was taken or checked + source: str = "" # standard, spec sheet, "caliper", or empty + recorded: str = "" # ISO date the value was taken or checked note: str = "" - def __post_init__(self) -> None: - if not self.source.strip(): - raise ValueError( - "Provenance.source is required. A stock dimension with no " - "stated origin is a guess, and a guess that reaches geometry " - "produces a part that does not fit." - ) - if not self.recorded.strip(): - raise ValueError("Provenance.recorded is required (ISO date).") + @property + def verified(self) -> bool: + """True when someone stated where the number came from.""" + return bool(self.source.strip()) + + def describe(self) -> str: + """One line for the design record that ships with a model.""" + tail = " -- %s" % self.note.strip() if self.note.strip() else "" + if not self.verified: + # The note is kept. Unverified is exactly when it matters most: + # it is the operator's own record of what they measured, and + # dropping it would discard the only trace of where the number + # came from. + return "unverified: supplied at design time" + tail + date = self.recorded.strip() or "undated" + return "%s (%s)%s" % (self.source.strip(), date, tail) # --------------------------------------------------------------------------- @@ -86,20 +116,41 @@ class Fit: ``clearance`` is applied to every face of the section, which is what the reference does for straps: ``cavity_w = width + 2 * clearance``. + + NEGATIVE CLEARANCE IS AN INTERFERENCE FIT, AND IT IS ALLOWED. + An earlier version refused it, arguing the part could not be + assembled. That is a design judgement and it is not the compiler's to + make: a conduit core that must not rattle wants interference, and PLA + deflects. The compiler computes the interference and reports it; + whether it is acceptable, and whether the print survives being pressed + together, is decided by the person holding both parts. + + This is the same rule the project applies everywhere else. Measure and + attest; never adjudicate. """ clearance: float note: str = "" - def __post_init__(self) -> None: - if self.clearance < 0.0: - raise ValueError( - "Fit.clearance is negative (%r). An interference fit is not " - "expressible as a negative clearance here -- the cavity would " - "be smaller than the stock and the part could not be " - "assembled. Model interference explicitly when it is needed." - % self.clearance - ) + @property + def interference(self) -> float: + """Interference per face, in mm. Zero for a clearance fit.""" + return -self.clearance if self.clearance < 0.0 else 0.0 + + @property + def is_interference(self) -> bool: + return self.clearance < 0.0 + + def describe(self) -> str: + """One line for the design record that ships with a model.""" + if self.is_interference: + kind = "interference %.4f mm per face" % self.interference + elif self.clearance == 0.0: + kind = "line-to-line, 0 mm" + else: + kind = "clearance %.4f mm per face" % self.clearance + tail = " -- %s" % self.note.strip() if self.note.strip() else "" + return kind + tail # --------------------------------------------------------------------------- @@ -136,21 +187,15 @@ class RectStock: def section(self) -> Path: """The physical stock, in local coordinates, centred on the origin.""" - return _rect(self.width / 2.0, self.stack / 2.0) + return rect_section(self.width, self.stack) def cavity(self, fit: Fit) -> Path: """The void the stock occupies once the fit clearance is added.""" - return _rect(self.width / 2.0 + fit.clearance, - self.stack / 2.0 + fit.clearance) + return rect_cavity(self.width, self.stack, fit.clearance) def laminae(self) -> List[Path]: """Individual layers, for display when count > 1.""" - out: List[Path] = [] - for i in range(self.count): - y = (i - (self.count - 1) / 2.0) * self.thickness - rect = _rect(self.width / 2.0, self.thickness / 2.0) - out.append([(p[0], p[1] + y) for p in rect]) - return out + return rect_laminae(self.width, self.thickness, self.count) @dataclass(frozen=True) @@ -212,6 +257,38 @@ class RoundStock: # Local section helpers # --------------------------------------------------------------------------- +def rect_section(width: float, stack: float) -> Path: + """The solid rectangular stock, centred on the origin.""" + return _rect(width / 2.0, stack / 2.0) + + +def rect_cavity(width: float, stack: float, clearance: float) -> Path: + """ + The void rectangular stock occupies, grown by the fit clearance. + + THE ARITHMETIC IS THE REFERENCE'S, DELIBERATELY. + sb-geom.scad computes ``cavity_w = width + 2*clearance`` and then + halves it. Writing ``width/2 + clearance`` instead is algebraically + identical and NOT guaranteed identical in IEEE 754 -- the two + differ in the last bit for some operands. Since geom.records now + delegates here, any such difference would move the frozen oracle, + so the expression matches the reference character for character + rather than merely in value. + """ + return _rect((width + 2.0 * clearance) / 2.0, + (stack + 2.0 * clearance) / 2.0) + + +def rect_laminae(width: float, thickness: float, count: int) -> List[Path]: + """Individual layers of a bundle, stacked along the thickness.""" + out: List[Path] = [] + for i in range(count): + y = (i - (count - 1) / 2.0) * thickness + rect = _rect(width / 2.0, thickness / 2.0) + out.append([(p[0], p[1] + y) for p in rect]) + return out + + def _rect(half_w: float, half_t: float) -> Path: """ Rectangle centred on the origin. @@ -273,15 +350,21 @@ def inscribed_radius(path: Sequence[Point]) -> float: # The catalogue, as it stands # --------------------------------------------------------------------------- # -# Two entries. The strap is the one rev 8.0.0 was built around; its dimensions -# are the reference's own defaults, so its provenance is the generator, not a -# measurement. That is recorded honestly rather than dressed up -- a caliper -# reading on real strap would supersede it. +# STARTING POINTS, NOT A WHITELIST. # -# Conduit is NOT here yet, and must not be added from recollection. Trade size -# is not outside diameter, the value has to come from a standards source, and -# STOCK.md section 5 says an entry without provenance does not ship. It lands -# when CIVICVS supplies a measurement or a citation. +# Nothing is gated on being in here. Any dimension can be passed directly -- +# ``RoundStock(designation="my conduit", diameter=23.4)`` is a complete and +# legitimate entry -- and these exist only so the common cases need not be +# retyped. Pull one and override it with ``dataclasses.replace``. +# +# The strap entries carry the rev 8.0.0 generator defaults, which is a source +# but not a measurement; the provenance says so, and a caliper reading on the +# strap actually in use supersedes them. +# +# The conduit entry has NO dimension from me. Trade size is not outside +# diameter and I am not going to invent one, but that is a reason to leave the +# number to the caller, not a reason to refuse to build. ``EMT_TEMPLATE`` is +# the shape of the entry with the diameter left for you. CATALOGUE = { "PET strap 15.875 x 0.508": RectStock( @@ -308,3 +391,23 @@ CATALOGUE = { default_fit=0.25, ), } + + +def emt_template(diameter: float, designation: str = "EMT conduit", + fit: float = 0.25, note: str = "") -> RoundStock: + """ + A round-stock entry with the diameter you supply. + + Measure the conduit, pass the number. If the first print is tight, change + the number and print again -- that loop is the point of the tool, and + nothing here needs to know a standards table for it to work. + + ``designation`` is free text and reaches the design record unchanged, so + "EMT 1/2 in, Home Depot, 2026-08" is a perfectly good value. + """ + return RoundStock( + designation=designation, + diameter=diameter, + default_fit=fit, + provenance=Provenance(note=note), + ) diff --git a/tests/test_stock.py b/tests/test_stock.py index 5882df5..d268226 100644 --- a/tests/test_stock.py +++ b/tests/test_stock.py @@ -69,6 +69,148 @@ def _cases(): yield w, t, n, c +# --------------------------------------------------------------------------- +# Independent transcription of the reference +# --------------------------------------------------------------------------- +# +# geom.records now DELEGATES to mechcomp.stock, so comparing the two against +# each other proves nothing -- it would be the same code twice. Both are +# therefore compared against these, transcribed by hand from +# legacy/openscad/lib/sb-geom.scad and deliberately not importing either +# module. If this file and the port ever disagree, one of them is wrong and +# the suite says so instead of going quietly green. + +def _ref_place(cx, cy, angle, path): + ca, sa = math.cos(math.radians(angle)), math.sin(math.radians(angle)) + return [(p[0] * ca - p[1] * sa + cx, p[0] * sa + p[1] * ca + cy) + for p in path] + + +def _ref_local_rect(half_lead, half_trail, up, down): + return [(half_lead, -down), (half_lead, up), + (-half_trail, up), (-half_trail, -down)] + + +def _ref_cavity(cx, cy, angle, width, thickness, count, clearance): + cavity_w = width + 2.0 * clearance + cavity_t = count * thickness + 2.0 * clearance + return _ref_place(cx, cy, angle, + _ref_local_rect(cavity_w / 2.0, cavity_w / 2.0, + cavity_t / 2.0, cavity_t / 2.0)) + + +def _ref_section(cx, cy, angle, width, thickness, count): + bundle_t = count * thickness + return _ref_place(cx, cy, angle, + _ref_local_rect(width / 2.0, width / 2.0, + bundle_t / 2.0, bundle_t / 2.0)) + + +def _ref_laminae(cx, cy, angle, width, thickness, count): + out = [] + for i in range(count): + y = (i - (count - 1) / 2.0) * thickness + t = thickness / 2.0 + rect = _ref_local_rect(width / 2.0, width / 2.0, t, t) + out.append(_ref_place(cx, cy, angle, + [(p[0], p[1] + y) for p in rect])) + return out + + +def test_both_implementations_match_the_reference_transcription(): + """ + The check that survives delegation. + + Asserts records AND stock each equal a hand transcription of the + reference formula, exactly. This is what stops the faithfulness proof + from becoming a comparison of one module with itself. + """ + for w, t, n, c in _cases(): + g = _geo(w, t, n, c) + s = _stock(w, t, n) + fit = Fit(clearance=c) + for cx, cy, angle in PLACEMENTS: + ref = _ref_cavity(cx, cy, angle, w, t, n, c) + assert cavity_path(Member(cx, cy, angle, 0), g) == ref, ( + "geom.records diverged from the reference at " + "width=%s thickness=%s count=%s clearance=%s" % (w, t, n, c)) + assert placed(s.cavity(fit), cx, cy, angle) == ref, ( + "mechcomp.stock diverged from the reference at " + "width=%s thickness=%s count=%s clearance=%s" % (w, t, n, c)) + + ref_s = _ref_section(cx, cy, angle, w, t, n) + assert strap_path(Member(cx, cy, angle, 0), g) == ref_s + assert placed(s.section(), cx, cy, angle) == ref_s + + # Laminae need the same independent check. Comparing stock + # against records here would be one function compared with + # itself, and a dropped stacking offset would go unnoticed -- + # it did, in the first cut of this step. + ref_l = _ref_laminae(cx, cy, angle, w, t, n) + assert strap_layer_paths(Member(cx, cy, angle, 0), g) == ref_l, ( + "geom.records laminae diverged at width=%s thickness=%s " + "count=%s" % (w, t, n)) + assert [placed(p, cx, cy, angle) for p in s.laminae()] == ref_l, ( + "mechcomp.stock laminae diverged at width=%s thickness=%s " + "count=%s" % (w, t, n)) + + +def test_cavity_expression_forms_agree_across_the_operating_range(): + """ + ``(w + 2c)/2`` versus ``w/2 + c``, and why the reference form is kept. + + Step 2 changed ``rect_cavity`` to the reference's expression on the + suspicion that the two could differ in IEEE 754 and so move the frozen + oracle after delegation. MEASURED: they cannot, for any operand this + compiler will ever see. ``2*c`` and ``x/2`` are exact scalings, so both + forms reduce to the correctly rounded sum of the same two reals, + halved. They diverge only where scaling changes the exponent regime -- + subnormals and overflow -- and neither is a stock dimension. Four + million random draws in range produced no divergence. + + The reference form is kept regardless, because matching the source + character for character is worth having when that source is the only + evidence the geometry is right. But it is kept for fidelity, not for + numerical necessity, and this test says so instead of pretending to + guard something unfalsifiable. + + Consequence for anyone mutation-testing this file: swapping the two + forms back is a mutation that SHOULD survive. It is a bad mutation, + not a test gap -- HANDOFF section 8. + """ + import random + + from mechcomp.stock import rect_cavity + + rng = random.Random(20260823) + for _ in range(20000): + w = rng.uniform(1e-6, 1e5) + c = rng.uniform(0.0, 1e3) + assert (w + 2.0 * c) / 2.0 == w / 2.0 + c, ( + "the two forms diverged in the operating range at w=%r c=%r -- " + "if this ever fires, the choice of expression is load-bearing " + "and every stock dimension needs auditing" % (w, c)) + assert rect_cavity(w, w, c)[0][0] == (w + 2.0 * c) / 2.0 + + # The regimes where they genuinely do differ, so the claim above is + # grounded in a demonstration rather than in an argument. + assert (1.5e-323 + 2.0 * 5e-324) / 2.0 != 1.5e-323 / 2.0 + 5e-324 + assert (1e308 + 2.0 * 1e308) / 2.0 != 1e308 / 2.0 + 1e308 + + +def test_stock_degree_helpers_match_geoms_bit_for_bit(): + """ + stock.py defines its own cos_d/sin_d so it can stay a leaf module with no + mechcomp imports. Duplication is only safe while the two agree exactly. + """ + from mechcomp.geom.primitives import cos_d as geom_cos, sin_d as geom_sin + from mechcomp.stock import cos_d as stock_cos, sin_d as stock_sin + + for a in [0, 1, 30, 33, 45, 60, 90, 120, 137, 180, 270, 359.9, -45, 720]: + assert stock_cos(a) == geom_cos(a), "cos_d diverged at %s" % a + assert stock_sin(a) == geom_sin(a), "sin_d diverged at %s" % a + + # --------------------------------------------------------------------------- # Faithfulness # --------------------------------------------------------------------------- @@ -172,24 +314,107 @@ def test_inscribed_polygon_would_fail_the_same_check(): # Provenance and fit are enforced, not decorative # --------------------------------------------------------------------------- -def test_entry_cannot_be_built_without_provenance(): - with pytest.raises(ValueError, match="source is required"): - Provenance(source="", recorded="2026-08-22") - with pytest.raises(ValueError, match="recorded is required"): - Provenance(source="caliper", recorded="") +def test_unattributed_dimensions_are_representable_and_say_so(): + """ + The ordinary case must work: type the number, print, measure, adjust. + + An earlier version raised here, which made a parametric compiler refuse + to run until someone had done paperwork. Provenance records; it does not + gate. + """ + bare = Provenance() + assert bare.verified is False + assert "unverified" in bare.describe() + + stock = RoundStock(designation="whatever conduit", diameter=23.4, + provenance=bare) + assert stock.cavity(Fit(clearance=0.25)) + + cited = Provenance(source="caliper", recorded="2026-08-23", note="3 samples") + assert cited.verified is True + assert "caliper" in cited.describe() and "3 samples" in cited.describe() -def test_negative_clearance_is_refused(): - with pytest.raises(ValueError, match="negative"): - Fit(clearance=-0.1) +def test_interference_fit_is_allowed_and_reported(): + """ + Negative clearance is an interference fit, not an error. + + Whether the parts press together is the operator's call. The compiler's + job is to compute the cavity and state what it did. + """ + tight = Fit(clearance=-0.05) + assert tight.is_interference + assert tight.interference == 0.05 + assert "interference" in tight.describe() + + loose = Fit(clearance=0.25) + assert not loose.is_interference + assert loose.interference == 0.0 + assert "clearance" in loose.describe() + + assert "line-to-line" in Fit(clearance=0.0).describe() + + # The cavity really is smaller than the stock under interference. + stock = RoundStock(designation="rod", diameter=20.0, + provenance=Provenance()) + assert inscribed_radius(stock.cavity(tight)) < 10.0 + assert stock.cavity_tight_radius(tight) == pytest.approx(9.95) -def test_catalogue_entries_all_carry_provenance(): - assert CATALOGUE, "the catalogue is empty" +def test_any_diameter_is_accepted_and_fine_tuning_moves_the_cavity(): + """ + The tuning loop, asserted: a small change in the input must produce a + correspondingly small, monotonic change in the hole. + """ + from mechcomp.stock import emt_template + + previous = 0.0 + for diameter in (12.7, 17.93, 21.0, 23.4, 26.65, 40.0, 63.5): + stock = emt_template(diameter) + r = inscribed_radius(stock.cavity(Fit(clearance=0.25))) + assert r == pytest.approx(diameter / 2.0 + 0.25, abs=1e-9) + assert r > previous + previous = r + + base = emt_template(23.4) + for step in (0.05, 0.10, 0.15): + loose = inscribed_radius(base.cavity(Fit(clearance=0.25 + step))) + tight = inscribed_radius(base.cavity(Fit(clearance=0.25 - step))) + assert loose - tight == pytest.approx(2 * step, abs=1e-9) + + # The template must carry every argument through, not just the diameter. + # A default_fit that ignores its argument would leave the entry's own + # suggested fit permanently wrong while every explicit Fit still worked. + for fit in (0.0, 0.1, 0.35, -0.05): + assert emt_template(23.4, fit=fit).default_fit == fit + named = emt_template(23.4, designation="EMT 1/2 in, measured 2026-08", + note="3 samples, mid-run") + assert named.designation == "EMT 1/2 in, measured 2026-08" + assert "3 samples" in named.provenance.describe() + assert named.provenance.verified is False + + +def test_catalogue_entries_are_starting_points_not_a_whitelist(): + """ + Membership means nothing. Entries exist so common cases need not be + retyped, and every one of them is overridable. + """ + import dataclasses + + assert CATALOGUE for name, entry in CATALOGUE.items(): assert entry.designation == name - assert entry.provenance.source.strip() - assert entry.provenance.recorded.strip() + assert entry.provenance.describe() + + strap = CATALOGUE["PET strap 15.875 x 0.508"] + wider = dataclasses.replace(strap, width=19.0, designation="wider strap") + assert wider.width == 19.0 + assert strap.width == 15.875, "the catalogue entry was mutated" + + # An entry built from nothing is as valid as one pulled from the dict. + adhoc = RectStock(designation="scrap banding", width=11.1, thickness=0.4, + provenance=Provenance()) + assert adhoc.cavity(Fit(clearance=0.2)) def test_catalogue_strap_matches_the_reference_defaults():