431 lines
22 KiB
Python
431 lines
22 KiB
Python
"""Native command regressions for sketch cancellation and assembly drawings."""
|
|
import json
|
|
import math
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
import FreeCAD as App
|
|
import FreeCADGui as Gui
|
|
from PySide import QtCore, QtGui
|
|
try:
|
|
from PySide import QtWidgets
|
|
except ImportError:
|
|
QtWidgets = QtGui
|
|
try:
|
|
from PySide import QtTest
|
|
except ImportError:
|
|
try:
|
|
from PySide6 import QtTest
|
|
except ImportError:
|
|
from PySide2 import QtTest
|
|
|
|
|
|
def run(output_dir):
|
|
import qi_commands as commands
|
|
import qi_model as model
|
|
import qi_ui as ui
|
|
|
|
out = Path(output_dir).absolute()
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
initial_docs = set(App.listDocuments())
|
|
initial_active = App.ActiveDocument.Name if App.ActiveDocument else None
|
|
report = {"status": "running", "passed": 0, "failed": 0,
|
|
"checks": [], "artifacts": []}
|
|
main = Gui.getMainWindow()
|
|
commands.register_commands()
|
|
|
|
def settle(ms=100):
|
|
QtWidgets.QApplication.processEvents()
|
|
QtTest.QTest.qWait(ms)
|
|
|
|
def capture(name):
|
|
settle()
|
|
path = out / (name + ".png")
|
|
assert main.grab().save(str(path)), "Cannot save actual GUI evidence: " + str(path)
|
|
report["artifacts"].append(str(path))
|
|
|
|
def check(name, fn):
|
|
try:
|
|
evidence = fn()
|
|
report["checks"].append({"name": name, "passed": True, "evidence": evidence})
|
|
report["passed"] += 1
|
|
except Exception:
|
|
report["checks"].append({"name": name, "passed": False,
|
|
"error": traceback.format_exc()})
|
|
report["failed"] += 1
|
|
(out / "command-workflows-report.json").write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
def document_state(doc):
|
|
return (tuple(obj.Name for obj in doc.Objects), doc.UndoCount, doc.RedoCount,
|
|
bool(Gui.getDocument(doc.Name).Modified))
|
|
|
|
def cancel_plane_without_document():
|
|
Gui.Selection.clearSelection()
|
|
App.setActiveDocument("")
|
|
assert App.ActiveDocument is None, "Fixture did not deactivate the current document"
|
|
before = set(App.listDocuments())
|
|
states = {name: document_state(doc) for name, doc in App.listDocuments().items()}
|
|
evidence = {"cancel_clicked": False, "dialog_seen": False}
|
|
errors = []
|
|
attempts = [0]
|
|
timer = QtCore.QTimer(main)
|
|
|
|
def cancel_visible_plane_dialog():
|
|
attempts[0] += 1
|
|
dialogs = [widget for widget in QtWidgets.QApplication.topLevelWidgets()
|
|
if isinstance(widget, QtWidgets.QDialog) and widget.isVisible()
|
|
and widget.windowTitle() == "Плоскость эскиза"]
|
|
if dialogs:
|
|
dialog = dialogs[0]
|
|
evidence["dialog_seen"] = True
|
|
try:
|
|
path = out / "command-plane-cancel-dialog.png"
|
|
assert dialog.grab().save(str(path)), "Plane dialog screenshot was not saved"
|
|
report["artifacts"].append(str(path))
|
|
box = dialog.findChild(QtWidgets.QDialogButtonBox)
|
|
button = box.button(QtWidgets.QDialogButtonBox.Cancel) if box else None
|
|
assert button and button.isVisible(), "The actual plane Cancel button is missing"
|
|
QtTest.QTest.mouseClick(button, QtCore.Qt.LeftButton)
|
|
evidence["cancel_clicked"] = True
|
|
except Exception:
|
|
errors.append(traceback.format_exc())
|
|
dialog.reject()
|
|
timer.stop()
|
|
elif attempts[0] >= 100:
|
|
errors.append("Plane chooser did not appear within five seconds")
|
|
modal = QtWidgets.QApplication.activeModalWidget()
|
|
if isinstance(modal, QtWidgets.QDialog):
|
|
modal.reject()
|
|
timer.stop()
|
|
|
|
timer.timeout.connect(cancel_visible_plane_dialog)
|
|
timer.start(50)
|
|
try:
|
|
# Exercise the real command and modal chooser; no chooser stub.
|
|
Gui.runCommand("QI_NewSketch", 0)
|
|
finally:
|
|
timer.stop()
|
|
timer.deleteLater()
|
|
settle()
|
|
assert not errors, errors
|
|
assert evidence["dialog_seen"] and evidence["cancel_clicked"], evidence
|
|
assert set(App.listDocuments()) == before, "Cancelling the plane chooser created a project"
|
|
assert App.ActiveDocument is None, "Cancelling the plane chooser activated a document"
|
|
assert states == {name: document_state(doc) for name, doc in App.listDocuments().items()}, \
|
|
"Cancelling the plane chooser modified an existing document or its history"
|
|
return dict(evidence, documents_preserved=True, no_active_document=True)
|
|
|
|
def projection_signature(page):
|
|
model.prepare_drawing(page)
|
|
signature = {}
|
|
for view in page.Views:
|
|
edges = view.getVisibleEdges()
|
|
assert len(edges) >= 4, "Native drawing projection contains no usable geometry: " + view.Name
|
|
assert view.getStatusString() == "Valid", view.getStatusString()
|
|
signature[view.Name] = sorted([
|
|
[round(float(edge.Length), 6),
|
|
round(float(edge.BoundBox.XMin), 6), round(float(edge.BoundBox.XMax), 6),
|
|
round(float(edge.BoundBox.YMin), 6), round(float(edge.BoundBox.YMax), 6)]
|
|
for edge in edges])
|
|
return signature
|
|
|
|
def assert_sources(page, links):
|
|
assert len(page.Views) == 3, "Drawing did not contain three associative views"
|
|
expected = {link.Name for link in links}
|
|
assembly = page.Document.getObject("Assembly")
|
|
assert assembly.TypeId == "Assembly::AssemblyObject", "Drawing lost its native assembly"
|
|
actual = [obj for obj in assembly.Group if obj.isDerivedFrom("App::Link")]
|
|
assert {obj.Name for obj in actual} == expected, "Native assembly components were lost"
|
|
assert all(obj.getLinkedObject() is not None for obj in actual), "Linked definitions were lost"
|
|
for view in page.Views:
|
|
assert view.Source == [assembly], "Drawing must follow the live native assembly membership"
|
|
|
|
drawing_snapshots = {}
|
|
|
|
def snapshot_drawing(stage, page, signature):
|
|
def placement_values(placement):
|
|
return {"base": list(placement.Base), "rotation_quaternion": list(placement.Rotation.Q)}
|
|
|
|
def shape_values(shape):
|
|
box = shape.BoundBox
|
|
return {"volume_mm3": shape.Volume, "valid": shape.isValid(),
|
|
"solids": len(shape.Solids),
|
|
"bounds_mm": [box.XMin, box.YMin, box.ZMin, box.XMax, box.YMax, box.ZMax]}
|
|
|
|
sources = {}
|
|
views = {}
|
|
for view in page.Views:
|
|
edges = view.getVisibleEdges()
|
|
views[view.Name] = {
|
|
"state": list(view.State), "status": view.getStatusString(),
|
|
"source_names": [obj.Name for obj in view.Source],
|
|
"direction": list(view.Direction), "scale": float(view.Scale),
|
|
"scale_type": str(view.ScaleType),
|
|
"raw_visible_edge_values": [
|
|
[float(edge.Length), edge.BoundBox.XMin, edge.BoundBox.XMax,
|
|
edge.BoundBox.YMin, edge.BoundBox.YMax] for edge in edges],
|
|
}
|
|
for source in view.Source:
|
|
if source.Name in sources:
|
|
continue
|
|
definition = source.getLinkedObject() if source.isDerivedFrom("App::Link") else source
|
|
parameters = {}
|
|
for name in ("QILength", "QIWidth", "QIThickness", "QIRadius", "QIHeight"):
|
|
if hasattr(definition, name):
|
|
value = getattr(definition, name)
|
|
parameters[name] = float(value.Value if hasattr(value, "Value") else value)
|
|
sources[source.Name] = {
|
|
"type": source.TypeId, "definition_name": definition.Name,
|
|
"shape": shape_values(source.Shape),
|
|
"definition_shape": shape_values(definition.Shape),
|
|
"placement": placement_values(source.Placement),
|
|
"link_placement": placement_values(source.LinkPlacement)
|
|
if source.isDerivedFrom("App::Link") else None,
|
|
"definition_placement": placement_values(definition.Placement),
|
|
"parameters": parameters,
|
|
}
|
|
drawing_snapshots[stage] = {
|
|
"document": page.Document.Name, "page": page.Name, "page_scale": float(page.Scale),
|
|
"projection_signature": signature, "views": views, "sources": sources,
|
|
}
|
|
path = out / "command-assembly-drawing-snapshots.json"
|
|
path.write_text(json.dumps(drawing_snapshots, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
if str(path) not in report["artifacts"]:
|
|
report["artifacts"].append(str(path))
|
|
|
|
def assembly_drawing_command_roundtrip():
|
|
Gui.Selection.clearSelection()
|
|
Gui.runCommand("QI_NewAssembly", 0)
|
|
doc = App.ActiveDocument
|
|
assert doc and doc.Name not in initial_docs, "New Assembly command did not create a project"
|
|
doc.Label = "Проверка команды чертежа сборки"
|
|
gui_doc = Gui.getDocument(doc.Name)
|
|
if gui_doc.getInEdit():
|
|
gui_doc.resetEdit()
|
|
model.create_assembly_demo(doc)
|
|
assembly = doc.getObject("Assembly")
|
|
links = [obj for obj in assembly.Group if obj.isDerivedFrom("App::Link")]
|
|
assert len(links) == 3 and assembly.solve(False) == 0, "Native assembly fixture is not solved"
|
|
gui_doc.activeView().setActiveObject("pdbody", None)
|
|
Gui.Selection.clearSelection()
|
|
assert not Gui.Selection.getSelection(), "Drawing fixture has an implicit selection"
|
|
assert gui_doc.activeView().getActiveObject("pdbody") is None
|
|
assert not any(obj.isDerivedFrom("TechDraw::DrawPage") for obj in doc.Objects)
|
|
Gui.runCommand("QI_Drawing", 0)
|
|
settle()
|
|
pages = [obj for obj in doc.Objects if obj.isDerivedFrom("TechDraw::DrawPage")]
|
|
assert len(pages) == 1, "Drawing command did not create a page for an unselected assembly"
|
|
page = pages[0]
|
|
assert_sources(page, links)
|
|
assert page.Template.EditableTexts.get("Material") == "См. спецификацию", \
|
|
"Mixed assembly title block incorrectly declares its first component's material"
|
|
before = projection_signature(page)
|
|
snapshot_drawing("before_definition_edit", page, before)
|
|
capture("command-assembly-drawing-before")
|
|
|
|
plate = links[0].getLinkedObject()
|
|
pin = links[1].getLinkedObject()
|
|
model.set_dimensions(plate, {"QILength": 160})
|
|
model.set_dimensions(pin, {"QIHeight": 42})
|
|
doc.recompute()
|
|
assert assembly.solve(False) == 0, "Assembly failed to solve after changing definitions"
|
|
after = projection_signature(page)
|
|
snapshot_drawing("after_definition_edit", page, after)
|
|
front_height = max(edge[4] for edge in after["FrontView"]) - min(
|
|
edge[3] for edge in after["FrontView"])
|
|
assert abs(front_height - 42 * page.Scale) < 1e-5, \
|
|
"Front projection retained an intermediate pin height after consecutive definition edits"
|
|
assert all(before[name] != after[name] for name in before), \
|
|
"A native projection did not update after editing assembly definitions"
|
|
assert abs(links[1].Shape.Volume - math.pi * 5.8 ** 2 * 42) < 1e-5
|
|
capture("command-assembly-drawing-updated")
|
|
|
|
project = out / "command-assembly-drawing.FCStd"
|
|
model.save_project(doc, project)
|
|
assert project.is_file() and project.stat().st_size > 1000
|
|
report["artifacts"].append(str(project))
|
|
page_name, plate_name = page.Name, plate.Name
|
|
link_names = [link.Name for link in links]
|
|
gui_doc.resetEdit()
|
|
Gui.Selection.clearSelection()
|
|
App.closeDocument(doc.Name)
|
|
reopened = App.openDocument(str(project))
|
|
App.setActiveDocument(reopened.Name)
|
|
restored = reopened.getObject(page_name)
|
|
restored_links = [reopened.getObject(name) for name in link_names]
|
|
assert_sources(restored, restored_links)
|
|
restored_signature = projection_signature(restored)
|
|
snapshot_drawing("reopened_immediate", restored, restored_signature)
|
|
# The bundled native TechDraw regression test allows two seconds for
|
|
# HLR threads. Record that settled state separately before changing any
|
|
# equality requirement, so actual geometry and async deltas are visible.
|
|
settle(2200)
|
|
settled_signature = projection_signature(restored)
|
|
snapshot_drawing("reopened_after_native_thread_wait", restored, settled_signature)
|
|
assert restored_signature == after, "FCStd reopening changed native projections"
|
|
assert reopened.getObject("Assembly").solve(False) == 0
|
|
model.set_dimensions(reopened.getObject(plate_name), {"QILength": 180})
|
|
reopened.recompute()
|
|
after_reopen_edit = projection_signature(restored)
|
|
snapshot_drawing("after_reopened_definition_edit", restored, after_reopen_edit)
|
|
assert all(after[name] != after_reopen_edit[name] for name in after), \
|
|
"Reopened drawing lost its association to component definitions"
|
|
model.save_project(reopened, project)
|
|
svg = Path(model.export_drawing(restored, out / "command-assembly-drawing.svg"))
|
|
pdf = Path(model.export_drawing(restored, out / "command-assembly-drawing.pdf"))
|
|
assert svg.stat().st_size > 1000 and model._svg_has_geometry(svg)
|
|
assert pdf.stat().st_size > 1000 and pdf.read_bytes().startswith(b"%PDF-")
|
|
report["artifacts"].extend([str(svg), str(pdf)])
|
|
capture("command-assembly-drawing-reopened")
|
|
return {"selection_required": False, "linked_components": len(links), "views": 3,
|
|
"definition_edits_update_native_projections": True,
|
|
"fcstd_reopen_preserves_associativity": True,
|
|
"pdf_bytes": pdf.stat().st_size, "svg_bytes": svg.stat().st_size}
|
|
|
|
def empty_drawing_rejection():
|
|
doc = model.new_part("Пустой документ для чертежа")
|
|
Gui.Selection.clearSelection()
|
|
before = document_state(doc)
|
|
try:
|
|
commands._drawing()
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("Empty document was accepted by the Drawing command")
|
|
assert document_state(doc) == before, "Rejected drawing mutated objects, save state or Undo history"
|
|
assert not any(obj.isDerivedFrom("TechDraw::DrawPage") for obj in doc.Objects)
|
|
return {"empty_rejected": True, "objects_and_history_preserved": True}
|
|
|
|
def assembly_title_material_uses_actual_sources():
|
|
doc = model.new_assembly("Материал выбранной сборки")
|
|
assembly = doc.getObject("Assembly")
|
|
steel = model.create_box(20, 10, 5, doc)
|
|
model.set_material(steel, "Сталь")
|
|
link = model.add_component(assembly, steel)
|
|
steel.ViewObject.Visibility = False
|
|
unrelated = model.create_box(12, 8, 4, doc)
|
|
model.set_material(unrelated, "Алюминий")
|
|
doc.recompute()
|
|
assert {row["material"] for row in model.bom_rows(doc)} == {"Сталь", "Алюминий"}
|
|
Gui.Selection.clearSelection()
|
|
page = model.create_drawing(doc)
|
|
assert_sources(page, [link])
|
|
assert page.Template.EditableTexts.get("Material") == "Сталь", \
|
|
"An unrelated document part contaminated the assembly drawing material"
|
|
assert page.Template.EditableTexts.get("Designation") == steel.QIPartNumber, \
|
|
"The title designation did not follow the actual drawing source"
|
|
capture("command-assembly-title-material-scope")
|
|
return {"drawing_source": link.Name, "material": "Сталь",
|
|
"unrelated_aluminum_part_ignored_by_title": True}
|
|
|
|
def failed_cached_solid_drawing_rejection():
|
|
doc = model.new_part("Ошибка перестроения перед чертежом")
|
|
body = model.create_box(20, 10, 5, doc)
|
|
pad = body.Tip
|
|
cached_volume = body.Shape.Volume
|
|
pad.setExpression("Length", None)
|
|
pad.Length = 0
|
|
doc.recompute()
|
|
assert "Invalid" in pad.State and pad.getStatusString() != "Valid", \
|
|
"Fixture did not trigger a real native Pad error"
|
|
assert body.Shape.isValid() and abs(body.Shape.Volume - cached_volume) < 1e-6, \
|
|
"Fixture did not retain the old valid cached solid"
|
|
Gui.Selection.clearSelection()
|
|
before = document_state(doc)
|
|
try:
|
|
commands._drawing()
|
|
except ValueError as exc:
|
|
assert "ошибку перестроения" in str(exc), str(exc)
|
|
else:
|
|
raise AssertionError("Drawing command accepted cached geometry after a failed native rebuild")
|
|
assert document_state(doc) == before, "Rejected cached-solid drawing added objects or an Undo step"
|
|
assert not any(obj.isDerivedFrom("TechDraw::DrawPage") for obj in doc.Objects)
|
|
return {"native_error": pad.getStatusString(), "cached_volume_mm3": cached_volume,
|
|
"page_not_created": True, "objects_and_history_preserved": True}
|
|
|
|
def existing_page_failed_source_export_rejection():
|
|
import Part
|
|
from uuid import uuid4
|
|
|
|
source_doc = model.new_part("Источник существующего чертежа")
|
|
source = model.create_box(20, 10, 5, source_doc)
|
|
model.save_project(source_doc, out / "existing-drawing-guard-source.FCStd")
|
|
doc = model.new_assembly("Существующий чертёж связанной детали")
|
|
model.save_project(doc, out / "existing-drawing-guard-sheet.FCStd")
|
|
link = model.add_component(doc.getObject("Assembly"), source)
|
|
page = model.create_drawing(doc)
|
|
model.prepare_drawing(page)
|
|
assert all(view.Source == [doc.getObject("Assembly")] for view in page.Views)
|
|
model.create_box(12, 8, 6, doc)
|
|
wire = doc.addObject("Part::Feature", "IndependentWire")
|
|
wire.Shape = Part.makePolygon([App.Vector(), App.Vector(10, 2, 6),
|
|
App.Vector(18, 4, 8), App.Vector(24, 8, 10)])
|
|
independent_page = model.create_drawing(doc)
|
|
for view in independent_page.Views:
|
|
view.Source = [wire]
|
|
doc.recompute()
|
|
model.prepare_drawing(independent_page)
|
|
assert wire.Shape.isValid() and not wire.Shape.Solids
|
|
|
|
pad = source.Tip
|
|
cached_volume = source.Shape.Volume
|
|
pad.Profile = None
|
|
source_doc.recompute()
|
|
assert "Invalid" in pad.State and pad.Shape.isValid() and not pad.Shape.isNull()
|
|
assert source.Shape.isValid() and abs(source.Shape.Volume - cached_volume) < 1e-6
|
|
before = (document_state(source_doc), document_state(doc), App.ActiveDocument.Name)
|
|
rejected = {}
|
|
for suffix in ("dxf", "pdf", "svg"):
|
|
previous = out / ("existing-page-protected." + suffix)
|
|
previous_bytes = ("preserve-previous-" + suffix).encode("ascii")
|
|
previous.write_bytes(previous_bytes)
|
|
missing = out / ("rejected-page-parent-" + uuid4().hex) / ("drawing." + suffix)
|
|
assert not missing.parent.exists()
|
|
for target in (previous, missing):
|
|
try:
|
|
model.export_drawing(page, target)
|
|
except ValueError as exc:
|
|
assert "ошибку перестроения" in str(exc), str(exc)
|
|
rejected[suffix] = str(exc)
|
|
else:
|
|
raise AssertionError("Existing page exported cached failed source: " + suffix)
|
|
assert previous.read_bytes() == previous_bytes, "Rejected export overwrote previous file"
|
|
assert not missing.exists() and not missing.parent.exists(), "Rejected export created a path"
|
|
assert before == (document_state(source_doc), document_state(doc), App.ActiveDocument.Name), \
|
|
"Rejected export modified model objects, history, save state or active document"
|
|
|
|
valid_outputs = {}
|
|
for suffix in ("dxf", "pdf", "svg"):
|
|
target = Path(model.export_drawing(independent_page, out / ("independent-wire-page." + suffix)))
|
|
assert target.is_file() and target.stat().st_size > 1000
|
|
valid_outputs[suffix] = target.stat().st_size
|
|
assert "Invalid" in pad.State, "Independent export silently repaired the unrelated source"
|
|
return {"linked_cached_native_failure_rejected": rejected, "cached_volume_mm3": cached_volume,
|
|
"existing_outputs_and_absent_directories_preserved": True,
|
|
"native_wire_page_exports_despite_unrelated_failed_source": valid_outputs}
|
|
|
|
try:
|
|
check("actual_plane_dialog_cancel_without_document", cancel_plane_without_document)
|
|
check("assembly_drawing_command_definition_edit_and_fcstd_exports", assembly_drawing_command_roundtrip)
|
|
check("empty_document_drawing_rejected_without_mutation", empty_drawing_rejection)
|
|
check("assembly_title_material_follows_actual_view_sources", assembly_title_material_uses_actual_sources)
|
|
check("failed_cached_native_solid_drawing_rejected_without_mutation", failed_cached_solid_drawing_rejection)
|
|
check("existing_page_cached_linked_source_exports_rejected", existing_page_failed_source_export_rejection)
|
|
finally:
|
|
Gui.Selection.clearSelection()
|
|
for name in set(App.listDocuments()) - initial_docs:
|
|
gui_doc = Gui.getDocument(name)
|
|
if gui_doc.getInEdit():
|
|
gui_doc.resetEdit()
|
|
App.closeDocument(name)
|
|
if initial_active in App.listDocuments():
|
|
App.setActiveDocument(initial_active)
|
|
else:
|
|
App.setActiveDocument("")
|
|
ui.select_tab("3D-модель")
|
|
report["status"] = "passed" if report["failed"] == 0 else "failed"
|
|
(out / "command-workflows-report.json").write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return report
|