714 lines
35 KiB
Python
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
|