import importlib import struct import sys import tempfile import unittest from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import Mock, patch from blender_test_support import mock_bpy_imports PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT / "src")) class ClipboardImageTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.previous_addon_modules = { name: module for name, module in sys.modules.items() if name == "anyimage" or name.startswith("anyimage.") } cls.temporary_directory = tempfile.TemporaryDirectory() cls.remove_anyimage_modules() fake_bpy = ModuleType("bpy") fake_bpy.app = SimpleNamespace(version=(5, 1, 0)) fake_bpy.props = SimpleNamespace( BoolProperty=lambda **options: options, CollectionProperty=lambda **options: options, EnumProperty=lambda **options: options, FloatProperty=lambda **options: options, FloatVectorProperty=lambda **options: options, IntProperty=lambda **options: options, PointerProperty=lambda **options: options, StringProperty=lambda **options: options, ) fake_bpy.types = SimpleNamespace( AddonPreferences=object, GizmoGroup=object, Image=object, Menu=object, Operator=object, Panel=object, PropertyGroup=object, Scene=object, TOPBAR_MT_file_defaults=SimpleNamespace(), ) fake_bpy.utils = SimpleNamespace() fake_bpy.context = SimpleNamespace( window_manager=SimpleNamespace( keyconfigs=SimpleNamespace(addon=None), ) ) fake_bpy.data = SimpleNamespace() cls.bpy_import_patch = mock_bpy_imports(fake_bpy) cls.bpy_import_patch.start() cls.addClassCleanup(cls.bpy_import_patch.stop) cls.fake_bpy = fake_bpy cls.addon = ModuleType("anyimage") cls.addon.__path__ = [str(PROJECT_ROOT / "src" / "anyimage")] sys.modules["anyimage"] = cls.addon operators = ModuleType("anyimage.operators") operators.__path__ = [str(PROJECT_ROOT / "src" / "anyimage" / "operators")] sys.modules["anyimage.operators"] = operators cls.addon.clipboard_image = importlib.import_module( "anyimage.operators.clipboard_image.operators" ) cls.actions = importlib.import_module("anyimage.operators.clipboard_image.actions") cls.clipboard = importlib.import_module("anyimage.operators.clipboard_image.clipboard") @classmethod def tearDownClass(cls): cls.remove_anyimage_modules() cls.temporary_directory.cleanup() sys.modules.update(cls.previous_addon_modules) def test_operators_use_anyimage_prefix_and_plain_properties(self): clipboard_image = self.addon.clipboard_image self.assertEqual( clipboard_image.PasteClipboardImage.bl_idname, "anyimage.paste_clipboard_image", ) self.assertEqual( clipboard_image.TrackNativeCopy.bl_idname, "anyimage.track_native_copy", ) for name in clipboard_image.PasteClipboardImage.__annotations__: self.assertFalse(name.startswith("anyimage_"), name) self.assertFalse(name.startswith("o_"), name) def test_registers_and_removes_clipboard_shortcuts(self): class KeyMapItems: def __init__(self): self.items = [] def new(self, operator_id, event_type, value, **modifiers): item = SimpleNamespace( idname=operator_id, type=event_type, value=value, **modifiers, ) self.items.append(item) return item def remove(self, item): self.items.remove(item) class KeyMaps: def __init__(self): self.items = [] def new(self, name, space_type): keymap = SimpleNamespace( name=name, space_type=space_type, keymap_items=KeyMapItems(), ) self.items.append(keymap) return keymap keymaps = KeyMaps() window_manager = SimpleNamespace( keyconfigs=SimpleNamespace( addon=SimpleNamespace(keymaps=keymaps), ), ) clipboard_image = self.addon.clipboard_image with patch.object( self.fake_bpy.context, "window_manager", window_manager, ): clipboard_image.register_keymaps() items = [ item for keymap in keymaps.items for item in keymap.keymap_items.items ] self.assertEqual( [item.idname for item in items], [ "anyimage.paste_clipboard_image", "anyimage.track_native_copy", "anyimage.paste_clipboard_image", "anyimage.track_native_copy", ], ) modifier = "oskey" if sys.platform == "darwin" else "ctrl" self.assertTrue(all(getattr(item, modifier) for item in items)) clipboard_image.unregister_keymaps() self.assertTrue( all(not keymap.keymap_items.items for keymap in keymaps.items) ) def test_routes_supported_contexts(self): contexts = ( (self.make_context("VIEW_3D"), "PLANE"), ( self.make_context("VIEW_3D", mode="SCULPT"), "TOOL_TEXTURE", ), ( self.make_context("VIEW_3D", mode="PAINT_VERTEX"), "TOOL_TEXTURE", ), ( self.make_context("VIEW_3D", mode="PAINT_TEXTURE"), "TOOL_TEXTURE", ), ( self.make_context( "NODE_EDITOR", tree_type="ShaderNodeTree", ), "NODE", ), ( self.make_context( "NODE_EDITOR", tree_type="CompositorNodeTree", ), "NODE", ), ( self.make_context( "NODE_EDITOR", tree_type="GeometryNodeTree", ), "NODE", ), ) for context, expected_target in contexts: with self.subTest(expected_target=expected_target): self.assertEqual( self.actions.target_for_context(context), expected_target, ) def test_rejects_unsupported_node_tree(self): context = self.make_context( "NODE_EDITOR", tree_type="TextureNodeTree", ) self.assertIsNone(self.actions.target_for_context(context)) def test_world_shader_uses_environment_texture(self): world_space = SimpleNamespace( tree_type="ShaderNodeTree", shader_type="WORLD", ) material_space = SimpleNamespace( tree_type="ShaderNodeTree", shader_type="OBJECT", ) self.assertEqual( self.actions._node_type_for_space(world_space), "ShaderNodeTexEnvironment", ) self.assertEqual( self.actions._node_type_for_space(material_space), "ShaderNodeTexImage", ) def test_pinned_world_tree_uses_environment_texture(self): world_space = SimpleNamespace( tree_type="ShaderNodeTree", shader_type="OBJECT", id=SimpleNamespace( bl_rna=SimpleNamespace(identifier="World"), ), ) self.assertEqual( self.actions._node_type_for_space(world_space), "ShaderNodeTexEnvironment", ) def test_node_location_matches_native_ui_scale_and_anchor_offset(self): view2d = SimpleNamespace( region_to_view=Mock(return_value=(200.0, 100.0)), ) context = SimpleNamespace( region=SimpleNamespace(view2d=view2d), preferences=SimpleNamespace( system=SimpleNamespace(ui_scale=2.0), ), space_data=SimpleNamespace(cursor_location=(1.0, 2.0)), ) event = SimpleNamespace(mouse_region_x=80, mouse_region_y=60) location = self.actions._node_location(context, event) self.assertEqual(location, (85.0, 55.0)) view2d.region_to_view.assert_called_once_with(80, 60) def test_node_location_without_event_uses_node_cursor(self): context = SimpleNamespace( space_data=SimpleNamespace(cursor_location=(12.0, -8.0)), ) self.assertEqual( self.actions._node_location(context, None), (12.0, -8.0), ) def test_view3d_location_projects_mouse_at_cursor_depth(self): projected_location = (4.0, 5.0, 6.0) project = Mock(return_value=projected_location) bpy_extras = ModuleType("bpy_extras") view3d_utils = ModuleType("bpy_extras.view3d_utils") view3d_utils.region_2d_to_location_3d = project bpy_extras.view3d_utils = view3d_utils cursor_location = Mock() context = SimpleNamespace( scene=SimpleNamespace( cursor=SimpleNamespace(location=cursor_location), ), region=object(), region_data=object(), ) event = SimpleNamespace(mouse_region_x=120, mouse_region_y=75) with patch.dict( sys.modules, { "bpy_extras": bpy_extras, "bpy_extras.view3d_utils": view3d_utils, }, ): result = self.actions._view3d_location(context, event) self.assertIs(result, projected_location) project.assert_called_once_with( context.region, context.region_data, (120, 75), cursor_location, ) def test_view3d_location_without_event_copies_cursor_location(self): copied_location = object() cursor_location = SimpleNamespace( copy=Mock(return_value=copied_location), ) context = SimpleNamespace( scene=SimpleNamespace( cursor=SimpleNamespace(location=cursor_location), ), ) result = self.actions._view3d_location(context) self.assertIs(result, copied_location) cursor_location.copy.assert_called_once_with() def test_preserves_image_aspect_ratio_for_plane(self): self.assertEqual( self.actions._plane_half_dimensions(1920, 1080), (1.0, 0.5625), ) self.assertEqual( self.actions._plane_half_dimensions(800, 1200), (2 / 3, 1.0), ) def test_plane_import_defaults_to_reference_image(self): options = ( self.addon.clipboard_image.PasteClipboardImage .__annotations__["import_as"] ) self.assertEqual(options["default"], "REFERENCE") self.assertEqual( [item[0] for item in options["items"]], ["PLANE", "REFERENCE"], ) def test_plane_shadeless_is_disabled_by_default(self): options = ( self.addon.clipboard_image.PasteClipboardImage .__annotations__["shadeless"] ) self.assertFalse(options["default"]) annotations = self.addon.clipboard_image.PasteClipboardImage.__annotations__ self.assertNotIn("subdivisions", annotations) self.assertNotIn("thickness", annotations) self.assertNotIn("unshaded", annotations) def test_native_copy_takes_priority_while_clipboard_is_unchanged(self): clipboard_image = self.addon.clipboard_image original_token = clipboard_image._native_copy_token try: clipboard_image._native_copy_token = 41 with patch.object( clipboard_image, "clipboard_change_token", return_value=41, ): self.assertTrue(clipboard_image._should_defer_to_native_paste()) finally: clipboard_image._native_copy_token = original_token def test_new_system_clipboard_content_restores_image_paste(self): clipboard_image = self.addon.clipboard_image original_token = clipboard_image._native_copy_token try: clipboard_image._native_copy_token = 41 with patch.object( clipboard_image, "clipboard_change_token", return_value=42, ): self.assertFalse(clipboard_image._should_defer_to_native_paste()) finally: clipboard_image._native_copy_token = original_token def test_native_copy_tracker_passes_through(self): clipboard_image = self.addon.clipboard_image original_token = clipboard_image._native_copy_token try: with patch.object( clipboard_image, "clipboard_change_token", return_value=73, ): result = clipboard_image.TrackNativeCopy().execute(None) self.assertEqual(result, {"PASS_THROUGH"}) self.assertEqual(clipboard_image._native_copy_token, 73) finally: clipboard_image._native_copy_token = original_token def test_plane_paste_uses_shared_material_for_both_shading_modes(self): from anyimage.operators.convert_to_plane import image_plane image = SimpleNamespace(name="Clipboard", size=(16, 8)) context = SimpleNamespace(mode="OBJECT", scene=object()) for shadeless in (False, True): with ( patch.object(self.actions, "create_image_material", return_value="Material") as create_material, patch.object(image_plane, "create_image_plane_object") as create_plane, patch.object(self.actions, "_place_in_view"), patch.object(self.actions, "_select_only"), ): self.actions.add_image_plane(context, image, shadeless=shadeless) create_material.assert_called_once_with( image, image, shadeless=shadeless, scene=context.scene, ) self.assertEqual(create_plane.call_args.args[3], "Material") def test_reference_import_routes_to_image_empty(self): context = self.make_context("VIEW_3D") image = object() expected_reference = object() expected_location = (1.0, 2.0, 3.0) original_add_reference_image = self.actions.add_reference_image self.actions.add_reference_image = ( lambda received_context, received_image, location=None: expected_reference if ( received_context is context and received_image is image and location == expected_location ) else None ) try: result = self.actions.paste_image( context, image, location=expected_location, import_as="REFERENCE", ) finally: self.actions.add_reference_image = original_add_reference_image self.assertIs(result, expected_reference) def test_reference_image_uses_drag_and_drop_size(self): reference = SimpleNamespace(select_set=Mock()) objects = SimpleNamespace(new=Mock(return_value=reference)) context = SimpleNamespace( mode="OBJECT", collection=SimpleNamespace(objects=SimpleNamespace(link=Mock())), selected_objects=(), view_layer=SimpleNamespace(objects=SimpleNamespace(active=None)), region_data=None, ) image = SimpleNamespace(name="Clipboard", size=(1920, 1080)) with patch.object(self.actions.bpy.data, "objects", objects, create=True): result = self.actions.add_reference_image( context, image, location=(1.0, 2.0, 3.0), ) self.assertIs(result, reference) self.assertEqual(reference.empty_display_size, 5.0) def test_only_import_as_and_shadeless_are_drawn_for_plane_paste(self): operator = self.addon.clipboard_image.PasteClipboardImage() drawn_properties = [] operator.layout = SimpleNamespace( prop=lambda owner, name: drawn_properties.append((owner, name)), ) operator.paste_target = "PLANE" operator.import_as = "PLANE" operator.draw(None) operator.import_as = "REFERENCE" operator.draw(None) operator.paste_target = "NODE" operator.draw(None) self.assertEqual( drawn_properties, [ (operator, "import_as"), (operator, "shadeless"), (operator, "import_as"), ], ) def test_redo_reuses_captured_node_location(self): operator = self.addon.clipboard_image.PasteClipboardImage() operator.paste_target = "NODE" operator.location = (10.0, 20.0, 0.0) operator.location_set = True operator.import_as = "REFERENCE" operator.shadeless = False operator.report = Mock() context = self.make_context( "NODE_EDITOR", tree_type="ShaderNodeTree", ) image = SimpleNamespace(name="Clipboard Image") clipboard_image = self.addon.clipboard_image with ( patch.object( clipboard_image, "_should_defer_to_native_paste", return_value=False, ), patch.object( clipboard_image, "read_clipboard_image", return_value=(b"image", ".png"), ), patch.object( clipboard_image, "acquire_packed_image", return_value=(image, True), ), patch.object(clipboard_image, "paste_image") as paste_image, ): result = operator.execute(context) self.assertEqual(result, {"FINISHED"}) paste_image.assert_called_once_with( context, image, None, location=(10.0, 20.0), import_as="REFERENCE", shadeless=False, ) def test_image_plane_requires_object_mode(self): context = SimpleNamespace(mode="EDIT_MESH") image = SimpleNamespace(size=(100, 100)) with self.assertRaisesRegex( self.actions.PasteTargetError, "Object Mode", ): self.actions.add_image_plane(context, image) def test_stencil_dimensions_preserve_scale_and_image_aspect(self): self.assertEqual( self.actions._stencil_dimensions( (1920, 1080), (400.0, 200.0), ), (400.0, 225.0), ) self.assertEqual( self.actions._stencil_dimensions( (800, 1200), (128.0, 256.0), ), (256.0 * 2 / 3, 256.0), ) def test_finds_existing_packed_clipboard_image(self): matching_image = FakeImage( self.actions.CLIPBOARD_HASH_PROPERTY, "matching-hash", packed=True, ) unpacked_image = FakeImage( self.actions.CLIPBOARD_HASH_PROPERTY, "unpacked-hash", packed=False, ) original_data = self.actions.bpy.data self.actions.bpy.data = SimpleNamespace( images=[matching_image, unpacked_image], ) try: self.assertIs( self.actions._find_packed_clipboard_image("matching-hash"), matching_image, ) self.assertIsNone( self.actions._find_packed_clipboard_image("unpacked-hash") ) finally: self.actions.bpy.data = original_data def test_names_new_packed_image_clipboard(self): image = FakeLoadedImage() images = FakeLoadedImages(image) original_data = self.actions.bpy.data self.actions.bpy.data = SimpleNamespace(images=images) try: loaded_image, reused = self.actions.acquire_packed_image( b"image-data", ".png", ) finally: self.actions.bpy.data = original_data self.assertIs(loaded_image, image) self.assertFalse(reused) self.assertEqual(image.name, "Clipboard") self.assertTrue(image.packed) def test_sets_and_reuses_stencil_brush_texture(self): textures = FakeTextures() original_data = self.actions.bpy.data self.actions.bpy.data = SimpleNamespace(textures=textures) brush = SimpleNamespace( is_editable=True, texture=None, texture_slot=SimpleNamespace( texture=None, map_mode="VIEW_PLANE", ), stencil_dimension=(256.0, 256.0), update_tag=lambda: None, ) context = SimpleNamespace(brush=brush) image = SimpleNamespace(name="Clipboard Image", size=(1920, 1080)) try: first_texture = self.actions.set_tool_texture(context, image) second_texture = self.actions.set_tool_texture(context, image) finally: self.actions.bpy.data = original_data self.assertIs(first_texture, second_texture) self.assertEqual(len(textures), 1) self.assertIs(brush.texture, first_texture) self.assertIs(brush.texture_slot.texture, first_texture) self.assertEqual(brush.texture_slot.map_mode, "STENCIL") self.assertEqual(brush.stencil_dimension, (256.0, 144.0)) def test_localizes_read_only_active_brush(self): local_brush = SimpleNamespace(is_editable=True, library=None) linked_brush = SimpleNamespace( is_editable=False, library=None, make_local=lambda: local_brush, ) self.assertIs( self.actions._ensure_editable_brush( SimpleNamespace(), linked_brush, ), local_brush, ) def test_copies_and_activates_runtime_library_brush(self): local_brush = SimpleNamespace( name="Paint Hard Clipboard", is_editable=True, library=None, ) library_brush = SimpleNamespace( name="Paint Hard", is_editable=True, library=object(), ) paint_settings = SimpleNamespace(brush=library_brush) context = SimpleNamespace( mode="SCULPT", tool_settings=SimpleNamespace(sculpt=paint_settings), ) original_ops = getattr(self.actions.bpy, "ops", None) def save_brush_as(**options): self.assertEqual( options["asset_library_reference"], "LOCAL", ) self.assertEqual(options["name"], "Paint Hard Clipboard") paint_settings.brush = local_brush return {"FINISHED"} self.actions.bpy.ops = SimpleNamespace( brush=SimpleNamespace(asset_save_as=save_brush_as), ) try: result = self.actions._ensure_editable_brush( context, library_brush, ) finally: if original_ops is None: del self.actions.bpy.ops else: self.actions.bpy.ops = original_ops self.assertIs(result, local_brush) def test_active_brush_prefers_current_mode_settings(self): correct_brush = object() context = SimpleNamespace( brush=object(), mode="SCULPT", tool_settings=SimpleNamespace( sculpt=SimpleNamespace(brush=correct_brush), ), ) self.assertIs(self.actions._active_brush(context), correct_brush) def test_vertex_and_texture_paint_use_unbiased_white(self): for mode in ("PAINT_VERTEX", "PAINT_TEXTURE"): with self.subTest(mode=mode): brush = SimpleNamespace(color=(0.2, 0.4, 0.8)) unified_settings = SimpleNamespace(color=(0.8, 0.4, 0.2)) context = SimpleNamespace( mode=mode, tool_settings=SimpleNamespace( **{ self.actions.PAINT_SETTINGS_BY_MODE[mode][0]: SimpleNamespace( unified_paint_settings=unified_settings, ) }, ), ) self.actions._set_unbiased_paint_color(context, brush) self.assertEqual(brush.color, (1.0, 1.0, 1.0)) self.assertEqual( unified_settings.color, (1.0, 1.0, 1.0), ) def test_sculpt_keeps_brush_color(self): brush = SimpleNamespace(color=(0.2, 0.4, 0.8)) self.actions._set_unbiased_paint_color( SimpleNamespace(mode="SCULPT"), brush, ) self.assertEqual(brush.color, (0.2, 0.4, 0.8)) def test_wraps_info_dib_as_bmp(self): dib_header = struct.pack( "