import importlib import sys import tempfile import types import unittest from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] MODULE_DIR = REPO_ROOT / "src" / "Mod" / "FreeCADExchange" if str(MODULE_DIR) not in sys.path: sys.path.insert(0, str(MODULE_DIR)) def _install_fake_freecad(): class Vector: def __init__(self, x=0.0, y=0.0, z=0.0): self.x = float(x) self.y = float(y) self.z = float(z) class Rotation: def multVec(self, vector): return vector class Placement: def __init__(self, base=None, rotation=None): self.Base = base or Vector() self.Rotation = rotation or Rotation() fake_freecad = types.ModuleType("FreeCAD") fake_freecad.Vector = Vector fake_freecad.Rotation = Rotation fake_freecad.Placement = Placement fake_freecad.ActiveDocument = None fake_freecad.Console = types.SimpleNamespace( PrintMessage=lambda *args, **kwargs: None, PrintWarning=lambda *args, **kwargs: None, PrintError=lambda *args, **kwargs: None, ) sys.modules["FreeCAD"] = fake_freecad fake_importgui = types.ModuleType("ImportGui") def insert(name, docName=None, merge=False, useLinkGroup=True): doc = fake_freecad.ActiveDocument obj = doc.addObject("Part::Feature", "ImportedBatchModel") obj.ImportedPath = name return obj fake_importgui.insert = insert sys.modules["ImportGui"] = fake_importgui fake_freecadgui = types.ModuleType("FreeCADGui") fake_freecadgui.addCommand = lambda *args, **kwargs: None fake_freecadgui.Selection = types.SimpleNamespace(getSelection=lambda: []) sys.modules["FreeCADGui"] = fake_freecadgui class FakeViewObject: def __init__(self): self.Visibility = True class FakeObject: def __init__(self, name, type_id): self.Name = name self.Label = name self.TypeId = type_id self.PropertiesList = [] self.Group = [] self.InList = [] self.ViewObject = FakeViewObject() self.Placement = sys.modules["FreeCAD"].Placement() self.Shape = None def isDerivedFrom(self, type_name): if self.TypeId == type_name: return True if type_name == "App::DocumentObjectGroup": return self.TypeId == "App::DocumentObjectGroup" if type_name == "App::LocalCoordinateSystem": return self.TypeId in {"Part::LocalCoordinateSystem", "PartDesign::CoordinateSystem"} return False def addProperty(self, _prop_type, prop_name, _group_name, _description): if prop_name not in self.PropertiesList: self.PropertiesList.append(prop_name) def addObject(self, child): if child not in self.Group: self.Group.append(child) if self not in child.InList: child.InList.append(self) class FakeDocument: def __init__(self): self.Objects = [] self.Name = "QETScene" self.recompute_count = 0 def addObject(self, type_name, name): obj = FakeObject(name, type_name) self.Objects.append(obj) return obj def getObject(self, name): for obj in self.Objects: if obj.Name == name: return obj return None def recompute(self): self.recompute_count += 1 def _reload_modules(*extra_names): for name in ["RoutingNetwork", "WiringObjects", "TemplateSemantics", "TerminalObjects", "BatchAssembly"] + list(extra_names): sys.modules.pop(name, None) terminal_objects = importlib.import_module("TerminalObjects") batch_assembly = importlib.import_module("BatchAssembly") return terminal_objects, batch_assembly class BatchAssemblyTest(unittest.TestCase): def _qet_device(self, doc, terminal_objects, label, instance_id=None, element_uuid=None): token = terminal_objects.safe_token(label) device = doc.addObject("App::DocumentObjectGroup", "QETDevice_" + token) device.Label = label terminal_objects.ensure_string_property(device, "QetGroupKind", "QET Exchange", "", "Device") terminal_objects.ensure_string_property( device, "QetElementUuid", "QET Exchange", "", element_uuid or label, ) terminal_objects.ensure_string_property( device, "QetInstanceId", "QET Exchange", "", instance_id or label, ) return device def _terminal(self, doc, terminal_objects, device, terminal_uuid, label): terminal_group = terminal_objects.ensure_terminal_group( doc, device, project_uuid="project-1", instance_id=device.QetInstanceId, ) terminal = terminal_objects.create_lcs_object( doc, "QETTerminal_" + terminal_objects.safe_token(terminal_uuid), label=label, ) terminal_group.addObject(terminal) terminal_objects.set_terminal_semantics( terminal, "project-1", device.QetElementUuid, terminal_uuid, device.QetInstanceId, label=label, ) return terminal def test_layout_existing_terminal_block_places_qet_terminal_slices_without_local_rebind(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") rail = doc.addObject("App::DocumentObjectGroup", "DINRail") rail.Placement = app.Placement(app.Vector(100, 10, 5), app.Rotation()) terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") ud2 = self._qet_device(doc, terminal_objects, "UD:2", instance_id="ud-2", element_uuid="element-ud-2") ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") self._terminal(doc, terminal_objects, ud2, "terminal-ud-2", "UD:2") self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") report = batch_assembly.layout_existing_terminal_block( doc, rail, block_name="UD", pitch_mm=5.2, start_offset_mm=10.0, ) self.assertEqual("qet_existing", report["source"]) self.assertEqual(2, report["updated_devices"]) self.assertEqual(0, report["created_devices"]) self.assertEqual(["UD:1", "UD:2"], [device.Label for device in report["devices"]]) self.assertEqual([110.0, 115.2], [device.Placement.Base.x for device in report["devices"]]) self.assertEqual(["terminal-ud-1", "terminal-ud-2"], [terminal.QetTerminalUuid for terminal in report["terminals"]]) self.assertFalse(any(terminal.QetTerminalUuid.startswith("local:") for terminal in report["terminals"])) self.assertEqual("layout_existing", ud1.QetBatchAssemblyMode) self.assertEqual("rail", ud1.QetMountHostKind) def test_layout_existing_terminal_block_moves_group_children_for_document_groups(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") rail = doc.addObject("App::DocumentObjectGroup", "DINRail") rail.Placement = app.Placement(app.Vector(100, 0, 0), app.Rotation()) terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") body = doc.addObject("Part::Feature", "TerminalSlice_GreenBody") body.Placement = app.Placement(app.Vector(10, 20, 30), app.Rotation()) ud1.addObject(body) self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") report = batch_assembly.layout_existing_terminal_block( doc, rail, block_name="UD", pitch_mm=5.2, start_offset_mm=10.0, ) self.assertEqual(1, report["updated_devices"]) self.assertNotEqual(10.0, body.Placement.Base.x) self.assertAlmostEqual(110.0, ud1.Placement.Base.x) def test_available_terminal_strip_names_comes_from_existing_qet_devices(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") id2 = self._qet_device(doc, terminal_objects, "ID:2", instance_id="id-2", element_uuid="element-id-2") self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") self._terminal(doc, terminal_objects, id2, "terminal-id-2", "ID:2") self.assertEqual(["ID", "UD"], batch_assembly.available_terminal_strip_names(doc)) def test_layout_existing_devices_filters_qet_breakers_and_ignores_terminal_slices(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") rail = doc.addObject("App::DocumentObjectGroup", "DINRail") rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") qf2 = self._qet_device(doc, terminal_objects, "QF2", instance_id="qf-2", element_uuid="element-qf-2") qf1 = self._qet_device(doc, terminal_objects, "QF1", instance_id="qf-1", element_uuid="element-qf-1") ta1 = self._qet_device(doc, terminal_objects, "TA1", instance_id="ta-1", element_uuid="element-ta-1") ud1 = self._qet_device(doc, terminal_objects, "UD:1", instance_id="ud-1", element_uuid="element-ud-1") self._terminal(doc, terminal_objects, ud1, "terminal-ud-1", "UD:1") qf0 = self._qet_device(doc, terminal_objects, "QF0", instance_id="qf-0", element_uuid="element-qf-0") terminal_objects.ensure_string_property( qf0, "QetBatchAssemblyKind", "QET Batch Assembly", "", "breaker_batch", ) report = batch_assembly.layout_existing_devices( doc, rail, prefix="QF", pitch_mm=18.0, start_offset_mm=5.0, kind="breaker_batch", ) self.assertEqual("qet_existing", report["source"]) self.assertEqual(["QF1", "QF2"], [device.Label for device in report["devices"]]) self.assertNotIn(ta1, report["devices"]) self.assertNotIn(ud1, report["devices"]) self.assertNotIn(qf0, report["devices"]) self.assertEqual([5.0, 23.0], [device.Placement.Base.x for device in report["devices"]]) self.assertEqual("layout_existing", qf1.QetBatchAssemblyMode) def test_create_terminal_block_places_slices_and_local_terminals_along_selected_rail(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") rail = doc.addObject("App::DocumentObjectGroup", "DINRail") rail.Placement = app.Placement(app.Vector(100, 10, 5), app.Rotation()) terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") report = batch_assembly.create_terminal_block( doc, rail, block_name="XT1", count=3, pitch_mm=5.2, start_offset_mm=10.0, ) self.assertEqual(3, report["created_devices"]) self.assertEqual(3, report["created_terminals"]) self.assertEqual("XT1", report["group"].Label) placements = [item.Placement.Base.x for item in report["devices"]] self.assertEqual([110.0, 115.2, 120.4], placements) terminal_labels = [terminal.Label for terminal in report["terminals"]] self.assertEqual(["XT1:1", "XT1:2", "XT1:3"], terminal_labels) self.assertTrue(all(terminal.QetTerminalUuid.startswith("local:") for terminal in report["terminals"])) self.assertTrue(all(not terminal.ViewObject.Visibility for terminal in report["terminals"])) def test_create_breakers_generates_numbered_devices_and_terminal_labels(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") rail = doc.addObject("App::DocumentObjectGroup", "DINRail") rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") report = batch_assembly.create_breakers( doc, rail, base_name="QF", count=2, pitch_mm=18.0, start_offset_mm=0.0, terminal_numbers=("1", "2", "3", "4", "5", "6"), ) self.assertEqual(["QF1", "QF2"], [device.Label for device in report["devices"]]) self.assertEqual(12, report["created_terminals"]) self.assertTrue(all(device.Name.startswith("QETDevice_") for device in report["devices"])) self.assertEqual(["QF1", "QF2"], [device.QetInstanceId for device in report["devices"]]) self.assertEqual(["QF1", "QF2"], [device.QetElementUuid for device in report["devices"]]) labels = [terminal.Label for terminal in report["terminals"]] self.assertEqual(["QF1:1", "QF1:2", "QF1:3", "QF1:4", "QF1:5", "QF1:6"], labels[:6]) self.assertEqual(["QF2:1", "QF2:2", "QF2:3", "QF2:4", "QF2:5", "QF2:6"], labels[6:]) self.assertEqual([0.0, 18.0], [device.Placement.Base.x for device in report["devices"]]) def test_created_breaker_local_slots_can_be_promoted_to_qet_terminal_uuid(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules("AutoRouting") auto_routing = importlib.import_module("AutoRouting") app = sys.modules["FreeCAD"] doc = FakeDocument() terminal_objects.ensure_root_group(doc, "project-1") rail = doc.addObject("App::DocumentObjectGroup", "DINRail") rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") batch_assembly.create_breakers( doc, rail, base_name="QF", count=1, terminal_numbers=("1", "2"), ) payload = { "project_uuid": "project-1", "wires": [ { "wire_uuid": "wire-1", "start_instance_id": "QF1", "start_terminal_uuid": "terminal-qf1-1", "start_terminal_display": "1", "end_instance_id": "QF1", "end_terminal_uuid": "terminal-qf1-2", "end_terminal_display": "2", } ], } report = auto_routing.bind_wire_task_terminals_from_payload(doc, payload) self.assertEqual(2, report["bound"]) indexed = auto_routing.index_terminals(doc) self.assertIn("terminal-qf1-1", indexed) self.assertIn("terminal-qf1-2", indexed) self.assertFalse(any(key.startswith("local:QF1") for key in indexed)) def test_create_breakers_can_import_model_template_instead_of_placeholder_box(self): _install_fake_freecad() terminal_objects, batch_assembly = _reload_modules() app = sys.modules["FreeCAD"] doc = FakeDocument() app.ActiveDocument = doc terminal_objects.ensure_root_group(doc, "project-1") rail = doc.addObject("App::DocumentObjectGroup", "DINRail") rail.Placement = app.Placement(app.Vector(0, 0, 0), app.Rotation()) terminal_objects.ensure_string_property(rail, "QetCarrierKind", "QET Wiring", "", "rail") terminal_objects.ensure_string_property(rail, "QetCarrierAxis", "QET Wiring", "", "x") with tempfile.TemporaryDirectory() as temp_dir: model_path = Path(temp_dir) / "breaker.step" model_path.write_text("fake step", encoding="utf-8") report = batch_assembly.create_breakers( doc, rail, base_name="QF", count=1, model_path=str(model_path), ) imported_children = [child for child in report["devices"][0].Group if getattr(child, "ImportedPath", "")] self.assertEqual(1, len(imported_children)) self.assertEqual(str(model_path), imported_children[0].QetBatchSourceModelPath) self.assertEqual("QF1 模型", imported_children[0].Label) if __name__ == "__main__": unittest.main()