Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Tests for the membership-based schematic diff (checks/diff.py, SPEC §3.6).
Core invariants under test:
* nets are matched by pin MEMBERSHIP (Jaccard), never by display name — so a
pure rename is reported as a name-only change, not add+remove;
* components match UniqueID > (value, footprint, pin-count) signature > refdes,
and the resulting refdes-rename map remaps net membership before matching;
* cross-revision diffs (no shared UniqueIDs) raise the low_confidence flag.
"""
from __future__ import annotations
import os
import pytest
from akcli.checks import diff
from akcli.model import Component, Net, Pin, PinType, Schematic
from akcli.readers import altium_sch
FIX = os.path.join(os.path.dirname(__file__), "fixtures")
# --------------------------------------------------------------------------- #
# builders
# --------------------------------------------------------------------------- #
def _pins(n: int) -> list[Pin]:
return [Pin(number=str(i + 1), name=None, x_mil=0.0, y_mil=0.0) for i in range(n)]
def comp(des, value=None, footprint=None, npins=2, uid=None, lib=None,
rotation=0, mirror="none") -> Component:
return Component(
designator=des, library_ref=lib, x_mil=0.0, y_mil=0.0,
rotation=rotation, mirror=mirror, value=value, footprint=footprint,
unique_id=uid, pins=_pins(npins),
)
def net(name, members, *, named=True, aliases=None) -> Net:
return Net(name=name, members=sorted(members), is_named=named, aliases=aliases or [])
def sch(components, nets, fmt="altium") -> Schematic:
return Schematic(source_path="x", source_format=fmt, components=components, nets=nets)
# --------------------------------------------------------------------------- #
# net matching by membership, not name
# --------------------------------------------------------------------------- #
def test_pure_rename_same_membership_is_name_only_change():
members = [("U1", "1"), ("U2", "2")]
a = sch([comp("U1", uid="a"), comp("U2", uid="b")], [net("OLD_NAME", members)])
b = sch([comp("U1", uid="a"), comp("U2", uid="b")], [net("NEW_NAME", members)])
rep = diff.run(a, b)
# one matched net, NOT an add + remove
assert len(rep.matched_nets) == 1
assert not rep.added_nets and not rep.removed_nets
renamed = rep.renamed_nets
assert len(renamed) == 1
assert renamed[0].name_a == "OLD_NAME" and renamed[0].name_b == "NEW_NAME"
assert renamed[0].name_changed and not renamed[0].membership_changed
assert renamed[0].jaccard == pytest.approx(1.0)
# no membership churn
assert not rep.member_changed_nets
def test_same_name_different_membership_is_not_a_free_match():
# identical display name but disjoint membership -> NOT the same net
a = sch([comp("U1", uid="a"), comp("U2", uid="b")],
[net("VBUS", [("U1", "1"), ("U2", "1")])])
b = sch([comp("U3", uid="c"), comp("U4", uid="d")],
[net("VBUS", [("U3", "1"), ("U4", "1")])])
rep = diff.run(a, b)
assert not rep.matched_nets
assert len(rep.added_nets) == 1
assert len(rep.removed_nets) == 1
def test_membership_change_added_and_removed_members():
a = sch([comp("U1", uid="a"), comp("U2", uid="b"), comp("U3", uid="c")],
[net("SIG", [("U1", "1"), ("U2", "1"), ("U3", "1")])])
# drop U3.1, add U2.2 -> jaccard 2/4 = 0.5, still a match
b = sch([comp("U1", uid="a"), comp("U2", uid="b"), comp("U3", uid="c")],
[net("SIG", [("U1", "1"), ("U2", "1"), ("U2", "2")])])
rep = diff.run(a, b)
assert len(rep.matched_nets) == 1
nc = rep.member_changed_nets[0]
assert nc.membership_changed and not nc.name_changed
assert ("U2", "2") in nc.added_members
assert ("U3", "1") in nc.removed_members
assert nc.jaccard == pytest.approx(0.5)
# the JSON export uses "REF.PIN" strings — ONE member encoding across
# net/nets/diff/verify (agents keep a single parser)
exported = nc.export()
assert exported["added_members"] == ["U2.2"]
assert exported["removed_members"] == ["U3.1"]
def test_unrelated_nets_below_threshold_do_not_match():
# share exactly one pin out of many -> jaccard 1/5 < MIN_JACCARD
a = sch([comp(f"U{i}", uid=str(i)) for i in range(1, 5)],
[net("A", [("U1", "1"), ("U2", "1"), ("U3", "1")])])
b = sch([comp(f"U{i}", uid=str(i)) for i in range(1, 5)],
[net("B", [("U1", "1"), ("U4", "1"), ("U4", "2")])])
rep = diff.run(a, b)
assert not rep.matched_nets
assert len(rep.added_nets) == 1 and len(rep.removed_nets) == 1
# --------------------------------------------------------------------------- #
# component matching priority
# --------------------------------------------------------------------------- #
def test_component_matched_by_unique_id_despite_refdes_change():
a = sch([comp("R7", value="10k", footprint="0402", uid="UID-1")], [])
b = sch([comp("R8", value="10k", footprint="0402", uid="UID-1")], [])
rep = diff.run(a, b)
assert not rep.added_components and not rep.removed_components
m = rep.matched_components[0]
assert m.method == "unique_id"
assert m.designator_a == "R7" and m.designator_b == "R8"
assert "designator" in m.field_changes
assert m.field_changes["designator"] == ("R7", "R8")
def test_unique_id_match_beats_signature_collision():
# two parts with identical signature; uid forces the correct pairing
a = sch([comp("R1", value="1k", footprint="0402", uid="X"),
comp("R2", value="1k", footprint="0402", uid="Y")], [])
b = sch([comp("R2", value="1k", footprint="0402", uid="X"),
comp("R1", value="1k", footprint="0402", uid="Y")], [])
rep = diff.run(a, b)
pairs = {(m.designator_a, m.designator_b): m for m in rep.matched_components}
# X stayed X across the rename R1->R2; Y across R2->R1
assert ("R1", "R2") in pairs and pairs[("R1", "R2")].method == "unique_id"
assert ("R2", "R1") in pairs and pairs[("R2", "R1")].method == "unique_id"
def test_component_matched_by_signature_when_no_uid():
a = sch([comp("C1", value="100nF", footprint="0402", npins=2)], [])
b = sch([comp("C9", value="100nF", footprint="0402", npins=2)], [])
rep = diff.run(a, b)
m = rep.matched_components[0]
assert m.method == "signature"
assert m.designator_a == "C1" and m.designator_b == "C9"
def test_signature_prefers_same_refdes_tiebreak():
a = sch([comp("R1", value="1k", footprint="0402"),
comp("R2", value="1k", footprint="0402")], [])
b = sch([comp("R2", value="1k", footprint="0402"),
comp("R1", value="1k", footprint="0402")], [])
rep = diff.run(a, b)
# no uids -> signature pass, refdes tiebreak keeps R1<->R1, R2<->R2
pairs = {(m.designator_a, m.designator_b) for m in rep.matched_components}
assert ("R1", "R1") in pairs and ("R2", "R2") in pairs
def test_component_matched_by_refdes_when_signature_differs():
# same refdes, changed value -> refdes pass, value reported as a field change
a = sch([comp("U3", value="nRF52833", footprint="QFN48")], [])
b = sch([comp("U3", value="nRF52840", footprint="QFN48")], [])
rep = diff.run(a, b)
m = rep.matched_components[0]
assert m.method == "refdes"
assert m.field_changes["value"] == ("nRF52833", "nRF52840")
def test_added_and_removed_components():
a = sch([comp("R1", uid="a"), comp("R2", uid="b")], [])
b = sch([comp("R1", uid="a"), comp("R3", uid="c")], [])
rep = diff.run(a, b)
assert [c.designator_a for c in rep.removed_components] == ["R2"]
assert [c.designator_b for c in rep.added_components] == ["R3"]
# --------------------------------------------------------------------------- #
# refdes-rename map remaps net membership
# --------------------------------------------------------------------------- #
def test_refdes_rename_keeps_net_matched_via_remap():
# U1 -> U9 (matched by uid); the net it touches must still match, not churn
a = sch([comp("U1", uid="K", npins=2), comp("U2", uid="L", npins=2)],
[net("DAT", [("U1", "1"), ("U2", "1")])])
b = sch([comp("U9", uid="K", npins=2), comp("U2", uid="L", npins=2)],
[net("DAT", [("U9", "1"), ("U2", "1")])])
rep = diff.run(a, b)
assert rep.rename_map == {"U1": "U9"}
assert len(rep.matched_nets) == 1
nc = rep.matched_nets[0]
assert not nc.membership_changed # remap made memberships identical
assert not rep.added_nets and not rep.removed_nets
# --------------------------------------------------------------------------- #
# low-confidence (cross-revision) flag
# --------------------------------------------------------------------------- #
def test_low_confidence_when_no_shared_unique_ids():
a = sch([comp("R1", value="1k", footprint="0402")],
[net("N", [("R1", "1"), ("R1", "2")])])
b = sch([comp("R1", value="1k", footprint="0402")],
[net("N", [("R1", "1"), ("R1", "2")])])
rep = diff.run(a, b)
assert rep.low_confidence is True
assert rep.notes
assert "UniqueID" in rep.notes[0]
def test_high_confidence_when_uids_match():
a = sch([comp("R1", uid="a"), comp("R2", uid="b")], [])
b = sch([comp("R1", uid="a"), comp("R2", uid="b")], [])
rep = diff.run(a, b)
assert rep.low_confidence is False
def test_low_confidence_lowers_net_confidence():
members = [("R1", "1"), ("R1", "2")]
a = sch([comp("R1", value="1k", footprint="0402")], [net("N", members)])
b = sch([comp("R1", value="1k", footprint="0402")], [net("N", members)])
rep = diff.run(a, b)
assert rep.matched_nets[0].confidence <= 0.6
# --------------------------------------------------------------------------- #
# identity / determinism / export / findings
# --------------------------------------------------------------------------- #
def test_identity_diff_is_empty():
c = [comp("U1", uid="a"), comp("U2", uid="b")]
n = [net("GND", [("U1", "1"), ("U2", "1")])]
a = sch(list(c), list(n))
b = sch(list(c), list(n))
rep = diff.run(a, b)
assert not rep.added_components and not rep.removed_components
assert not rep.changed_components
assert not rep.added_nets and not rep.removed_nets
assert not rep.renamed_nets and not rep.member_changed_nets
def test_run_is_deterministic():
a = sch([comp("U1", uid="a"), comp("U2", uid="b"), comp("U3", uid="c")],
[net("A", [("U1", "1"), ("U2", "1")]), net("B", [("U3", "1"), ("U3", "2")])])
b = sch([comp("U1", uid="a"), comp("U2", uid="b")],
[net("A2", [("U1", "1"), ("U2", "1")])])
r1, r2 = diff.run(a, b), diff.run(a, b)
assert r1.export() == r2.export()
def test_export_is_json_native():
import json
a = sch([comp("U1", uid="a")], [net("A", [("U1", "1"), ("U1", "2")])])
b = sch([comp("U1", uid="a"), comp("U2", uid="b")],
[net("A", [("U1", "1"), ("U1", "2")]), net("B", [("U2", "1"), ("U2", "2")])])
rep = diff.run(a, b)
s = json.dumps(rep.export()) # must not raise
back = json.loads(s)
assert back["summary"]["nets"]["added"] == 1
assert back["summary"]["components"]["added"] == 1
def test_findings_cover_changes():
a = sch([comp("U1", value="A", footprint="F", uid="a"), comp("U2", uid="b")],
[net("OLD", [("U1", "1"), ("U2", "1")])])
b = sch([comp("U1", value="B", footprint="F", uid="a"), comp("U3", uid="c")],
[net("NEW", [("U1", "1"), ("U2", "1")]), net("EXTRA", [("U3", "1"), ("U3", "2")])])
rep = diff.run(a, b)
codes = {f.code for f in rep.findings()}
assert "DIFF_COMPONENT_CHANGED" in codes # U1 value A->B
assert "DIFF_COMPONENT_REMOVED" in codes # U2
assert "DIFF_COMPONENT_ADDED" in codes # U3
assert "DIFF_NET_RENAMED" in codes # OLD->NEW same membership
assert "DIFF_NET_ADDED" in codes # EXTRA
def test_pin_count_change_reported():
a = sch([comp("U1", value="v", footprint="f", npins=4, uid="a")], [])
b = sch([comp("U1", value="v", footprint="f", npins=6, uid="a")], [])
rep = diff.run(a, b)
m = rep.matched_components[0]
assert m.field_changes["pin_count"] == (4, 6)
# --------------------------------------------------------------------------- #
# real fixture end-to-end (uses the frozen Altium reader)
# --------------------------------------------------------------------------- #
def test_real_fixture_self_diff_is_clean():
a = altium_sch.read(os.path.join(FIX, "shared_name_label.SchDoc"))
b = altium_sch.read(os.path.join(FIX, "shared_name_label.SchDoc"))
rep = diff.run(a, b)
assert not rep.added_nets and not rep.removed_nets
assert not rep.member_changed_nets and not rep.renamed_nets
assert not rep.added_components and not rep.removed_components
def test_real_fixture_detects_added_component():
a = altium_sch.read(os.path.join(FIX, "two_gnd_ports.SchDoc"))
b = altium_sch.read(os.path.join(FIX, "shared_name_label.SchDoc"))
rep = diff.run(a, b)
# different boards -> something must differ and it is flagged low-confidence
assert rep.low_confidence is True
assert rep.net_changes # at minimum the GND vs STAT nets diverge
# --------------------------------------------------------------------------- #
# GUI rotate/mirror pin-swap: the diff must never go silent on it
# --------------------------------------------------------------------------- #
def test_rotated_symbol_label_swap_is_loudly_reported():
"""Rotating a symmetric 2-pin part 180° in the GUI swaps which pin sits
under each label: SIG_A={U1.1,R1.1}/SIG_B={U1.2,R2.1} becomes
SIG_A={U1.2,R1.1}/SIG_B={U1.1,R2.1}. Both post-edit nets score below the
Jaccard match floor against both pre-edit nets (1/3 < 0.34), so the diff
presents the swap as two removed + two added nets rather than two
membership changes — a known presentation rough edge. This test pins the
load-bearing property: every affected net raises a WARNING finding and
every affected pin appears in the report; a future matcher refactor must
not lose a side of the swap."""
comps_a = [comp("U1", uid="u"), comp("R1", uid="r1"), comp("R2", uid="r2")]
comps_b = [comp("U1", uid="u"), comp("R1", uid="r1"), comp("R2", uid="r2")]
a = sch(comps_a, [net("SIG_A", [("U1", "1"), ("R1", "1")]),
net("SIG_B", [("U1", "2"), ("R2", "1")])])
b = sch(comps_b, [net("SIG_A", [("U1", "2"), ("R1", "1")]),
net("SIG_B", [("U1", "1"), ("R2", "1")])])
rep = diff.run(a, b)
# not silent: four WARNING findings name both nets on both sides
codes = [f.code for f in rep.findings()]
assert codes.count("DIFF_NET_REMOVED") == 2
assert codes.count("DIFF_NET_ADDED") == 2
# every affected pin is visible in the reported membership deltas
removed = {m for n in rep.removed_nets for m in n.removed_members}
added = {m for n in rep.added_nets for m in n.added_members}
swap_pins = {("U1", "1"), ("U1", "2"), ("R1", "1"), ("R2", "1")}
assert swap_pins <= removed
assert swap_pins <= added
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.