tools/epro2/kicad: fix two --all crashes found running the other 4 Pro projects

Running the new --all on the remaining 4 Pro projects (X86 motherboard,
220V power supply, Taishan Pi, Liangshan Pi) surfaced two crash modes
not covered by ESP-VoCat:

1. Odd inner-layer count → KiCad rejects the file at load with
   "3 is not a valid layer count". The 220V power boards have one
   used inner SIGNAL layer (3 copper total: F.Cu / In1.Cu / B.Cu),
   but KiCad requires an even copper count. Fixed pcb_writer to pad
   with one empty inner layer when the inner count is odd, so the
   total stays even (2, 4, 6, ...).

2. Two BOARDs sharing the same META.title — twin "显示板" boards in
   the 220V power project — landed in the same project directory
   and the second silently overwrote the first's .kicad_sch /
   .kicad_pcb / .kicad_pro. Fixed --all to detect title collisions
   and suffix every colliding basename with the BOARD uuid prefix
   (so both '显示板' boards become '显示板_52e8cc76' and
   '显示板_55d32906' rather than one quietly winning).

71 → 73 unit tests pass (test_odd_inner_signal_count_padded_to_even_total
+ test_duplicate_board_titles_get_distinct_basenames).

Tangentially noted while running this: Taishan Pi and Liangshan Pi are
Pro 2.x JSON, not EPRO2 streams — our replay layer reads the files but
doesn't decode docType, so SCH/PCB grouping returns nothing. Pro 2.x
needs a separate writer; out of scope for this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:48:46 +08:00
parent cb868988b9
commit 61fd3ff072
4 changed files with 84 additions and 3 deletions

View File

@@ -45,6 +45,26 @@ def test_writer_emits_header_and_layers():
assert "Edge.Cuts" in by_name
def test_odd_inner_signal_count_padded_to_even_total():
"""KiCad rejects odd copper layer counts ('3 is not a valid layer
count'). A board with one used inner SIGNAL layer must therefore
declare two — the second is empty padding, but without it the
loader refuses to open the file at all."""
d = _pcb([
('["LAYER",1]', {"_type": "LAYER", "layerType": "TOP", "use": True}),
('["LAYER",2]', {"_type": "LAYER", "layerType": "BOTTOM", "use": True}),
('["LAYER",15]', {"_type": "LAYER", "layerType": "SIGNAL", "use": True}),
("ln1", {"_type": "LINE", "layerId": 15,
"startX": 0, "startY": 0, "endX": 100, "endY": 0, "width": 6}),
])
text = write_pcb(d, project_relations=_empty_pr(d))
p = parse(text)
rows = [r for r in _block(p, "layers")[0][1:] if isinstance(r, list)]
cu_layers = [r[1] for r in rows if r[1].endswith(".Cu")]
# 4 copper total: F.Cu, In1.Cu (used), In2.Cu (padding), B.Cu
assert cu_layers == ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]
def test_inner_signal_layers_inserted_in_id_order():
"""An EPRO2 4-layer board with SIGNAL ids 15 and 16 actually used must
map to In1.Cu and In2.Cu (in EPRO2-id sorted order) so the PCB

View File

@@ -36,3 +36,41 @@ def test_pro_top_level_keys_present_for_kicad_8():
j = json.loads(text)
for key in ("board", "meta", "schematic", "sheets", "net_settings"):
assert key in j, f"missing top-level key: {key}"
def test_duplicate_board_titles_get_distinct_basenames():
"""Two BOARDs that happen to share a title (seen on the 220V power
project — twin '显示板' boards in the same EPRO2 project) must end up
in distinct project directories. Dedup uses the BOARD uuid prefix
so each .kicad_pro/.kicad_sch/.kicad_pcb trio stays self-contained."""
from tools.epro2.kicad.__main__ import _project_basename, _group_by_board
from tools.epro2.replay import Document, Project
p = Project(project_uuid="p")
for board_uuid, sch_uuid, pcb_uuid in [
("b1aaaaa1", "s1aaaaa1", "p1aaaaa1"),
("b2bbbbb2", "s2bbbbb2", "p2bbbbb2"),
]:
sch = Document(doc_uuid=sch_uuid, doc_type="SCH")
sch.objects["META"] = {"_type": "META", "title": "显示板", "board": board_uuid}
p.documents[sch_uuid] = sch
pcb = Document(doc_uuid=pcb_uuid, doc_type="PCB")
pcb.objects["META"] = {"_type": "META", "title": "显示板", "board": board_uuid}
p.documents[pcb_uuid] = pcb
boards = _group_by_board(p)
assert len(boards) == 2
# Re-implement the dedup logic the CLI uses (kept inline so we exercise
# exactly that path; if it changes, this test breaks loudly).
base_counts: dict[str, int] = {}
for slot in boards.values():
base_counts[_project_basename(slot["title"])] = (
base_counts.get(_project_basename(slot["title"]), 0) + 1
)
basenames = [
_project_basename(slot["title"])
if base_counts[_project_basename(slot["title"])] == 1
else f"{_project_basename(slot['title'])}_{board_id[:8]}"
for board_id, slot in boards.items()
]
assert len(set(basenames)) == 2, f"basenames collided: {basenames}"