Files
FacereDataset/tools/epro2/tests/test_std_writers.py
Knowit 3866e24189 tools/epro2/std: rewrite to Option 2 (objects dump) per downstream spec
Downstream came back with concrete requirements: don't pre-compute Std
shape[] tilde strings, just dump the raw EPRO2 `objects: {id: payload}`
dict and they'll write a ~100-LoC adapter on their side. Pulling the
tilde-mapping work back saves us from second-guessing positional fields
without their parser to verify against, and shortens our pcb_writer
from ~500 lines to ~40.

Output shape (Std envelope intact, just no `shape[]`):

    {
      "success": true, "code": 0,
      "result": {
        "uuid", "puuid", "title",
        "docType": 3 | 1,
        "components": {},
        "dataStr": {
          "head": {
            "docType": "3" | "1",
            "editorVersion": "facere-epro2/0.1 (epro2 <X.Y.Z>)",
            "units": "mil",
            "epro2_doc_uuid": ...,
            "epro2_editor_version": ...,
          },
          "BBox": {x, y, width, height},   # mil
          "layers": [...],                  # Std layer-string array
          "objects": dict(doc.objects),     # raw EPRO2, 1:1
          "preference": {}, "netColors": [], "DRCRULE": {},
        }
      }
    }

Per-doc spec downstream gave us:
  - shape[] dropped (empty placeholder misleads adapter)
  - all units mil (no mm conversion — Std canvas already declares mil)
  - head.units="mil" so adapter doesn't have to guess
  - BBox min/max across known x/y/startX/endX/centerX fields; adapter
    can refine by walking path arrays itself
  - layers[] keeps Std's 17-line default + inner SIGNAL layers actually
    used (21~Inner1.., 22~Inner2..)
  - empty stubs preference/netColors/DRCRULE for grep-based triage

New: docs/sources/epro2_to_std_mapping.md with the full EPRO2 OPTYPE →
Std verb table that downstream's adapter authors will copy from. Tables
include the layer-id remapping (the 5↔7 paste/mask flip, 11→10 outline,
12→11 multi, SIGNAL 15+→21+), PCB op mappings, SCH op mappings (marked
best-effort: no Std SCH samples in our corpus), and the 5-Voltage
placeholder COMPONENT → extra net flag trick. Extracted from the
previous Option-3 writer (commit fe6971f) so adapter writers don't
have to reverse-engineer it from source.

ESP-VoCat smoke: 6 PCB + 9 SCH = 15 JSON files, head.units=mil
preserved, no shape[] field present. 82 → 84 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 01:41:12 +08:00

167 lines
6.3 KiB
Python

