"""Actual Qt and native A4 drawing BOM, restore and protected export workflows.""" import hashlib from collections import Counter import io import json import math import os from pathlib import Path import re import subprocess import sys import tempfile import traceback import uuid import xml.etree.ElementTree as ET import zipfile import FreeCAD as App import FreeCADGui as Gui from PySide import QtCore, QtGui try: from PySide import QtWidgets, QtSvg except ImportError: QtWidgets = QtGui from PySide import QtSvg try: from PySide6 import QtTest except ImportError: from PySide2 import QtTest SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor" if str(SOURCE) not in sys.path: sys.path.insert(0, str(SOURCE)) def _near(actual, expected): assert math.isclose(float(actual), float(expected), rel_tol=1e-9, abs_tol=1e-10), (actual, expected) def _state(doc): contents = [] for obj in doc.Objects: with zipfile.ZipFile(io.BytesIO(bytes(obj.dumpContent()))) as archive: members = tuple((item.filename, archive.read(item)) for item in archive.infolist()) contents.append((obj.Name, members, tuple(obj.State))) return tuple(contents), doc.UndoCount, doc.RedoCount, Gui.getDocument(doc.Name).Modified def _assert_native_state(doc, before, diagnostic_path): after = _state(doc) if after == before: return old = {name: (dict(members), state) for name, members, state in before[0]} new = {name: (dict(members), state) for name, members, state in after[0]} changed = {} for name in old.keys() | new.keys(): previous, current = old.get(name), new.get(name) if previous == current: continue payload = {"state_before": previous[1] if previous else None, "state_after": current[1] if current else None, "members": {}} old_members, new_members = previous[0] if previous else {}, current[0] if current else {} for member in old_members.keys() | new_members.keys(): old_bytes, new_bytes = old_members.get(member), new_members.get(member) if old_bytes == new_bytes: continue difference = {} for stage, value in (("before", old_bytes), ("after", new_bytes)): difference[stage] = None if value is None else {"size": len(value), "sha256": hashlib.sha256(value).hexdigest()} if value is not None and member.endswith(".xml"): difference[stage]["xml"] = value.decode("utf-8", errors="replace") payload["members"][member] = difference changed[name] = payload Path(diagnostic_path).write_text(json.dumps({"before_history": before[1:], "after_history": after[1:], "changed_objects": changed}, ensure_ascii=False, indent=2), encoding="utf-8") raise AssertionError("Native document state changed: " + str(diagnostic_path)) def _settle(ms=100): QtWidgets.QApplication.processEvents() QtTest.QTest.qWait(ms) def _click(widget): QtTest.QTest.mouseClick(widget, QtCore.Qt.LeftButton) _settle() def _combo(combo, value): index = combo.findData(value) assert index >= 0 combo.setFocus() QtTest.QTest.keyClick(combo, QtCore.Qt.Key_Home) for _ in range(index): QtTest.QTest.keyClick(combo, QtCore.Qt.Key_Down) _settle() assert combo.currentData() == value def _literal(content): return content[1:] if content.startswith("'") else content def _actual_cells(view, snapshot): actual = {address: view.Source.getContents(address) for address in snapshot["cells"]} assert actual == snapshot["cells"], (actual, snapshot["cells"]) return actual def _symbol(view, snapshot): assert view.ViewObject.LegacyScaling is False, "Native view provider must use current spreadsheet scaling" symbol = view.Symbol renderer = QtSvg.QSvgRenderer(QtCore.QByteArray(symbol.encode("utf-8"))) assert renderer.isValid() texts = ["".join(element.itertext()) for element in ET.fromstring(symbol).iter() if element.tag.rsplit("}", 1)[-1] in ("text", "tspan")] # Native page exports outline the glyphs. Check the actual text before # outlining, then retain rendered PDF/SVG images as visual evidence. for content in snapshot["cells"].values(): assert _literal(content) in texts, (_literal(content), texts) box = renderer.viewBoxF() width, height = box.width() * .1 * view.Scale, box.height() * .1 * view.Scale assert 0 < width <= 138.01 and 0 < height <= 56.01, (width, height) x, y = float(view.X), float(view.Y) assert x - width / 2 >= 5 and x + width / 2 <= 292 assert y - height / 2 >= 24 and y + height / 2 <= 205 assert snapshot["layout"]["minimum_text_height_mm"] >= 2.5 return {"texts": texts, "printed_bounds_mm": [x - width / 2, y - height / 2, x + width / 2, y + height / 2], "hash": hashlib.sha256(symbol.encode("utf-8")).hexdigest()} def _dxf_entities(path): raw = Path(path).read_text(encoding="utf-8").splitlines() assert len(raw) % 2 == 0 pairs = [(int(raw[index].strip()), raw[index + 1].strip()) for index in range(0, len(raw), 2)] start = next(index + 2 for index in range(len(pairs) - 1) if pairs[index] == (0, "SECTION") and pairs[index + 1] == (2, "ENTITIES")) end = next(index for index in range(start, len(pairs)) if pairs[index] == (0, "ENDSEC")) entities, current = [], [] for pair in pairs[start:end]: if pair[0] == 0 and current: entities.append(current); current = [] current.append(pair) if current: entities.append(current) return pairs, entities def _dxf_field(entity, code): return next(value for key, value in entity if key == code) def _dxf_evidence(page, path, output, stem): import TechDraw import Import import qi_drawing_bom as service native_path = output / (stem + "-native-projections.dxf") TechDraw.writeDXFPage(page, str(native_path)) _native_pairs, native = _dxf_entities(native_path) pairs, completed = _dxf_entities(path) version_index = next(index for index, pair in enumerate(pairs) if pair == (9, "$ACADVER")) assert pairs[version_index + 1] == (1, "AC1021"), "Cyrillic DXF requires native-supported UTF-8 header" assert [entity for entity in completed if _dxf_field(entity, 8) != "QI_BOM"] == native grid = [entity for entity in completed if entity[0] == (0, "LINE") and _dxf_field(entity, 8) == "QI_BOM"] texts = [entity for entity in completed if entity[0] == (0, "TEXT") and _dxf_field(entity, 8) == "QI_BOM"] values = [_dxf_field(entity, 1) for entity in texts] view = service.table_for_page(page) snapshot = service.validate_table(page) expected = Counter("".join(node.itertext()) for node in ET.fromstring(view.Symbol).iter() if node.tag.rsplit("}", 1)[-1] == "text" and "".join(node.itertext())) assert Counter(values) == expected and {"Сталь", "Алюминий"}.issubset(values) xs = [float(value) for entity in grid for key, value in entity if key in (10, 11)] ys = [float(value) for entity in grid for key, value in entity if key in (20, 21)] width, height = snapshot["native_symbol_bounds_mm"]["width"], snapshot["native_symbol_bounds_mm"]["height"] expected_box = [float(view.X) - width / 2, float(view.Y) - height / 2, float(view.X) + width / 2, float(view.Y) + height / 2] grid_box = [min(xs), min(ys), max(xs), max(ys)] for actual, expected_value in zip(grid_box, expected_box): _near(actual, expected_value) assert all(float(_dxf_field(entity, 40)) >= 2.5 for entity in texts) # Exercise the actual bundled C++ reader, with text enabled, and prove # editable native text rather than decoding escapes in the test parser. prefs = App.ParamGet("User parameter:BaseApp/Preferences/Mod/Draft") prior = [entry for entry in prefs.GetContents() if entry[1] == "dxftext"] active = App.ActiveDocument.Name if App.ActiveDocument else None imported = App.newDocument("DrawingBOMReadback") try: prefs.SetBool("dxftext", True) App.setActiveDocument(imported.Name) stats = Import.readDXF(str(path)) imported.recompute() native_texts = [] native_fields = [] edited = False for obj in imported.Objects: if "Text" in obj.PropertiesList: value = obj.Text native_texts.append("\n".join(value) if isinstance(value, (list, tuple)) else str(value)) flags = obj.getEditorMode("Text") native_fields.append({"name": obj.Name, "type": obj.TypeId, "property_type": obj.getTypeIdOfProperty("Text"), "editor_flags": flags}) assert isinstance(flags, (tuple, list)) and not set(flags) & {"ReadOnly", "Hidden"}, native_fields[-1] if not edited: replacement = ["Проверка редактирования"] if isinstance(value, (list, tuple)) else "Проверка редактирования" obj.Text = replacement imported.recompute() assert obj.Text == replacement obj.Text = value imported.recompute() assert obj.Text == value edited = True assert Counter(native_texts) == expected, (native_texts, list(expected)) assert edited finally: if prior: prefs.SetBool("dxftext", bool(prior[0][2])) else: prefs.RemBool("dxftext") App.closeDocument(imported.Name) if active in App.listDocuments(): App.setActiveDocument(active) return {"version": "AC1021", "actual_UTF8_text": values, "native_editable_text_readback": native_texts, "native_text_properties": native_fields, "native_text_edit_restore": edited, "native_projection_entities_unchanged": True, "native_projection_layers": sorted({_dxf_field(entity, 8) for entity in native}), "grid_paper_bounds_mm": grid_box, "native_reader_stats": stats} def _render_exports(page, output, stem): import qi_model as model svg = Path(model.export_drawing(page, output / (stem + ".svg"))) pdf = Path(model.export_drawing(page, output / (stem + ".pdf"))) dxf = Path(model.export_drawing(page, output / (stem + ".dxf"))) assert svg.stat().st_size > 1000 and model._svg_has_geometry(svg) assert pdf.read_bytes().startswith(b"%PDF-") and pdf.stat().st_size > 1000 tree = ET.parse(svg) content = next(element for element in tree.iter() if element.attrib.get("id") == "DrawingContent") assert any(element.tag.rsplit("}", 1)[-1] == "rect" for element in content.iter()), "Native BOM grid omitted from SVG" assert not any(element.tag.rsplit("}", 1)[-1] == "image" for element in content.iter()), "BOM should remain native vector geometry" # Test the real exported scene, independently of Symbol * Scale. Native # LegacyScaling on the view provider otherwise enlarges an apparently # valid Symbol and clips the grid outside the paper. rectangles = [] def native_rects(element, parent_matrix=(1., 0., 0., 1., 0., 0.)): transform = element.attrib.get("transform", "") matrix = parent_matrix if transform: match = re.fullmatch(r"matrix\(([^)]+)\)", transform) assert match, transform local = tuple(float(value) for value in re.split(r"[\s,]+", match.group(1).strip())) assert len(local) == 6 a, b, c, d, e, f = parent_matrix g, h, i, j, k, l = local matrix = (a*g+c*h, b*g+d*h, a*i+c*j, b*i+d*j, a*k+c*l+e, b*k+d*l+f) if element.tag.rsplit("}", 1)[-1] == "rect": x, y = float(element.attrib.get("x", 0)), float(element.attrib.get("y", 0)) width, height = float(element.attrib["width"]), float(element.attrib["height"]) a, b, c, d, e, f = matrix points = [(a*px+c*py+e, b*px+d*py+f) for px, py in ((x, y), (x+width, y), (x, y+height), (x+width, y+height))] rectangles.append(points) for child in element: native_rects(child, matrix) native_rects(content) points = [point for rectangle in rectangles for point in rectangle] grid_bounds = [min(point[0] for point in points)*.1, min(point[1] for point in points)*.1, max(point[0] for point in points)*.1, max(point[1] for point in points)*.1] left, top, right, bottom = grid_bounds assert left >= 5 and right <= 292 and top >= 5 and bottom <= 149, grid_bounds assert 0 < right-left <= 138.1 and 0 < bottom-top <= 56.1, grid_bounds renderer = QtSvg.QSvgRenderer(str(svg)) assert renderer.isValid() image = QtGui.QImage(1485, 1050, QtGui.QImage.Format_ARGB32) image.fill(QtCore.Qt.white) painter = QtGui.QPainter(image) renderer.render(painter) painter.end() svg_png = output / (stem + "-svg.png") assert image.save(str(svg_png)) dxf_evidence = _dxf_evidence(page, dxf, output, stem) return {"svg": str(svg), "pdf": str(pdf), "dxf": str(dxf), "dxf_evidence": dxf_evidence, "rendered": [str(svg_png)], "actual_SVG_grid_bounds_mm": grid_bounds, "pdf_visual_review": "Separate offline renderer; the CAD runtime does not bundle QtPdf"} def native_restore_child(project, page_name, bolt_name, report_path): """Independent headless native process: persisted proxy and dependency chain.""" import qi_drawing_bom as drawing_bom import qi_model as model assert not App.GuiUp doc = App.openDocument(str(project)) page = doc.getObject(page_name) doc.recompute() before = drawing_bom.validate_table(page, recompute=False) assert before["summary"]["components"] == 7 _near(before["summary"]["mass_kg"], .006342) table = drawing_bom.table_for_page(page) assert table.TypeId == "TechDraw::DrawViewSpreadsheet" assert table.Source.TypeId == "Spreadsheet::SheetPython" and table.Source.Proxy is not None _actual_cells(table, before) model.set_dimensions(doc.getObject(bolt_name), {"QIHeight": 8}) doc.recompute() after = drawing_bom.validate_table(page, recompute=False) _near(after["summary"]["mass_kg"], .007284) assert after["cells"] != before["cells"] _actual_cells(table, after) doc.recompute() assert drawing_bom.validate_table(page, recompute=False)["cells"] == after["cells"] Path(report_path).write_text(json.dumps({"passed": True, "gui_up": bool(App.GuiUp), "before": before["cells"], "after": after["cells"], "mass_kg": after["summary"]["mass_kg"], "service_sha256": hashlib.sha256(Path(drawing_bom.__file__).read_bytes()).hexdigest()}, ensure_ascii=False, indent=2), encoding="utf-8") App.closeDocument(doc.Name) def _restore_in_new_native_process(project, page_name, bolt_name, output): import qi_drawing_bom directory = Path(tempfile.mkdtemp(prefix="drawing-bom-restore-", dir=str(output))) report_path = directory / "report.json" macro = directory / "restore.FCMacro" macro.write_text("import sys\n" + "sys.path.insert(0, " + repr(str(Path(__file__).resolve().parent)) + ")\n" + "import drawing_bom_gui_suite\ndrawing_bom_gui_suite.native_restore_child(" + repr(str(project)) + ", " + repr(page_name) + ", " + repr(bolt_name) + ", " + repr(str(report_path)) + ")\n", encoding="utf-8") engine = Path(App.getHomePath()) / "bin" / "FreeCADCmd.exe" assert engine.is_file() environment = os.environ.copy() environment.pop("QI_TEST", None) for name, folder in (("APPDATA", "appdata"), ("LOCALAPPDATA", "localappdata")): environment[name] = str(directory / folder) Path(environment[name]).mkdir() options = {} if os.name == "nt": options["creationflags"] = subprocess.CREATE_NO_WINDOW startup = subprocess.STARTUPINFO() startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW startup.wShowWindow = subprocess.SW_HIDE options["startupinfo"] = startup args = [str(engine), "--user-cfg", str(directory / "user.cfg"), "--system-cfg", str(directory / "system.cfg"), "--log-file", str(directory / "native.log"), str(macro)] with (directory / "process.log").open("wb") as stream: process = subprocess.Popen(args, cwd=str(directory), env=environment, stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, **options) try: code = process.wait(timeout=90) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=10) raise AssertionError("Native FCStd restore process timed out: " + str(directory)) assert code == 0 and report_path.is_file(), (code, str(directory / "process.log")) result = json.loads(report_path.read_text(encoding="utf-8")) assert result["passed"] and not result["gui_up"] assert result["service_sha256"] == hashlib.sha256(Path(qi_drawing_bom.__file__).read_bytes()).hexdigest() return {"native_exit_code": code, "report": str(report_path), "mass_kg_after_edit": result["mass_kg"]} def run(output_dir): import bom_suite import qi_commands as commands import qi_drawing_bom as drawing_bom import qi_model as model import qi_ui as ui output = Path(output_dir).absolute() output.mkdir(parents=True, exist_ok=True) original_docs = set(App.listDocuments()) original_active = App.ActiveDocument.Name if App.ActiveDocument else None report = {"status": "running", "passed": 0, "failed": 0, "checks": [], "input_delivery": "actual Qt ribbon and drawing BOM controls; native CAD/PDF/SVG/DXF", "os_pointer_access": False} report_path = output / "drawing-bom-gui-report.json" dialogs = [] main = Gui.getMainWindow() main.resize(1440, 940) main.hide(); main.showNormal(); main.raise_(); main.activateWindow() assert QtTest.QTest.qWaitForWindowExposed(main.windowHandle(), 5000) commands.register_commands() def write(): report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str), encoding="utf-8") def check(name, action): report["running_case"] = name write() try: report["checks"].append({"name": name, "passed": True, "evidence": action()}) report["passed"] += 1 except Exception: report["checks"].append({"name": name, "passed": False, "error": traceback.format_exc()}) report["failed"] += 1 write() def fixture(prefix): doc, assembly, bolt, housing, _sub, _a, _b, _direct = bom_suite.nested_fixture(prefix) page = model.create_drawing(doc) # This valid solid is outside the already-created projection sources. outside = model.create_box(2, 2, 2, doc) outside.Label = "Деталь вне листа" doc.recompute() model.prepare_drawing(page) gui_doc = Gui.getDocument(doc.Name) if gui_doc.getInEdit(): gui_doc.resetEdit() return doc, assembly, bolt, housing, page, outside def open_dialog(page): App.setActiveDocument(page.Document.Name) Gui.Selection.clearSelection() ui.select_tab("Чертёж") _settle() buttons = [button for button in ui._ribbon.buttons["QI_DrawingBOM"] if button.isVisible()] assert len(buttons) == 1 and buttons[0].isEnabled() _click(buttons[0]) candidates = [dialog for dialog in main.findChildren(QtWidgets.QDialog, "QI_DrawingBOM") if dialog.isVisible() and getattr(dialog, "page", None) == page] assert len(candidates) == 1 dialog = candidates[0] dialogs.append(dialog) assert not dialog.isModal() return dialog def assert_flat(snapshot): assert snapshot["summary"]["components"] == 7 rows = {row["part_number"]: row for row in snapshot["rows"]} assert set(rows) == {"BOLT", "HOUSING"} assert rows["BOLT"]["quantity"] == 5 and rows["HOUSING"]["quantity"] == 2 _near(snapshot["summary"]["mass_kg"], .006342) def actual_controls_and_atomic_table(): empty = model.new_part("Нет листа для спецификации") baseline = _state(empty) try: commands._drawing_bom() except ValueError as error: assert "лист" in str(error) else: raise AssertionError("Missing page was accepted") assert _state(empty) == baseline assert not any(dialog.isVisible() for dialog in main.findChildren(QtWidgets.QDialog, "QI_DrawingBOM")) doc, assembly, _bolt, _housing, page, _outside = fixture("Предпросмотр спецификации") extra = doc.addObject("TechDraw::DrawPage", "ExtraPage") Gui.Selection.clearSelection() baseline = _state(doc) try: commands._drawing_bom() except ValueError as error: assert "один" in str(error) else: raise AssertionError("Ambiguous page was accepted") assert _state(doc) == baseline Gui.Selection.addSelection(page) assert commands._drawing_bom_page() is page Gui.Selection.clearSelection() doc.removeObject(extra.Name) doc.recompute() model.prepare_drawing(page) baseline = _state(doc) dialog = open_dialog(page) native_sources = {source.Name for projection in page.Views if projection.isDerivedFrom("TechDraw::DrawViewPart") for source in projection.Source} assert native_sources == set(dialog.snapshot["source_names"]) == {assembly.Name} assert dialog.scope.isReadOnly() and all(doc.Name + "." + name in dialog.scope.toPlainText() for name in native_sources) assert_flat(dialog.snapshot) assert all(not dialog.table.item(row, column).flags() & QtCore.Qt.ItemIsEditable for row in range(dialog.table.rowCount()) for column in range(dialog.table.columnCount())) _combo(dialog.mode_selector, "structured") assert any(row["quantity"] == 2 and row["quantity_total"] == 4 for row in dialog.rows) dialog.close(); _settle() assert drawing_bom.table_for_page(page) is None _assert_native_state(doc, baseline, output / "preview-cancel-state-diff.json") dialog = open_dialog(page) before_undo = doc.UndoCount _click(dialog.apply_button) assert "размещена" in dialog.status.text(), dialog.status.text() view = drawing_bom.table_for_page(page) assert view.TypeId == "TechDraw::DrawViewSpreadsheet" and view.Source.TypeId == "Spreadsheet::SheetPython" assert view.Source not in assembly.Group and view not in assembly.Group assert doc.UndoCount == before_undo + 1 model.prepare_drawing(page) flat = drawing_bom.validate_table(page) assert_flat(flat); _actual_cells(view, flat) first_symbol = _symbol(view, flat) flat_e3_value = view.Source.getPropertyByName("E3") flat_e3_type = view.Source.getTypeIdOfProperty("E3") _combo(dialog.mode_selector, "structured") before_undo = doc.UndoCount _click(dialog.apply_button) assert "обновлена" in dialog.status.text() and drawing_bom.table_for_page(page) is view assert doc.UndoCount == before_undo + 1 model.prepare_drawing(page) structured = drawing_bom.validate_table(page) assert structured["mode"] == "structured" assert any(row["quantity"] == 2 and row["quantity_total"] == 4 for row in structured["rows"]) _near(structured["summary"]["mass_kg"], .006342) _actual_cells(view, structured) assert view.Source.getTypeIdOfProperty("E3") == flat_e3_type second_symbol = _symbol(view, structured) assert first_symbol["hash"] != second_symbol["hash"] doc.undo() # Check the native evaluated property immediately. A spreadsheet # transaction typecast error must not be masked by later recompute. assert "E3" in view.Source.PropertiesList assert view.Source.getPropertyByName("E3") == flat_e3_value assert view.Source.getTypeIdOfProperty("E3") == flat_e3_type doc.recompute(); model.prepare_drawing(page) assert drawing_bom.validate_table(page)["mode"] == "flat" doc.redo(); doc.recompute(); model.prepare_drawing(page) assert drawing_bom.validate_table(page)["mode"] == "structured" dialog.close() csv_dialog = ui.show_bom(doc) dialogs.append(csv_dialog) QtWidgets.QApplication.sendEvent(csv_dialog.buttons, QtCore.QEvent(QtCore.QEvent.LanguageChange)) assert csv_dialog.export_button.text() == "Экспорт CSV" csv_dialog.close() screenshot = output / "drawing-bom-preview.png" dialog = open_dialog(page) assert dialog.grab().save(str(screenshot)) dialog.close() return {"missing_and_ambiguous_page_rejected_before_dialog": True, "preview_cancel_exact_native_state": True, "page_sources_scope": flat["source_names"], "single_native_transaction_create_and_update": True, "undo_redo_mode": True, "flat_symbol": first_symbol, "structured_symbol": second_symbol, "csv_caption_survives_language_change": True, "screenshot": str(screenshot)} def source_restore_and_export(): doc, _assembly, bolt, _housing, page, outside = fixture("Перестроение спецификации на листе") dialog = open_dialog(page) _click(dialog.apply_button) view = drawing_bom.table_for_page(page) model.prepare_drawing(page) before = drawing_bom.validate_table(page) assert_flat(before) first = _symbol(view, before) model.set_dimensions(bolt, {"QIHeight": 8}) doc.recompute(); model.prepare_drawing(page) after = drawing_bom.validate_table(page) _near(after["summary"]["mass_kg"], .007284) _actual_cells(view, after) second = _symbol(view, after) assert first["hash"] != second["hash"] model.set_material(outside, "Алюминий") doc.recompute(); model.prepare_drawing(page) assert drawing_bom.validate_table(page)["cells"] == after["cells"] updated_exports = _render_exports(page, output, "drawing-bom-updated") model.set_dimensions(bolt, {"QIHeight": 4}) doc.recompute(); model.prepare_drawing(page) project = output / ("drawing-bom-" + uuid.uuid4().hex + ".FCStd") page_name, bolt_name = page.Name, bolt.Name dialog.close() model.save_project(doc, project) child = _restore_in_new_native_process(project, page_name, bolt_name, output) gui_doc = Gui.getDocument(doc.Name) if gui_doc.getInEdit(): gui_doc.resetEdit() model.prepare_drawing(page) App.closeDocument(doc.Name) restored = App.openDocument(str(project)) restored_page = restored.getObject(page_name) restored.recompute(); model.prepare_drawing(restored_page) restored_snapshot = drawing_bom.validate_table(restored_page) assert_flat(restored_snapshot) _actual_cells(drawing_bom.table_for_page(restored_page), restored_snapshot) exports = _render_exports(restored_page, output, "drawing-bom-reopened") return {"automatic_source_edit_cells_and_symbol": True, "outside_source_ignored": True, "updated": updated_exports, "FCStd": str(project), "independent_native_restore": child, "reopened": exports} def errors_preserve_table_and_exports(): import TechDrawGui import TechDraw doc, assembly, _bolt, housing, page, _outside = fixture("Защита спецификации и экспорта") dialog = open_dialog(page) _click(dialog.apply_button) view = drawing_bom.table_for_page(page) model.prepare_drawing(page) valid = drawing_bom.validate_table(page) files = _render_exports(page, output, "drawing-bom-protected") protected = {Path(files[key]): Path(files[key]).read_bytes() for key in ("svg", "pdf", "dxf")} sibling = output / "drawing-bom-protected.geometry-check.svg" sibling.write_bytes(b"User-owned companion file") pad = housing.Tip profile = pad.Profile doc.openTransaction("Ошибка исходной операции") pad.Profile = None doc.recompute(); doc.commitTransaction() assert "Invalid" in pad.State and not pad.Shape.isNull() # Native tree error navigation expands the failed feature on its next # Qt event turn. Capture the strict baseline after that UI response. _settle(200) before = _state(doc) _click(dialog.apply_button) assert dialog.isVisible() and not dialog.apply_button.isEnabled() and "не изменена" in dialog.status.text() assert "Исправьте исходную операцию" in dialog.status.text() and "No object linked" not in dialog.status.text() _assert_native_state(doc, before, output / "invalid-source-apply-state-diff.json") assert {key: view.Source.getContents(key) for key in valid["cells"]} == valid["cells"] for path, original in protected.items(): try: model.export_drawing(page, path) except ValueError: pass else: raise AssertionError("Invalid cached source was exported") assert path.read_bytes() == original doc.openTransaction("Исправить исходную операцию") pad.Profile = profile doc.recompute(); doc.commitTransaction() model.prepare_drawing(page) _click(dialog.refresh_button) assert dialog.apply_button.isEnabled() written_paths = [] for namespace, function, extension in ((TechDrawGui, "exportPageAsSvg", "svg"), (TechDraw, "writeDXFPage", "dxf")): original_writer = getattr(namespace, function) def write_then_fail(native_page, path): original_writer(native_page, path) written_paths.append(str(path)) assert Path(path).is_file() and Path(path).stat().st_size > 1000 raise RuntimeError("Native writer fault after actual " + extension.upper() + " output") before_temps = set(output.glob(".qinventor*")) setattr(namespace, function, write_then_fail) try: try: model.export_drawing(page, Path(files[extension])) except RuntimeError as error: assert "Native writer fault" in str(error) else: raise AssertionError("Native backend fault was ignored") finally: setattr(namespace, function, original_writer) assert Path(files[extension]).read_bytes() == protected[Path(files[extension])] assert set(output.glob(".qinventor*")) == before_temps assert len(written_paths) == 2 assert sibling.read_bytes() == b"User-owned companion file" doc.openTransaction("Много разных деталей для A4") definitions = doc.getObject("Definitions") for index in range(30): source = doc.addObject("Part::Feature", "AdditionalPart") import Part source.Shape = Part.makeBox(1, 1, 1) source.Label = "P" + str(index) source.addProperty("App::PropertyString", "QIPartNumber") source.QIPartNumber = "P" + str(index) source.addProperty("App::PropertyString", "QIMaterial") source.QIMaterial = "Сталь" source.addProperty("App::PropertyFloat", "QIDensity") source.QIDensity = 7850 definitions.addObject(source) bom_suite._link(assembly, source, "AdditionalOccurrence") doc.recompute(); doc.commitTransaction() _settle(200) before = _state(doc) _click(dialog.refresh_button) assert "A4" in dialog.status.text() and not dialog.apply_button.isEnabled() _click(dialog.apply_button) _assert_native_state(doc, before, output / "overflow-apply-state-diff.json") assert {key: view.Source.getContents(key) for key in valid["cells"]} == valid["cells"] for path, original in protected.items(): try: model.export_drawing(page, path) except ValueError as error: assert "A4" in str(error) else: raise AssertionError("Overflow BOM was exported") assert path.read_bytes() == original assert sibling.read_bytes() == b"User-owned companion file" dialog.close() return {"cached_invalid_shape_rejects_UI_and_export": True, "A4_overflow_rejects_without_writes": True, "last_good_table_cells_preserved": True, "native_writer_fault_after_output": written_paths, "previous_PDF_SVG_DXF_bytes_preserved": True, "owned_temps_removed": True, "user_companion_file_untouched": str(sibling)} try: for name, action in (("Actual ribbon preview cancel insertion mode update native Undo/Redo and A4 bounds", actual_controls_and_atomic_table), ("Live seven-leaf source cells Symbol native PDF/SVG/DXF and independent FCStd restore", source_restore_and_export), ("Cached native errors A4 overflow and post-writer fault protect table and old exports", errors_preserve_table_and_exports)): check(name, action) finally: for dialog in dialogs: try: dialog.close() except RuntimeError: pass Gui.Selection.clearSelection() for name in set(App.listDocuments()) - original_docs: gui_doc = Gui.getDocument(name) if gui_doc.getInEdit(): gui_doc.resetEdit() _settle() for name in set(App.listDocuments()) - original_docs: document = App.getDocument(name) for page in (obj for obj in document.Objects if obj.TypeId == "TechDraw::DrawPage"): try: model.prepare_drawing(page) except ValueError: pass # Deliberately invalid/overflow fixture; drain native geometry workers below. for _ in range(100): if not any(getattr(view, "isBusy", lambda: False)() for obj in document.Objects if obj.TypeId == "TechDraw::DrawPage" for view in obj.Views): break _settle(50) App.closeDocument(name) if original_active and original_active in App.listDocuments(): App.setActiveDocument(original_active) report.pop("running_case", None) report["status"] = "passed" if report["failed"] == 0 else "failed" report["count"] = len(report["checks"]) write() return report