"""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))