Files
Qinventor/tests/drawing_bom_gui_suite.py
T

714 lines
35 KiB
Python

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