Files
FacereDataset/tools/epro2/tests/test_sym_writer.py
Knowit 54f0173947 tools/epro2/kicad: fix two structural ERC bugs — wire_dangling -88%, pin_not_connected -52%
Bisect found two semantics mismatches between EPRO2 and KiCad that cause
the 850 real-connectivity ERC violations on the ESP-VoCat ref project:

1. sym_writer was emitting lib coords without negating Y, but KiCad lib
   uses Y-up and re-flips Y on placement (Y-down schematic). So vertically
   arranged pins ended up at Y-mirrored absolute positions and wires that
   reach the geometric pin tip in EPRO2 missed the rendered pin tip in
   KiCad. Fix: lib_y = -epro2_y, lib_rot = (360 - rot) % 360 for pin/text.

2. sch_writer was treating each LINE as an isolated wire — but EPRO2
   binds segments into nets by NAME (WIRE.NET attr), not just geometry.
   Multi-segment nets like GND/VBUS show up as N disconnected stubs to
   KiCad. Fix: per-LINE, look up lineGroup → WIRE → NET attr and emit a
   `(label "<NET>")` at the LINE's start. Same-named labels on distinct
   physical wires is how KiCad's ERC recognizes a multi-segment net.

ESP-VoCat 9 sheets:
  wire_dangling           444 →  52  (-88%)
  pin_not_connected       406 → 196  (-52%)
  real connectivity total 850 → 248  (-71%)

Why we did NOT round to grid (the obvious-looking fix): EPRO2 places
some pins on a 10-mil pitch (e.g. magnetic socket); rounding to KiCad's
default 50-mil ERC grid would collapse those pins. The 248 residual is
fundamentally cross-sheet — single-sheet ERC can't see a net's other
endpoints on sibling sheets — and is a Phase-3 (hierarchical sheet)
problem, not a per-sheet one.

41 → 46 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:43:11 +08:00

190 lines
8.1 KiB
Python

