The downstream colleague's "encrypted_external" / "string old format"
projects were Pro 2.x, not Pro 3.x EPRO2. Pro 2.x ships each doc as a
JSON file whose `dataStr` is a plaintext op-stream — one JSON array per
line, e.g. `["COMPONENT","e1","",0,0,0,0,{},0]`. Different wire format
from EPRO2's binary tilde/pipe streams; same Std envelope works for
output.
- tools/epro2/std/pro2_writer.py: parses dataStr line-by-line, keys
objects by id (position 1 for most ops, OPTYPE for singletons),
extracts BBox by walking known coord positions per OPTYPE, derives
layers from LAYER ops directly (Pro 2.x almost matches Std layer
string format already). PCB blobs that are encrypted-external
(`dataStrId` URL + `iv` + `key`, no inline dataStr — Taishan PCB)
return None so the CLI skips with a message instead of stubbing.
- tools/epro2/std/__main__.py: auto-detect via manifest's
editor_version. "2.x" → Pro 2.x writer; otherwise the existing
EPRO2 replay path. CLI surface and output layout unchanged.
- docs/sources/epro2_to_std_mapping.md: adds a Pro 2.x section.
Adapter dispatches on `head.epro_format`: absent / "epro2" gets
dict-shaped objects values, "pro2" gets array-shaped values
(`[OPTYPE, arg1, ...]`). Lists the Pro 2.x-specific OPTYPEs
(FONTSTYLE / LINESTYLE / CONNECT / OBJ / REGION / DIMENSION /
STRING / TEARDROP) the EPRO2 vocabulary doesn't have.
Smoke (re-running --all on all 5 Pro projects): 191 → 222 JSON files.
Liangshan adds 3 (2 SCH + inline 5357-object PCB). Taishan adds 28
(SCH only — PCB skipped, encrypted-external; source/<uuid>.json still
keeps the dataStrId/iv/key for a later fetch+decrypt pass).
84 → 86 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
8.8 KiB
Python
230 lines
8.8 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_pro2_parses_inline_datastr_into_objects_dict():
|
|
"""Pro 2.x docs store dataStr as a plaintext op-stream (one JSON
|
|
array per line). Each id-keyed op (most of them — COMPONENT / ATTR /
|
|
LINE / WIRE / VIA / ...) lands in objects[id] as the raw array.
|
|
Singletons (DOCTYPE / HEAD / CANVAS / ...) get keyed by their
|
|
OPTYPE name. This is what downstream's adapter receives and
|
|
dispatches on."""
|
|
import tempfile
|
|
from pathlib import Path
|
|
from tools.epro2.std.pro2_writer import write_pro2_doc
|
|
|
|
json_text = json.dumps({
|
|
"uuid": "doc-1",
|
|
"title": "test sch",
|
|
"docType": 1,
|
|
"dataStr": (
|
|
'["DOCTYPE","SCH","1.1"]\n'
|
|
'["HEAD",{"originX":0,"originY":0,"version":"2.1.39","maxId":42}]\n'
|
|
'["COMPONENT","e1","",0,0,90,0,{},0]\n'
|
|
'["WIRE","e2",100,200,300,200]\n'
|
|
'["LINE","e3",1,500,600,700,600,6,"st1",0]\n'
|
|
),
|
|
})
|
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
|
|
f.write(json_text)
|
|
tmp = Path(f.name)
|
|
payload = write_pro2_doc(tmp, project_uuid="proj-1")
|
|
objs = payload["result"]["dataStr"]["objects"]
|
|
# singletons keyed by OPTYPE
|
|
assert objs["DOCTYPE"] == ["DOCTYPE", "SCH", "1.1"]
|
|
assert objs["HEAD"][0] == "HEAD"
|
|
# id-keyed by position-1 string
|
|
assert objs["e1"] == ["COMPONENT", "e1", "", 0, 0, 90, 0, {}, 0]
|
|
assert objs["e2"] == ["WIRE", "e2", 100, 200, 300, 200]
|
|
# head propagates the EPRO2 format hint so adapter knows which dispatch
|
|
head = payload["result"]["dataStr"]["head"]
|
|
assert head["epro_format"] == "pro2"
|
|
assert head["units"] == "mil"
|
|
assert "2.1.39" in head["editorVersion"]
|
|
|
|
|
|
def test_pro2_skips_encrypted_external_pcb():
|
|
"""Pro 2.x PCB docs sometimes store the dataStr at modules.lceda.cn
|
|
keyed by `dataStrId` + AES-decrypted with `iv`/`key`. We don't fetch
|
|
those — return None so the CLI can skip with a stats bump rather
|
|
than emit a stub JSON the downstream parser can't make sense of."""
|
|
import tempfile
|
|
from pathlib import Path
|
|
from tools.epro2.std.pro2_writer import write_pro2_doc
|
|
|
|
enc = json.dumps({
|
|
"uuid": "doc-pcb",
|
|
"docType": 3,
|
|
"dataStrId": "https://modules.lceda.cn/datastr/abc...",
|
|
"iv": "abcd1234abcd1234abcd1234",
|
|
"key": "deadbeef" * 8,
|
|
})
|
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
|
|
f.write(enc)
|
|
tmp = Path(f.name)
|
|
assert write_pro2_doc(tmp) is None
|
|
|
|
|
|
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))
|