Add comprehensive tests for image processing nodes and refactor node imports
- Introduced new test files for image layer, image plane, normal scale, object normal rotation, shadeless, and temporary attributes. - Implemented tests to validate the behavior of alpha curves, depth layers, and normal scales in various rendering engines. - Refactored import paths for node groups to align with the new directory structure. - Added checks for temporary attribute lifetimes and ensured proper cleanup without warnings. - Enhanced CLI commands to require the Blender group and validate the presence of generated node assets before packaging. - Updated packaging scripts to enforce the existence of necessary assets, preventing build failures.
This commit is contained in:
@@ -11,9 +11,9 @@ from anyimage.operators.clipboard_image import actions as clipboard_actions
|
||||
from anyimage.operators.convert_to_plane import object as plane_object
|
||||
from anyimage.operators.cutout_tool import shape as cutout_shape
|
||||
from tests.support.image_objects import _depth_image, _depth_metadata, _evaluated_positions, _inputs
|
||||
from tools.nodes.groups.image_plane import build_image_plane_group
|
||||
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
from nodes.groups.image_plane import build_image_plane_group
|
||||
from nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
|
||||
|
||||
def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
||||
|
||||
1
tests/nodes/__init__.py
Normal file
1
tests/nodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Node group behavior tests."""
|
||||
@@ -5,7 +5,7 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import evaluated
|
||||
from tests.tools.nodes.test_boundary_smoothing import boundary_neighbors, edge_band
|
||||
from tests.nodes.test_boundary_smoothing import boundary_neighbors, edge_band
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_plane_validity_hole_blurs_depth_and_protects_rectangle():
|
||||
|
||||
@pytest.mark.parametrize("region", ["seam", "pole"])
|
||||
def test_panorama_blurs_opening_radially(region):
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
distance = np.minimum(xx, 128 - xx)
|
||||
@@ -108,7 +108,7 @@ def test_smoothing_without_split_moves_outline_only_within_boundary_band():
|
||||
|
||||
|
||||
def test_panorama_smoothing_preserves_interior_and_uvs():
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
depth = np.where(xx > 35 + yy * 0.6, 8.0, 2.0)
|
||||
@@ -192,7 +192,7 @@ def test_validity_cut_smoothing_preserves_original_outline_and_interior():
|
||||
|
||||
|
||||
def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
from tests.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
|
||||
|
||||
yy, xx = np.mgrid[:64, :128]
|
||||
alpha = ((xx-64)**2 + (yy-32)**2 > 15**2).astype(np.float32)
|
||||
@@ -5,7 +5,7 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
from tests.tools.nodes.test_boundary_smoothing import diagonal_surface
|
||||
from tests.nodes.test_boundary_smoothing import diagonal_surface
|
||||
|
||||
|
||||
def profile_values(obj):
|
||||
@@ -9,9 +9,9 @@ from scipy.spatial import cKDTree
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tests.support.depth_surface import assert_closed, evaluated
|
||||
from tools.nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
from nodes.common.normal_map import AXIS_ATTRIBUTE, FACE_ATTRIBUTE, ROTATION_ATTRIBUTE
|
||||
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
|
||||
|
||||
def _set_value(obj, modifier, group):
|
||||
@@ -4,7 +4,7 @@ import bpy
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tools.nodes.groups.image_cutout import build_image_cutout_group
|
||||
from nodes.groups.image_cutout import build_image_cutout_group
|
||||
from tests.support.nodes import CUTOUT_BUILDERS
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@ def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh():
|
||||
restored, restored_faces = evaluated(obj)
|
||||
assert restored_faces == faces
|
||||
np.testing.assert_array_equal(restored, original)
|
||||
|
||||
@@ -7,7 +7,7 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
||||
from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_group
|
||||
from nodes.groups.image_depth_panorama import build_image_depth_panorama_group
|
||||
|
||||
|
||||
def panorama(depth=3, alpha=1, subdivide=3):
|
||||
@@ -333,7 +333,7 @@ def test_split_corners_keep_their_rays_and_own_face_distance():
|
||||
|
||||
|
||||
def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch):
|
||||
from tools.nodes.common import depth_surface
|
||||
from nodes.common import depth_surface
|
||||
|
||||
depth = np.broadcast_to(1 + 6 * (np.arange(128) % 16 < 8), (64, 128))
|
||||
with monkeypatch.context() as patch:
|
||||
@@ -3,7 +3,7 @@ import pytest
|
||||
|
||||
|
||||
from tests.support.materials import image_layer
|
||||
from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
from nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
|
||||
|
||||
def test_alpha_curve_preserves_endpoints_and_suppresses_soft_edges(image_layer):
|
||||
@@ -18,7 +18,7 @@ def evaluated_mesh(obj):
|
||||
|
||||
|
||||
def test_material_inheritance_removes_only_source_vertices():
|
||||
from tools.nodes.common.material_slots import inherit_material_slots
|
||||
from nodes.common.material_slots import inherit_material_slots
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_count = 4
|
||||
@@ -6,7 +6,7 @@ from mathutils import Euler, Vector, Matrix
|
||||
|
||||
from tests.support.materials import image_layer
|
||||
from anyimage.common.object import blender_frame_rotation
|
||||
from tools.nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
from nodes.common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
|
||||
|
||||
|
||||
SYMMETRY_Y_TO_X = Matrix.Rotation(1.5707963267948966, 4, "Z").to_3x3()
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
|
||||
from tests.support.depth_surface import surface
|
||||
from tests.support.planes import create_surface
|
||||
from tests.tools.nodes.test_image_depth_panorama import panorama
|
||||
from tests.nodes.test_image_depth_panorama import panorama
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["cutout", "plane", "panorama"])
|
||||
@@ -1,6 +1,6 @@
|
||||
import bpy
|
||||
import pytest
|
||||
from tools.nodes.groups.image_layer import build_image_layer_group
|
||||
from nodes.groups.image_layer import build_image_layer_group
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shared assertions and asset selection for node tests."""
|
||||
from tools.nodes.groups.image_cutout import build_image_cutout_group
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
from nodes.groups.image_cutout import build_image_cutout_group
|
||||
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from nodes.groups.image_cutout_symmetry import build_image_cutout_symmetry_group
|
||||
|
||||
CUTOUT_BUILDERS = {
|
||||
"FLAT": build_image_cutout_group,
|
||||
|
||||
@@ -4,9 +4,9 @@ import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.nodes.groups.image_plane import build_image_plane_group
|
||||
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from tools.nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
from nodes.groups.image_plane import build_image_plane_group
|
||||
from nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from nodes.groups.image_relief_plane import build_image_relief_plane_group
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -8,10 +8,10 @@ import pytest
|
||||
from tools.nodes.arrangement.arrange import arrange_nodes
|
||||
from tools.nodes.arrangement.dimensions import estimate_node_dimensions
|
||||
from tools.nodes.check import ASSET_PATH, SHADER_NAMES
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
from tools.nodes.groups.image_layer import build_image_layer_group
|
||||
from tools.nodes.groups.shadeless import build_shadeless_group
|
||||
from nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from nodes.groups.image_depth_layer import build_image_depth_layer_group
|
||||
from nodes.groups.image_layer import build_image_layer_group
|
||||
from nodes.groups.shadeless import build_shadeless_group
|
||||
|
||||
|
||||
BUILDERS = (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
import importlib.util
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
@@ -17,84 +18,75 @@ def load_launcher():
|
||||
return module
|
||||
|
||||
|
||||
def test_blender_path_prefers_explicit_path(tmp_path):
|
||||
def test_commands_require_the_blender_group(monkeypatch):
|
||||
launcher = load_launcher()
|
||||
executable = tmp_path / "blender"
|
||||
executable.touch()
|
||||
|
||||
with patch.dict("os.environ", {"BLENDER_BIN": "ignored"}):
|
||||
assert launcher.blender_path(executable) == executable
|
||||
monkeypatch.setattr("sys.argv", ["node-group", "check"])
|
||||
with patch.object(launcher.importlib.util, "find_spec", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="--group blender"):
|
||||
launcher.main()
|
||||
|
||||
|
||||
def test_blender_path_reads_environment(tmp_path):
|
||||
launcher = load_launcher()
|
||||
executable = tmp_path / "blender"
|
||||
executable.touch()
|
||||
|
||||
with patch.dict("os.environ", {"BLENDER_BIN": str(executable)}):
|
||||
assert launcher.blender_path() == executable
|
||||
|
||||
|
||||
def test_blender_path_reads_project_dotenv(tmp_path):
|
||||
launcher = load_launcher()
|
||||
executable = tmp_path / "Blender 4.5" / "blender.exe"
|
||||
executable.parent.mkdir()
|
||||
executable.touch()
|
||||
env_path = tmp_path / ".env"
|
||||
env_path.write_text(
|
||||
f'BLENDER_BIN="{executable}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), patch.object(
|
||||
launcher,
|
||||
"PROJECT_ROOT",
|
||||
tmp_path,
|
||||
):
|
||||
assert launcher.blender_path() == executable
|
||||
|
||||
|
||||
def test_blender_path_reports_missing_configuration(tmp_path):
|
||||
launcher = load_launcher()
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), patch.object(
|
||||
launcher.shutil,
|
||||
"which",
|
||||
return_value=None,
|
||||
), patch.object(
|
||||
launcher,
|
||||
"PROJECT_ROOT",
|
||||
tmp_path,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Blender not found"):
|
||||
launcher.blender_path()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arguments,modules,run_tests", [
|
||||
(["build"], ("tools.nodes.build", "tools.nodes.check"), True),
|
||||
(["check", "--skip-tests"], ("tools.nodes.check",), False),
|
||||
(["preview", "--fragment"], ("tools.nodes.preview.export",), False),
|
||||
@pytest.mark.parametrize("arguments,commands", [
|
||||
(["build"], [
|
||||
("tools.nodes.build", ()),
|
||||
("tools.nodes.check", ()),
|
||||
("pytest", ("tests/nodes", "tests/tools/nodes")),
|
||||
]),
|
||||
(["check", "--skip-tests"], [("tools.nodes.check", ())]),
|
||||
(["preview", "--fragment"], [
|
||||
("tools.nodes.preview.export", "preview"),
|
||||
]),
|
||||
])
|
||||
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, modules, run_tests):
|
||||
def test_commands_dispatch_requested_work(monkeypatch, tmp_path, arguments, commands):
|
||||
launcher = load_launcher()
|
||||
output = tmp_path / "node groups.html"
|
||||
command = ["node-group", *arguments]
|
||||
if arguments[0] == "preview":
|
||||
command += ["--output", str(output)]
|
||||
monkeypatch.setattr("sys.argv", command)
|
||||
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
|
||||
with (
|
||||
patch.object(launcher, "run_blender_modules") as blender,
|
||||
patch.object(launcher, "require_bpy"),
|
||||
patch.object(launcher, "run_module") as run_module,
|
||||
patch.object(launcher, "run") as run,
|
||||
patch.object(launcher.webbrowser, "open") as open_browser,
|
||||
):
|
||||
launcher.main()
|
||||
if arguments[0] == "preview":
|
||||
blender.assert_called_once_with("blender", modules, ["--output", str(output), "--fragment"])
|
||||
run_module.assert_called_once_with(
|
||||
"tools.nodes.preview.export",
|
||||
["--output", str(output), "--fragment"],
|
||||
)
|
||||
open_browser.assert_not_called()
|
||||
else:
|
||||
blender.assert_called_once_with("blender", modules)
|
||||
if run_tests:
|
||||
run.assert_called_once_with((launcher.sys.executable, "-m", "pytest"))
|
||||
expected_modules = [item[0] for item in commands if item[0] != "pytest"]
|
||||
assert [call.args[0] for call in run_module.call_args_list] == expected_modules
|
||||
if any(item[0] == "pytest" for item in commands):
|
||||
run.assert_called_once_with(
|
||||
(
|
||||
launcher.sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"tests/nodes",
|
||||
"tests/tools/nodes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
def test_module_failure_stops_later_steps(monkeypatch):
|
||||
launcher = load_launcher()
|
||||
monkeypatch.setattr("sys.argv", ["node-group", "build"])
|
||||
with (
|
||||
patch.object(launcher, "require_bpy"),
|
||||
patch.object(
|
||||
launcher,
|
||||
"run_module",
|
||||
side_effect=subprocess.CalledProcessError(1, "build"),
|
||||
) as run_module,
|
||||
patch.object(launcher, "run") as run,
|
||||
):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
launcher.main()
|
||||
run_module.assert_called_once_with("tools.nodes.build")
|
||||
run.assert_not_called()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import subprocess
|
||||
import tomllib
|
||||
|
||||
from tests.support.paths import PROJECT_ROOT
|
||||
@@ -43,14 +44,38 @@ def test_ai_and_model_groups_own_their_runtime_dependencies():
|
||||
|
||||
|
||||
def test_workflows_run_the_regular_blender_suite():
|
||||
command = "uv run --group blender pytest tests/anyimage tests/tools"
|
||||
build_command = "uv run --group blender node-group build --skip-tests"
|
||||
test_command = (
|
||||
"uv run --group blender pytest tests/anyimage tests/nodes tests/tools"
|
||||
)
|
||||
for name in ("test.yml", "release.yml"):
|
||||
workflow = (PROJECT_ROOT / ".github" / "workflows" / name).read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert command in workflow
|
||||
assert build_command in workflow
|
||||
assert test_command in workflow
|
||||
assert workflow.index(build_command) < workflow.index(test_command)
|
||||
assert "BLENDER_BIN" not in workflow
|
||||
assert "download" not in workflow.lower()
|
||||
|
||||
release = (PROJECT_ROOT / ".github" / "workflows" / "release.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "uv run pack" in release
|
||||
|
||||
|
||||
def test_generated_node_asset_is_ignored_and_untracked():
|
||||
asset = "src/anyimage/assets/O_AnyImage.blend"
|
||||
ignored = subprocess.run(
|
||||
["git", "check-ignore", "--quiet", asset],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
)
|
||||
tracked = subprocess.run(
|
||||
["git", "ls-files", "--error-unmatch", asset],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
assert ignored.returncode == 0
|
||||
assert tracked.returncode != 0
|
||||
|
||||
@@ -42,6 +42,16 @@ class ArchiveContents:
|
||||
|
||||
|
||||
class ExtensionPackagingTest(unittest.TestCase):
|
||||
def test_rejects_missing_generated_node_asset(self):
|
||||
pack = load_pack()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with patch.object(pack, "SOURCE_DIR", Path(directory)):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
"uv run --group blender node-group build",
|
||||
):
|
||||
pack._build_extension("windows-x64", [])
|
||||
|
||||
def test_selects_platform_archive_contents_and_manifest(self):
|
||||
pack = load_pack()
|
||||
records = {}
|
||||
|
||||
Reference in New Issue
Block a user