feat: Implement depth surface strip cleanup and organize tool CLI

- Added a proposal for depth surface strip cleanup, detailing the rationale and changes to the depth plane and cutout nodes.
- Created specifications for the new cleanup requirements, including scenarios for strip removal and boundary edge detection.
- Developed tasks for implementing shared strip detection logic and integrating new interfaces for depth nodes.
- Established a new CLI structure for organizing node groups and model commands, replacing old entry points with a more intuitive command structure.
- Introduced a prototype for panorama surface generation, including requirements for distance reconstruction and geometry node surfaces.
- Added comprehensive tests for depth strip cleanup functionality, ensuring correct behavior for various mesh topologies and edge cases.
This commit is contained in:
2026-09-10 00:53:43 +08:00
parent 1622afa7a2
commit 27ca25224a
42 changed files with 1970 additions and 70 deletions

View File

@@ -422,16 +422,17 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
assert np.ptp(positions[:, 2]) < 1e-4
def test_depth_surface_split_preserves_original_faces():
def test_depth_surface_split_removes_isolated_triangle_without_loose_geometry():
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_step_object()
split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold")
for value in (0., .5, 1.):
# The raised corner becomes an isolated triangle after separation.
for value, expected in ((0., (9, 8)), (.5, (8, 7)), (1., (8, 7))):
object_data.set_modifier_input(modifier, split_id, value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert vertices >= 9 and faces == 8
assert (vertices, faces) == expected
assert not loose_edges and not loose_points

View File

@@ -3,6 +3,97 @@ import importlib.util
import sys
import unittest
from unittest.mock import patch
from dataclasses import replace
from pathlib import Path
import pytest
@pytest.mark.parametrize("command", ["prepare", "sync"])
def test_model_commands_prepare_all_before_upload(command):
module = load_sync_models()
events = []
with (
patch("sys.argv", ["model", command]),
patch.object(module, "prepare_model", side_effect=lambda model: events.append(model)),
patch.object(module, "upload_models", side_effect=lambda: events.append("upload")),
):
module.main()
assert events == [*module.MODEL_FILES, *(["upload"] if command == "sync" else [])]
def test_prepare_failure_prevents_upload():
module = load_sync_models()
with (
patch("sys.argv", ["model", "sync"]),
patch.object(module, "prepare_model", side_effect=RuntimeError("invalid model")),
patch.object(module, "upload_models") as upload,
):
with pytest.raises(RuntimeError, match="invalid model"):
module.main()
upload.assert_not_called()
def test_valid_model_cache_skips_all_preparation():
module = load_sync_models()
with (
patch.object(module, "verify_file", return_value=True),
patch.object(module, "build_model") as build,
patch.object(module, "download_file") as download,
patch.object(module.subprocess, "run") as run,
):
for model in module.MODEL_FILES:
module.prepare_model(model)
build.assert_not_called()
download.assert_not_called()
run.assert_not_called()
@pytest.mark.parametrize("valid", [True, False])
def test_pisa_export_validates_all_outputs_before_replacing(tmp_path, valid):
module = load_sync_models()
digest = module.hashlib.sha256(b"new").hexdigest()
models = tuple(module.ModelFile("PISA_SR", "pisa-sr", name, 3, digest) for name in ("a.onnx", "b.onnx"))
def export(command, **kwargs):
destination = Path(command[-1])
(destination / "a.onnx").write_bytes(b"new")
(destination / "b.onnx").write_bytes(b"new" if valid else b"bad")
with patch.object(module, "MODELS_DIR", tmp_path), patch.object(module, "MODEL_FILES", models), patch.object(module.subprocess, "run", side_effect=export):
models[1].destination.parent.mkdir()
models[1].destination.write_bytes(b"old")
if valid:
module.prepare_model(models[0])
assert all(model.destination.read_bytes() == b"new" for model in models)
else:
with pytest.raises(RuntimeError, match="SHA-256"):
module.prepare_model(models[0])
assert not models[0].destination.exists()
assert models[1].destination.read_bytes() == b"old"
assert not list(tmp_path.glob(".pisa-sr-*"))
def test_local_export_failure_cleans_temporary_output(tmp_path):
module = load_sync_models()
model = replace(next(model for model in module.MODEL_FILES if model.build), folder="test")
def export(exporter, source, destination):
destination.write_bytes(b"partial")
raise RuntimeError("export failed")
with (
patch.object(module, "MODELS_DIR", tmp_path),
patch.object(module, "PROJECT_ROOT", tmp_path),
patch.object(module, "prepare_source", return_value=tmp_path / "weights.pth"),
patch("tools.models.exports.export_model", side_effect=export),
):
model.destination.parent.mkdir()
model.destination.write_bytes(b"original")
with pytest.raises(RuntimeError, match="export failed"):
module.build_model(model)
assert model.destination.read_bytes() == b"original"
assert not model.destination.with_name(model.filename + ".part").exists()
def load_sync_models():

View File

@@ -77,7 +77,7 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp
calls = []
monkeypatch.setattr(launcher, "blender_path", lambda _: executable)
monkeypatch.setattr(launcher, "run", calls.append)
monkeypatch.setattr("sys.argv", ["build_nodes"])
monkeypatch.setattr("sys.argv", ["node-group", "build"])
launcher.main()
@@ -86,3 +86,53 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp
assert "runpy.run_module('tools.nodes.check', run_name='__main__')" in calls[1][-1]
assert all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1])
assert calls[-1][1:] == ("-m", "pytest")
@pytest.mark.parametrize("skip_tests", [False, True])
def test_check_validates_without_building(monkeypatch, skip_tests):
launcher = load_launcher()
calls = []
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
monkeypatch.setattr(launcher, "run", calls.append)
monkeypatch.setattr("sys.argv", ["node-group", "check", *(["--skip-tests"] if skip_tests else [])])
launcher.main()
assert len(calls) == (1 if skip_tests else 2)
assert "tools.nodes.check" in calls[0][-1]
assert "tools.nodes.build" not in calls[0][-1]
def test_build_stops_after_blender_failure(monkeypatch):
launcher = load_launcher()
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
monkeypatch.setattr("sys.argv", ["node-group", "build"])
with patch.object(launcher, "run", side_effect=RuntimeError("failed")) as run:
with pytest.raises(RuntimeError, match="failed"):
launcher.main()
assert run.call_count == 1
@pytest.mark.parametrize("arguments,opens", [([], True), (["--no-open"], False), (["--fragment"], False)])
def test_preview_passes_isolated_arguments_and_opens_result(monkeypatch, tmp_path, arguments, opens):
launcher = load_launcher()
output = tmp_path / "node groups.html"
calls = []
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
monkeypatch.setattr(launcher, "run", calls.append)
monkeypatch.setattr("sys.argv", ["node-group", "preview", "--output", str(output), *arguments])
with patch.object(launcher.webbrowser, "open") as open_browser:
launcher.main()
assert len(calls) == 1
expression = calls[0][-1]
import ast
assignment = next(
node for node in ast.parse(expression).body
if isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) == "sys.argv"
)
assert ast.literal_eval(assignment.value) == [
"tools.nodes.preview.export", "--output", str(output),
*(["--fragment"] if "--fragment" in arguments else []),
]
if opens:
open_browser.assert_called_once_with(output.as_uri())
else:
open_browser.assert_not_called()