"""Symbol writer regression: synthetic SYMBOL doc → KiCad lib symbol entry."""
from tools.epro2.kicad._sexpr_reader import parse
from tools.epro2.kicad.sch_writer import write_sch_page
from tools.epro2.kicad.sexpr import to_sexpr
from tools.epro2.kicad.sym_writer import write_lib_symbol
from tools.epro2.project_relations import ProjectRelations
from tools.epro2.replay import Document, Project
def _sym_doc(uuid: str, part_id: str, primitives: list[tuple[str, dict]]) -> Document:
d = Document(doc_uuid=uuid, doc_type="SYMBOL")
d.objects[part_id] = {"_type": "PART", "BBOX": [-10, 10, 10, -10], "title": part_id}
for k, v in primitives:
d.objects[k] = v
return d
def _block(parsed, name):
return [c for c in parsed if isinstance(c, list) and c and c[0] == name]
def test_write_lib_symbol_emits_outer_wrapper_and_body():
d = _sym_doc("sym1", "MyPart.1", [])
entry = write_lib_symbol(d)
assert entry is not None
text = to_sexpr(entry)
p = parse(text)
assert p[0] == "symbol"
assert p[1] == "facere:MyPart.1"
# Properties: Reference / Value / Footprint / Datasheet
props = _block(p, "property")
by_name = {x[1]: x[2] for x in props}
assert by_name["Reference"] == "U"
assert by_name["Value"] == "MyPart.1"
assert by_name["Footprint"] == ""
# Inner body wrapper named <part>_1_1
inner = _block(p, "symbol")
assert len(inner) == 1
assert inner[0][1] == "MyPart.1_1_1"
def test_lib_symbol_omits_pin_numbers_block():
"""KiCad 8 rejects (pin_numbers (hide no)). When pins should be visible
we must omit the pin_numbers block entirely (default = visible)."""
d = _sym_doc("sym1", "MyPart.1", [])
entry = write_lib_symbol(d)
text = to_sexpr(entry)
parsed = parse(text)
assert not _block(parsed, "pin_numbers"), \
"pin_numbers block should be omitted (KiCad 8 syntax)"
def test_pin_renders_with_attr_pulled_metadata():
d = _sym_doc("sym1", "MyPart.1", [
("e5", {"_type": "PIN", "partId": "MyPart.1",
"x": -15, "y": 0, "length": 10, "rotation": 0}),
("e6", {"_type": "ATTR", "parentId": "e5", "key": "Pin Name", "value": "VCC"}),
("e7", {"_type": "ATTR", "parentId": "e5", "key": "Pin Number", "value": "1"}),
("e8", {"_type": "ATTR", "parentId": "e5", "key": "Pin Type", "value": "POWER_IN"}),
])
entry = write_lib_symbol(d)
text = to_sexpr(entry)
parsed = parse(text)
inner_body = next(c for c in parsed if isinstance(c, list) and c and c[0] == "symbol")
pins = [c for c in inner_body if isinstance(c, list) and c and c[0] == "pin"]
assert len(pins) == 1
pin = pins[0]
assert pin[1] == "power_in" # electrical type mapped
assert pin[2] == "line" # shape
name_block = next(c for c in pin if isinstance(c, list) and c and c[0] == "name")
num_block = next(c for c in pin if isinstance(c, list) and c and c[0] == "number")
assert name_block[1] == "VCC"
assert num_block[1] == "1"
def test_rect_poly_circle_text_emit_correct_shapes():
d = _sym_doc("sym1", "MyPart.1", [
("r1", {"_type": "RECT", "partId": "MyPart.1",
"dotX1": -10, "dotY1": -5, "dotX2": 10, "dotY2": 5}),
("p1", {"_type": "POLY", "partId": "MyPart.1",
"points": [{"x": 0, "y": 0}, {"x": 5, "y": 0}, {"x": 5, "y": 5}],
"closed": True}),
("c1", {"_type": "CIRCLE", "partId": "MyPart.1",
"centerX": 0, "centerY": 0, "radius": 3}),
("t1", {"_type": "TEXT", "partId": "MyPart.1",
"x": 0, "y": -5, "value": "label"}),
])
entry = write_lib_symbol(d)
parsed = parse(to_sexpr(entry))
inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol")
types = [c[0] for c in inner if isinstance(c, list)]
assert "rectangle" in types
assert "polyline" in types
assert "circle" in types
assert "text" in types
def test_non_symbol_doc_returns_none():
d = Document(doc_uuid="x", doc_type="SCH_PAGE")
assert write_lib_symbol(d) is None
def test_sch_writer_embeds_lib_symbols_via_project_relations():
sym = _sym_doc("sym1", "MyPart.1", [
("e5", {"_type": "PIN", "partId": "MyPart.1", "x": 0, "y": 0, "length": 10}),
("e6", {"_type": "ATTR", "parentId": "e5", "key": "Pin Name", "value": "1"}),
("e7", {"_type": "ATTR", "parentId": "e5", "key": "Pin Number", "value": "1"}),
])
sch = Document(doc_uuid="sch1", doc_type="SCH_PAGE")
sch.objects["e1"] = {"_type": "COMPONENT", "partId": "MyPart.1", "x": 0, "y": 0, "rotation": 0}
sch.objects["e2"] = {"_type": "COMPONENT", "partId": "GhostPart.99", "x": 50, "y": 50}
p = Project(project_uuid="p")
p.documents["sym1"] = sym
p.documents["sch1"] = sch
pr = ProjectRelations.build(p)
text = write_sch_page(sch, project_relations=pr)
parsed = parse(text)
lib = next(c for c in parsed if isinstance(c, list) and c[0] == "lib_symbols")
embedded = [c for c in lib[1:] if isinstance(c, list) and c[0] == "symbol"]
assert len(embedded) == 1 # MyPart found, GhostPart missed
assert embedded[0][1] == "facere:MyPart.1"
stats = getattr(write_sch_page, "last_stats")
assert stats.lib_symbols_embedded == 1
assert stats.lib_symbols_missing == 1
def test_pin_y_negated_for_kicad_lib_y_up_convention():
"""KiCad lib uses Y-up; the schematic uses Y-down and KiCad re-flips Y on
placement. To make the rendered placement land where EPRO2's wire ends
expect it, sym_writer must NEGATE Y for lib coords. Without this,
vertically arranged pins land at Y-mirrored positions and ERC reports
pin_not_connected even when wire and pin share an X coord."""
d = _sym_doc("sym1", "MyPart.1", [
("e5", {"_type": "PIN", "partId": "MyPart.1",
"x": -20, "y": 10, "length": 20, "rotation": 0}),
])
entry = write_lib_symbol(d)
parsed = parse(to_sexpr(entry))
inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol")
pin = next(c for c in inner if isinstance(c, list) and c[0] == "pin")
at = next(c for c in pin if isinstance(c, list) and c[0] == "at")
# EPRO2 y=10 → lib y=-10 mil = -0.254 mm
assert at[1] == -20 * 0.0254
assert at[2] == -10 * 0.0254
def test_pin_rotation_mirrored_to_compensate_y_flip():
"""Pin angle in lib must mirror across X-axis (rot' = 360-rot mod 360)
so that after KiCad's lib→sch Y-flip the pin extends in the same
direction it does in EPRO2."""
d = _sym_doc("sym1", "MyPart.1", [
("e5", {"_type": "PIN", "partId": "MyPart.1",
"x": 0, "y": 0, "length": 10, "rotation": 90}),
])
entry = write_lib_symbol(d)
parsed = parse(to_sexpr(entry))
inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol")
pin = next(c for c in inner if isinstance(c, list) and c[0] == "pin")
at = next(c for c in pin if isinstance(c, list) and c[0] == "at")
assert at[3] == 270.0 # 90 → 270
def test_rect_y_negated():
d = _sym_doc("sym1", "MyPart.1", [
("r1", {"_type": "RECT", "partId": "MyPart.1",
"dotX1": -10, "dotY1": -5, "dotX2": 10, "dotY2": 5}),
])
entry = write_lib_symbol(d)
parsed = parse(to_sexpr(entry))
inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol")
rect = next(c for c in inner if isinstance(c, list) and c[0] == "rectangle")
start = next(c for c in rect if isinstance(c, list) and c[0] == "start")
end = next(c for c in rect if isinstance(c, list) and c[0] == "end")
# Y inputs were -5 and 5 → after negation: 5 and -5
assert start[2] == 5 * 0.0254
assert end[2] == -5 * 0.0254
def test_sch_writer_without_project_relations_emits_empty_lib_symbols():
sch = Document(doc_uuid="sch1", doc_type="SCH_PAGE")
sch.objects["e1"] = {"_type": "COMPONENT", "partId": "X.1", "x": 0, "y": 0}
text = write_sch_page(sch) # no pr passed
parsed = parse(text)
lib = next(c for c in parsed if isinstance(c, list) and c[0] == "lib_symbols")
# Phase-1 stub: just `(lib_symbols)` with no children
assert lib == ["lib_symbols"]