- Implement tests for image layer functionality including alpha curve and depth layer behavior. - Create tests for image plane evaluation and modifier input handling. - Add tests to verify the saved asset inventory and its persistence. - Introduce tests for the documentation command to ensure correct project version deployment. - Validate the extension manifest structure and required permissions. - Develop tests for the packaging process, ensuring correct platform handling and dependency management. - Enhance image depth plane group functionality with new split threshold input and surface processing.
934 lines
31 KiB
Python
934 lines
31 KiB
Python
from tests.support.paths import PROJECT_ROOT
|
|
import importlib
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
from tests.support.blender import mock_bpy_imports
|
|
|
|
|
|
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(
|
|
"<IiiHHIIiiII",
|
|
40,
|
|
1,
|
|
1,
|
|
1,
|
|
24,
|
|
0,
|
|
4,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
)
|
|
dib_data = dib_header + b"\x00\x00\xff\x00"
|
|
|
|
bmp_data = self.clipboard.dib_to_bmp(dib_data)
|
|
|
|
signature, file_size, _, _, pixel_offset = struct.unpack_from(
|
|
"<2sIHHI",
|
|
bmp_data,
|
|
)
|
|
self.assertEqual(signature, b"BM")
|
|
self.assertEqual(file_size, len(bmp_data))
|
|
self.assertEqual(pixel_offset, 54)
|
|
self.assertEqual(bmp_data[14:], dib_data)
|
|
|
|
def test_rejects_truncated_dib(self):
|
|
with self.assertRaises(self.clipboard.ClipboardImageError):
|
|
self.clipboard.dib_to_bmp(b"\x28\x00\x00\x00")
|
|
|
|
def test_reads_macos_clipboard_image_as_png(self):
|
|
def export_image(_osascript, output_file):
|
|
output_file.write_bytes(b"png image")
|
|
|
|
with patch.object(
|
|
self.clipboard.shutil,
|
|
"which",
|
|
return_value="/usr/bin/osascript",
|
|
), patch.object(
|
|
self.clipboard,
|
|
"_export_macos_clipboard_image",
|
|
side_effect=export_image,
|
|
):
|
|
image_data, suffix = self.clipboard._read_macos_clipboard_image()
|
|
|
|
self.assertEqual(image_data, b"png image")
|
|
self.assertEqual(suffix, ".png")
|
|
|
|
def test_macos_clipboard_without_image_is_unavailable(self):
|
|
with patch.object(
|
|
self.clipboard.shutil,
|
|
"which",
|
|
return_value="/usr/bin/osascript",
|
|
), patch.object(
|
|
self.clipboard,
|
|
"_export_macos_clipboard_image",
|
|
):
|
|
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
|
|
self.clipboard._read_macos_clipboard_image()
|
|
|
|
def test_linux_clipboard_prefers_wayland_png(self):
|
|
def find_command(command_name):
|
|
if command_name == "wl-paste":
|
|
return "/usr/bin/wl-paste"
|
|
return None
|
|
|
|
with patch.object(
|
|
self.clipboard.shutil,
|
|
"which",
|
|
side_effect=find_command,
|
|
), patch.object(
|
|
self.clipboard,
|
|
"_run_image_command",
|
|
return_value=b"png image",
|
|
) as run_command:
|
|
image_data, suffix = self.clipboard._read_linux_clipboard_image()
|
|
|
|
self.assertEqual(image_data, b"png image")
|
|
self.assertEqual(suffix, ".png")
|
|
run_command.assert_called_once_with(
|
|
[
|
|
"/usr/bin/wl-paste",
|
|
"--no-newline",
|
|
"--type",
|
|
"image/png",
|
|
]
|
|
)
|
|
|
|
def test_linux_clipboard_falls_back_to_xclip(self):
|
|
def find_command(command_name):
|
|
if command_name == "xclip":
|
|
return "/usr/bin/xclip"
|
|
return None
|
|
|
|
with patch.object(
|
|
self.clipboard.shutil,
|
|
"which",
|
|
side_effect=find_command,
|
|
), patch.object(
|
|
self.clipboard,
|
|
"_run_image_command",
|
|
return_value=b"png image",
|
|
) as run_command:
|
|
image_data, suffix = self.clipboard._read_linux_clipboard_image()
|
|
|
|
self.assertEqual(image_data, b"png image")
|
|
self.assertEqual(suffix, ".png")
|
|
run_command.assert_called_once_with(
|
|
[
|
|
"/usr/bin/xclip",
|
|
"-selection",
|
|
"clipboard",
|
|
"-t",
|
|
"image/png",
|
|
"-o",
|
|
]
|
|
)
|
|
|
|
def test_linux_clipboard_requires_supported_command(self):
|
|
with patch.object(
|
|
self.clipboard.shutil,
|
|
"which",
|
|
return_value=None,
|
|
):
|
|
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
|
|
self.clipboard._read_linux_clipboard_image()
|
|
|
|
@staticmethod
|
|
def make_context(area_type, mode="OBJECT", **space_options):
|
|
return SimpleNamespace(
|
|
area=SimpleNamespace(type=area_type),
|
|
mode=mode,
|
|
region=SimpleNamespace(type="WINDOW"),
|
|
space_data=SimpleNamespace(**space_options),
|
|
)
|
|
|
|
@staticmethod
|
|
def remove_anyimage_modules():
|
|
for module_name in tuple(sys.modules):
|
|
if module_name == "anyimage" or module_name.startswith("anyimage."):
|
|
sys.modules.pop(module_name)
|
|
|
|
|
|
class FakeImage:
|
|
def __init__(self, property_name, image_hash, packed):
|
|
self.properties = {property_name: image_hash}
|
|
self.packed_file = object() if packed else None
|
|
self.packed_files = ()
|
|
|
|
def get(self, property_name, default=None):
|
|
return self.properties.get(property_name, default)
|
|
|
|
|
|
class FakeLoadedImage(dict):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.name = None
|
|
self.packed = False
|
|
|
|
def pack(self):
|
|
self.packed = True
|
|
|
|
|
|
class FakeLoadedImages(list):
|
|
def __init__(self, loaded_image):
|
|
super().__init__()
|
|
self.loaded_image = loaded_image
|
|
|
|
def load(self, _path, check_existing):
|
|
return self.loaded_image
|
|
|
|
def remove(self, image):
|
|
super().remove(image)
|
|
|
|
|
|
class FakeTexture(dict):
|
|
def __init__(self, name, texture_type):
|
|
super().__init__()
|
|
self.name = name
|
|
self.type = texture_type
|
|
self.image = None
|
|
|
|
|
|
class FakeTextures(list):
|
|
def new(self, name, type):
|
|
texture = FakeTexture(name, type)
|
|
self.append(texture)
|
|
return texture
|
|
|
|
def remove(self, texture):
|
|
super().remove(texture)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|