View File

@@ -5,6 +5,7 @@ import bpy
import numpy as np
import pytest
from tests.support.nodes import CUTOUT_BUILDERS
from tools.nodes.arrangement.flow import origin_socket
from anyimage.operators.cutout_tool.object import load_cutout_node_group
@@ -43,7 +44,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"]
expected = ["Cleanup Threshold"]
if group == groups["DEPTH_SOLID"]:
expected += ["Split Threshold", "Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
elif group == groups["DEPTH_BALLOON"]:
expected += ["Reference Depth"]
expected += ["Smooth Weight"]
@@ -52,7 +53,10 @@ def test_cutout_interfaces_and_thickness_modes(source):
assert options == expected, (group.name, options)
assert inputs["Smooth"].socket_type == "NodeSocketInt" and inputs["Smooth"].default_value == 0
assert inputs["Smooth Weight"].default_value == 1
incoming = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"]
incoming = [link for link in group.links
if link.to_node.bl_idname != "NodeReroute"
and origin_socket(link.from_socket).node.bl_idname == "NodeGroupInput"
and origin_socket(link.from_socket).name == "Geometry"]
assert len(incoming) == 1 and incoming[0].to_node.bl_idname == "GeometryNodeDeleteGeometry"
mesh = bpy.data.meshes.new("Profile")
mesh.from_pydata([(-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])

View File

@@ -188,13 +188,15 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
def test_surface_interface_and_node_structure(surface):
obj, _, _, inputs, _ = surface
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", "Valid Only",
"Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold", "Uniform Scale", "Depth Image"]
expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only",
"Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
assert list(inputs) == expected
assert inputs["Split Threshold"].default_value == 0
assert inputs["Split Threshold"].min_value == 0
assert inputs["Split Threshold"].max_value == 1
assert inputs["Split Threshold"].subtype == "FACTOR"
assert inputs["Split Threshold"].parent.name == "Options"
assert inputs["Split Threshold"].parent == inputs["Smooth"].parent
assert inputs["Split Threshold"].parent.name != "Options"
assert inputs["Validity Threshold"].default_value == pytest.approx(0.9)
assert inputs["Validity Threshold"].parent.name == "Options"
assert inputs["Validity Threshold"].subtype == "FACTOR"
@@ -341,7 +343,7 @@ def test_plane_interface_panels(source, kind):
assert [item.name for item in panels] == ["Options", "Data"]
controls = ["Reference Depth"]
if kind == "DEPTH":
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold"]
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"]
assert [item.name for item in panels[0].interface_items] == controls
assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"]
assert all(panel.default_closed for panel in panels)

View File

@@ -0,0 +1,222 @@
"""Evaluate opposite-boundary cleanup on explicit mesh topology."""
import bpy
import numpy as np
import pytest
from tools.nodes.common.depth_surface import _remove_strip_faces
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
from tools.nodes.groups.image_plane import build_image_plane_group
def cleaned_faces(vertices, faces, *, triangles=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Topology")
mesh.from_pydata(vertices, [], faces)
ids = mesh.attributes.new("source_face", "INT", "FACE")
ids.data.foreach_set("value", np.arange(len(faces), dtype=np.int32))
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
uv.data[loop.index].uv = mesh.vertices[loop.vertex_index].co[:2]
obj = bpy.data.objects.new("Topology", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Strip cleanup test", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
result = _remove_strip_faces(group, source.outputs["Geometry"], triangles=triangles)
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Cleanup", "NODES").node_group = group
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert all(np.isfinite(vertex.co).all() for vertex in result.vertices)
if not result.polygons:
assert not result.vertices and not result.edges
return set()
assert "UVMap" in result.attributes
for loop in result.loops:
np.testing.assert_allclose(result.uv_layers["UVMap"].data[loop.index].uv,
result.vertices[loop.vertex_index].co[:2])
return {value.value for value in result.attributes["source_face"].data}
finally:
evaluated.to_mesh_clear()
@pytest.mark.parametrize("cells, expected", [
([(0, 0)], set()),
([(x, 0) for x in range(5)], set()),
([(x, y) for y in range(3) for x in range(3)], set(range(9))),
# The center becomes isolated only after its three arms disappear.
([(0, 0), (1, 0), (2, 0), (1, 1)], {1}),
])
@pytest.mark.parametrize("offset, reverse", [(0, False), (1, False), (2, True), (3, True)])
def test_quad_strips_and_corners_are_classified_once(cells, expected, offset, reverse):
vertices, indices, faces = [], {}, []
for x, y in cells:
face = []
for point in [(x, y, 0), (x + 1, y, 0), (x + 1, y + 1, 0), (x, y + 1, 0)]:
if point not in indices:
indices[point] = len(vertices)
vertices.append(point)
face.append(indices[point])
face = face[offset:] + face[:offset]
faces.append(face[::-1] if reverse else face)
assert cleaned_faces(vertices, faces) == expected
@pytest.mark.parametrize("count", [3, 5, 6])
def test_non_quad_faces_are_preserved(count):
angles = np.linspace(0, 2 * np.pi, count, endpoint=False)
vertices = [(np.cos(a), np.sin(a), 0) for a in angles]
assert cleaned_faces(vertices, [tuple(range(count))]) == {0}
def test_nonmanifold_edge_is_not_a_boundary():
vertices = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0),
(2, 0, 0), (1, 2, 0), (0, 2, 1), (-1, 0, 0)]
faces = [(0, 1, 2, 3), (2, 1, 4), (3, 2, 5), (2, 3, 6), (0, 3, 7)]
assert cleaned_faces(vertices, faces) == set(range(5))
@pytest.mark.parametrize("width, height, expected", [(1, 1, 0), (5, 1, 0), (2, 2, 8), (3, 3, 18)])
@pytest.mark.parametrize("reverse", [False, True])
def test_triangle_pairs_remove_strips_and_preserve_surface_corners(width, height, expected, reverse):
vertices = [(x, y, 0) for y in range(height + 1) for x in range(width + 1)]
faces = []
for y in range(height):
for x in range(width):
a = y * (width + 1) + x
b, c, d = a + 1, a + width + 2, a + width + 1
faces.extend([(a, b, c), (a, c, d)])
# Reverse alternate faces to test matching by vertex identity, not winding.
if reverse:
faces = [face[::-1] if i % 2 else face[1:] + face[:1] for i, face in enumerate(faces)]
result = cleaned_faces(vertices, faces, triangles=True)
assert len(result) == expected
def test_isolated_triangle_is_removed_only_for_cutout():
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
assert cleaned_faces(vertices, [(0, 1, 2)], triangles=True) == set()
assert cleaned_faces(vertices, [(0, 1, 2)]) == {0}
def test_triangle_pair_requires_a_manifold_shared_edge():
vertices = [(0, 0, 0), (1, 0, 0), (.5, 1, 0), (.5, -1, 0), (.5, 0, 1)]
faces = [(0, 1, 2), (1, 0, 3), (0, 1, 4)]
assert cleaned_faces(vertices, faces, triangles=True) == {0, 1, 2}
def test_triangle_pair_requires_four_distinct_vertices():
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
assert cleaned_faces(vertices, [(0, 1, 2), (2, 1, 0)], triangles=True) == {0, 1}
def test_triangle_cleanup_marks_both_faces_without_recursive_peeling():
vertices = [(0, 0, 0), (2, 0, 0), (1, 2, 0), (1, -2, 0),
(3, 2, 0), (-1, 2, 0), (-1, -1, 0)]
faces = [(0, 1, 2), (1, 0, 3), (2, 1, 4), (0, 2, 5), (3, 0, 6)]
assert cleaned_faces(vertices, faces, triangles=True) == {0, 2, 3}
@pytest.mark.parametrize("kind", ["plane", "cutout"])
def test_split_input_precedes_smooth_without_cleanup_switch(kind):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = (build_image_depth_plane_group(build_image_plane_group()) if kind == "plane"
else build_image_depth_cutout_group())
inputs = [s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
names = [s.name for s in inputs]
assert names.count("Split Threshold") == 1
assert names[names.index("Smooth") - 1] == "Split Threshold"
split = inputs[names.index("Split Threshold")]
assert split.parent == inputs[names.index("Smooth")].parent
assert split.parent.name != "Options"
assert (split.default_value, split.min_value, split.max_value, split.subtype) == (0, 0, 1, "FACTOR")
assert "Remove Strip Faces" not in names
@pytest.mark.parametrize("kind", ["plane", "cutout"])
def test_strips_are_removed_before_projection_and_closed_thickness(kind):
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
from tests.support.planes import create_surface
if kind == "plane":
obj, set_value, _, _, image = create_surface("definition", "DEPTH")
set_value("Subdivide", 1)
else:
obj, set_value = surface(step=0.0)
image = bpy.data.images["Camera"]
width, height = image.size
pixels = np.array(image.pixels[:], np.float32).reshape(height, width, 4)
pixels[..., 2] = 1
pixels[:, width // 4:3 * width // 4, 2] = 21
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Depth Scale", 1.0)
assert len(evaluated(obj)[1]) == 8
set_value("Split Threshold", 0.5)
vertices, faces = evaluated(obj)
assert len(faces) == 4 and len(vertices) == 9
assert component_count(faces) == 1
set_value("Thickness", 0.2)
before_smooth, shell = evaluated(obj)
assert len(shell) == 16
assert_closed(shell)
set_value("Smooth", 2)
after_smooth, smooth_shell = evaluated(obj)
assert smooth_shell == shell
assert np.isfinite(after_smooth).all()
assert_closed(smooth_shell)
assert component_count(smooth_shell) == 1
set_value("Thickness", 0.0)
assert len(evaluated(obj)[1]) == 4
@pytest.mark.parametrize("kind", ["plane", "cutout"])
def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
from tests.support.depth_surface import surface, evaluated
from tests.support.planes import create_surface
if kind == "plane":
obj, set_value, _, _, _ = create_surface("definition", "DEPTH")
set_value("Subdivide", 0)
else:
obj, set_value = surface(step=0.0, resolution=1)
original, faces = evaluated(obj)
assert len(faces) == 2
set_value("Split Threshold", 0.001)
assert not evaluated(obj)[1]
set_value("Thickness", 0.2)
assert not evaluated(obj)[1]
set_value("Thickness", 0.0)
set_value("Split Threshold", 0.0)
restored, restored_faces = evaluated(obj)
assert restored_faces == faces
np.testing.assert_array_equal(restored, original)
def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
import bmesh
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=0.0, resolution=1)
mesh = bmesh.new()
mesh.from_mesh(obj.data)
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
mesh.to_mesh(obj.data)
mesh.free()
set_value("Split Threshold", 0.0)
original, faces = evaluated(obj)
assert len(faces) == 4
set_value("Split Threshold", 0.001)
assert not evaluated(obj)[1]
set_value("Thickness", 0.2)
assert not evaluated(obj)[1]
set_value("Thickness", 0.0)
set_value("Split Threshold", 0.0)
restored, restored_faces = evaluated(obj)
assert restored_faces == faces
np.testing.assert_array_equal(restored, original)

View File

@@ -147,11 +147,14 @@ def test_coincident_independent_sheets_do_not_weld_together():
@pytest.mark.parametrize("resolution", [1, 2, 4])
def test_planar_depth_has_no_split_at_any_density(resolution):
def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
obj, set_value = surface(step=0.0, resolution=resolution)
original, original_faces = evaluated(obj)
set_value("Split Threshold", 1.0)
coords, faces = evaluated(obj)
if resolution == 1:
assert len(coords) == 0 and not faces
return
assert len(coords) == len(obj.data.vertices)
assert len(faces) == len(obj.data.polygons)
np.testing.assert_allclose(coords, original, atol=1e-6)
@@ -219,8 +222,17 @@ def test_temporary_attributes_are_removed_and_user_attributes_survive(thickness,
result.to_mesh_clear()
def output_split_mesh(obj):
"""Inspect separation before boundary strip cleanup."""
group = obj.modifiers[0].node_group
split = next(node for node in group.nodes if node.bl_idname == "GeometryNodeSplitEdges")
output = next(node for node in group.nodes if node.bl_idname == "NodeGroupOutput")
group.links.new(split.outputs["Mesh"], output.inputs["Geometry"])
def test_zero_center_distance_does_not_split_or_produce_nan():
obj, set_value = surface(step=6.0)
output_split_mesh(obj)
mesh = bpy.data.meshes.new("Coincident face centers")
mesh.from_pydata(
[(0, 0, 0), (1, 0, 0), (0.5, 1, 0), (0.5, 1, 0)], [], [(0, 1, 2), (1, 0, 3)]
@@ -258,6 +270,12 @@ def test_triangle_corner_weights_do_not_open_sloped_side_walls():
@pytest.mark.parametrize("resolution", [1, 2, 4])
def test_linear_depth_slope_has_density_independent_selection(resolution):
obj, set_value = surface(step=0.0, resolution=resolution)
output_split_mesh(obj)
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(obj.data)
bm.free()
image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
pixels[..., 2] = 1 + 4 * pixels[..., 0]

View File

@@ -1,6 +1,26 @@
from tests.support.paths import PROJECT_ROOT
import tomllib
import unittest
import importlib
from unittest.mock import patch
import pytest
@pytest.mark.parametrize("module_name", ["tools.nodes.cli", "tools.models.cli"])
@pytest.mark.parametrize("arguments,code", [([], None), (["--help"], 0), (["unknown"], 2), (["--bad-option"], 2)])
def test_help_and_invalid_arguments_do_not_start_work(module_name, arguments, code, capsys):
module = importlib.import_module(module_name)
with patch("sys.argv", ["tool", *arguments]), patch.object(module.subprocess, "run") as run:
if code is None:
module.main()
else:
with pytest.raises(SystemExit) as error:
module.main()
assert error.value.code == code
run.assert_not_called()
captured = capsys.readouterr()
assert "usage:" in captured.out + captured.err
class ToolCommandsTests(unittest.TestCase):
@@ -12,8 +32,8 @@ class ToolCommandsTests(unittest.TestCase):
project["scripts"],
{
"pack": "tools.pack:main",
"build_nodes": "tools.nodes.cli:main",
"sync_models": "tools.models.cli:main",
"node-group": "tools.nodes.cli:main",
"model": "tools.models.cli:main",
"docs": "tools.docs:main",
},
)