1065 lines
58 KiB
Python
1065 lines
58 KiB
Python
"""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
|