"""Config-file part references resolved against a fake board. Real kipy objects (Pad/Via/BoardRectangle/BoardText - _to_electrode and the labeled-rectangle scan dispatch on isinstance), a duck-typed board. """ from types import SimpleNamespace as NS import numpy as np import pytest from kipy.board_types import BoardRectangle, BoardText, Net, Pad, Via from kipy.geometry import Vector2 from kipy.util.board_layer import layer_from_canonical_name from fill_resistance import board_io, config from fill_resistance.configfile import PartRef, TerminalSpec from fill_resistance.errors import ConfigError, SelectionError from fill_resistance.geometry import Polygon MM = 1_000_000 def _pad(x_mm, y_mm, number, net): p = Pad() p.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM)) p.number = number p.net = Net(name=net) return p def _fp(ref, pads): return NS(reference_field=NS(text=NS(value=ref)), definition=NS(pads=pads)) def _rect(x0_mm, y0_mm, x1_mm, y1_mm, layer="User.3"): r = BoardRectangle() r.layer = layer_from_canonical_name(layer) r.top_left = Vector2.from_xy(int(x0_mm * MM), int(y0_mm * MM)) r.bottom_right = Vector2.from_xy(int(x1_mm * MM), int(y1_mm * MM)) return r def _text(x_mm, y_mm, value, layer="User.3"): t = BoardText() t.layer = layer_from_canonical_name(layer) t.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM)) t.value = value return t def _via(x_mm, y_mm, net, drill_mm=0.3): v = Via() v.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM)) v.net = Net(name=net) v.drill_diameter = int(drill_mm * MM) return v class _FakeBoard: def __init__(self, footprints=(), shapes=(), texts=(), vias=()): self._fps = list(footprints) self._shapes = list(shapes) self._texts = list(texts) self._vias = list(vias) def get_footprints(self): return list(self._fps) def get_pads(self): return [p for fp in self._fps for p in fp.definition.pads] def get_shapes(self): return list(self._shapes) def get_text(self): return list(self._texts) def get_vias(self): return list(self._vias) def get_item_bounding_box(self, item): p = item.position return NS(pos=NS(x=p.x - 500_000, y=p.y - 500_000), size=NS(x=1_000_000, y=1_000_000)) def get_pad_shapes_as_polygons(self, pad, layer): return None # rect fallback is fine here def _board(): u7 = _fp("U7", [_pad(10, 10, "1", "VCC"), _pad(12, 10, "2", "GND"), _pad(14, 10, "3", "VCC")]) j1 = _fp("J1", [_pad(0, 0, "1", "VCC")]) return _FakeBoard( footprints=[u7, j1], shapes=[_rect(20, 20, 30, 26), _rect(40, 20, 46, 26), _rect(50, 50, 52, 52)], # the last one unnamed texts=[_text(25, 23, "ZONE_A"), _text(43, 23, "ZONE_B"), _text(90, 90, "ELSEWHERE")], vias=[_via(5, 5, "VCC"), _via(8, 5, "GND")], ) def _ctx(board=None, net="VCC"): return board_io._RefContext(board or _board(), None, net) def test_footprint_resolves_only_net_pads(): els = _ctx().resolve(PartRef(kind="footprint", ref="U7"), "t") assert len(els) == 2 # pads 1 and 3, not the GND one assert all("VCC" in e.label for e in els) def test_footprint_not_found(): with pytest.raises(ConfigError, match="'U9' not found"): _ctx().resolve(PartRef(kind="footprint", ref="U9"), "t") def test_footprint_without_net_pads_lists_its_nets(): with pytest.raises(ConfigError, match="GND|VCC"): _ctx(net="V5").resolve(PartRef(kind="footprint", ref="U7"), "t") def test_pad_by_number(): els = _ctx().resolve(PartRef(kind="pad", ref="U7", pad="3"), "t") assert len(els) == 1 assert els[0].label == "pad 3@VCC" def test_pad_number_missing_lists_pads(): with pytest.raises(ConfigError, match="no pad '9'.*1.*2.*3"): _ctx().resolve(PartRef(kind="pad", ref="U7", pad="9"), "t") def test_pad_net_mismatch(): with pytest.raises(ConfigError, match="'U7.2' is on 'GND', not 'VCC'"): _ctx().resolve(PartRef(kind="pad", ref="U7", pad="2"), "t") def test_duplicate_refdes_is_ambiguous(): board = _board() board._fps.append(_fp("U7", [_pad(50, 50, "1", "VCC")])) with pytest.raises(ConfigError, match="ambiguous"): _ctx(board).resolve(PartRef(kind="footprint", ref="U7"), "t") def test_labeled_rect_resolves(): els = _ctx().resolve(PartRef(kind="rect_label", label="ZONE_A"), "t") assert len(els) == 1 r = els[0].rect assert (r.x0, r.y0, r.x1, r.y1) == (20 * MM, 20 * MM, 30 * MM, 26 * MM) assert els[0].contact == "all" def test_unknown_label_lists_found_names(): with pytest.raises(ConfigError, match="ZONE_A.*ZONE_B"): _ctx().resolve(PartRef(kind="rect_label", label="NOPE"), "t") def test_unnamed_rect_warns_and_is_skipped(capsys): ctx = _ctx() ctx._labeled_rects(config.ELECTRODE_PDN_LAYER) assert "unnamed rectangle" in capsys.readouterr().out def test_two_texts_in_one_rect_is_an_error(): board = _board() board._texts.append(_text(26, 24, "ZONE_A2")) # also inside rect 1 with pytest.raises(ConfigError, match="2 text items"): _ctx(board)._labeled_rects(config.ELECTRODE_PDN_LAYER) def test_same_name_rects_form_a_multipart_ref(): """Two rectangles sharing one name are ONE multi-part reference (the grouping mechanism for bonded multi-contact terminals).""" board = _board() board._texts.append(_text(43, 24, "ZONE_A")) # names rect 2 too board._texts.remove(board._texts[1]) # drop ZONE_B els = _ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"), "t") assert len(els) == 2 assert {e.rect.x0 for e in els} == {20 * MM, 40 * MM} def test_rect_mm_converts_and_scopes(): els = _ctx().resolve(PartRef(kind="rect_mm", rect_mm=(1.0, 2.0, 3.0, 4.0), contact="B.Cu"), "t") r = els[0].rect assert (r.x0, r.y0, r.x1, r.y1) == (1 * MM, 2 * MM, 3 * MM, 4 * MM) assert els[0].contact == "B.Cu" def test_via_mm_nearest_of_the_net(): els = _ctx().resolve(PartRef(kind="via_mm", via_mm=(5.2, 5.0)), "t") assert len(els) == 1 assert els[0].drill_nm == 300_000 assert els[0].center == (5 * MM, 5 * MM) # not the closer GND via def test_via_mm_too_far(): with pytest.raises(ConfigError, match="mm away"): _ctx().resolve(PartRef(kind="via_mm", via_mm=(5.0, 30.0)), "t") def test_resolve_terminal_specs_maps_names_and_scopes(): specs = [ TerminalSpec(name="src", role="supply", parts=[PartRef(kind="pad", ref="J1", pad="1")], r_out_ohm=0.01, v_oc=3.28), TerminalSpec(name="zone", role="load", parts=[PartRef(kind="rect_label", label="ZONE_A"), PartRef(kind="rect_mm", rect_mm=(0, 0, 1, 1), contact="In1.Cu")], i_draw_a=2.0, contact="F.Cu"), ] terms = board_io.resolve_terminal_specs(_board(), None, specs, "VCC") src, zone = terms assert src.label == "src" and src.role == "supply" assert src.r_out_ohm == pytest.approx(0.01) assert src.v_oc == pytest.approx(3.28) assert zone.i_draw_a == pytest.approx(2.0) # terminal-level scope applies where the part has none... assert zone.electrodes[0].contact == "F.Cu" # ...but an explicit part-level contact wins assert zone.electrodes[1].contact == "In1.Cu" def test_resolve_classic_parts(): es1, es2 = board_io.resolve_classic_parts( _board(), None, [PartRef(kind="pad", ref="J1", pad="1")], [PartRef(kind="footprint", ref="U7")], "VCC") assert len(es1) == 1 and len(es2) == 2 # --- labeled rects across marker layers / the PDN editor scan ---------------- def test_labeled_rects_cached_per_layer(): ctx = _ctx() a = ctx._labeled_rects("User.3") assert ctx._labeled_rects("User.3") is a # keyed cache assert ctx._labeled_rects("User.1") is not a def test_rect_label_resolves_on_editor_marker_layers(): board = _board() board._shapes.append(_rect(60, 10, 66, 14, layer="User.1")) board._texts.append(_text(63, 12, "VIN", layer="User.1")) els = _ctx(board).resolve(PartRef(kind="rect_label", label="VIN"), "t") r = els[0].rect assert (r.x0, r.y0) == (60 * MM, 10 * MM) def test_rect_label_cross_layer_collision_errors(): board = _board() board._shapes.append(_rect(60, 10, 66, 14, layer="User.1")) board._texts.append(_text(63, 12, "ZONE_A", layer="User.1")) with pytest.raises(ConfigError, match="User.3 and User.1"): _ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"), "t") def _marker_board(pos=(), neg=(), extra_shapes=(), extra_texts=()): """pos/neg: iterables of (x0, y0, x1, y1, name_or_None).""" shapes, texts = list(extra_shapes), list(extra_texts) for layer, group in (("User.1", pos), ("User.2", neg)): for x0, y0, x1, y1, name in group: shapes.append(_rect(x0, y0, x1, y1, layer=layer)) if name is not None: texts.append(_text((x0 + x1) / 2, (y0 + y1) / 2, name, layer=layer)) return _FakeBoard(shapes=shapes, texts=texts) def test_scan_roles_names_and_reading_order(): board = _marker_board( pos=[(0, 10, 2, 12, "VIN"), (0, 2, 2, 4, None)], neg=[(20, 0, 22, 2, None), (10, 0, 12, 2, "CPU")]) terms = board_io.scan_marker_terminals(board) # supplies first, each group in (y, x) reading order assert [(t.name, t.role, t.labeled) for t in terms] == [ ("S1", "supply", False), # y=2 before the labeled y=10 one ("VIN", "supply", True), ("CPU", "load", True), # same y: x=10 before x=20 ("L1", "load", False), ] assert terms[0].electrodes[0].label == "S1" r = terms[1].electrodes[0].rect assert (r.x0, r.y0, r.x1, r.y1) == (0, 10 * MM, 2 * MM, 12 * MM) def test_scan_auto_names_skip_taken_labels(): board = _marker_board(pos=[(0, 0, 2, 2, "S1"), (0, 4, 2, 6, None)], neg=[(10, 0, 12, 2, None)]) terms = board_io.scan_marker_terminals(board) assert [t.name for t in terms] == ["S1", "S2", "L1"] def test_scan_requires_rects_on_both_layers(): board = _marker_board(pos=[(0, 0, 2, 2, None)], neg=[]) with pytest.raises(SelectionError, match="1 on User.1.*0 on User.2"): board_io.scan_marker_terminals(board) def test_scan_duplicate_name_across_pos_and_neg(): board = _marker_board(pos=[(0, 0, 2, 2, "X")], neg=[(10, 0, 12, 2, "X")]) with pytest.raises(ConfigError, match="User.1 and User.2"): board_io.scan_marker_terminals(board) def test_scan_name_collision_with_pdn_layer_labels(): board = _marker_board( pos=[(0, 0, 2, 2, "ZONE")], neg=[(10, 0, 12, 2, None)], extra_shapes=[_rect(50, 50, 56, 54, layer="User.3")], extra_texts=[_text(53, 52, "ZONE", layer="User.3")]) with pytest.raises(ConfigError, match="unique across"): board_io.scan_marker_terminals(board) def test_scan_groups_same_label_rects_into_one_bonded_terminal(): """Two rectangles labeled identically on one layer = one bonded terminal (a multi-pin package: total known, split solved).""" board = _marker_board( pos=[(0, 0, 2, 2, "VIN")], neg=[(10, 0, 12, 2, "PKG"), (20, 0, 22, 2, "PKG"), (30, 0, 32, 2, None)]) terms = board_io.scan_marker_terminals(board) assert [(t.name, t.role, t.bonded, len(t.electrodes)) for t in terms] == [ ("VIN", "supply", False, 1), ("PKG", "load", True, 2), ("L1", "load", False, 1), ] xs = sorted(e.rect.x0 for e in terms[1].electrodes) assert xs == [10 * MM, 20 * MM] def test_scan_two_texts_in_one_rect_propagates(): board = _marker_board(pos=[(0, 0, 4, 4, "A")], neg=[(10, 0, 12, 2, None)], extra_texts=[_text(1, 1, "B", layer="User.1")]) with pytest.raises(ConfigError, match="2 text items"): board_io.scan_marker_terminals(board) # --- merging newly drawn rectangles into a config-backed set ----------------- def test_scan_without_require_both_allows_empty_layers(): board = _marker_board(pos=[(0, 0, 4, 4, "VIN")], neg=[]) with pytest.raises(SelectionError): board_io.scan_marker_terminals(board) terms = board_io.scan_marker_terminals(board, require_both=False) assert [(t.name, t.role) for t in terms] == [("VIN", "supply")] def test_new_marker_terminals_filters_covered_rects(): board = _marker_board(pos=[(0, 0, 4, 4, "VIN")], neg=[(10, 0, 12, 2, "CPU"), (20, 0, 22, 2, None), (30, 0, 32, 2, "FAN")]) scanned = board_io.scan_marker_terminals(board) specs = [ TerminalSpec(name="VIN", role="supply", parts=[PartRef(kind="rect_label", label="VIN")]), TerminalSpec(name="CPU", role="load", parts=[PartRef(kind="rect_label", label="CPU")]), # the unnamed rect was frozen as coordinates by an earlier save TerminalSpec(name="L_old", role="load", parts=[PartRef(kind="rect_mm", rect_mm=(20.0, 0.0, 22.0, 2.0))]), ] new = board_io.new_marker_terminals(specs, scanned) assert [mt.name for mt in new] == ["FAN"] # only the new one def test_new_marker_terminals_handles_name_collisions(capsys): board = _marker_board(pos=[(0, 0, 4, 4, None)], neg=[(10, 0, 12, 2, "mcu")]) scanned = board_io.scan_marker_terminals(board) specs = [ TerminalSpec(name="S1", role="supply", parts=[PartRef(kind="footprint", ref="U1")]), TerminalSpec(name="mcu", role="load", parts=[PartRef(kind="footprint", ref="U7")]), ] new = board_io.new_marker_terminals(specs, scanned) # the labeled collision is skipped with a note (ambiguous - the # config's "mcu" does not reference the rectangle); the colliding # auto name is simply renumbered assert [mt.name for mt in new] == ["S2"] assert new[0].electrodes[0].label == "S2" assert "collides" in capsys.readouterr().out # --- component hints (the dialog's Component column) ------------------------- def test_component_hints_direct_and_nearest(): u5 = _fp("U5", [_pad(21, 21, "1", "VCC")]) # inside the first rect j2 = _fp("J2", [_pad(60, 24, "1", "GND")]) # nearest to the second board = _FakeBoard(footprints=[u5, j2], shapes=[_rect(20, 20, 30, 26), _rect(40, 20, 46, 26)]) groups = [[board_io._to_electrode(board, r)] for r in board.get_shapes()] hints = board_io.component_hints(board, groups) assert hints[0] == "U5" # no intersection: J2's pad (14 mm away) beats U5's (19 mm); the # net does not matter - this is spatial identification only assert hints[1] == "near J2" def test_component_hints_multiple_hits_and_empty_board(): a = _fp("U1", [_pad(21, 21, "1", "VCC")]) b = _fp("R5", [_pad(29, 25, "1", "VCC")]) board = _FakeBoard(footprints=[a, b], shapes=[_rect(20, 20, 30, 26)]) e = board_io._to_electrode(board, board.get_shapes()[0]) assert board_io.component_hints(board, [[e]]) == ["U1, R5"] bare = _FakeBoard(shapes=[_rect(0, 0, 1, 1)]) e2 = board_io._to_electrode(bare, bare.get_shapes()[0]) assert board_io.component_hints(bare, [[e2]]) == [""] def test_component_hints_check_every_rect_of_a_group(): # bonded group: the pad sits in the SECOND rectangle - still a hit u9 = _fp("U9", [_pad(45, 23, "1", "VCC")]) board = _FakeBoard(footprints=[u9], shapes=[_rect(0, 0, 2, 2), _rect(44, 22, 46, 24)]) es = [board_io._to_electrode(board, r) for r in board.get_shapes()] assert board_io.component_hints(board, [es]) == ["U9"] def _poly(x0_mm, y0_mm, x1_mm, y1_mm): return Polygon(outline=np.array( [[x0_mm, y0_mm], [x1_mm, y0_mm], [x1_mm, y1_mm], [x0_mm, y1_mm]], dtype=np.int64) * MM) def test_group_nets_by_copper_overlap(): board = _FakeBoard(shapes=[_rect(0, 0, 4, 4), _rect(10, 0, 14, 4)]) groups = [[board_io._to_electrode(board, r)] for r in board.get_shapes()] copper = {"VCC": {"F.Cu": [_poly(0, 0, 6, 6)]}, "GND": {"B.Cu": [_poly(8, 0, 20, 6)]}} assert board_io.group_nets(copper, groups) == [ frozenset({"VCC"}), frozenset({"GND"})] # a rectangle over bare board overlaps nothing bare = _FakeBoard(shapes=[_rect(40, 40, 42, 42)]) e = board_io._to_electrode(bare, bare.get_shapes()[0]) assert board_io.group_nets(copper, [[e]]) == [frozenset()]