Files
FacereDataset/tools/epro2/tests/test_relations.py
Knowit 7f9e2fad73 tools/epro2: add Relations layer for cross-object navigation
在 replay 的扁平 objects[id] -> payload 之上盖一层 Relations,建索引和
反向引用,把孤立对象拼成可遍历的图,是后续 EPRO2 → KiCad 转换器的
中间表示前置。

Relations.build(doc) 单遍扫所有对象,得到:

主集合(按类型分桶):
  parts / components / pins / pads / wires / nets / layers / rules

复合 ID 解析(关键):
  '["LAYER",1]'                          → layers[1]
  '["NET","GND"]'                        → nets["GND"]
  '["PAD_NET","e0","1","e7"]'            → pad_nets_by_pad/by_net
  '["RULE","SAFE","copperThickness1oz"]' → rules[("RULE","SAFE",...)]

反向引用:
  obj_ids_by_part         partId            → 引用对象 ids(lib 内 RECT/TEXT/PIN 都带 partId)
  components_by_part      partId            → component ids
  attrs_by_parent         parentId          → ATTR ids
  lines_by_wire           WIRE.id           → LINE ids(wire 由若干 LINE 段组成)
  pad_nets_by_pad         PAD.id            → PAD_NET 记录
  pad_nets_by_net         net name          → PAD_NET 记录
  objects_on_layer / objects_in_net  字段反查

便捷 accessor:
  attrs_dict(parent_id)   折叠所有 ATTR ops 到 {key: value} dict(last
                          write wins),KiCad 转换时按 component 拿
                          Designator/Value/Footprint 的常用入口

ATTR.parentId 解析(实测发现的两种坑):
1. 不仅指向 COMPONENT/PART —— 也大量指向 WIRE(schematic 上的网络
   标签 / 网络属性)。原查重函数漏算,636 个 false positive
   unresolved;改为"任意 doc.objects[parentId] 命中即算 resolved"
2. 复合形式 `<comp_id>-<pin_id>` 用于把 ATTR 挂在某 component 的某个
   pin 上(如 PinName)。`_resolve_parent()` 用 split("-",1) 兜底

CLI 加 --relations,按 docType 聚合 stats:
  uv run python -m tools.epro2 data/raw/oshwhub/<uuid> --relations

ESP-VoCat 验证:
  SCH_PAGE 9 docs : 572 components, 563 wires, 934 lines_grouped,
                    4111 attrs_attached, 0 unresolved_parents
  PCB      6 docs : 206 components, 807 pad_nets, 173 nets, 544 layers
  SYMBOL 105 docs : 106 parts, 560 pins, 1680 attrs_attached
  FOOTPRINT 55 docs: 496 pads, 9 nets, 1771 layers, 140 rules

注:PCB 内 pads=6 vs pad_nets=807 不矛盾 —— PAD 实例存在 FOOTPRINT
文档里,PCB stream 用 ["PAD_NET",comp,pin,pad] 复合 id 跨文档引用;
解析"comp 的某 pin 通过哪个 footprint 的哪个 pad"需要 project-级
Relations 聚合(下个 task)。

测试:tools/epro2/tests/test_relations.py 9 个单测覆盖复合 id 解析、
lineGroup 链接、parentId 直/复合解析、partId 反查、attrs 折叠。
parser + relations 共 15/15 通过。

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

127 lines
5.1 KiB
Python