"""Std writer regression: synthetic EPRO2 docs → Option-2 Std-shaped JSON."""
import json
from tools.epro2.replay import Document
from tools.epro2.std.pcb_writer import write_pcb_std
from tools.epro2.std.sch_writer import write_sch_std
def _doc(typ, uuid="d") -> Document:
d = Document(doc_uuid=uuid, doc_type=typ)
d.head = {"docType": typ, "editVersion": "3.2.91"}
return d
# -- PCB ---------------------------------------------------------------
def test_pcb_envelope_has_required_keys():
"""Downstream's adapter checks for `success`/`code`/`result` at the
top level and `head`/`BBox`/`layers`/`objects` inside `dataStr` —
the ~100 LoC adapter's first job is to find those keys, so missing
any is a hard failure for the entire pipeline."""
d = _doc("PCB", "p1")
d.objects["META"] = {"_type": "META", "title": "Test"}
payload = write_pcb_std(d)
assert payload["success"] is True
assert payload["code"] == 0
r = payload["result"]
assert r["docType"] == 3
assert r["uuid"] == "p1"
ds = r["dataStr"]
for required in ("head", "BBox", "layers", "objects",
"preference", "netColors", "DRCRULE"):
assert required in ds, f"missing dataStr.{required}"
def test_pcb_no_shape_field():
"""No `shape` array in our output. Downstream said `shape` empty
placeholder is misleading — they'll generate it themselves."""
d = _doc("PCB", "p1")
payload = write_pcb_std(d)
assert "shape" not in payload["result"]["dataStr"]
def test_pcb_head_carries_units_and_editor_version():
"""`head.units = "mil"` is the explicit hint the adapter keys off
to skip mm conversion. `head.editorVersion` exposes the EPRO2
editor build the source was authored with — used by Wokwi to pick
its parser branch."""
d = _doc("PCB", "p1")
payload = write_pcb_std(d)
head = payload["result"]["dataStr"]["head"]
assert head["units"] == "mil"
assert head["docType"] == "3"
assert "3.2.91" in head["editorVersion"]
def test_pcb_objects_dict_is_full_document_objects():
"""The whole point of Option 2: pass the raw EPRO2 objects through
untouched so the adapter can dispatch on `_type` and access every
field without going through us. Verify the dict is preserved
1:1 (not just shallow keys)."""
d = _doc("PCB", "p1")
d.objects["e0"] = {"_type": "COMPONENT", "x": 100, "y": -50, "angle": 90}
d.objects["ln1"] = {
"_type": "LINE", "layerId": 1, "netName": "GND", "width": 6,
"startX": 0, "startY": 0, "endX": 100, "endY": 0,
}
payload = write_pcb_std(d)
objs = payload["result"]["dataStr"]["objects"]
assert objs["e0"] == {"_type": "COMPONENT", "x": 100, "y": -50, "angle": 90}
assert objs["ln1"]["netName"] == "GND"
def test_pcb_bbox_is_min_max_of_numeric_x_y_pairs():
"""BBox is best-effort min/max across known x/y/startX/startY/...
fields. Adapter can refine by walking `path` arrays itself; we just
give it a gross outer rectangle good enough for canvas centering.
All coords stay in mil (no mm conversion) — `head.units` says so."""
d = _doc("PCB", "p1")
d.objects["v1"] = {"_type": "VIA", "centerX": -100, "centerY": 50}
d.objects["ln1"] = {
"_type": "LINE", "startX": 0, "startY": -200,
"endX": 300, "endY": 100,
}
payload = write_pcb_std(d)
b = payload["result"]["dataStr"]["BBox"]
assert b["x"] == -100 # min x across all points
assert b["y"] == -200 # min y
assert b["width"] == 400 # 300 - (-100)
assert b["height"] == 300 # 100 - (-200)
def test_pcb_layers_appends_used_inner_signals():
"""An EPRO2 4-layer board with SIGNAL inner ids 15 + 16 carrying real
geometry must add Std layer entries `21~Inner1...` and `22~Inner2...`
— the adapter relies on these being present to know the stack-up.
Unused SIGNAL inners (declared in LAYER ops but no primitives)
don't get an entry; they'd just be noise."""
d = _doc("PCB", "p1")
d.objects["ln1"] = {"_type": "LINE", "layerId": 15,
"startX": 0, "startY": 0, "endX": 1, "endY": 0}
d.objects["ln2"] = {"_type": "LINE", "layerId": 16,
"startX": 0, "startY": 0, "endX": 1, "endY": 0}
payload = write_pcb_std(d)
layers = payload["result"]["dataStr"]["layers"]
inner_lines = [s for s in layers if "Inner" in s]
assert any(s.startswith("21~Inner1") for s in inner_lines)
assert any(s.startswith("22~Inner2") for s in inner_lines)
def test_pcb_non_pcb_doc_rejected():
d = _doc("SCH_PAGE", "x")
try:
write_pcb_std(d)
except ValueError:
return
raise AssertionError("expected ValueError for non-PCB doc")
# -- SCH ---------------------------------------------------------------
def test_sch_envelope_carries_doctype_1():
"""docType=1 routes the file to downstream's schematic parser
instead of the PCB one."""
d = _doc("SCH_PAGE", "s1")
d.objects["META"] = {"_type": "META", "title": "Test"}
payload = write_sch_std(d)
assert payload["result"]["docType"] == 1
assert payload["result"]["dataStr"]["head"]["docType"] == "1"
def test_sch_layers_empty():
"""Schematic has no copper stack-up; layers[] is empty."""
d = _doc("SCH_PAGE", "s1")
payload = write_sch_std(d)
assert payload["result"]["dataStr"]["layers"] == []
def test_sch_objects_dict_preserved():
d = _doc("SCH_PAGE", "s1")
d.objects["e1"] = {"_type": "COMPONENT", "partId": "ABC.1",
"x": 100, "y": 200, "rotation": 0}
d.objects["a1"] = {"_type": "ATTR", "parentId": "e1",
"key": "Designator", "value": "U1"}
payload = write_sch_std(d)
objs = payload["result"]["dataStr"]["objects"]
assert objs["e1"]["partId"] == "ABC.1"
assert objs["a1"]["value"] == "U1"
# -- json round-trip ---------------------------------------------------
def test_writers_round_trip_through_json_dump():
"""Our payloads must survive json.dumps without TypeError — catches
Decimal / datetime / bytes leaks early."""
d_pcb = _doc("PCB", "p1")
d_pcb.objects["META"] = {"_type": "META", "title": "Test"}
json.dumps(write_pcb_std(d_pcb))
d_sch = _doc("SCH_PAGE", "s1")
d_sch.objects["META"] = {"_type": "META", "title": "Test"}
json.dumps(write_sch_std(d_sch))