design record: an authorship field, recorded and in neither id
The person is identified by an email address. A handle may be chosen later and does not replace it: the handle is a display name, the address is the id. Excluded from input_id and build_id. input_id answers whether two records describe the same part as specified, so two people specifying the same part must collide there; hashing the author would make identical parts claim to be different designs. The guarantee is structural rather than careful -- the ids are computed from the resolved parameter set and the author never enters it. That exclusion narrows the one-way door it was scheduled against but does not close it. A record regenerated later with the author filled in keeps its input_id, but build_id covers the code revision and the Shapely and GEOS versions, and boolean results on near-degenerate geometry can shift between GEOS releases -- the F-034 mechanism. Regenerate after a GEOS bump and you have attribution under a different build identity beside a printed part nobody can tie to either. Recoverable, not free. Priority order in HANDOFF section 4 is reversed accordingly: this before STL export, so the first coupon off the printer carries its author. The rendered line carries its own limit -- "(self-declared, unverified)". Nothing checks that the address belongs to whoever typed it, and a bare email in a field called author reads as identity to someone finding the file in five years. The record is meant to outlive everyone present, so it states what it knows. When an authenticated identity exists the qualifier changes and the distinction stays legible. Same discipline as Provenance.describe(), a separate type: provenance is about measurement, and design_record imports nothing from mechcomp, which is what keeps a failure in the record off the build path. parse() gets its own branch because authorship is neither input nor output and inherits neither rule. It recovers the address and never the verification: a text file cannot attest to its own verification, so reading a verified record back as self-declared understates the claim, which is the only safe direction. REVISION stays 8.0.0. The field reaches no geometry. 547 passed. The 529 are unchanged and no oracle case moved. The eighteen new assertions were mutation-tested with PYTHONDONTWRITEBYTECODE=1 and caches cleared between runs: leaking the author into input_canonical, deafening the parser, trusting the adjective, dropping the qualifier, and turning an empty author into an empty claim each fail the suite.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
The authorship field: what it records, and what it must never touch.
|
||||
|
||||
WHY A SEPARATE FILE RATHER THAN LINES IN ``test_design_record.py``
|
||||
Delivery here is a tarball that expands over the tree, so every file in the
|
||||
set is shipped whole. Reproducing a 16 kB test module in order to add
|
||||
assertions to it risks corrupting sixteen kilobytes of working tests to add
|
||||
one; a new file overwrites nothing and cannot damage what is already green.
|
||||
If these belong beside the others later, moving them is a local edit made
|
||||
in the container.
|
||||
|
||||
WHAT THESE TESTS ARE ACTUALLY FOR
|
||||
One claim carries the whole design: an author reaches the record and
|
||||
neither id. Most of what follows exists to make that claim falsifiable
|
||||
rather than merely stated -- every negative assertion is paired with a
|
||||
positive control showing the same assertion could have failed (F-027).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from mechcomp.design_record import (
|
||||
Author,
|
||||
DesignRecord,
|
||||
author_from,
|
||||
record_for,
|
||||
)
|
||||
|
||||
DEFAULTS = {
|
||||
"strap_width_mm": 15.875,
|
||||
"strap_thickness_mm": 0.508,
|
||||
"fit_clearance_mm": 0.25,
|
||||
"bundle_count": 1,
|
||||
}
|
||||
|
||||
EMAIL = "theron@kane-il.us"
|
||||
|
||||
|
||||
def make(author=None, params=None) -> DesignRecord:
|
||||
return record_for(
|
||||
family="3x", profile="Y",
|
||||
defaults=DEFAULTS, params=params or {},
|
||||
code_revision="deadbee", generator_revision="8.0.0",
|
||||
author=author,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The claim: authorship is in neither hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_author_changes_neither_id():
|
||||
without = make()
|
||||
with_one = make(author=EMAIL)
|
||||
assert with_one.input_id == without.input_id
|
||||
assert with_one.build_id == without.build_id
|
||||
|
||||
|
||||
def test_a_different_author_changes_neither_id():
|
||||
"""Not just presence -- identity. Two people, one part, one design id."""
|
||||
a = make(author=EMAIL)
|
||||
b = make(author="someone.else@example.org")
|
||||
assert a.input_id == b.input_id
|
||||
assert a.build_id == b.build_id
|
||||
|
||||
|
||||
def test_the_ids_are_sensitive_to_something():
|
||||
"""
|
||||
The positive control for both tests above.
|
||||
|
||||
Without this, an ``input_id`` that ignored its inputs entirely would pass
|
||||
them, and the suite would be asserting that a constant equals itself.
|
||||
"""
|
||||
base = make(author=EMAIL)
|
||||
moved = make(author=EMAIL, params={"fit_clearance_mm": 0.15})
|
||||
assert moved.input_id != base.input_id
|
||||
assert moved.build_id != base.build_id
|
||||
|
||||
|
||||
def test_author_is_not_a_parameter():
|
||||
"""
|
||||
The mechanism behind the claim, asserted directly.
|
||||
|
||||
The ids are computed from the resolved parameter set, so the guarantee is
|
||||
structural: if ``author`` never enters ``params``, it cannot reach a hash
|
||||
however the hashing is later rewritten.
|
||||
"""
|
||||
rec = make(author=EMAIL)
|
||||
assert "author" not in rec.params
|
||||
assert EMAIL not in rec.input_canonical
|
||||
assert EMAIL not in rec.build_canonical
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What the rendered line says
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_rendered_line_carries_its_own_limit():
|
||||
text = make(author=EMAIL).render()
|
||||
line = [ln for ln in text.splitlines() if ln.startswith("author ")]
|
||||
assert len(line) == 1
|
||||
assert EMAIL in line[0]
|
||||
assert "self-declared, unverified" in line[0]
|
||||
|
||||
|
||||
def test_absent_author_renders_no_line_at_all():
|
||||
text = make().render()
|
||||
assert not [ln for ln in text.splitlines() if ln.startswith("author ")]
|
||||
|
||||
|
||||
def test_empty_and_whitespace_are_absent_not_empty():
|
||||
for value in ("", " ", None):
|
||||
assert author_from(value) is None
|
||||
assert not [ln for ln in make(author=value).render().splitlines()
|
||||
if ln.startswith("author ")]
|
||||
|
||||
|
||||
def test_a_verified_author_says_so():
|
||||
"""
|
||||
The positive control for the downgrade test below: the qualifier really
|
||||
does change, so a parser that always reported "unverified" would be
|
||||
distinguishable from one that read the text.
|
||||
"""
|
||||
author = Author(email=EMAIL, verified=True, method="OIDC, 2026-09-11")
|
||||
line = author.describe()
|
||||
assert "verified: OIDC, 2026-09-11" in line
|
||||
assert "self-declared" not in line
|
||||
|
||||
|
||||
def test_handle_decorates_but_does_not_replace_the_address():
|
||||
author = Author(email=EMAIL, handle="TheRON")
|
||||
described = author.describe()
|
||||
assert described.startswith(EMAIL)
|
||||
assert "TheRON" in described
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading a record back
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_recovers_the_address():
|
||||
back = DesignRecord.parse(make(author=EMAIL).render())
|
||||
assert back.author is not None
|
||||
assert back.author.email == EMAIL
|
||||
|
||||
|
||||
def test_parse_recovers_the_address_when_a_handle_is_present():
|
||||
rec = make(author=Author(email=EMAIL, handle="TheRON"))
|
||||
back = DesignRecord.parse(rec.render())
|
||||
assert back.author.email == EMAIL
|
||||
|
||||
|
||||
def test_parse_never_upgrades_a_claim():
|
||||
"""
|
||||
A verified record read back is self-declared, and that asymmetry is the
|
||||
point: a text file cannot attest to its own verification, so the parser
|
||||
trusts the address and not the adjective.
|
||||
"""
|
||||
rec = make(author=Author(email=EMAIL, verified=True, method="OIDC"))
|
||||
assert "verified: OIDC" in rec.render() # it really was in the text
|
||||
back = DesignRecord.parse(rec.render())
|
||||
assert back.author.verified is False
|
||||
|
||||
|
||||
def test_reading_and_rewriting_does_not_strip_attribution():
|
||||
once = make(author=EMAIL).render()
|
||||
twice = DesignRecord.parse(once).render()
|
||||
assert EMAIL in twice
|
||||
|
||||
|
||||
def test_parse_of_a_record_with_no_author_yields_none():
|
||||
back = DesignRecord.parse(make().render())
|
||||
assert back.author is None
|
||||
|
||||
|
||||
def test_parse_tolerates_a_record_written_before_the_field_existed():
|
||||
"""
|
||||
Old records have no author line and must still read. The format version is
|
||||
unchanged because nothing that identifies a design changed.
|
||||
"""
|
||||
text = make().render()
|
||||
assert "MECHCOMP DESIGN RECORD 1" in text
|
||||
back = DesignRecord.parse(text)
|
||||
assert back.family == "3x" and back.profile == "Y"
|
||||
assert back.author is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accepting what a caller has
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_author_from_accepts_a_bare_address_or_an_author():
|
||||
assert author_from(EMAIL) == Author(email=EMAIL)
|
||||
explicit = Author(email=EMAIL, verified=True, method="OIDC")
|
||||
assert author_from(explicit) is explicit
|
||||
|
||||
|
||||
def test_a_bare_address_is_never_verified():
|
||||
assert author_from(EMAIL).verified is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Through the real build path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_carries_the_author_and_moves_no_id():
|
||||
"""
|
||||
The same claim again, through ``build()`` rather than ``record_for``, so a
|
||||
wiring mistake between the two cannot hide behind a passing unit test.
|
||||
"""
|
||||
profiles = pytest.importorskip("mechcomp.profiles")
|
||||
|
||||
plain = profiles.build("3x", "Y")
|
||||
signed = profiles.build("3x", "Y", author=EMAIL)
|
||||
|
||||
assert signed.record.author.email == EMAIL
|
||||
assert plain.record.author is None
|
||||
assert signed.record.input_id == plain.record.input_id
|
||||
assert signed.record.build_id == plain.record.build_id
|
||||
assert signed.report == plain.report
|
||||
Reference in New Issue
Block a user