"""Relations builder regression tests.
These run a tiny synthetic Document through Relations.build to exercise:
- composite-id parsing for NET / LAYER / PAD_NET / RULE
- LINE.lineGroup → WIRE indexing
- ATTR.parentId resolution (direct + compound `<a>-<b>` form)
- cross-references on partId / netName / layerId
"""
from tools.epro2.relations import Relations, parse_composite_id
from tools.epro2.replay import Document
def _doc(obj_pairs):
"""Build a Document with given (id, payload) entries; payload _type required."""
d = Document(doc_uuid="test", doc_type="PCB")
for oid, payload in obj_pairs:
d.objects[oid] = payload
return d
def test_parse_composite_id_basic():
assert parse_composite_id('["LAYER",1]') == ["LAYER", 1]
assert parse_composite_id('["NET","+12V"]') == ["NET", "+12V"]
assert parse_composite_id('["PAD_NET","e0","1","e7"]') == ["PAD_NET", "e0", "1", "e7"]
assert parse_composite_id("e1") is None # plain id
assert parse_composite_id("[bad]") is None # malformed JSON
def test_layer_and_net_extraction():
d = _doc([
('["LAYER",1]', {"_type": "LAYER", "layerName": "Top Layer"}),
('["LAYER",2]', {"_type": "LAYER", "layerName": "Bottom Layer"}),
('["NET","GND"]', {"_type": "NET", "netType": None}),
])
rel = Relations.build(d)
assert rel.layers[1]["layerName"] == "Top Layer"
assert rel.layers[2]["layerName"] == "Bottom Layer"
assert "GND" in rel.nets
def test_lines_grouped_by_wire():
d = _doc([
("e637", {"_type": "WIRE", "groupId": "", "zIndex": 53}),
("ln1", {"_type": "LINE", "lineGroup": "e637", "startX": 0, "startY": 0, "endX": 10, "endY": 0}),
("ln2", {"_type": "LINE", "lineGroup": "e637", "startX": 10, "startY": 0, "endX": 10, "endY": 10}),
("ln3", {"_type": "LINE", "lineGroup": "e999", "startX": 0, "startY": 0, "endX": 1, "endY": 1}), # orphan
])
rel = Relations.build(d)
assert sorted(rel.lines_by_wire["e637"]) == ["ln1", "ln2"]
assert rel.lines_by_wire["e999"] == ["ln3"]
assert rel.unresolved_wires == 1 # ln3's wire 'e999' doesn't exist
def test_attr_parent_resolution_direct_and_compound():
d = _doc([
("e1", {"_type": "COMPONENT", "partId": "pidABC", "x": 0, "y": 0}),
("a1", {"_type": "ATTR", "parentId": "e1", "key": "Designator", "value": "R1"}),
("a2", {"_type": "ATTR", "parentId": "e1-pin3", "key": "PinName", "value": "VCC"}),
("a3", {"_type": "ATTR", "parentId": "ghost", "key": "X", "value": "Y"}), # truly orphan
])
rel = Relations.build(d)
assert sorted(rel.attrs_by_parent["e1"]) == ["a1"]
assert rel.attrs_by_parent["e1-pin3"] == ["a2"]
assert rel.unresolved_parents == 1 # only `ghost` is fully unresolved
def test_pad_net_indexing():
d = _doc([
('["PAD_NET","e0","1","e7"]', {"_type": "PAD_NET", "padNet": "GND"}),
('["PAD_NET","e0","2","e8"]', {"_type": "PAD_NET", "padNet": "VCC"}),
('["PAD_NET","e1","1","e7"]', {"_type": "PAD_NET", "padNet": "GND"}), # same pad, diff comp/pin
])
rel = Relations.build(d)
# pad e7 is referenced by 2 PAD_NETs (different (comp,pin) pairs)
assert len(rel.pad_nets_by_pad["e7"]) == 2
# net GND has 2 references; VCC has 1
assert len(rel.pad_nets_by_net["GND"]) == 2
assert len(rel.pad_nets_by_net["VCC"]) == 1
def test_attrs_dict_collapse():
d = _doc([
("e1", {"_type": "COMPONENT", "partId": "p"}),
("a1", {"_type": "ATTR", "parentId": "e1", "key": "Designator", "value": "R1"}),
("a2", {"_type": "ATTR", "parentId": "e1", "key": "Value", "value": "10kΩ"}),
("a3", {"_type": "ATTR", "parentId": "e1", "key": "Designator", "value": "R2"}), # last write wins
])
rel = Relations.build(d)
flat = rel.attrs_dict("e1")
assert flat == {"Designator": "R2", "Value": "10kΩ"}
def test_components_by_part_index():
d = _doc([
("e1", {"_type": "COMPONENT", "partId": "pidA"}),
("e2", {"_type": "COMPONENT", "partId": "pidA"}),
("e3", {"_type": "COMPONENT", "partId": "pidB"}),
])
rel = Relations.build(d)
assert sorted(rel.components_by_part["pidA"]) == ["e1", "e2"]
assert rel.components_by_part["pidB"] == ["e3"]
def test_objects_on_layer_and_in_net():
d = _doc([
('["LAYER",1]', {"_type": "LAYER", "layerName": "Top"}),
("v1", {"_type": "VIA", "layerId": 1, "netName": "GND"}),
("v2", {"_type": "VIA", "layerId": 1, "netName": "VCC"}),
("p1", {"_type": "POLY", "layerId": 99, "netName": "GND"}), # layer 99 doesn't exist → unresolved
])
rel = Relations.build(d)
assert sorted(rel.objects_on_layer[1]) == ["v1", "v2"]
assert rel.unresolved_layers == 1
assert sorted(rel.objects_in_net["GND"]) == ["p1", "v1"]
def test_summary_keys_present():
d = _doc([])
rel = Relations.build(d)
s = rel.summary()
for key in ("parts", "components", "pins", "pads", "nets", "layers", "rules",
"lines_grouped", "attrs_attached", "pad_nets",
"unresolved_parents", "unresolved_wires", "unresolved_layers",
"bad_composite_ids"):
assert key in s, f"missing summary key: {key}"