mirror of
https://github.com/blender/blender
synced 2026-09-29 04:37:17 +03:00
UI: Add tests for drag in editors
Tests for mouse drag interactions and modal transformations across multiple Blender editors. These help catch regressions involving modal object translation, viewport navigation, keyframe dragging, multi-element relative offsetting, UV manipulation, and editor view panning. The following UI workflows are now covered: 3D Viewport - Object Drag: Modal translation of active object with undo/redo state checks - Navigation Drags: Middle-mouse view rotation, pan (Shift + MMB), and zoom (Ctrl + MMB) Node Editor (Compositor) - Single Node Drag: Translation of an active individual node - Multiple Node Drag: Concurrent translation of multiple selected nodes maintaining relative offsets Dope Sheet - Keyframe Drag: Translation of selected keyframes and selection retention verification Graph Editor - Keyframe Drag: 2D translation of curve control points and selection retention UV Editor - UV Vertex Drag: Translation of selected mesh UV vertices in Edit Mode with undo verification Image Editor - View Pan: Middle-mouse panning and 2D view coordinate transformation check Pull Request: https://projects.blender.org/blender/blender/pulls/161200
This commit is contained in:
parent
77734b69d7
commit
dc7ec5a042
2 changed files with 423 additions and 0 deletions
|
|
@ -1766,6 +1766,14 @@ if(WITH_UI_TESTS)
|
|||
test_render.interactive_rendering_eevee
|
||||
test_render.animation_rendering_and_player
|
||||
test_bpy_types.unregister_referenced_type
|
||||
test_drag_interactions.view3d_object_drag
|
||||
test_drag_interactions.viewport_navigation_drags
|
||||
test_drag_interactions.node_single_drag
|
||||
test_drag_interactions.node_multiple_drag
|
||||
test_drag_interactions.dopesheet_keyframe_drag
|
||||
test_drag_interactions.graph_editor_drag
|
||||
test_drag_interactions.uv_editor_drag
|
||||
test_drag_interactions.image_editor_pan
|
||||
test_sequencer.compositor_modifier_mask_with_single_input
|
||||
test_menu_search_and_exec.view3d_add
|
||||
test_menu_search_and_exec.modifier_add
|
||||
|
|
|
|||
415
tests/python/ui_simulate/test_drag_interactions.py
Normal file
415
tests/python/ui_simulate/test_drag_interactions.py
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
This file does not run anything; its methods are accessed by ``run_blender_setup.py``.
|
||||
|
||||
These tests use real mouse motion in Blender's event, but not native
|
||||
OS drag-and-drop.
|
||||
"""
|
||||
|
||||
import modules.ui_test_utils as ui
|
||||
|
||||
|
||||
def _window_area(window, area_type):
|
||||
area = ui.largest_area(window.screen)
|
||||
area.type = area_type
|
||||
return area
|
||||
|
||||
|
||||
def _mouse_move_line(e, start_xy, end_xy, steps=8):
|
||||
start_x, start_y = start_xy
|
||||
end_x, end_y = end_xy
|
||||
|
||||
for step in range(steps + 1):
|
||||
factor = step / steps
|
||||
x = round(start_x + (end_x - start_x) * factor)
|
||||
y = round(start_y + (end_y - start_y) * factor)
|
||||
e.cursor_position_set(x, y, move=True)
|
||||
yield
|
||||
|
||||
|
||||
def _modal_translate(e, start_xy, delta_xy, steps=8):
|
||||
"""
|
||||
Start a standard Blender grab/translate and move the cursor along a line.
|
||||
"""
|
||||
end_xy = (start_xy[0] + delta_xy[0], start_xy[1] + delta_xy[1])
|
||||
e.cursor_position_set(*start_xy, move=True)
|
||||
yield
|
||||
yield e.g()
|
||||
yield from _mouse_move_line(e, start_xy, end_xy, steps=steps)
|
||||
yield e.ret()
|
||||
yield
|
||||
|
||||
|
||||
def _setup_compositor_tree(window):
|
||||
import bpy
|
||||
|
||||
scene = window.scene
|
||||
tree = bpy.data.node_groups.new(name="ui_simulate_single_drag", type="CompositorNodeTree")
|
||||
scene.compositing_node_group = tree
|
||||
return tree
|
||||
|
||||
|
||||
def _setup_node_editor(window, tree):
|
||||
area = _window_area(window, "NODE_EDITOR")
|
||||
space = area.spaces.active
|
||||
space.tree_type = tree.bl_idname
|
||||
space.node_tree = tree
|
||||
return area
|
||||
|
||||
|
||||
def _setup_dopesheet(window):
|
||||
area = _window_area(window, "DOPESHEET_EDITOR")
|
||||
space = area.spaces.active
|
||||
space.ui_mode = "DOPESHEET"
|
||||
space.show_markers = True
|
||||
return area, space
|
||||
|
||||
|
||||
def _setup_graph_editor(window):
|
||||
area = _window_area(window, "GRAPH_EDITOR")
|
||||
space = area.spaces.active
|
||||
return area, space
|
||||
|
||||
|
||||
def _setup_image_editor(window):
|
||||
area = _window_area(window, "IMAGE_EDITOR")
|
||||
space = area.spaces.active
|
||||
return area, space
|
||||
|
||||
|
||||
def _setup_uv_editor(window):
|
||||
area = _window_area(window, "IMAGE_EDITOR")
|
||||
space = area.spaces.active
|
||||
space.mode = "UV"
|
||||
return area
|
||||
|
||||
|
||||
def _animated_cube():
|
||||
import bpy
|
||||
cube = bpy.data.objects["Cube"]
|
||||
scene = bpy.context.scene
|
||||
scene.frame_set(1)
|
||||
cube.location = (0.0, 0.0, 0.0)
|
||||
cube.keyframe_insert(data_path="location", frame=1)
|
||||
scene.frame_set(20)
|
||||
cube.location = (4.0, 1.5, 2.0)
|
||||
cube.keyframe_insert(data_path="location", frame=20)
|
||||
scene.frame_set(1)
|
||||
return cube
|
||||
|
||||
|
||||
def _select_keyframe(action, fcurve, index=0):
|
||||
"""
|
||||
Select only the requested keyframe on an F-Curve.
|
||||
"""
|
||||
action.deselect_keys()
|
||||
|
||||
keyframe = fcurve.keyframe_points[index]
|
||||
keyframe.select_control_point = True
|
||||
return keyframe
|
||||
|
||||
|
||||
def _prepare_uv_vertex_selection(mesh):
|
||||
import bmesh
|
||||
import bpy
|
||||
|
||||
# use_uv_select_sync lives on Scene.tool_settings, not on Mesh
|
||||
bpy.context.scene.tool_settings.use_uv_select_sync = True
|
||||
bm = bmesh.from_edit_mesh(mesh)
|
||||
|
||||
for vert in bm.verts:
|
||||
vert.select = False
|
||||
|
||||
# Select vertex 0 for the drag test.
|
||||
bm.verts[0].select = True
|
||||
bm.select_history.clear()
|
||||
bm.select_history.add(bm.verts[0])
|
||||
bmesh.update_edit_mesh(mesh, loop_triangles=False, destructive=False)
|
||||
|
||||
|
||||
def _get_fcurve(obj, data_path, index=0):
|
||||
"""
|
||||
Return (action, fcurve) for the requested animation channel.
|
||||
"""
|
||||
action = obj.animation_data.action
|
||||
slot = obj.animation_data.action_slot
|
||||
channelbag = action.layers[0].strips[0].channelbag(slot)
|
||||
return action, channelbag.fcurves.find(data_path, index=index)
|
||||
|
||||
|
||||
def view3d_object_drag():
|
||||
import bpy
|
||||
e, t, window = ui.test_window()
|
||||
area = _window_area(window, "VIEW_3D")
|
||||
cube = bpy.data.objects["Cube"]
|
||||
cube.select_set(True)
|
||||
window.view_layer.objects.active = cube
|
||||
yield
|
||||
|
||||
before_location = tuple(cube.location)
|
||||
before_active = window.view_layer.objects.active
|
||||
|
||||
yield from _modal_translate(e, ui.get_area_center(area), (120, 70))
|
||||
|
||||
after_location = tuple(cube.location)
|
||||
t.assertNotEqual(after_location, before_location, "Object location should change after dragging")
|
||||
t.assertIs(window.view_layer.objects.active, before_active, "Active object should remain the dragged object")
|
||||
t.assertTrue(cube.select_get(), "Selection should be preserved")
|
||||
|
||||
yield e.ctrl.z()
|
||||
yield
|
||||
cube = bpy.data.objects["Cube"]
|
||||
t.assertEqual(tuple(cube.location), before_location, "Undo should restore the exact object location")
|
||||
|
||||
yield e.ctrl.shift.z()
|
||||
yield
|
||||
cube = bpy.data.objects["Cube"]
|
||||
t.assertEqual(tuple(cube.location), after_location, "Redo should restore the moved object location")
|
||||
|
||||
|
||||
def viewport_navigation_drags():
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
area = _window_area(window, "VIEW_3D")
|
||||
region = next(region for region in area.regions if region.type == "WINDOW")
|
||||
rv3d = region.data
|
||||
|
||||
before_rotation = tuple(rv3d.view_rotation)
|
||||
before_location = tuple(rv3d.view_location)
|
||||
before_distance = rv3d.view_distance
|
||||
|
||||
center = ui.get_area_center(area)
|
||||
yield from e.middlemouse.cursor_motion(ui.cursor_motion_data_circle(center, 80))
|
||||
t.assertNotEqual(tuple(rv3d.view_rotation), before_rotation, "Rotate drag should change the view rotation")
|
||||
|
||||
yield from e.shift.middlemouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
t.assertNotEqual(tuple(rv3d.view_location), before_location, "Pan drag should change the view location")
|
||||
|
||||
yield from e.ctrl.middlemouse.cursor_motion(ui.cursor_motion_data_y(window))
|
||||
t.assertNotEqual(rv3d.view_distance, before_distance, "Zoom drag should change the view distance")
|
||||
|
||||
|
||||
def node_single_drag():
|
||||
"""
|
||||
Translate a single node in the compositor node editor.
|
||||
"""
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
tree = _setup_compositor_tree(window)
|
||||
area = _setup_node_editor(window, tree)
|
||||
|
||||
node = tree.nodes.new("CompositorNodeRGB")
|
||||
node.name = "TestRGBNode"
|
||||
node.location = (0.0, 0.0)
|
||||
node.select = True
|
||||
tree.nodes.active = node
|
||||
yield
|
||||
|
||||
before_location = tuple(node.location)
|
||||
|
||||
yield from _modal_translate(e, ui.get_area_center(area), (140, 90))
|
||||
|
||||
after_location = tuple(node.location)
|
||||
|
||||
t.assertNotEqual(
|
||||
after_location,
|
||||
before_location,
|
||||
"Node location should change after translate",
|
||||
)
|
||||
|
||||
|
||||
def node_multiple_drag():
|
||||
"""
|
||||
Drag multiple selected nodes together and verify the relative offsets stay intact.
|
||||
"""
|
||||
e, t, window = ui.test_window()
|
||||
tree = _setup_compositor_tree(window)
|
||||
area = _setup_node_editor(window, tree)
|
||||
|
||||
nodes = [
|
||||
tree.nodes.new("CompositorNodeRGB"),
|
||||
tree.nodes.new("CompositorNodeRGB"),
|
||||
tree.nodes.new("CompositorNodeRGB"),
|
||||
]
|
||||
locations = [(0.0, 0.0), (160.0, 40.0), (-120.0, -80.0)]
|
||||
for node, location in zip(nodes, locations, strict=True):
|
||||
node.location = location
|
||||
node.select = True
|
||||
tree.nodes.active = nodes[0]
|
||||
yield
|
||||
|
||||
before = [tuple(node.location) for node in nodes]
|
||||
yield from _modal_translate(e, ui.get_area_center(area), (110, 65))
|
||||
after = [tuple(node.location) for node in nodes]
|
||||
|
||||
delta_0 = (after[0][0] - before[0][0], after[0][1] - before[0][1])
|
||||
t.assertNotEqual(delta_0, (0.0, 0.0), "Selected nodes should move")
|
||||
for idx, (before_loc, after_loc) in enumerate(zip(before, after, strict=True)):
|
||||
delta = (after_loc[0] - before_loc[0], after_loc[1] - before_loc[1])
|
||||
t.assertAlmostEqual(
|
||||
delta[0],
|
||||
delta_0[0],
|
||||
places=4,
|
||||
msg=f"Selected node {idx} X delta differs",
|
||||
)
|
||||
t.assertAlmostEqual(
|
||||
delta[1],
|
||||
delta_0[1],
|
||||
places=4,
|
||||
msg=f"Selected node {idx} Y delta differs",
|
||||
)
|
||||
|
||||
|
||||
def dopesheet_keyframe_drag():
|
||||
"""
|
||||
Drag a selected keyframe in the Dope Sheet.
|
||||
"""
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
area, _ = _setup_dopesheet(window)
|
||||
|
||||
cube = _animated_cube()
|
||||
action, fcurve = _get_fcurve(cube, "location", index=0)
|
||||
keyframe = _select_keyframe(action, fcurve, index=1)
|
||||
yield
|
||||
|
||||
before = tuple(keyframe.co)
|
||||
|
||||
yield from _modal_translate(
|
||||
e,
|
||||
ui.get_area_center(area),
|
||||
(120, 0),
|
||||
)
|
||||
|
||||
after = tuple(keyframe.co)
|
||||
|
||||
t.assertNotEqual(
|
||||
after,
|
||||
before,
|
||||
"Dope Sheet keyframe coordinates should change after dragging",
|
||||
)
|
||||
|
||||
t.assertTrue(
|
||||
keyframe.select_control_point,
|
||||
"Dragged keyframe should remain selected",
|
||||
)
|
||||
|
||||
|
||||
def graph_editor_drag():
|
||||
"""
|
||||
Drag a selected Graph Editor keyframe.
|
||||
"""
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
area, _ = _setup_graph_editor(window)
|
||||
|
||||
cube = _animated_cube()
|
||||
action, fcurve = _get_fcurve(cube, "location", index=0)
|
||||
keyframe = _select_keyframe(action, fcurve, index=1)
|
||||
yield
|
||||
|
||||
before = tuple(keyframe.co)
|
||||
|
||||
yield from _modal_translate(
|
||||
e,
|
||||
ui.get_area_center(area),
|
||||
(120, 60),
|
||||
)
|
||||
|
||||
after = tuple(keyframe.co)
|
||||
|
||||
t.assertNotEqual(
|
||||
after,
|
||||
before,
|
||||
"Graph keyframe coordinates should change after dragging",
|
||||
)
|
||||
|
||||
# Verify the dragged keyframe remains selected after the interaction.
|
||||
t.assertTrue(
|
||||
keyframe.select_control_point,
|
||||
"Dragged keyframe should remain selected",
|
||||
)
|
||||
|
||||
|
||||
def uv_editor_drag():
|
||||
"""
|
||||
Drag a UV vertex in the UV editor.
|
||||
"""
|
||||
import bmesh
|
||||
|
||||
e, t, window = ui.test_window()
|
||||
area = _setup_uv_editor(window)
|
||||
|
||||
# Create a plane and enter Edit Mode.
|
||||
yield from ui.call_menu(e, "Add -> Mesh -> Plane")
|
||||
plane = window.view_layer.objects.active
|
||||
plane.select_set(True)
|
||||
window.view_layer.objects.active = plane
|
||||
|
||||
yield e.tab()
|
||||
t.assertEqual(plane.mode, 'EDIT', "Object should be in Edit Mode after pressing Tab")
|
||||
|
||||
mesh = plane.data
|
||||
|
||||
# Ensure a UV map exists.
|
||||
if mesh.uv_layers.active is None:
|
||||
mesh.uv_layers.new(name="UVMap")
|
||||
|
||||
_prepare_uv_vertex_selection(mesh)
|
||||
yield
|
||||
|
||||
bm = bmesh.from_edit_mesh(mesh)
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
|
||||
t.assertIsNotNone(uv_layer, "Mesh should have an active UV layer")
|
||||
t.assertGreater(len(bm.faces), 0, "Mesh should contain at least one face")
|
||||
|
||||
loop = bm.faces[0].loops[0]
|
||||
before = tuple(loop[uv_layer].uv)
|
||||
|
||||
yield from _modal_translate(e, ui.get_area_center(area), (100, 50))
|
||||
|
||||
bmesh.update_edit_mesh(mesh)
|
||||
|
||||
bm = bmesh.from_edit_mesh(mesh)
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
loop = bm.faces[0].loops[0]
|
||||
after = tuple(loop[uv_layer].uv)
|
||||
|
||||
t.assertNotEqual(after, before, "UV coordinates should change after dragging")
|
||||
|
||||
yield e.ctrl.z()
|
||||
|
||||
# Undo may re-allocate the object and Mesh datablocks, so reacquire
|
||||
# both from the current view layer.
|
||||
plane = window.view_layer.objects.active
|
||||
mesh = plane.data
|
||||
|
||||
bm = bmesh.from_edit_mesh(mesh)
|
||||
uv_layer = bm.loops.layers.uv.active
|
||||
loop = bm.faces[0].loops[0]
|
||||
|
||||
t.assertEqual(tuple(loop[uv_layer].uv), before, "Undo should restore the exact UV coordinates")
|
||||
|
||||
|
||||
def image_editor_pan():
|
||||
"""
|
||||
Pan the image editor with the middle mouse button.
|
||||
"""
|
||||
e, t, window = ui.test_window()
|
||||
area, space = _setup_image_editor(window)
|
||||
region = next(region for region in area.regions if region.type == "WINDOW")
|
||||
|
||||
space.zoom_percentage = 400.0
|
||||
yield
|
||||
|
||||
probe_x, probe_y = ui.get_area_center(area)
|
||||
before_cur = tuple(region.view2d.region_to_view(probe_x - region.x, probe_y - region.y))
|
||||
|
||||
yield from e.middlemouse.cursor_motion(ui.cursor_motion_data_x(window))
|
||||
after_cur = tuple(region.view2d.region_to_view(probe_x - region.x, probe_y - region.y))
|
||||
|
||||
t.assertNotEqual(after_cur, before_cur, "Image editor view should pan")
|
||||
Loading…
Add table
Add a link
Reference in a new issue