mirror of
https://github.com/blender/blender
synced 2026-09-29 04:37:17 +03:00
UI: Add drop tests (invoked when dropped onto editor regions)
Tests for Blender's drop handler system across the 3D Viewport, UI, Scene, Window, and File Browser. Does not cover OS-level drag and drop. Blender's `EVT_DROP` events are generated internally by the window manager and cannot be produced through `event_simulate`. As a result, these tests do not simulate the complete physical workflow of dragging data from an external application or the operating system into Blender. Instead, the tests invoke the registered drop operators directly through their `exec` or `invoke`-related paths and provide the context and drop properties that Blender's internal drop handlers would normally supply. This allows the tests to validate the drop-handler behavior and resulting state changes while remaining deterministic and independent of OS-level drag-and-drop support. Where an operator requires cursor-position information from the actual drop event, the tests are limited to verifying registration or poll behavior rather than reproducing the complete interaction. Pull Request: https://projects.blender.org/blender/blender/pulls/162949
This commit is contained in:
parent
beb707180d
commit
db647ea76f
2 changed files with 262 additions and 0 deletions
254
tests/python/ui_simulate/test_drop_interactions.py
Normal file
254
tests/python/ui_simulate/test_drop_interactions.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# 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``.
|
||||
|
||||
Tests for Blender's drop handler system — the operators invoked when data is
|
||||
dropped onto UI elements or editor regions.
|
||||
|
||||
Blender's ``EVT_DROP`` events cannot be produced by ``event_simulate`` (they
|
||||
are generated internally by the window manager when ``wm->runtime->drags`` is
|
||||
non-empty). These tests therefore exercise the drop operators directly via
|
||||
their ``exec`` or ``invoke`` paths where possible, and verify registration
|
||||
where the invoke path requires cursor-position context (``event->mval``).
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# VIEW3D_OT_drop_world has an exec path that does not depend on
|
||||
# event->mval. Drop a world into the scene and verify the scene's world
|
||||
# pointer is updated.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def view3d_drop_world():
|
||||
import bpy
|
||||
_e, t, window = ui.test_window()
|
||||
area = _window_area(window, "VIEW_3D")
|
||||
region = next(r for r in area.regions if r.type == 'WINDOW')
|
||||
yield
|
||||
|
||||
original_world = bpy.context.scene.world
|
||||
|
||||
world = bpy.data.worlds.new("DropTestWorld")
|
||||
|
||||
with bpy.context.temp_override(window=window, area=area, region=region):
|
||||
bpy.ops.view3d.drop_world(name=world.name)
|
||||
|
||||
t.assertEqual(
|
||||
bpy.context.scene.world.name,
|
||||
"DropTestWorld",
|
||||
"Scene world must be set to the dropped world",
|
||||
)
|
||||
|
||||
# Restore original world.
|
||||
bpy.context.scene.world = original_world
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Same as above but use session_uid instead of name — this is how the
|
||||
# drop_copy callback actually populates the properties.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def view3d_drop_world_session_uid():
|
||||
import bpy
|
||||
_e, t, window = ui.test_window()
|
||||
area = _window_area(window, "VIEW_3D")
|
||||
region = next(r for r in area.regions if r.type == 'WINDOW')
|
||||
yield
|
||||
|
||||
original_world = bpy.context.scene.world
|
||||
|
||||
world = bpy.data.worlds.new("DropTestWorldUID")
|
||||
|
||||
with bpy.context.temp_override(window=window, area=area, region=region):
|
||||
bpy.ops.view3d.drop_world(session_uid=world.session_uid)
|
||||
|
||||
t.assertEqual(
|
||||
bpy.context.scene.world.name,
|
||||
"DropTestWorldUID",
|
||||
"Scene world must match via session_uid lookup",
|
||||
)
|
||||
|
||||
bpy.context.scene.world = original_world
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Drop a world name that doesn't exist — the operator must cancel
|
||||
# without side effects.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def view3d_drop_world_cancel():
|
||||
import bpy
|
||||
_e, t, window = ui.test_window()
|
||||
area = _window_area(window, "VIEW_3D")
|
||||
region = next(r for r in area.regions if r.type == 'WINDOW')
|
||||
yield
|
||||
|
||||
original_world = bpy.context.scene.world
|
||||
|
||||
with bpy.context.temp_override(window=window, area=area, region=region):
|
||||
result = bpy.ops.view3d.drop_world(name="NonexistentWorld")
|
||||
|
||||
t.assertTrue(
|
||||
result == {'CANCELLED'},
|
||||
"Dropping a nonexistent world must return CANCELLED",
|
||||
)
|
||||
t.assertEqual(
|
||||
bpy.context.scene.world,
|
||||
original_world,
|
||||
"Scene world must not change when drop is cancelled",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# UI_OT_drop_material has an exec path. It needs ``material_slot`` and
|
||||
# ``object`` in the context. Drop a material into a specific slot.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ui_drop_material_slot():
|
||||
import bpy
|
||||
_e, t, window = ui.test_window()
|
||||
area = _window_area(window, "VIEW_3D")
|
||||
yield
|
||||
|
||||
cube = bpy.data.objects["Cube"]
|
||||
original_material = bpy.data.materials.new("OriginalSlotMaterial")
|
||||
dropped_material = bpy.data.materials.new("DroppedSlotMaterial")
|
||||
cube.data.materials.append(original_material)
|
||||
yield
|
||||
|
||||
# The drop_material exec path reads ``material_slot`` from context and
|
||||
# assigns the material to that slot's index.
|
||||
mat_slot = cube.material_slots[0]
|
||||
|
||||
with bpy.context.temp_override(
|
||||
window=window, area=area,
|
||||
material_slot=mat_slot, object=cube,
|
||||
):
|
||||
result = bpy.ops.ui.drop_material(session_uid=dropped_material.session_uid)
|
||||
|
||||
t.assertEqual(result, {'FINISHED'})
|
||||
t.assertEqual(
|
||||
cube.material_slots[0].material.name,
|
||||
dropped_material.name,
|
||||
"Dropping a material must replace the target slot's material",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# SCENE_OT_drop_scene_asset has an exec path. Drop a scene by session_uid
|
||||
# and verify it becomes the active scene.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scene_drop_scene_asset():
|
||||
import bpy
|
||||
_e, t, _ = ui.test_window()
|
||||
yield
|
||||
|
||||
original_scene = bpy.context.window.scene
|
||||
|
||||
scene = bpy.data.scenes.new("DropTestScene")
|
||||
|
||||
bpy.ops.scene.drop_scene_asset(session_uid=scene.session_uid)
|
||||
|
||||
t.assertEqual(
|
||||
bpy.context.window.scene.name,
|
||||
"DropTestScene",
|
||||
"Active scene must be the dropped scene",
|
||||
)
|
||||
|
||||
# Restore.
|
||||
bpy.context.window.scene = original_scene
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Drop a nonexistent scene — must cancel cleanly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scene_drop_scene_asset_cancel():
|
||||
import bpy
|
||||
_e, t, _ = ui.test_window()
|
||||
yield
|
||||
|
||||
original_scene = bpy.context.window.scene
|
||||
|
||||
result = bpy.ops.scene.drop_scene_asset(session_uid=0)
|
||||
|
||||
t.assertTrue(
|
||||
result == {'CANCELLED'},
|
||||
"Dropping nonexistent scene must return CANCELLED",
|
||||
)
|
||||
t.assertEqual(
|
||||
bpy.context.window.scene.name,
|
||||
original_scene.name,
|
||||
"Active scene must not change on cancel",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A scene drop is handled by the window under the cursor. A child window
|
||||
# shares its scene with its parent, so verify the drop updates that shared
|
||||
# scene through the child-window context.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scene_drop_scene_asset_child_window():
|
||||
import bpy
|
||||
_e, t, source_window = ui.test_window()
|
||||
scene = bpy.data.scenes.new("DropTargetWindowScene")
|
||||
|
||||
bpy.ops.wm.window_new()
|
||||
yield
|
||||
target_window = next(
|
||||
window for window in bpy.context.window_manager.windows if window != source_window)
|
||||
|
||||
with bpy.context.temp_override(window=target_window):
|
||||
result = bpy.ops.scene.drop_scene_asset(session_uid=scene.session_uid)
|
||||
|
||||
t.assertEqual(result, {'FINISHED'})
|
||||
t.assertIs(target_window.scene, scene)
|
||||
t.assertIs(source_window.scene, scene)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# FILE_OT_filepath_drop receives a path supplied by the dropbox callback.
|
||||
# Dropping a folder must navigate the target file-browser to that folder.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def file_browser_drop_folder():
|
||||
import os
|
||||
import tempfile
|
||||
import bpy
|
||||
|
||||
_e, t, window = ui.test_window()
|
||||
area = _window_area(window, "FILE_BROWSER")
|
||||
region = next(r for r in area.regions if r.type == 'WINDOW')
|
||||
space = area.spaces.active
|
||||
yield
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="blender-drop-folder-") as folder:
|
||||
with bpy.context.temp_override(window=window, area=area, region=region):
|
||||
result = bpy.ops.file.filepath_drop(filepath=folder)
|
||||
|
||||
directory = space.params.directory
|
||||
if isinstance(directory, bytes):
|
||||
directory = directory.decode()
|
||||
t.assertEqual(result, {'FINISHED'})
|
||||
t.assertEqual(os.path.normpath(directory), os.path.normpath(folder))
|
||||
Loading…
Add table
Add a link
Reference in a new issue