Files
Qinventor/tests/sketch_operations_gui_suite.py

1065 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Actual Qt dialog input for native, associative sketch transformations.
Sketch edges are preselected through FreeCAD's public selection API. Ribbon
buttons, dialog fields, preview/create/cancel and edit selectors receive Qt
mouse/key events. This suite does not claim physical unlocked-desktop input.
"""
import json
import math
import os
import traceback
import FreeCAD as App
import FreeCADGui as Gui
import Part
import Sketcher
from PySide import QtCore, QtGui
try:
from PySide import QtWidgets, QtTest
except ImportError:
from PySide import QtWidgets
try:
from PySide6 import QtTest
except ImportError:
from PySide2 import QtTest
def run(output):
import qi_commands as commands
import qi_localization as locale
import qi_sketch_patterns as patterns
import qi_ui as ui
os.makedirs(output, exist_ok=True)
report_path = os.path.join(output, "sketch-operations-gui-suite.json")
report = {"status": "running", "passed": 0, "failed": 0, "checks": [],
"input_delivery": "Qt button and keyboard events on native dialogs",
"selection_method": "explicit native Sketch Edge1 preselection through Selection API",
"persistence_boundary": "Qt QI_Save and native App.openDocument on the saved FCStd",
"os_pointer_access": False}
owned_documents = set()
main = Gui.getMainWindow()
main.resize(1440, 940)
main.hide()
main.showNormal()
main.raise_()
main.activateWindow()
assert QtTest.QTest.qWaitForWindowExposed(main.windowHandle(), 5000), "CAD window is not exposed"
ribbon = ui.show_ribbon()
commands.register_commands()
command_names = {"mirror": "QI_SketchMirror", "rectangular": "QI_SketchRectangularPattern",
"circular": "QI_SketchCircularPattern"}
def settle(ms=100):
QtWidgets.QApplication.processEvents()
QtTest.QTest.qWait(ms)
def write():
with open(report_path, "w", encoding="utf-8") as stream:
json.dump(report, stream, ensure_ascii=False, indent=2, default=str)
def capture(name):
Gui.activeDocument().activeView().fitAll()
Gui.activeDocument().activeView().redraw()
settle(300)
assert main.grab().save(os.path.join(output, name + ".png"))
def capture_dialog(dialog, name):
# Modal dialogs are independent top-level windows: main.grab() only
# captures the CAD window and cannot prove their rendered labels.
settle(120)
path = os.path.join(output, name + ".png")
assert dialog.isVisible() and dialog.grab().save(path)
return path
def framebuffer(view, name):
view.redraw()
settle(200)
rendered = view.getViewer().grabFramebuffer()
path = os.path.join(output, name + ".png")
assert not rendered.isNull() and rendered.save(path)
return rendered, path
def screen_point(view, point, rendered):
projected = view.getPointOnScreen(point)
physical = view.getViewer().getSoRenderManager().getViewportRegion().getViewportSizePixels().getValue()
# Native screen projection has a bottom-left origin and uses the
# Coin viewport's physical pixels. The framebuffer may have a
# different Qt DPR, so compare its own actual image dimensions.
return (round(float(projected[0]) * rendered.width() / physical[0]),
round((physical[1] - 1 - float(projected[1])) * rendered.height() / physical[1]))
def matching_pixels(rendered, position, predicate):
x, y = position
assert 0 <= x < rendered.width() and 0 <= y < rendered.height(), (position, rendered.size())
pixels = []
for row in range(max(0, y - 5), min(rendered.height(), y + 6)):
for column in range(max(0, x - 5), min(rendered.width(), x + 6)):
rgb = rendered.pixelColor(column, row).getRgb()[:3]
if predicate(*rgb):
pixels.append({"position": [column, row], "rgb": list(rgb)})
return pixels
def camera_frame(view):
camera = view.getCameraNode()
return {"position": tuple(camera.position.getValue().getValue()),
"orientation": tuple(camera.orientation.getValue().getValue()),
"height": float(camera.height.getValue())}
def history(doc):
return {"undo_count": doc.UndoCount, "redo_count": doc.RedoCount,
"undo_names": list(doc.UndoNames), "redo_names": list(doc.RedoNames)}
def circles(sketch):
return [(i, geometry) for i, geometry in enumerate(sketch.Geometry)
if isinstance(geometry, Part.Circle)]
def signature(sketch):
def vector(value):
return tuple(round(float(component), 9) for component in value)
geometry = []
for item in sketch.Geometry:
if isinstance(item, Part.Circle):
geometry.append(("Circle", vector(item.Center), vector(item.Axis), round(item.Radius, 9)))
elif isinstance(item, Part.LineSegment):
geometry.append(("LineSegment", vector(item.StartPoint), vector(item.EndPoint)))
else:
bbox = item.toShape().BoundBox
geometry.append((type(item).__name__, tuple(round(getattr(bbox, field), 9) for field in
("XMin", "YMin", "ZMin", "XMax", "YMax", "ZMax"))))
constraints = [tuple(round(float(c.Value), 9) if field == "Value" else getattr(c, field) for field in
("Type", "First", "FirstPos", "Second", "SecondPos",
"Third", "ThirdPos", "Value", "Name"))
for c in sketch.Constraints]
return {"geometry": geometry, "constraints": constraints,
"datum_label_positions": [
(float(sketch.getLabelDistance(i)), float(sketch.getLabelPosition(i)))
for i in range(len(sketch.Constraints))],
"construction": [sketch.getConstruction(i) for i in range(sketch.GeometryCount)],
"property_names": sorted(sketch.PropertiesList),
"patterns": patterns.list_patterns(sketch)}
def fixture(name, placed=False):
doc = App.newDocument("SketchOperationsQA")
owned_documents.add(doc.Name)
doc.Label = "Проверка — " + name
doc.UndoMode = 1
body = doc.addObject("PartDesign::Body", "Body")
body.Label = "Деталь"
if placed:
parent = doc.addObject("App::Part", "PlacedGroup")
parent.Label = "Смещённая группа деталей"
parent.addObject(body)
parent.Placement = App.Placement(App.Vector(30, -18, 12), App.Rotation(App.Vector(0, 0, 1), 25))
body.Placement = App.Placement(App.Vector(8, 3, 5), App.Rotation(App.Vector(0, 1, 0), 20))
sketch = body.newObject("Sketcher::SketchObject", "Sketch")
sketch.Label = "Эскиз окружности"
sketch.addGeometry(Part.Circle(App.Vector(7, 4, 0), App.Vector(0, 0, 1), 2), False)
sketch.addConstraint([Sketcher.Constraint("DistanceX", 0, 3, 7),
Sketcher.Constraint("DistanceY", 0, 3, 4),
Sketcher.Constraint("Radius", 0, 2)])
for index, label in enumerate(("ЦентрX", "ЦентрY", "Радиус")):
sketch.renameConstraint(index, label)
doc.recompute()
assert sketch.solve() == 0 and sketch.FullyConstrained
path = os.path.join(output, "sketch-operation-" + name + ".FCStd")
doc.saveAs(path)
Gui.getDocument(doc.Name).setEdit(sketch.Name)
settle(700)
assert commands._editing() == sketch
Gui.activeDocument().activeView().fitAll()
settle(250)
# Native entering-edit can mark a document dirty. Save while the
# editor is active so cancellation is tested against a real clean
# native baseline, not a manually cleared Modified flag.
click_ribbon("QI_Save")
settle(150)
assert commands._editing() == sketch, "Saving unexpectedly left sketch edit"
assert not Gui.getDocument(doc.Name).Modified, "Saved editing fixture is not clean"
Gui.Selection.clearSelection()
Gui.Selection.addSelection(sketch, "Edge1")
settle(80)
selected = [(item.Object.Name, list(item.SubElementNames)) for item in Gui.Selection.getSelectionEx()]
assert selected == [(sketch.Name, ["Edge1"])], selected
return doc, sketch, path
def close_document(doc):
try:
name = doc.Name
except ReferenceError:
return
if name not in App.listDocuments():
return
Gui.Selection.clearSelection()
if commands._editing() and commands._editing().Document == doc:
Gui.runCommand("QI_FinishSketch")
settle(150)
App.closeDocument(name)
def click_ribbon(name, allow_scroll=True):
assert name in ribbon.buttons, "Missing ribbon command: " + name
button = ribbon.buttons[name][0]
tab = "Файл" if name in ("QI_Save", "QI_SaveAs") else "Эскиз"
tab_index = next(i for i in range(ribbon.tabs.count()) if ribbon.tabs.tabText(i) == tab)
QtTest.QTest.mouseClick(ribbon.tabs.tabBar(), QtCore.Qt.LeftButton,
QtCore.Qt.NoModifier, ribbon.tabs.tabBar().tabRect(tab_index).center())
settle(80)
if allow_scroll:
ancestor = button.parentWidget()
while ancestor is not None:
if isinstance(ancestor, QtWidgets.QScrollArea):
ancestor.ensureWidgetVisible(button)
settle(50)
break
ancestor = ancestor.parentWidget()
assert button.isEnabled() and button.isVisible(), name + " is not available"
assert button.visibleRegion().contains(button.rect().center()), name + " is clipped"
QtTest.QTest.mouseClick(button, QtCore.Qt.LeftButton)
def click_dialog(dialog, name):
button = dialog.findChild(QtWidgets.QPushButton, name)
assert button and button.isVisible() and button.isEnabled(), "Dialog button is unavailable: " + name
QtTest.QTest.mouseClick(button, QtCore.Qt.LeftButton)
settle(100)
def enter_number(dialog, name, value):
widget = dialog.findChild(QtWidgets.QAbstractSpinBox, "QI_" + name)
assert widget is not None, "Missing numeric field: " + name
editor = widget.lineEdit()
QtTest.QTest.mouseClick(editor, QtCore.Qt.LeftButton)
QtTest.QTest.keyClick(editor, QtCore.Qt.Key_A, QtCore.Qt.ControlModifier)
QtTest.QTest.keyClicks(editor, str(value).replace(".", ","))
QtTest.QTest.keyClick(editor, QtCore.Qt.Key_Tab)
settle(50)
assert abs(widget.value() - float(value)) < 1e-6, (name, widget.value(), value)
def choose_axis(dialog, axis):
combo = dialog.findChild(QtWidgets.QComboBox, "QI_axis")
index = combo.findData(axis)
assert index >= 0
QtTest.QTest.mouseClick(combo, QtCore.Qt.LeftButton, QtCore.Qt.NoModifier,
QtCore.QPoint(combo.width() - 12, combo.height() // 2))
settle(80)
item = combo.model().index(index, combo.modelColumn(), combo.rootModelIndex())
popup = combo.view()
assert popup.isVisible(), "Axis popup did not open through its Qt button"
# Windows can be locked during QA; QListView hover therefore cannot
# depend on the desktop cursor. Navigate the real open popup with
# native Qt key events and commit its chosen row with Enter.
QtTest.QTest.keyClick(popup, QtCore.Qt.Key_Home)
for _ in range(index):
QtTest.QTest.keyClick(popup, QtCore.Qt.Key_Down)
assert popup.currentIndex() == item
QtTest.QTest.keyClick(popup, QtCore.Qt.Key_Return)
settle(50)
assert combo.currentData() == axis
def configure(dialog, kind, revised=False):
if kind == "mirror":
choose_axis(dialog, "horizontal" if revised else "vertical")
elif kind == "rectangular":
enter_number(dialog, "count_x", 4 if revised else 3)
enter_number(dialog, "count_y", 2)
enter_number(dialog, "spacing_x", 12)
enter_number(dialog, "spacing_y", 15)
enter_number(dialog, "angle", 0)
else:
enter_number(dialog, "count", 6 if revised else 4)
enter_number(dialog, "center_x", 0)
enter_number(dialog, "center_y", 0)
enter_number(dialog, "angle", 180 if revised else 360)
def configure_offset(dialog, distance):
enter_number(dialog, "distance", distance)
for name in ("loop_select", "constrain"):
checkbox = dialog.findChild(QtWidgets.QCheckBox, "QI_" + name)
assert checkbox is not None, "Missing offset option: " + name
if not checkbox.isChecked():
QtTest.QTest.mouseClick(checkbox, QtCore.Qt.LeftButton)
settle(50)
assert checkbox.isChecked()
def verify_offset(sketch, distance, radius=2):
native_circles = circles(sketch)
assert len(native_circles) == 2 and sketch.GeometryCount == 2
assert sketch.solve() == 0 and sketch.Shape.isValid()
actual = sorted((round(g.Center.x, 6), round(g.Center.y, 6), round(g.Radius, 6)) for _, g in native_circles)
expected = sorted([(7, 4, radius), (7, 4, radius + distance)])
assert actual == expected, {"actual": actual, "expected": expected}
assert not sketch.getConstruction(0) and not sketch.getConstruction(1)
assert any(c.Type == "Distance" for c in sketch.Constraints), "Offset has no native driving distance"
return {"circle_count": 2, "centers_and_radii_mm": actual,
"constraint_types": [c.Type for c in sketch.Constraints], "solver_result": sketch.solve()}
def verify_pattern(sketch, kind, revised=False, radius=2):
expected_count = 2 if kind == "mirror" else ((8 if revised else 6) if kind == "rectangular" else (6 if revised else 4))
native_circles = circles(sketch)
assert len(native_circles) == expected_count, (kind, len(native_circles), expected_count)
entries = patterns.list_patterns(sketch)
assert len(entries) == 1 and entries[0]["kind"] == kind and entries[0]["associative"] is True
created_ids = entries[0]["created_ids"]
assert len(created_ids) == expected_count - 1
assert set(created_ids) == {i for i, _ in native_circles if i != 0}
assert all(not sketch.getConstruction(i) for i in created_ids)
assert sketch.solve() == 0 and sketch.Shape.isValid()
actual = sorted((round(g.Center.x, 6), round(g.Center.y, 6), round(g.Radius, 6)) for _, g in native_circles)
if kind == "mirror":
expected = [(7, 4, radius), (7, -4, radius)] if revised else [(7, 4, radius), (-7, 4, radius)]
elif kind == "rectangular":
expected = [(7 + i * 12, 4 + j * 15, radius) for i in range(4 if revised else 3) for j in range(2)]
else:
count = 6 if revised else 4
step = math.radians(180 / (count - 1) if revised else 360 / count)
expected = [(7 * math.cos(i * step) - 4 * math.sin(i * step),
7 * math.sin(i * step) + 4 * math.cos(i * step), radius) for i in range(count)]
expected = sorted(tuple(round(value, 6) for value in item) for item in expected)
assert actual == expected, {"actual": actual, "expected": expected}
return {"circle_count": len(native_circles), "native_geometry_count": sketch.GeometryCount,
"constraint_count": len(sketch.Constraints), "centers_and_radii_mm": actual,
"metadata": entries, "solver_result": sketch.solve()}
def audit_dialog(dialog):
locale.localize_surface()
candidates = locale.untranslated_surface()
assert not candidates, candidates
labels = [w.text() for w in dialog.findChildren(QtWidgets.QLabel) if w.isVisible() and w.text()]
assert any("Выбрано элементов" in text for text in labels)
for name, text in (("QISketchPreview", "Предпросмотр"), ("QISketchCreate", "Создать"), ("QISketchCancel", "Отмена")):
assert dialog.findChild(QtWidgets.QPushButton, name).text() == text
return {"window_title": dialog.windowTitle(), "visible_labels": labels, "untranslated_candidates": candidates}
def modal_action(command, callback, edit_selector=False):
state = {"called": False, "error": None, "result": None}
retries = [0]
timer = QtCore.QTimer(main)
timer.setSingleShot(True)
def drive():
retries[0] += 1
active = QtWidgets.QApplication.activeModalWidget()
if edit_selector and isinstance(active, QtWidgets.QInputDialog):
box = active.findChild(QtWidgets.QDialogButtonBox)
ok = box.button(QtWidgets.QDialogButtonBox.Ok) if box is not None else None
if ok is not None and ok.isVisible():
QtTest.QTest.mouseClick(ok, QtCore.Qt.LeftButton)
timer.start(80)
return
if active is None or active.objectName() != "QISketchOperationDialog":
if retries[0] < 50:
timer.start(80)
return
state["error"] = "Expected sketch operation dialog did not open"
if active is not None:
active.reject()
return
state["called"] = True
try:
state["result"] = callback(active)
except Exception:
state["error"] = traceback.format_exc()
capture("sketch-operation-dialog-failure")
finally:
if active.isVisible():
cancel = active.findChild(QtWidgets.QPushButton, "QISketchCancel")
if cancel is not None:
QtTest.QTest.mouseClick(cancel, QtCore.Qt.LeftButton)
else:
active.reject()
timer.timeout.connect(drive)
timer.start(100)
try:
click_ribbon(command)
settle(150)
finally:
timer.stop()
timer.deleteLater()
assert state["called"], state["error"] or "Dialog callback was not delivered"
assert not state["error"], state["error"]
return state["result"]
def preview_cancel(kind):
doc, sketch, _ = fixture(kind + "-cancel")
try:
before = signature(sketch)
undo_before = history(doc)
modified_before = bool(Gui.getDocument(doc.Name).Modified)
def drive(dialog):
configure(dialog, kind)
localized = audit_dialog(dialog)
click_dialog(dialog, "QISketchPreview")
assert not dialog.findChild(QtWidgets.QLabel, "QISketchOperationError").text()
initial = verify_pattern(sketch, kind)
configure(dialog, kind, revised=True)
click_dialog(dialog, "QISketchPreview")
assert not dialog.findChild(QtWidgets.QLabel, "QISketchOperationError").text()
revised = verify_pattern(sketch, kind, revised=True)
capture("sketch-" + kind + "-repeated-preview")
localized["rendered_dialog"] = capture_dialog(dialog, "sketch-" + kind + "-russian-dialog")
click_dialog(dialog, "QISketchCancel")
return {"initial_preview": initial, "revised_preview": revised, "russian_dialog": localized}
result = modal_action(command_names[kind], drive)
assert signature(sketch) == before, "Cancel changed native geometry, constraints or association metadata"
assert history(doc) == undo_before, {"before": undo_before, "after": history(doc)}
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before, "Cancelled preview changed unsaved state"
assert commands._editing() == sketch
result.update(cancel_restored=True, undo_before=undo_before, undo_after=history(doc),
modified_before=modified_before, modified_after=bool(Gui.getDocument(doc.Name).Modified))
return result
finally:
close_document(doc)
def create_reopen(kind):
doc, sketch, path = fixture(kind + "-created")
reopened = None
try:
undo_before = doc.UndoCount
def drive(dialog):
configure(dialog, kind)
click_dialog(dialog, "QISketchPreview")
preview = verify_pattern(sketch, kind)
click_dialog(dialog, "QISketchCreate")
assert not dialog.isVisible(), "Create did not accept the operation"
return preview
preview = modal_action(command_names[kind], drive)
created = verify_pattern(sketch, kind)
assert doc.UndoCount == undo_before + 1, {"before": undo_before, "after": doc.UndoCount}
assert Gui.getDocument(doc.Name).Modified, "Accepted native pattern lost the unsaved marker"
click_ribbon("QI_FinishSketch", allow_scroll=False)
settle(180)
click_ribbon("QI_Save")
settle(150)
assert os.path.isfile(path) and not Gui.getDocument(doc.Name).Modified
close_document(doc)
reopened = App.openDocument(path)
settle(500)
owned_documents.add(reopened.Name)
restored = reopened.getObject("Sketch")
assert restored is not None
after_open = verify_pattern(restored, kind)
reopened.openTransaction("Изменить радиус исходной окружности")
restored.setDatum(2, App.Units.Quantity("3 mm"))
reopened.recompute()
reopened.commitTransaction()
after_dimension = verify_pattern(restored, kind, radius=3)
capture("sketch-" + kind + "-saved-association")
return {"preview": preview, "created": created, "reopened": after_open,
"source_driving_radius_mm": 3, "regenerated_after_reopen": after_dimension,
"saved_file": path, "single_undo_step": True, "modified_after_create": True}
finally:
if reopened is not None:
close_document(reopened)
close_document(doc)
def invalid_preview():
doc, sketch, _ = fixture("invalid-step")
try:
before = signature(sketch)
undo_before = history(doc)
modified_before = bool(Gui.getDocument(doc.Name).Modified)
assert not modified_before
def drive(dialog):
configure(dialog, "rectangular")
click_dialog(dialog, "QISketchPreview")
verify_pattern(sketch, "rectangular")
enter_number(dialog, "spacing_x", 0)
click_dialog(dialog, "QISketchPreview")
message = dialog.findChild(QtWidgets.QLabel, "QISketchOperationError").text()
assert message and any("А" <= char <= "я" or char in "Ёё" for char in message), message
assert signature(sketch) == before, "Failed preview left previous preview or partial geometry"
assert history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before, "Failed preview changed clean/dirty state"
capture("sketch-invalid-step-russian-error")
click_dialog(dialog, "QISketchCancel")
return {"russian_error": message, "rollback_restored": True,
"modified_before": modified_before,
"modified_after_error": bool(Gui.getDocument(doc.Name).Modified)}
result = modal_action(command_names["rectangular"], drive)
assert signature(sketch) == before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
return result
finally:
close_document(doc)
def offset_cancel():
doc, sketch, _ = fixture("offset-cancel")
try:
before = signature(sketch)
undo_before = history(doc)
modified_before = bool(Gui.getDocument(doc.Name).Modified)
def drive(dialog):
configure_offset(dialog, 2)
localized = audit_dialog(dialog)
click_dialog(dialog, "QISketchPreview")
first = verify_offset(sketch, 2)
configure_offset(dialog, 4)
click_dialog(dialog, "QISketchPreview")
revised = verify_offset(sketch, 4)
assert not dialog.findChild(QtWidgets.QLabel, "QISketchOperationError").text()
capture("sketch-offset-repeated-preview")
localized["rendered_dialog"] = capture_dialog(dialog, "sketch-offset-russian-dialog")
click_dialog(dialog, "QISketchCancel")
return {"initial_preview": first, "revised_preview": revised, "russian_dialog": localized}
result = modal_action("QI_SketchOffset", drive)
assert signature(sketch) == before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
result.update(cancel_restored=True, undo_before=undo_before, undo_after=history(doc))
return result
finally:
close_document(doc)
def offset_create_reopen():
doc, sketch, path = fixture("offset-created")
reopened = None
try:
undo_before = doc.UndoCount
def drive(dialog):
configure_offset(dialog, 2)
click_dialog(dialog, "QISketchPreview")
preview = verify_offset(sketch, 2)
click_dialog(dialog, "QISketchCreate")
assert not dialog.isVisible()
return preview
preview = modal_action("QI_SketchOffset", drive)
created = verify_offset(sketch, 2)
assert doc.UndoCount == undo_before + 1
assert Gui.getDocument(doc.Name).Modified, "Accepted offset lost the unsaved marker"
click_ribbon("QI_FinishSketch", allow_scroll=False)
settle(180)
click_ribbon("QI_Save")
settle(150)
assert not Gui.getDocument(doc.Name).Modified
close_document(doc)
reopened = App.openDocument(path)
settle(500)
owned_documents.add(reopened.Name)
restored = reopened.getObject("Sketch")
after_open = verify_offset(restored, 2)
reopened.openTransaction("Изменить радиус исходной окружности")
restored.setDatum(2, App.Units.Quantity("3 mm"))
reopened.recompute()
reopened.commitTransaction()
regenerated = verify_offset(restored, 2, radius=3)
capture("sketch-offset-saved-association")
return {"preview": preview, "created": created, "reopened": after_open,
"regenerated_after_reopen": regenerated, "saved_file": path,
"single_undo_step": True, "modified_after_create": True}
finally:
if reopened is not None:
close_document(reopened)
close_document(doc)
def edit_existing():
doc, sketch, _ = fixture("edit-pattern")
try:
def create(dialog):
configure(dialog, "rectangular")
click_dialog(dialog, "QISketchCreate")
assert not dialog.isVisible()
modal_action(command_names["rectangular"], create)
verify_pattern(sketch, "rectangular")
initial_id = patterns.list_patterns(sketch)[0]["id"]
before = signature(sketch)
undo_before = history(doc)
def cancel_edit(dialog):
assert dialog.findChild(QtWidgets.QSpinBox, "QI_count_x").value() == 3
configure(dialog, "rectangular", revised=True)
click_dialog(dialog, "QISketchPreview")
revised = verify_pattern(sketch, "rectangular", revised=True)
assert patterns.list_patterns(sketch)[0]["id"] == initial_id
click_dialog(dialog, "QISketchCancel")
return revised
cancelled = modal_action("QI_EditSketchPattern", cancel_edit, edit_selector=True)
assert signature(sketch) == before and history(doc) == undo_before
def commit_edit(dialog):
configure(dialog, "rectangular", revised=True)
click_dialog(dialog, "QISketchCreate")
assert not dialog.isVisible()
modal_action("QI_EditSketchPattern", commit_edit, edit_selector=True)
committed = verify_pattern(sketch, "rectangular", revised=True)
assert patterns.list_patterns(sketch)[0]["id"] == initial_id
doc.undo()
doc.recompute()
settle(120)
assert signature(sketch) == before, "Undo did not restore previous associative pattern parameters"
return {"cancelled_edit_preview": cancelled, "committed_edit": committed,
"pattern_id_preserved": initial_id, "cancel_and_undo_restored": True}
finally:
close_document(doc)
def redo_cancel():
doc, sketch, _ = fixture("redo-preservation", placed=True)
try:
doc.openTransaction("Радиус исходной окружности — 5 мм")
sketch.setDatum(2, App.Units.Quantity("5 mm"))
doc.recompute()
doc.commitTransaction()
click_ribbon("QI_Save")
settle(150)
assert not Gui.getDocument(doc.Name).Modified
doc.openTransaction("Радиус исходной окружности — 6 мм")
sketch.setDatum(2, App.Units.Quantity("6 mm"))
doc.recompute()
doc.commitTransaction()
doc.undo()
doc.recompute()
settle(150)
assert doc.RedoCount == 1 and abs(sketch.Geometry[0].Radius - 5) < 1e-7
Gui.Selection.clearSelection()
Gui.Selection.addSelection(sketch, "Edge1")
settle(100)
before = signature(sketch)
undo_before = history(doc)
modified_before = bool(Gui.getDocument(doc.Name).Modified)
documents_before = set(App.listDocuments())
graph = Gui.getDocument(doc.Name).activeView().getSceneGraph()
children_before = graph.getNumChildren()
evidence = {}
def drive(dialog):
configure(dialog, "mirror")
click_dialog(dialog, "QISketchPreview")
error = dialog.findChild(QtWidgets.QLabel, "QISketchOperationError").text()
assert not error, error
session = dialog.session
shadow = session.preview_sketch
assert shadow != sketch and shadow.isDerivedFrom("Sketcher::SketchObject")
preview = verify_pattern(shadow, "mirror", radius=5)
assert signature(sketch) == before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
detached = session.detached
assert detached is not None and detached.node is not None
assert graph.findChild(detached.node) >= 0, "Real preview outline is not attached to the source viewport"
coordinate_node = detached.node.getChild(2)
world_samples = [App.Vector(*point.getValue()) for point in coordinate_node.point.getValues()]
global_placement = sketch.getGlobalPlacement()
edit_transform = Gui.getDocument(doc.Name).EditingTransform
expected_center = edit_transform.multVec(App.Vector(-7, 4, 0))
expected_normal = edit_transform.multVec(App.Vector(0, 0, 1)) - edit_transform.multVec(App.Vector())
radius_errors = [abs((point - expected_center).Length - 5) for point in world_samples]
plane_errors = [abs((point - expected_center).dot(expected_normal)) for point in world_samples]
assert len(world_samples) >= 100 and max(radius_errors) < 1e-3
assert max(plane_errors) < 1e-3, "Preview overlay lost the source parent placement"
temporary_name = detached.document.Name
assert temporary_name in App.listDocuments() and temporary_name not in documents_before
capture("sketch-detached-native-preview-preserves-redo")
click_dialog(dialog, "QISketchCancel")
assert detached.node is None and detached.document is None and detached.shadow is None
evidence.update(preview=preview, temporary_document=temporary_name,
temporary_document_removed=temporary_name not in App.listDocuments(),
overlay_removed=graph.getNumChildren() == children_before,
source_global_placement=str(global_placement),
native_edit_transform=str(edit_transform),
model_global_mirror_center_mm=list(global_placement.multVec(App.Vector(-7, 4, 0))),
overlay_world_center_mm=list(expected_center),
overlay_sample_count=len(world_samples),
maximum_radius_error_mm=max(radius_errors),
maximum_plane_error_mm=max(plane_errors))
modal_action(command_names["mirror"], drive)
assert signature(sketch) == before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
assert set(App.listDocuments()) == documents_before
assert graph.getNumChildren() == children_before
assert evidence["temporary_document_removed"] and evidence["overlay_removed"]
native_redo = Gui.Command.get("Std_Redo")
assert native_redo.isActive()
shortcuts = [action.shortcut().toString() for action in native_redo.getAction()]
assert "Ctrl+Y" in shortcuts, shortcuts
main.setFocus(QtCore.Qt.OtherFocusReason)
QtTest.QTest.keyClick(main, QtCore.Qt.Key_Y, QtCore.Qt.ControlModifier)
settle(300)
assert doc.RedoCount == 0 and doc.UndoCount == undo_before["undo_count"] + 1
assert abs(sketch.Geometry[0].Radius - 6) < 1e-7 and sketch.solve() == 0
evidence.update(history_before=undo_before, history_after_redo=history(doc),
modified_before=modified_before, radius_after_native_redo_mm=sketch.Geometry[0].Radius,
redo_delivery="native Std_Redo keyboard shortcut Ctrl+Y", native_shortcuts=shortcuts)
return evidence
finally:
close_document(doc)
def binder_redo_cancel():
# This fixture keeps both component contexts in the same real
# document. Copying only the sketch and its outgoing references loses
# the source Part frame and moves its SubShapeBinder projection.
doc = App.newDocument("SketchBinderOperationsQA")
owned_documents.add(doc.Name)
doc.Label = "Проверка связанной геометрии"
doc.UndoMode = 1
source_part = doc.addObject("App::Part", "SourceComponent")
source_part.Label = "Исходный компонент"
source_part.Placement = App.Placement(App.Vector(40, 20, 0), App.Rotation(App.Vector(0, 0, 1), 20))
source = doc.addObject("Part::Box", "SourceBox")
source.Label = "Исходное основание"
source.Length, source.Width, source.Height = 20, 10, 8
source_part.addObject(source)
target_part = doc.addObject("App::Part", "SketchComponent")
target_part.Label = "Компонент эскиза"
target_part.Placement = App.Placement(App.Vector(-10, -5, 0), App.Rotation(App.Vector(0, 0, 1), -15))
body = doc.addObject("PartDesign::Body", "Body")
body.Label = "Деталь"
target_part.addObject(body)
binder = body.newObject("PartDesign::SubShapeBinder", "Binder")
binder.Label = "Связанная геометрия"
binder.Support = [(source, ["Face6"])]
sketch = body.newObject("Sketcher::SketchObject", "Sketch")
sketch.Label = "Эскиз связанного отрезка"
def endpoints(geometry):
return [list(geometry.StartPoint), list(geometry.EndPoint)]
def source_length():
geometry = sketch.Geometry[0]
return (geometry.EndPoint - geometry.StartPoint).Length
def endpoint_error(actual, expected):
return max((App.Vector(*a) - App.Vector(*b)).Length for a, b in zip(actual, expected))
try:
doc.recompute()
sketch.addExternal(binder.Name, "Face1", True, False)
doc.recompute()
external = sketch.ExternalGeo[2] # Native GeoId -3 / ExternalEdge1.
sketch.addGeometry(Part.LineSegment(external.StartPoint, external.EndPoint), False)
sketch.addConstraint([Sketcher.Constraint("Coincident", 0, 1, -3, 1),
Sketcher.Constraint("Coincident", 0, 2, -3, 2)])
doc.recompute()
assert sketch.solve() == 0 and sketch.FullyConstrained and binder.Shape.isValid()
source.ViewObject.Visibility = False
binder.ViewObject.Visibility = False
path = os.path.join(output, "sketch-operation-linked-projection-redo.FCStd")
doc.saveAs(path)
Gui.getDocument(doc.Name).setEdit(sketch.Name)
settle(700)
click_ribbon("QI_Save")
settle(150)
assert commands._editing() == sketch and not Gui.getDocument(doc.Name).Modified
doc.openTransaction("Изменить ширину связанного основания")
source.Width = 12
doc.recompute()
doc.commitTransaction()
assert abs(source_length() - 12) < 1e-7
doc.undo()
doc.recompute()
settle(150)
assert doc.RedoCount == 1 and abs(source.Width.Value - 10) < 1e-7
assert abs(source_length() - 10) < 1e-7
Gui.Selection.clearSelection()
Gui.Selection.addSelection(sketch, "Edge1")
settle(100)
before = signature(sketch)
undo_before = history(doc)
modified_before = bool(Gui.getDocument(doc.Name).Modified)
documents_before = set(App.listDocuments())
graph = Gui.getDocument(doc.Name).activeView().getSceneGraph()
children_before = graph.getNumChildren()
original_local = endpoints(sketch.Geometry[0])
original_external = endpoints(sketch.ExternalGeo[2])
direction = sketch.Geometry[0].EndPoint - sketch.Geometry[0].StartPoint
direction.normalize()
normal = App.Vector(-direction.y, direction.x, 0)
expected_local = [list(App.Vector(*point) + normal * 2) for point in original_local]
placement = sketch.getGlobalPlacement()
expected_world = [list(placement.multVec(App.Vector(*point))) for point in expected_local]
edit_transform = Gui.getDocument(doc.Name).EditingTransform
expected_display = [list(edit_transform.multVec(App.Vector(*point))) for point in expected_local]
evidence = {}
render_evidence = {}
def drive(dialog):
enter_number(dialog, "distance", 2)
loop = dialog.findChild(QtWidgets.QCheckBox, "QI_loop_select")
assert loop is not None
if loop.isChecked():
QtTest.QTest.mouseClick(loop, QtCore.Qt.LeftButton)
settle(50)
assert not loop.isChecked()
assert dialog.findChild(QtWidgets.QCheckBox, "QI_constrain").isChecked()
operation = dialog.operation
before_operation = {}
def inspect_target(values, target):
# Observe the exact native target passed to the unchanged
# backend, before it adds any geometry or constraints.
before_operation.update(local=endpoints(target.Geometry[0]),
external=endpoints(target.ExternalGeo[2]),
geometry_count=target.GeometryCount,
context_objects=[obj.Name for obj in target.Document.Objects])
assert target != sketch and target.GeometryCount == 1
assert endpoint_error(before_operation["local"], original_local) < 1e-7
assert endpoint_error(before_operation["external"], original_external) < 1e-7
assert {"SourceComponent", "SketchComponent", "SourceBox", "Binder", "Body"}.issubset(
before_operation["context_objects"])
return operation(values, target)
dialog.operation = inspect_target
localized = audit_dialog(dialog)
click_dialog(dialog, "QISketchPreview")
error = dialog.findChild(QtWidgets.QLabel, "QISketchOperationError").text()
assert not error, error
session = dialog.session
shadow, result, detached = session.preview_sketch, session.result, session.detached
assert shadow != sketch and shadow.solve() == 0 and shadow.Shape.isValid()
assert result["kind"] == "line" and result["loop_select"] is False
assert len(result["created_ids"]) == 1 and len(result["helper_ids"]) == 2
created = endpoints(shadow.Geometry[result["created_ids"][0]])
assert endpoint_error(endpoints(shadow.Geometry[0]), original_local) < 1e-7
assert endpoint_error(created, expected_local) < 1e-7, (created, expected_local)
assert detached.node is not None and graph.findChild(detached.node) >= 0
samples = [list(point.getValue()) for point in detached.node.getChild(2).point.getValues()]
assert len(samples) == 2 and endpoint_error(samples, expected_display) < 1e-3
assert signature(sketch) == before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
temporary_name = detached.document.Name
assert temporary_name not in documents_before and temporary_name in App.listDocuments()
capture("sketch-linked-binder-detached-offset")
view = Gui.getDocument(doc.Name).activeView()
preview_frame, preview_path = framebuffer(view, "sketch-linked-binder-detached-framebuffer")
midpoint = (App.Vector(*expected_display[0]) + App.Vector(*expected_display[1])) * .5
screen_midpoint = screen_point(view, midpoint, preview_frame)
orange_pixels = matching_pixels(preview_frame, screen_midpoint,
lambda r, g, b: r > 170 and 50 < g < 210 and b < 160
and r > g + 40 and g > b + 15)
assert orange_pixels, {"expected_overlay_midpoint_pixel": screen_midpoint,
"framebuffer": preview_path, "native_edit_transform": str(edit_transform)}
render_evidence.update(camera=camera_frame(view), screen_midpoint=screen_midpoint,
framebuffer_dimensions=[preview_frame.width(), preview_frame.height()])
localized["rendered_dialog"] = capture_dialog(dialog, "sketch-linked-binder-offset-dialog")
click_dialog(dialog, "QISketchCancel")
assert detached.node is None and detached.document is None and detached.shadow is None
evidence.update(original_local_endpoints_mm=original_local,
original_external_endpoints_mm=original_external,
shadow_before_native_operation=before_operation,
expected_offset_local_endpoints_mm=expected_local,
actual_offset_local_endpoints_mm=created,
expected_offset_world_endpoints_mm=expected_world,
expected_native_edit_endpoints_mm=expected_display,
actual_overlay_native_edit_endpoints_mm=samples,
maximum_overlay_endpoint_error_mm=endpoint_error(samples, expected_display),
detached_framebuffer=preview_path, overlay_midpoint_pixel=screen_midpoint,
orange_pixels_at_expected_offset_midpoint=orange_pixels,
native_edit_transform=str(edit_transform),
source_global_placement=str(placement), preview_result=result,
russian_dialog=localized, temporary_document=temporary_name)
modal_action("QI_SketchOffset", drive)
assert signature(sketch) == before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
assert set(App.listDocuments()) == documents_before and graph.getNumChildren() == children_before
main.setFocus(QtCore.Qt.OtherFocusReason)
assert Gui.Command.get("Std_Redo").isActive()
QtTest.QTest.keyClick(main, QtCore.Qt.Key_Y, QtCore.Qt.ControlModifier)
settle(300)
assert doc.RedoCount == 0 and doc.UndoCount == undo_before["undo_count"] + 1
assert abs(source.Width.Value - 12) < 1e-7 and abs(source_length() - 12) < 1e-7
assert sketch.solve() == 0
evidence.update(cancel_preserved_geometry_constraints_history_dirty_flag=True,
history_before=undo_before, history_after_native_redo=history(doc),
source_width_after_native_redo_mm=source.Width.Value,
linked_line_length_after_native_redo_mm=source_length())
# History preservation was proven above. Now build the identical
# real operation on the original sketch, with the original source
# width, and compare rendered pixels without changing the camera.
import qi_sketch_offset as offsets
doc.openTransaction("Проверить отображение настоящего смещения")
source.Width = 10
doc.recompute()
native_offset = offsets.offset(sketch, [0], 2, loop_select=False)
doc.recompute()
doc.commitTransaction()
actual_local = endpoints(sketch.Geometry[native_offset["created_ids"][0]])
assert endpoint_error(actual_local, expected_local) < 1e-7
Gui.Selection.clearSelection()
view = Gui.getDocument(doc.Name).activeView()
assert camera_frame(view) == render_evidence["camera"], "Native comparison changed the preview camera"
actual_frame, actual_path = framebuffer(view, "sketch-linked-binder-actual-offset-framebuffer")
actual_midpoint = screen_point(view, (edit_transform.multVec(App.Vector(*actual_local[0])) +
edit_transform.multVec(App.Vector(*actual_local[1]))) * .5,
actual_frame)
assert actual_midpoint == render_evidence["screen_midpoint"]
green_pixels = matching_pixels(actual_frame, actual_midpoint,
lambda r, g, b: g > 100 and g > r + 80 and g > b + 80)
assert green_pixels, {"expected_native_offset_midpoint_pixel": actual_midpoint,
"framebuffer": actual_path}
evidence.update(actual_native_offset_framebuffer=actual_path,
actual_native_offset_local_endpoints_mm=actual_local,
native_offset_midpoint_pixel=actual_midpoint,
green_pixels_at_native_offset_midpoint=green_pixels,
identical_native_camera=render_evidence["camera"])
return evidence
finally:
close_document(doc)
def existing_pattern_redo_cancel(kind):
doc, sketch, _ = fixture(kind + "-edit-existing-redo")
try:
def create(dialog):
configure(dialog, kind)
click_dialog(dialog, "QISketchCreate")
assert not dialog.isVisible()
modal_action(command_names[kind], create)
verify_pattern(sketch, kind)
assert any(geometry.TypeId == "Part::GeomPoint" for geometry in sketch.Geometry), \
"Fixture does not exercise native construction Points"
click_ribbon("QI_Save")
settle(150)
assert not Gui.getDocument(doc.Name).Modified
doc.openTransaction("Изменить радиус исходной окружности массива")
sketch.setDatum(2, App.Units.Quantity("3 mm"))
doc.recompute()
doc.commitTransaction()
verify_pattern(sketch, kind, radius=3)
doc.undo()
doc.recompute()
settle(150)
verify_pattern(sketch, kind)
assert doc.RedoCount == 1
before = signature(sketch)
registry_before = sketch.QISketchPatterns
formulas_before = list(sketch.ExpressionEngine)
undo_before = history(doc)
modified_before = bool(Gui.getDocument(doc.Name).Modified)
documents_before = set(App.listDocuments())
graph = Gui.getDocument(doc.Name).activeView().getSceneGraph()
children_before = graph.getNumChildren()
evidence = {}
def drive(dialog):
configure(dialog, kind, revised=True)
operation = dialog.operation
before_operation = {}
def inspect_target(values, target):
# The original metadata and all existing Point helpers
# must survive the native copy before set_parameters.
before_operation.update(signature=signature(target),
registry=target.QISketchPatterns,
formulas=list(target.ExpressionEngine),
point_count=sum(g.TypeId == "Part::GeomPoint" for g in target.Geometry))
assert target != sketch and before_operation["point_count"] > 0
assert before_operation["signature"] == before
assert before_operation["registry"] == registry_before
assert before_operation["formulas"] == formulas_before
return operation(values, target)
dialog.operation = inspect_target
click_dialog(dialog, "QISketchPreview")
error = dialog.findChild(QtWidgets.QLabel, "QISketchOperationError").text()
assert not error, error
session = dialog.session
shadow, detached = session.preview_sketch, session.detached
revised = verify_pattern(shadow, kind, revised=True)
assert revised["metadata"][0]["id"] == before["patterns"][0]["id"]
assert detached is not None and detached.node is not None and graph.findChild(detached.node) >= 0
assert signature(sketch) == before and sketch.QISketchPatterns == registry_before
assert list(sketch.ExpressionEngine) == formulas_before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
temporary_name = detached.document.Name
assert temporary_name in App.listDocuments() and temporary_name not in documents_before
capture("sketch-" + kind + "-existing-pattern-detached-edit")
dialog_path = capture_dialog(dialog, "sketch-" + kind + "-existing-pattern-edit-dialog")
click_dialog(dialog, "QISketchCancel")
assert detached.node is None and detached.document is None and detached.shadow is None
evidence.update(shadow_before_set_parameters=before_operation,
revised_native_preview=revised, temporary_document=temporary_name,
original_registry_preserved=True, original_formulas_preserved=True,
source_datum_label_positions_before=before["datum_label_positions"],
rendered_dialog=dialog_path)
modal_action("QI_EditSketchPattern", drive, edit_selector=True)
assert signature(sketch) == before and sketch.QISketchPatterns == registry_before
assert list(sketch.ExpressionEngine) == formulas_before and history(doc) == undo_before
assert bool(Gui.getDocument(doc.Name).Modified) == modified_before
assert set(App.listDocuments()) == documents_before and graph.getNumChildren() == children_before
positions_after_cancel = signature(sketch)["datum_label_positions"]
main.setFocus(QtCore.Qt.OtherFocusReason)
assert Gui.Command.get("Std_Redo").isActive()
QtTest.QTest.keyClick(main, QtCore.Qt.Key_Y, QtCore.Qt.ControlModifier)
settle(300)
regenerated = verify_pattern(sketch, kind, radius=3)
assert doc.RedoCount == 0 and doc.UndoCount == undo_before["undo_count"] + 1
assert sketch.QISketchPatterns == registry_before and list(sketch.ExpressionEngine) == formulas_before
evidence.update(cancel_preserved_native_data_history_dirty_flag=True,
source_datum_label_positions_after_cancel=positions_after_cancel,
history_before=undo_before, history_after_native_redo=history(doc),
source_radius_after_native_redo_mm=sketch.Geometry[0].Radius,
regenerated_pattern_after_native_redo=regenerated)
return evidence
finally:
close_document(doc)
def visible_controls():
doc, sketch, _ = fixture("layout")
try:
tab_index = next(i for i in range(ribbon.tabs.count()) if ribbon.tabs.tabText(i) == "Эскиз")
QtTest.QTest.mouseClick(ribbon.tabs.tabBar(), QtCore.Qt.LeftButton, QtCore.Qt.NoModifier,
ribbon.tabs.tabBar().tabRect(tab_index).center())
settle(150)
controls = {}
for name in list(command_names.values()) + ["QI_SketchOffset", "QI_EditSketchPattern", "QI_FinishSketch", "QI_CancelSketch"]:
button = ribbon.buttons[name][0]
visible = button.isVisible() and button.visibleRegion().contains(button.rect())
controls[name] = {"caption": button.text(), "tooltip": button.toolTip(), "visible_without_scroll": visible}
assert visible, "Sketch command clipped at 1440 px: " + name
assert any("А" <= char <= "я" or char in "Ёё" for char in button.toolTip()), name
locale.localize_surface()
candidates = locale.untranslated_surface()
assert not candidates, candidates
capture("sketch-transformations-russian-ribbon-1440")
return {"window_width": main.width(), "controls": controls, "untranslated_candidates": candidates}
finally:
close_document(doc)
def check(name, function):
try:
result = function()
report["checks"].append({"name": name, "passed": True, "result": result})
report["passed"] += 1
except Exception:
report["checks"].append({"name": name, "passed": False, "error": traceback.format_exc()})
report["failed"] += 1
if App.ActiveDocument and Gui.activeDocument():
capture("sketch-transformations-failure-" + str(report["failed"]))
write()
for kind in ("mirror", "rectangular", "circular"):
check(kind + ": repeated Qt preview replaces copies and Cancel restores sketch/history/dirty flag",
lambda kind=kind: preview_cancel(kind))
check(kind + ": Qt Create is one undo step; saved native association regenerates after reopen",
lambda kind=kind: create_reopen(kind))
check("Offset: repeated circle preview replaces geometry; Cancel restores constraints/history/dirty flag", offset_cancel)
check("Offset: Qt Create saves native distance association; source radius regenerates after reopen", offset_create_reopen)
check("Invalid repeated preview shows a Russian error and rolls back geometry/history", invalid_preview)
check("Qt Edit Pattern preserves association ID; cancel and undo restore previous pattern", edit_existing)
check("Detached native preview preserves existing Redo; cancel removes overlay and Ctrl+Y restores source radius", redo_cancel)
check("Detached offset retains placed SubShapeBinder context; cancel preserves Redo and native Ctrl+Y updates projection", binder_redo_cancel)
for kind in ("rectangular", "circular"):
check(kind + ": existing Point helpers survive detached Edit preview; Cancel preserves registry/formulas/Redo",
lambda kind=kind: existing_pattern_redo_cancel(kind))
check("Russian transformation controls and Finish/Cancel remain visible at 1440 px", visible_controls)
for name in owned_documents:
if name in App.listDocuments():
close_document(App.getDocument(name))
report["status"] = "passed" if report["failed"] == 0 else "failed"
import qi_sketch_preview as preview_module
report["native_shadow_guard"] = {
"module_file": preview_module.__file__,
"point_coordinate_attributes": [name for name in preview_module._geometry_matches.__code__.co_names
if name in ("X", "Y", "Z", "Point")]}
write()
return report