"""Real FreeCAD/Open CASCADE integration checks (no mocked geometry). Run inside portable FreeCAD Python, or import run(output_dir) from a GUI macro. The report contains explicit pass/fail/skip flags and persistent evidence files. """ import json import math from pathlib import Path import sys import traceback import FreeCAD as App import Part # FreeCAD itself may prepend workbench paths during import. Prefer repository # source for validation when present; the packaged workbench is the fallback. _SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor" if _SOURCE.is_dir(): sys.path.insert(0, str(_SOURCE)) import qi_model as model def _assert(condition, message): if not condition: raise AssertionError(message) def _near(actual, expected, relative=1e-8, absolute=1e-6): _assert(math.isclose(float(actual), float(expected), rel_tol=relative, abs_tol=absolute), "Expected %.12g, got %.12g" % (expected, actual)) def _sketches(body): return [o for o in body.Group if o.isDerivedFrom("Sketcher::SketchObject")] def _valid(body): _assert(body.Shape.isValid(), "Invalid BRep") _assert(len(body.Shape.Solids) == 1, "Expected a single solid") _assert(body.Shape.Volume > 0, "Empty solid") _assert(body.getStatusString() == "Valid", "Body status: " + body.getStatusString()) def _reject(fn): try: fn() except (ValueError, RuntimeError): return raise AssertionError("Invalid input was accepted") def _flat_exports_world_geometry_and_history(output_dir): """Analytical cold world geometry, immutable sources and atomic flat export.""" import hashlib import io import zipfile import Mesh def state(doc): objects=[] for obj in doc.Objects: raw=bytes(obj.dumpContent()) if zipfile.is_zipfile(io.BytesIO(raw)): with zipfile.ZipFile(io.BytesIO(raw)) as archive: payload={n:hashlib.sha256(archive.read(n)).hexdigest() for n in sorted(archive.namelist())} else:payload={'raw':hashlib.sha256(raw).hexdigest()} objects.append({'name':obj.Name,'type':obj.TypeId,'payload':payload, 'expressions':list(obj.ExpressionEngine),'state':list(obj.State)}) return {'objects':objects,'undo':doc.UndoCount,'redo':doc.RedoCount, 'active_transaction':App.getActiveTransaction(),'active_doc':App.ActiveDocument.Name if App.ActiveDocument else None, 'documents':sorted(App.listDocuments()),'filename':doc.FileName} def bounds(box):return [box.XMin,box.YMin,box.ZMin,box.XMax,box.YMax,box.ZMax] def geometry(shape): solids=list(shape.Solids); volume=sum(s.Volume for s in solids) com=sum((s.CenterOfMass*s.Volume for s in solids),App.Vector())/volume if volume else App.Vector() return {'type':shape.ShapeType,'valid':shape.isValid(),'solids':len(solids),'shells':len(shape.Shells), 'faces':len(shape.Faces),'volume_mm3':volume,'native_volume_mm3':shape.Volume, 'com_mm':list(com),'bounds_mm':bounds(shape.BoundBox)} def comparison(expected,actual,tolerance=1e-6): wanted,read=geometry(expected),geometry(actual) missing=expected.cut(actual); excess=actual.cut(expected) forward=0.0 if missing.isNull() else missing.Volume reverse=0.0 if excess.isNull() else excess.Volume return {'expected':wanted,'actual':read,'missing_volume_mm3':forward,'excess_volume_mm3':reverse, 'volume_delta_mm3':abs(read['volume_mm3']-wanted['volume_mm3']), 'com_distance_mm':math.dist(wanted['com_mm'],read['com_mm']), 'max_bound_delta_mm':max(abs(a-b) for a,b in zip(wanted['bounds_mm'],read['bounds_mm'])), 'matches':bool(read['valid'] and read['solids']==wanted['solids'] and forward=12*wanted["solids"] row.update(stl_facets=mesh.CountFacets,volume_mm3=mesh.Volume,bounds_mm=bounds(mesh.BoundBox)) else: actual=Part.read(str(destination));row["raw_readback"]=geometry(actual) if suffix==".iges" and not actual.Solids: actual.sewShape();shells=list(actual.Shells) assert shells and all(shell.isClosed() for shell in shells),"IGES surfaces do not form closed shells" actual=Part.makeCompound([Part.makeSolid(shell) for shell in shells]) row["iges_topology_readback"]="native sewShape of raw imported surfaces and closed-shell makeSolid" row["comparison"]=comparison(expected,actual,1e-5) assert row["comparison"]["matches"],row assert state(doc)==before,"Readback changed source payload/history/context: "+name doc.redo();doc.recompute();assert marker.Text=="after","Original Redo did not execute: "+name row["original_redo_executed"]=True;results.append(row) finally:close_created() for suffix in (".brep",".iges",".stl"): try: fault=fault_control(model,output,suffix) results.append({"case":"post-native-writer-fault"+suffix, **{key:value for key,value in fault.items() if key not in ("source_before","source_after")}}) finally:close_created() for suffix in (".brep",".iges",".stl"): try:results.append({"case":"all-hidden-array-rejection"+suffix,**empty_mask_control(model,output,suffix)}) finally:close_created() finally:close_created() assert len(results)==45 return {"checks":len(results),"analytical_geometry_and_history":results} def run(output_dir): out = Path(output_dir).absolute() out.mkdir(parents=True, exist_ok=True) initial_docs = set(App.listDocuments()) active_name = App.ActiveDocument.Name if App.ActiveDocument else None report = {"status": "running", "freecad_version": App.Version(), "gui": bool(App.GuiUp), "source": model.__file__, "passed": 0, "failed": 0, "skipped": 0, "checks": [], "artifacts": []} def check(name, fn): try: detail = fn() report["checks"].append({"name": name, "passed": True, "status": "passed", "detail": detail or {}}) report["passed"] += 1 except Exception as exc: report["checks"].append({"name": name, "passed": False, "status": "failed", "error": str(exc), "traceback": traceback.format_exc()}) report["failed"] += 1 # The report survives an unexpected later runtime/GUI failure. (out / "kernel-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") def artifact(path): path = Path(path) _assert(path.is_file() and path.stat().st_size > 0, "Missing artifact: " + str(path)) report["artifacts"].append(str(path)) return path def box_volume_constraints(): doc = model.new_part("Проверка бруска") body = model.create_box(80, 40, 20, doc) _valid(body) _near(body.Shape.Volume, 64000) for actual, expected in zip(model.mass_properties(body)["dimensions_mm"], (80, 40, 20)): _near(actual, expected) sketch = _sketches(body)[0] _assert(sketch.FullyConstrained and sketch.solve() == 0, "Rectangle not fully constrained") _assert({c.Name for c in sketch.Constraints} >= {"Длина", "Ширина"}, "Missing localized named driving constraints") _assert(body.Tip.TypeId == "PartDesign::Pad", "Box is not a native Pad") return {"volume_mm3": body.Shape.Volume, "fully_constrained": True, "feature": body.Tip.TypeId} def box_edit_undo_redo(): doc = model.new_part("Изменение размера") body = model.create_box(20, 30, 10, doc) model.set_dimensions(body, {"QILength": 25, "QIWidth": 40, "QIHeight": 12}) _near(body.Shape.Volume, 12000) doc.undo() doc.recompute() body = doc.getObject(body.Name) _near(body.Shape.Volume, 6000) doc.redo() doc.recompute() _near(body.Shape.Volume, 12000) # The native VarSet remains independently editable in the property editor. body.QIParameters.QIHeight = 15 doc.recompute() _near(body.Shape.Volume, 15000) _valid(body) return {"volume_mm3": body.Shape.Volume, "undo_redo": True, "native_parameters": body.QIParameters.TypeId} def creation_undo_redo(): doc = model.new_part("История построения") body = model.create_cylinder(8, 25, doc) name = body.Name doc.undo() doc.recompute() _assert(doc.getObject(name) is None, "Builder did not commit as a single undo operation") doc.redo() doc.recompute() _near(doc.getObject(name).Shape.Volume, math.pi * 8 ** 2 * 25) return {"single_creation_undo": True} def cylinder_material(): doc = model.new_part("Материал цилиндра") body = model.create_cylinder(12, 60, doc) _valid(body) _near(body.Shape.Volume, math.pi * 12 ** 2 * 60) _assert(_sketches(body)[0].FullyConstrained, "Circle not fully constrained") model.set_material(body, "Алюминий") props = model.mass_properties(body) _near(props["mass_kg"], body.Shape.Volume * 1e-9 * 2700) model.set_dimension(body, "QIRadius", 15) _near(body.Shape.Volume, math.pi * 15 ** 2 * 60) return {"material": props["material"], "mass_kg": props["mass_kg"]} def plate_parametric_holes(): doc = model.new_part("Проверка отверстий") body = model.create_plate_with_holes(100, 50, 8, 10, 60, doc) _near(body.Shape.Volume, (100 * 50 - 2 * math.pi * 5 ** 2) * 8) _assert(all(s.FullyConstrained and s.solve() == 0 for s in _sketches(body)), "Plate sketches have degrees of freedom") model.set_dimensions(body, {"QILength": 120, "QIWidth": 60, "QIThickness": 12, "QIHoleDiameter": 12, "QIHoleSpacing": 90}) _near(body.Shape.Volume, (120 * 60 - 2 * math.pi * 6 ** 2) * 12) holes = next(s for s in _sketches(body) if s.Name.startswith("HoleSketch")) for circle, x in zip(holes.Geometry, (15, 105)): _near(circle.Center.x, x) _near(circle.Center.y, 30) _near(circle.Radius, 6) _near(holes.Placement.Base.z, 12) _valid(body) return {"volume_mm3": body.Shape.Volume, "fully_constrained": True, "holes": 2} def atomic_dimensions_and_rejection(): doc = model.new_part("Атомарность размеров") body = model.create_plate_with_holes(100, 50, 8, 10, 60, doc) model.set_dimensions(body, {"QIHoleSpacing": 120, "QILength": 160}) _near(body.QILength.Value, 160) _near(body.QIHoleSpacing.Value, 120) before = body.Shape.Volume _reject(lambda: model.set_dimensions(body, {"QILength": 80, "QIWidth": 20})) _near(body.QILength.Value, 160) _near(body.QIWidth.Value, 50) _near(body.Shape.Volume, before) _reject(lambda: model.set_dimensions(body, {"QIThickness": 12, "unknown": 1})) _near(body.QIThickness.Value, 8) return {"rejected_without_partial_changes": True} def invalid_dimension_inputs(): doc = model.new_part("Проверка входных данных") before = len(doc.Objects) for invalid in (-1, 0, float("nan"), float("inf"), "abc", 1e20): _reject(lambda v=invalid: model.create_box(v, 10, 10, doc)) _reject(lambda v=invalid: model.create_cylinder(v, 10, doc)) _reject(lambda: model.create_plate_with_holes(40, 20, 5, 15, 30, doc)) _reject(lambda: model.create_sketch_profile("rectangle", 20, 10, "BAD", doc)) _assert(len(doc.Objects) == before, "Invalid inputs modified document") return {"invalid_cases": 14, "document_unchanged": True} def free_sketch_dimensions(): doc = model.new_part("Именованные размеры эскиза") sketch = model.create_sketch_profile("rectangle", 35, 20, doc=doc) model.set_dimensions(sketch, {"Length": 50, "Width": 25}) _assert(sketch.FullyConstrained, "Dimensions caused underconstraint") feature = model.pad(sketch, 10) _near(feature.Shape.Volume, 12500) circle = model.create_sketch_profile("circle", 7, 7, doc=doc) model.set_dimension(circle, "Radius", 9) feature2 = model.pad(circle, 15) _near(feature2.Shape.Volume, math.pi * 9 ** 2 * 15) return {"rectangle_driving_dimensions": [r["name"] for r in model.get_dimensions(sketch)], "circle_radius": 9} def all_origin_planes(): doc = model.new_part("Плоскости эскиза") expected_boxes = {"XY": (30, 20, 5), "XZ": (30, 5, 20), "YZ": (5, 30, 20)} for plane, expected in expected_boxes.items(): sketch = model.create_sketch_profile("rectangle", 30, 20, plane, doc) feature = model.pad(sketch, 5) _near(feature.Shape.Volume, 3000) box = feature.Shape.BoundBox for actual, target in zip((box.XLength, box.YLength, box.ZLength), expected): _near(actual, target) return {"planes": list(expected_boxes)} def pocket_and_rollback(): doc = model.new_part("Карман и откат") body = model.create_box(50, 40, 10, doc) sketch = model.create_sketch_profile("circle", 5, 5, doc=doc, body=body) model.position_sketch_on_top(sketch, body) placement = sketch.Placement placement.Base.x, placement.Base.y = 25, 20 sketch.Placement = placement cut = model.pocket(sketch, 10) _near(cut.Shape.Volume, 20000 - math.pi * 25 * 10) bad = model.create_sketch_profile("circle", 2, 2, doc=doc, body=body) model.position_sketch_on_top(bad, body) placement = bad.Placement placement.Base = App.Vector(500, 500, 10) bad.Placement = placement before_names = {o.Name for o in doc.Objects} _reject(lambda: model.pocket(bad, 10)) _assert({o.Name for o in doc.Objects} == before_names, "Failed feature transaction did not roll back") _near(cut.Shape.Volume, 20000 - math.pi * 25 * 10) return {"real_subtractive_volume_mm3": cut.Shape.Volume, "failed_pocket_rolled_back": True} def save_and_reopen(): doc = model.make_demo("bracket") name = doc.Name body = doc.getObject("Body") volume = body.Shape.Volume path = artifact(model.save_project(doc, out / "parametric-bracket.FCStd")) App.closeDocument(name) reopened = App.openDocument(str(path)) body = reopened.getObject("Body") _near(body.Shape.Volume, volume) _assert(all(s.FullyConstrained for s in _sketches(body)), "Saved constraints lost") model.set_dimensions(body, {"QIWallHeight": 55, "QIThickness": 10}) _near(body.Shape.Volume, (100 * 50 - 2 * math.pi * 5 ** 2) * 10 + 50 * 55 * 10) _valid(body) return {"native_project": str(path), "editable_after_reopen": True} def step_round_trip(): doc = model.make_demo("bracket") body = doc.getObject("Body") path = artifact(model.export_model(doc, out / "bracket.step")) shape = Part.read(str(path)) _assert(shape.isValid() and len(shape.Solids) == 1, "STEP did not round-trip a solid") _near(shape.Volume, body.Shape.Volume, relative=1e-6) return {"path": str(path), "imported_volume_mm3": shape.Volume, "valid": True} def solid_export_formats(): doc = model.new_part("Экспорт форматов") body = model.create_box(25, 20, 10, doc) paths = [artifact(model.export_model(doc, out / ("solid." + ext))) for ext in ("iges", "brep", "stl")] shape = Part.read(str(out / "solid.brep")) _near(shape.Volume, body.Shape.Volume) import Mesh mesh = Mesh.Mesh(str(out / "solid.stl")) _assert(mesh.CountFacets >= 12, "STL has insufficient facets") return {"formats": [p.suffix for p in paths], "stl_facets": mesh.CountFacets} def export_rejects_non_solids(): doc = model.new_part("Проверка геометрии экспорта") body = model.create_box(20, 10, 5, doc) sketch = _sketches(body)[0] empty = doc.addObject("Part::Feature", "Empty") wire = doc.addObject("Part::Feature", "Wire") wire.Shape = Part.makePolygon([App.Vector(0, 0, 0), App.Vector(10, 0, 0)]) mixed = doc.addObject("Part::Feature", "Mixed") mixed.Shape = Part.makeCompound([body.Shape, wire.Shape]) doc.recompute() rejected = [] for obj in (sketch, empty, wire, mixed): for ext in ("step", "iges", "brep", "stl"): path = out / ("rejected-" + obj.Name + "." + ext) try: model.export_model(doc, path, [obj]) except ValueError as exc: _assert("твердого тела" in str(exc), "Missing Russian solid-geometry error") else: raise AssertionError("Non-solid selection was exported: " + obj.Name) _assert(not path.exists(), "Rejected export created a misleading file") rejected.append(obj.Name + "." + ext) existing = out / "rejected-existing.step" existing.write_bytes(b"existing project exchange file") before = existing.read_bytes() _reject(lambda: model.export_model(doc, existing, [sketch])) _assert(existing.read_bytes() == before, "Rejected export overwrote an existing file") return {"rejected_selections": rejected, "existing_file_preserved": True} def export_rejects_cached_failed_feature(): doc = model.new_part("Ошибка перестроения перед экспортом") body = model.create_box(20, 10, 5, doc) pad = body.Tip link = doc.addObject("App::Link", "ExportLink") link.setLink(body) doc.recompute() volume = body.Shape.Volume pad.setExpression("Length", None) pad.Length = 0 doc.recompute() _assert("Invalid" in pad.State, "Fixture did not trigger a real native error") _assert(pad.getStatusString() != "Valid", "Fixture has no native error status") _assert(pad.Shape.isValid() and body.Shape.isValid(), "Fixture did not retain the old valid solid") _near(body.Shape.Volume, volume) native_status = pad.getStatusString() rejected = 0 for selection in (None, [body], [pad], [link]): for ext in ("step", "iges", "brep", "stl"): path = out / ("failed-rebuild-%d.%s" % (rejected, ext)) try: model.export_model(doc, path, selection) except ValueError as exc: _assert("ошибку перестроения" in str(exc), "Missing Russian rebuild error") else: raise AssertionError("A stale solid was exported after a native rebuild failure") _assert(not path.exists(), "Failed rebuild created a misleading export") rejected += 1 # An unrelated erroneous object should not prevent exporting an explicitly # chosen independent, valid Body or its native feature. good = model.create_box(7, 6, 5, doc) accepted = artifact(model.export_model(doc, out / "independent-good-body.step", [good])) _near(Part.read(str(accepted)).Volume, 210) pad.Length = 8 doc.recompute() _assert("Invalid" not in pad.State, "Native Pad did not recover") recovered = artifact(model.export_model(doc, out / "recovered-body.step", [body])) _near(Part.read(str(recovered)).Volume, 1600) return {"native_error": native_status, "cached_volume_mm3": volume, "rejected_exports": rejected, "independent_and_recovered_exports": True} def assembly_solver_links_bom(): doc = model.create_assembly_demo() assembly = doc.getObject("Assembly") _assert(assembly.TypeId == "Assembly::AssemblyObject" and assembly.Type == "Assembly", "Non-native assembly") links = [o for o in assembly.Group if o.isDerivedFrom("App::Link")] _assert(len(links) == 3, "Assembly requires three linked components") _assert(assembly.isPartGrounded(links[0]), "Base not grounded") types = {o.JointType for o in doc.Objects if hasattr(o, "JointType")} _assert(types == {"Fixed", "Revolute"}, "Missing native mates") fixed = links[1] moved = fixed.LinkPlacement moved.Base = App.Vector(45, 35, 5) fixed.LinkPlacement = moved status = assembly.solve(False) doc.recompute() _assert(status == 0, "Assembly solver failed") _near(fixed.LinkPlacement.Base.x, 20) _near(fixed.LinkPlacement.Base.y, 30) _near(fixed.LinkPlacement.Base.z, 0) rows = model.bom_rows(doc) _assert(sorted(r["quantity"] for r in rows) == [1, 2], "BOM quantity grouping incorrect") csv = artifact(model.export_bom(doc, out / "assembly-bom.csv")) _assert("QI-002" in csv.read_text(encoding="utf-8-sig"), "BOM CSV missing part") pin = links[1].getLinkedObject() model.set_dimensions(pin, {"QIRadius": 6, "QIHeight": 40}) doc.recompute() for link in links[1:]: _near(link.Shape.Volume, math.pi * 6 ** 2 * 40) artifact(model.save_project(doc, out / "native-assembly.FCStd")) return {"solver_status": status, "joint_types": sorted(types), "components": 3, "bom_rows": rows} def assembly_reopen_step(): path = out / "native-assembly.FCStd" _assert(path.is_file(), "Assembly artifact absent") reopened = App.openDocument(str(path)) assembly = reopened.getObject("Assembly") _assert(assembly.solve(False) == 0, "Reopened joints failed to solve") _assert(sorted(r["quantity"] for r in model.bom_rows(reopened)) == [1, 2], "BOM lost after reopen") step = artifact(model.export_model(reopened, out / "assembly.step")) shape = Part.read(str(step)) _assert(len(shape.Solids) == 3 and shape.isValid(), "STEP assembly lost components") return {"reopened_joints": True, "step_solids": len(shape.Solids)} def drawing_associativity(): doc = model.new_part("Чертеж детали") body = model.create_box(80, 40, 20, doc) page = model.create_drawing(doc) model.prepare_drawing(page) _assert(page.TypeId == "TechDraw::DrawPage" and len(page.Views) == 3, "Drawing projections absent") _assert(all(v.Source == [body] for v in page.Views), "Drawing source links missing") _assert(all(len(v.getVisibleEdges()) >= 4 for v in page.Views), "Native projections contain no visible edges") _assert(page.Template.Width.Value > 200 and page.Template.Height.Value > 200, "A4 template dimensions invalid") _assert([(v.X.Value, v.Y.Value) for v in page.Views] == [(80.0, 135.0), (80.0, 80.0), (205.0, 123.0)], "Drawing insertion reset projection layout") _near(page.Scale, 1) _assert(page.Template.EditableTexts.get("Scale") == "1:1", "Unexpected standard scale label") # Export immediately after rapid source edits. Waiting/preparing here # would hide a DXF exporter that serializes the previous cached view. model.set_dimensions(body, {"QILength": 90, "QIHeight": 35}) model.set_dimensions(body, {"QILength": 100, "QIHeight": 50}) dxf = artifact(model.export_drawing(page, out / "associative-drawing.dxf")) dxf_lines = dxf.read_text(encoding="utf-8", errors="replace").splitlines() section, record, front_lines = "", [], [] for raw_code, raw_value in zip(dxf_lines[::2], dxf_lines[1::2]): code, value = int(raw_code), raw_value.strip() if code == 0: if (section == "ENTITIES" and record and record[0] == (0, "LINE") and (8, "FrontView") in record): front_lines.append(record) record = [(code, value)] if value == "ENDSEC": section = "" elif code == 2 and record and record[0] == (0, "SECTION"): section = value else: record.append((code, value)) _assert(len(front_lines) >= 4, "DXF contains no native front projection") x_coordinates = [float(value) for record in front_lines for code, value in record if code in (10, 11)] y_coordinates = [float(value) for record in front_lines for code, value in record if code in (20, 21)] dxf_width = max(x_coordinates) - min(x_coordinates) dxf_height = max(y_coordinates) - min(y_coordinates) _near(dxf_width, 100 * page.Scale) _near(dxf_height, 50 * page.Scale) _assert(all(v.getStatusString() == "Valid" for v in page.Views), "Views failed after model edit") artifact(model.save_project(doc, out / "associative-drawing.FCStd")) if App.GuiUp: exported_svg = artifact(model.export_drawing(page, out / "associative-drawing.svg")) _assert(model._svg_has_geometry(exported_svg), "Exported SVG has no projected model geometry") artifact(model.export_drawing(page, out / "associative-drawing.pdf")) return {"views": 3, "model_change_recomputed": True, "gui_exports": bool(App.GuiUp), "direct_dxf_after_rapid_edits": True, "dxf_front_width_mm": dxf_width, "dxf_front_height_mm": dxf_height} def complete_examples(): volumes = {} for kind in ("bracket", "plate", "shaft", "pulley"): doc = model.make_demo(kind) body = doc.getObject("Body") _valid(body) _assert(all(s.FullyConstrained for s in _sketches(body)), "Example sketch not fully constrained") volumes[kind] = body.Shape.Volume return {"volumes_mm3": volumes} tests = [ ("box_volume_constraints", box_volume_constraints), ("box_edit_undo_redo", box_edit_undo_redo), ("creation_undo_redo", creation_undo_redo), ("cylinder_material", cylinder_material), ("plate_parametric_holes", plate_parametric_holes), ("atomic_dimensions_and_rejection", atomic_dimensions_and_rejection), ("invalid_dimension_inputs", invalid_dimension_inputs), ("free_sketch_dimensions", free_sketch_dimensions), ("all_origin_planes", all_origin_planes), ("pocket_and_rollback", pocket_and_rollback), ("save_and_reopen", save_and_reopen), ("step_round_trip", step_round_trip), ("solid_export_formats", solid_export_formats), ("flat_exports_world_geometry_and_history", lambda: _flat_exports_world_geometry_and_history(out)), ("export_rejects_non_solids", export_rejects_non_solids), ("export_rejects_cached_failed_feature", export_rejects_cached_failed_feature), ("assembly_solver_links_bom", assembly_solver_links_bom), ("assembly_reopen_step", assembly_reopen_step), ("drawing_associativity", drawing_associativity), ("complete_examples", complete_examples), ] try: for name, fn in tests: check(name, fn) finally: for name in set(App.listDocuments()) - initial_docs: App.closeDocument(name) if active_name in App.listDocuments(): App.setActiveDocument(active_name) if not App.GuiUp: report["skipped"] += 1 report["checks"].append({"name": "drawing_pdf_svg_gui", "passed": None, "status": "skipped", "reason": "Headless run: full page PDF/SVG export needs GUI; native page/DXF verified."}) report["status"] = "passed" if report["failed"] == 0 else "failed" report["success"] = report["failed"] == 0 (out / "kernel-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report if __name__ == "__main__": output = sys.argv[1] if len(sys.argv) > 1 else str(Path(__file__).resolve().parents[1] / "artifacts" / "kernel") result = run(output) print(json.dumps({k: result[k] for k in ("status", "passed", "failed", "skipped")}, ensure_ascii=False)) if result["failed"]: for item in result["checks"]: if item["status"] == "failed": print(item["name"] + ": " + item["error"]) raise SystemExit(0 if result["success"] else 1)