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:
2026-09-19 21:23:19 +08:00
parent 2b3c0194a6
commit 9debab5c0c
73 changed files with 500 additions and 217 deletions

View File

@@ -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
View File

@@ -0,0 +1 @@
"""Node group behavior tests."""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View File

@@ -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"])

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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 = (

View File

@@ -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()

View File

@@ -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

View File

@@ -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 = {}