Files
FacereDataset/tools/epro2/tests/test_sym_writer.py
Knowit fb577cc89f tools/epro2/kicad: fix two KiCad 8 parse blockers (newline + pin_numbers)
装 kicad 8.0.9 (apt PPA) 后跑 kicad-cli sch erc 校验我们 emit 的
.kicad_sch 文件,发现 9/9 sheets 一开始全部报 "Failed to load schematic
file" — 父节点解析就挂掉。Bisect 找到两个语法 bug:

1. **(pin_numbers (hide no)) 不被 KiCad 8 接受**
   KiCad 8 lib_symbols 里 `pin_numbers` 是 token-form,不接受 (hide
   yes/no) 子块。要么省略整个 block 默认 visible,要么 `(pin_numbers
   hide)` 表示隐藏。原来的 `(hide no)` 风格是 KiCad 7 旧语法。

   Fix: tools/epro2/kicad/sym_writer.py 删掉 (pin_numbers (hide no))
        行;KiCad 默认 visible 行为正是我们想要的。

2. **String 里的字面 \n / \r / \t 让 KiCad 解析器中止**
   ESP-VoCat 的 Overview sheet 有 TEXT "Battary\n3.7V 700mAH"(多行
   电池标签),EPRO2 里以**字面 0x0a 字符**存储。我们把它原样 emit
   成 "..." 包住的字符串 → KiCad reader 在 quoted string 内遇到 \n
   就报 parse error 不给 message。

   Fix: tools/epro2/kicad/sexpr.py 在 str escape 路径加 \n / \r / \t
        转义;reader 加 \r 解码(roundtrip 用)。

修完后:

  9/9 sheets parse OK in KiCad 8.0.9
  ERC 跑通,9 个 sheet 共 2793 violations,分布:
     1372 endpoint_off_grid        (49%, cosmetic — 30-mil EPRO2 grid 不
                                    snap KiCad 默认 50-mil grid)
      571 lib_symbol_issues        (20%, cosmetic — facere 库未注册到
                                    user library table;库已 embed 在
                                    .kicad_sch 内联可用)
      444 wire_dangling            (16%, real — wire 端点没精确对齐 pin)
      406 pin_not_connected        (15%, 同上的另一面)

  Cosmetic 占 70%,real connectivity 30%,下个 phase 处理:
    - grid 校准(把 coord 精确 round 到统一 grid 上)
    - pin tip 端点匹配(KiCad 需要 wire 端点 == pin (at) 字段对应的
      绝对坐标,浮点必须精确相等)
    - 生成 sym-lib-table 注册 facere 库(消 lib_symbol_issues)

测试:
  + test_string_escapes_newlines_and_tabs
  + test_lib_symbol_omits_pin_numbers_block
  reader 加 \r 解码

41/41 通过(39 旧 + 2 新)。

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

138 lines
5.7 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_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"]