import importlib import json import sys import tempfile import threading import unittest from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = PROJECT_ROOT / "src" class AddonTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.remove_modules() def append_status_bar(draw): draw._owner = "anyimage" cls.status_bar_draws.append(draw) fake_bpy = ModuleType("bpy") fake_bpy.props = SimpleNamespace( BoolProperty=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, Menu=object, Operator=object, Panel=object, PropertyGroup=object, WorkSpaceTool=object, Scene=type("Scene", (), {}), STATUSBAR_HT_header=SimpleNamespace( append=append_status_bar, remove=lambda draw: cls.status_bar_draws.remove(draw), ), VIEW3D_MT_object_context_menu=SimpleNamespace( append=lambda draw: cls.context_menu_draws.append(draw), remove=lambda draw: cls.context_menu_draws.remove(draw), ), OUTLINER_MT_object=SimpleNamespace( append=lambda draw: cls.outliner_menu_draws.append(draw), remove=lambda draw: cls.outliner_menu_draws.remove(draw), ), ) cls.registered = [] cls.registered_tools = [] cls.status_bar_draws = [] cls.context_menu_draws = [] cls.outliner_menu_draws = [] fake_bpy.utils = SimpleNamespace( register_class=cls.registered.append, unregister_class=cls.registered.remove, register_tool=lambda tool, **options: cls.registered_tools.append( (tool, options) ), unregister_tool=lambda tool: cls.registered_tools.remove( next(item for item in cls.registered_tools if item[0] is tool) ), ) fake_bpy.context = SimpleNamespace( preferences=SimpleNamespace(addons={}), ) fake_bpy.app = SimpleNamespace(online_access=True) fake_bpy.path = SimpleNamespace(abspath=lambda path: path) fake_bpy.data = SimpleNamespace( objects=SimpleNamespace(get=lambda _name: None), ) sys.modules["bpy"] = fake_bpy sys.path.insert(0, str(SOURCE_ROOT)) cls.paths_before_import = tuple(sys.path) cls.addon = importlib.import_module("anyimage") cls.job_runtime = importlib.import_module("anyimage.runtime") cls.model_operators = importlib.import_module("anyimage.operators.models") cls.image_depth = importlib.import_module("anyimage.operators.material") cls.image_data = importlib.import_module("anyimage.operators.image") cls.depth_operator = importlib.import_module( "anyimage.operators.depth_plane.operator" ) cls.depth_build = importlib.import_module( "anyimage.operators.depth_plane.build" ) cls.image_background = importlib.import_module("anyimage.operators.background") cls.image_mesh = importlib.import_module("anyimage.operators.surface") cls.balloon = importlib.import_module("anyimage.operators.balloon.operator") cls.balloon_geometry = importlib.import_module( "anyimage.operators.balloon.geometry" ) cls.balloon_depth = importlib.import_module("anyimage.operators.balloon.depth") cls.balloon_poisson = importlib.import_module( "anyimage.operators.balloon.poisson" ) cls.balloon_teddy = importlib.import_module("anyimage.operators.balloon.teddy") cls.paths_after_import = tuple(sys.path) cls.fake_bpy = fake_bpy @classmethod def tearDownClass(cls): sys.path.remove(str(SOURCE_ROOT)) sys.modules.pop("bpy", None) cls.remove_modules() @staticmethod def remove_modules(): for module_name in tuple(sys.modules): if module_name == "anyimage" or module_name.startswith("anyimage."): sys.modules.pop(module_name) def setUp(self): preferences = SimpleNamespace( storage_root="", device="CUDA", ) self.fake_bpy.context.preferences.addons = { "anyimage": SimpleNamespace(preferences=preferences) } def tearDown(self): self.addon.runtime.close_active() self.registered.clear() self.registered_tools.clear() self.status_bar_draws.clear() self.context_menu_draws.clear() self.outliner_menu_draws.clear() self.fake_bpy.data = SimpleNamespace( objects=SimpleNamespace(get=lambda _name: None), ) if hasattr(self.fake_bpy.types.Scene, "anyimage_settings"): del self.fake_bpy.types.Scene.anyimage_settings def test_runtime_and_operator_support_modules_have_explicit_boundaries(self): for name in ( "client.py", "environment.py", "storage.py", "models.py", "status_bar.py", ): self.assertFalse((SOURCE_ROOT / "anyimage" / name).exists(), name) self.assertTrue((SOURCE_ROOT / "anyimage" / "runtime.py").is_file()) self.assertFalse((SOURCE_ROOT / "anyimage" / "job_runtime.py").exists()) self.assertFalse((SOURCE_ROOT / "anyimage" / "client").exists()) self.assertFalse((SOURCE_ROOT / "anyimage" / "ui").exists()) self.assertFalse((SOURCE_ROOT / "anyimage" / "operators.py").exists()) self.assertFalse((SOURCE_ROOT / "anyimage" / "blender").exists()) def test_mps_requires_m4_or_newer(self): properties = self.addon.properties parse = properties.parse_apple_silicon_generation self.assertEqual(parse("Apple M2"), 2) self.assertEqual(parse("Apple M4 Pro"), 4) self.assertEqual(parse("Apple M10 Max"), 10) self.assertIsNone(parse("Intel(R) Core(TM) i9")) self.assertFalse(properties.supports_mps(3)) self.assertTrue(properties.supports_mps(4)) def test_unsupported_mac_hides_manifest_mps_device(self): properties = self.addon.properties manifest = {"devices": ["mps", "cpu"]} with ( patch.object( self.addon.runtime, "environment_manifest", return_value=manifest, ), patch.object(properties, "supports_mps", return_value=False), ): self.assertEqual(properties.supported_devices(), ["cpu"]) with ( patch.object( self.addon.runtime, "environment_manifest", return_value=manifest, ), patch.object(properties, "supports_mps", return_value=True), ): self.assertEqual(properties.supported_devices(), ["mps", "cpu"]) def test_windows_keeps_default_directml_device(self): properties = self.addon.properties with ( patch.object( self.addon.runtime, "environment_manifest", return_value={}, ), patch.object(properties.sys, "platform", "win32"), ): self.assertEqual( properties.supported_devices(), ["directml", "cpu"], ) def test_import_does_not_add_addon_directories_to_sys_path(self): self.assertEqual(self.paths_after_import, self.paths_before_import) self.assertFalse( any( name == "anyimage.server" or name.startswith("anyimage.server.") for name in sys.modules ) ) def test_job_request_is_not_part_of_blendjob(self): blendjob = importlib.import_module("blendjob") self.assertFalse(hasattr(blendjob, "JobRequest")) def test_operator_modules_use_plain_properties(self): operators = self.addon.operators for name in ( "AnyImageJobMixin", "ImageInputJobOperator", "GeometryImageJobOperator", "Da3ImageJobOperator", "Moge2ImageJobOperator", "BackgroundRequestMixin", ): self.assertFalse(hasattr(operators, name), name) for operator in operators.CLASSES: for name in getattr(operator, "__annotations__", {}): self.assertFalse(name.startswith("anyimage_"), (operator, name)) def test_concrete_job_operator_is_the_blender_operator(self): operator = self.addon.operators.GenerateMoge2Depth self.assertEqual(operator.bl_idname, "anyimage.generate_moge2_depth") self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__) self.assertIn(self.fake_bpy.types.Operator, operator.__mro__) def test_default_request_sends_every_operator_property(self): class Example(self.job_runtime.JobOperatorBase): value: object() enabled: object() operator = Example() operator.value = 42 operator.enabled = True self.assertEqual( operator.request(object()), {"value": 42, "enabled": True}, ) def test_overridden_request_purely_replaces_default_properties(self): class Example(self.job_runtime.JobOperatorBase): value: object() def request(self, context): return {"replacement": context.value} operator = Example() operator.value = 42 self.assertEqual( operator.request(SimpleNamespace(value=7)), {"replacement": 7}, ) def test_runtime_submits_job_type_and_plain_dict(self): calls = [] submitted = threading.Event() class Controller: def submit(self, job_type, parameters): calls.append((job_type, parameters)) submitted.set() return {"job_id": "job", "directory": "."} def cancel(self, _job_id): pass def mark_job_complete(self, _job_id): pass class Example(self.job_runtime.JobOperatorBase): job_type = "example" value: object() def controller(self, _runtime): return Controller() timers = [] window_manager = SimpleNamespace( progress_begin=lambda *_args: None, progress_update=lambda _value: None, event_timer_add=lambda *_args, **_kwargs: ( timers.append(object()) or timers[-1] ), event_timer_remove=lambda _timer: None, modal_handler_add=lambda _operator: None, ) context = SimpleNamespace( window_manager=window_manager, window=object(), ) operator = Example() operator.value = 9 self.assertEqual(operator.execute(context), {"RUNNING_MODAL"}) self.assertTrue(submitted.wait(1.0)) self.assertEqual(calls, [("example", {"value": 9})]) def test_runtime_owns_environment_and_storage_paths(self): runtime = self.addon.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) with patch.object(runtime, "storage_root_factory", root): self.assertEqual(runtime.storage_root(), root.resolve()) self.assertEqual(runtime.environment_directory(), root / ".venv") self.assertEqual( runtime.install_status_path(), root / "install-status.json" ) self.assertEqual(runtime.install_log_path(), root / "install.log") self.assertEqual(runtime.server_log_path(), root / "server.log") def test_runtime_checks_its_environment_dictionary_hash(self): runtime = self.addon.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) python = ( root / ".venv" / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") ) python.parent.mkdir(parents=True) python.touch() (root / "manifest.json").write_text( json.dumps({"environment_hash": runtime.environment_hash}), encoding="utf-8", ) with patch.object(runtime, "storage_root_factory", root): self.assertTrue(runtime.environment_ready()) def test_runtime_builds_the_standard_installer_command(self): runtime = self.addon.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) with patch.object(runtime, "storage_root_factory", root): command = runtime.install_command() self.assertEqual(command[0], sys.executable) self.assertEqual(Path(command[1]).name, "environment.py") self.assertEqual(Path(command[1]).parent.name, "installer") self.assertIn("--storage-root", command) self.assertIn("--config", command) self.assertNotIn("--venv-dir", command) self.assertNotIn("--models-dir", command) self.assertNotIn("--stages", command) def test_runtime_exposes_declared_environment_and_post_install(self): runtime = self.addon.runtime self.assertEqual(runtime.environment["python"], "3.12") self.assertIn("numpy==2.4.2", runtime.environment["packages"]) self.assertFalse( any( "blendjob" in package.lower() for package in runtime.environment["packages"] ) ) self.assertTrue(callable(runtime.post_install)) def test_anyimage_post_install_is_ordinary_code_using_runtime_request(self): calls = [] fake_runtime = SimpleNamespace( request=lambda job_type, parameters: calls.append((job_type, parameters)) ) self.job_runtime.post_install(fake_runtime) self.assertEqual( calls, [ ("download-model", {"model": "MOGE2_VITS_NORMAL"}), ("download-model", {"model": "BEN2_BASE"}), ], ) def test_anyimage_post_install_names_each_failed_model(self): calls = [] def request(_job_type, parameters): calls.append(parameters["model"]) raise RuntimeError("network unavailable") with self.assertRaisesRegex( RuntimeError, "MoGe-2 ViT-S Normal: network unavailable; BEN2 Base: network unavailable", ): self.job_runtime.post_install(SimpleNamespace(request=request)) self.assertEqual(calls, ["MOGE2_VITS_NORMAL", "BEN2_BASE"]) def test_environment_controller_runs_arbitrary_post_install_callable(self): runtime_module = importlib.import_module("blendjob.runtime") runtime = self.addon.runtime calls = [] started = threading.Event() release = threading.Event() def post_install(received): calls.append(received) started.set() release.wait(1.0) controller = runtime_module.EnvironmentController(runtime) controller.process = SimpleNamespace(poll=lambda: 0, returncode=0) with ( tempfile.TemporaryDirectory() as directory, patch.object( runtime, "post_install", post_install, ), patch.object( runtime, "install_status_path", return_value=Path(directory) / "missing.json", ), ): self.assertEqual(controller.status("environment")["state"], "running") self.assertTrue(started.wait(1.0)) release.set() controller.post_thread.join(1.0) status = controller.status("environment") self.assertEqual(calls, [runtime]) self.assertEqual(status["state"], "succeeded") def test_environment_controller_reports_post_install_failure(self): runtime_module = importlib.import_module("blendjob.runtime") runtime = self.addon.runtime def post_install(_runtime): raise RuntimeError("BEN2 download failed") controller = runtime_module.EnvironmentController(runtime) controller.process = SimpleNamespace(poll=lambda: 0, returncode=0) with ( tempfile.TemporaryDirectory() as directory, patch.object( runtime, "post_install", post_install, ), patch.object( runtime, "install_status_path", return_value=Path(directory) / "missing.json", ), ): controller.status("environment") controller.post_thread.join(1.0) status = controller.status("environment") self.assertEqual(status["state"], "failed") self.assertEqual(status["error"], "BEN2 download failed") def test_post_install_progress_resets_without_stage_fraction(self): runtime_module = importlib.import_module("blendjob.runtime") controller = runtime_module.EnvironmentController(self.addon.runtime) controller._update_post_progress( {"job_id": "first", "job_type": "download-model", "progress": 1.0} ) self.assertEqual(controller.post_status["job_id"], "first") controller._update_post_progress( {"job_id": "second", "job_type": "download-model", "progress": 0.1} ) self.assertEqual(controller.post_status["job_id"], "second") self.assertEqual(controller.post_status["progress"], 0.1) self.assertNotIn("stage", controller.post_status) self.assertNotIn("stages", controller.post_status) def test_conversion_model_lists_default_to_moge2_vits(self): properties = importlib.import_module("anyimage.properties") catalog = ( { "key": "SMALL", "family": "da3", "enabled": True, "label": "DA3 Small", "description": "DA3", "license": "Apache", }, { "key": "MOGE2_VITS_NORMAL", "family": "moge2", "enabled": True, "label": "MoGe-2 ViT-S", "description": "MoGe", "license": "MIT", }, ) with patch.object(properties, "model_catalog", return_value=catalog): depth_items = properties.depth_plane_model_enum_items(None, None) surface_items = properties.surface_model_enum_items(None, None) balloon_items = properties.balloon_depth_model_enum_items(None, None) self.assertEqual(depth_items[0][0], "MOGE2_VITS_NORMAL") self.assertEqual(surface_items[0][0], "MOGE2_VITS_NORMAL") self.assertEqual([item[0] for item in balloon_items], ["MOGE2_VITS_NORMAL"]) def test_required_model_operator_requests_both_models(self): operator = self.addon.operators.DownloadRequiredModels() with patch.object(self.model_operators, "require_environment"): parameters = operator.request(None) self.assertEqual( parameters, {"models": ["MOGE2_VITS_NORMAL", "BEN2_BASE"]}, ) def test_image_menu_offers_required_model_repair_when_missing(self): calls = [] class Layout: operator_context = "" enabled = True def __init__(self, enabled=True): self.enabled = enabled def row(self): return Layout(self.enabled) def operator(self, identifier, **_options): calls.append((identifier, self.enabled)) return SimpleNamespace() def separator(self): calls.append(("separator", self.enabled)) menu = self.addon.AnyImageImageMenu() menu.layout = Layout() with ( patch.object( self.addon.runtime, "environment_ready", return_value=True, ), patch( "anyimage.properties.required_models_ready", return_value=False, ), ): menu.draw(None) self.assertEqual( calls[0], ("anyimage.activate_balloon_tool", True), ) self.assertIn( ("anyimage.download_required_models", True), calls, ) self.assertIn(("anyimage.convert_to_depth_plane", False), calls) self.assertIn(("anyimage.convert_to_surface", False), calls) self.assertIn(("anyimage.remove_image_background", False), calls) def test_image_menu_keeps_disabled_features_when_environment_is_missing(self): calls = [] class Layout: operator_context = "" enabled = True def __init__(self, enabled=True): self.enabled = enabled def row(self): return Layout(self.enabled) def operator(self, identifier, **_options): calls.append((identifier, self.enabled)) return SimpleNamespace() def separator(self): calls.append(("separator", self.enabled)) menu = self.addon.AnyImageImageMenu() menu.layout = Layout() with ( patch.object( self.addon.runtime, "environment_ready", return_value=False, ), patch( "anyimage.properties.required_models_ready", return_value=False, ), ): menu.draw(None) self.assertEqual( calls[0], ("anyimage.activate_balloon_tool", True), ) self.assertIn(("anyimage.install_environment", True), calls) self.assertIn(("anyimage.install_environment_mirror", True), calls) self.assertNotIn(("anyimage.download_required_models", True), calls) self.assertIn(("anyimage.convert_to_depth_plane", False), calls) self.assertIn(("anyimage.convert_to_surface", False), calls) self.assertIn(("anyimage.remove_image_background", False), calls) def test_balloon_contour_is_smooth_and_evenly_resampled(self): import numpy as np contour = self.balloon_geometry.smooth_lasso_contour( ((0, 0), (3, 0), (4, 0), (4, 2), (0, 2)), 0.25, ) closed = np.vstack((contour, contour[0])) lengths = np.linalg.norm(np.diff(closed, axis=0), axis=1) self.assertGreater(len(contour), 20) self.assertGreater(self.balloon_geometry.polygon_area(contour), 0.0) self.assertLess(float(lengths.std() / lengths.mean()), 0.15) def test_balloon_interior_points_have_uniform_spacing(self): import numpy as np contour = np.asarray(((0, 0), (4, 0), (4, 4), (0, 4)), dtype=float) points = self.balloon_geometry.uniform_interior_points(contour, 0.5) distances = np.linalg.norm(points[:, None] - points[None, :], axis=2) np.fill_diagonal(distances, np.inf) nearest = distances.min(axis=1) self.assertGreater(len(points), 20) self.assertAlmostEqual(float(np.median(nearest)), 0.5, delta=0.02) self.assertGreater(float(nearest.min()), 0.49) def test_balloon_default_sampling_stays_coarse(self): import numpy as np polygon = np.asarray( ((100, 100), (900, 100), (950, 500), (800, 900), (200, 900), (50, 500)), dtype=float, ) contour, interior, _spacing = self.balloon_geometry.prepare_planar_points( polygon, (1000, 1000), (0.0, 1.0, 0.0, 1.0), ) self.assertLess(len(contour), 150) self.assertLess(len(interior), 400) def test_balloon_sampling_uses_world_distance(self): import numpy as np polygon = np.asarray( ((0, 0), (1000, 0), (1000, 1000), (0, 1000)), dtype=float, ) unit_contour, unit_interior, unit_spacing = ( self.balloon_geometry.prepare_planar_points( polygon, (1000, 1000), (0.0, 1.0, 0.0, 1.0), ) ) scaled_contour, scaled_interior, scaled_spacing = ( self.balloon_geometry.prepare_planar_points( polygon, (1000, 1000), (0.0, 2.0, 0.0, 2.0), world_metric=((0.25, 0.0), (0.0, 0.25)), ) ) self.assertEqual(unit_spacing, 0.1) self.assertEqual(scaled_spacing, 0.1) self.assertEqual(len(unit_contour), len(scaled_contour)) self.assertEqual(len(unit_interior), len(scaled_interior)) def test_poisson_balloon_is_zero_on_boundary_and_positive_inside(self): import numpy as np points = np.asarray( ((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)), dtype=float, ) faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)) boundary = np.asarray((True, True, True, True, False)) heights = self.balloon_poisson.poisson_balloon_heights( points, faces, boundary, 0.45 ) self.assertTrue((heights[:4] == 0.0).all()) self.assertGreater(heights[4], 0.0) def test_poisson_balloon_scales_in_world_units(self): import numpy as np points = np.asarray( ((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)), dtype=float, ) faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)) boundary = np.asarray((True, True, True, True, False)) original = self.balloon_poisson.poisson_balloon_heights( points, faces, boundary, 0.45 ) scaled = self.balloon_poisson.poisson_balloon_heights( points * 2.0, faces, boundary, 0.45 ) self.assertAlmostEqual(scaled[4], original[4] * 2.0, places=6) def test_poisson_balloon_makes_wide_regions_thicker(self): import numpy as np faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)) boundary = np.asarray((True, True, True, True, False)) wide = np.asarray( ((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)), dtype=float, ) narrow = np.asarray( ((0, 0), (4, 0), (4, 0.6), (0, 0.6), (2, 0.3)), dtype=float, ) wide_height = self.balloon_poisson.poisson_balloon_heights( wide, faces, boundary, 0.45 )[4] narrow_height = self.balloon_poisson.poisson_balloon_heights( narrow, faces, boundary, 0.45 )[4] self.assertGreater(wide_height, narrow_height) def test_poisson_balloon_supports_resolution_002(self): import numpy as np spacing = 0.02 size = 51 axis = np.arange(size, dtype=float) * spacing points = np.asarray( [(x, y) for y in axis for x in axis], dtype=float, ) faces = [] for row in range(size - 1): for column in range(size - 1): bottom_left = row * size + column bottom_right = bottom_left + 1 top_left = bottom_left + size top_right = top_left + 1 faces.append((bottom_left, bottom_right, top_right)) faces.append((bottom_left, top_right, top_left)) boundary = np.zeros(len(points), dtype=bool) boundary[:size] = True boundary[-size:] = True boundary[::size] = True boundary[size - 1 :: size] = True heights = self.balloon_poisson.poisson_balloon_heights( points, faces, boundary, 0.45, ) self.assertTrue(np.isfinite(heights).all()) self.assertTrue((heights[boundary] == 0.0).all()) self.assertGreater(heights[(size * size) // 2], 0.0) def test_poisson_solver_has_no_absolute_scale_cutoff(self): import numpy as np points = np.asarray( ((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)), dtype=float, ) faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)) boundary = np.asarray((True, True, True, True, False)) original = self.balloon_poisson.poisson_balloon_heights( points, faces, boundary, 0.45, ) tiny = self.balloon_poisson.poisson_balloon_heights( points * 1e-5, faces, boundary, 0.45, ) self.assertAlmostEqual(tiny[4], original[4] * 1e-5, places=12) def test_teddy_balloon_uses_chordal_axis_radius(self): import numpy as np points = np.asarray( ((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)), dtype=float, ) faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)) boundary = np.asarray((True, True, True, True, False)) nodes = np.asarray(((1, 1), (3, 1)), dtype=float) with patch.object( self.balloon_teddy, "chordal_axis", return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))), ): heights = self.balloon_teddy.teddy_balloon_heights( points, faces, points[:4], boundary, 0.45 ) self.assertTrue((heights[:4] == 0.0).all()) self.assertAlmostEqual(heights[4], 0.45, places=6) def test_balloon_dispatches_distinct_height_algorithms(self): import numpy as np points = np.asarray(((0, 0),), dtype=float) boundary = np.asarray((True,)) with ( patch.object( self.balloon_geometry, "poisson_balloon_heights", return_value=np.asarray((1.0,)), ) as poisson, patch.object( self.balloon_geometry, "teddy_balloon_heights", return_value=np.asarray((2.0,)), ) as teddy, ): poisson_result = self.balloon_geometry.balloon_heights( "POISSON", points, (), points, boundary, 0.45 ) teddy_result = self.balloon_geometry.balloon_heights( "TEDDY", points, (), points, boundary, 0.45 ) self.assertEqual(poisson_result[0], 1.0) self.assertEqual(teddy_result[0], 2.0) poisson.assert_called_once() teddy.assert_called_once() def test_balloon_geometry_has_a_shared_closed_seam(self): from collections import Counter import numpy as np planar = np.asarray( ((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)), dtype=float, ) faces_2d = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)) boundary = np.asarray((True, True, True, True, False)) heights = np.asarray((0, 0, 0, 0, 1), dtype=float) vertices, faces, uv = self.balloon_geometry.build_closed_geometry( planar, faces_2d, boundary, heights, (0.0, 2.0, 0.0, 2.0), ) edges = Counter() for face in faces: for start, end in zip(face, face[1:] + face[:1]): edges[tuple(sorted((start, end)))] += 1 self.assertEqual(len(vertices), 6) self.assertEqual(len(vertices), len(uv)) self.assertEqual(uv.dtype, np.float32) self.assertTrue(edges) self.assertTrue(all(users == 2 for users in edges.values())) def test_balloon_tool_operators_are_registered(self): self.assertIn(self.balloon.ExtractBalloon, self.addon.CLASSES) self.assertIn(self.balloon.ActivateBalloonTool, self.addon.CLASSES) self.assertNotIn(self.balloon.BalloonTool, self.addon.CLASSES) self.assertLess( self.addon.CLASSES.index(self.balloon.ExtractBalloon), self.addon.CLASSES.index(self.balloon.ActivateBalloonTool), ) def test_balloon_ai_analysis_is_registered_and_forwarded(self): import inspect self.assertIn(self.balloon.AnalyzeBalloon, self.addon.CLASSES) operator = self.balloon.ExtractBalloon self.assertIn("contour_analysis", operator.__annotations__) self.assertIn("depth_analysis", operator.__annotations__) self.assertIn("depth_model", operator.__annotations__) settings = self.addon.properties.AnyImageSettings.__annotations__ for name in ( "balloon_contour_analysis", "balloon_depth_analysis", ): self.assertIn(name, settings) self.assertFalse(settings[name]["default"]) self.assertIn("balloon_depth_model", settings) self.assertEqual(settings["balloon_contour_analysis"]["name"], "Contour") self.assertEqual(settings["balloon_depth_analysis"]["name"], "Depth") execute_source = inspect.getsource(operator.execute) invoke_source = inspect.getsource(operator.invoke) request_source = inspect.getsource(self.balloon.AnalyzeBalloon.request) response_source = inspect.getsource(self.balloon.AnalyzeBalloon.response) tool_source = inspect.getsource(self.balloon.BalloonTool.draw_settings) self.assertIn("bpy.ops.anyimage.analyze_balloon", execute_source) self.assertIn("prepare_image_input(", execute_source) self.assertIn( "settings.balloon_contour_analysis", invoke_source, ) self.assertIn( "settings.balloon_depth_analysis", invoke_source, ) self.assertIn("self.depth_model = settings.balloon_depth_model", invoke_source) self.assertIn("require_model(BEN2_MODEL_KEY)", request_source) self.assertIn( "require_model(self.depth_model)", request_source, ) self.assertIn('"polygon": polygon', request_source) self.assertIn('result.file("contour")', response_source) self.assertIn('result.file("depth")', response_source) self.assertIn("depth_data=depth_data", response_source) self.assertIn('push_undo("Extract Balloon")', response_source) self.assertIn('"balloon_contour_analysis"', tool_source) self.assertIn('"balloon_depth_analysis"', tool_source) self.assertIn('"balloon_depth_model"', tool_source) def test_balloon_analysis_requires_only_enabled_models(self): operator = self.balloon.AnalyzeBalloon() operator.input_path = "input.png" operator.selection_data = "[[0,0],[2,0],[0,2]]" operator.resolution_level = 5 operator.depth_model = "MOGE2_VITB_NORMAL" required = [] with ( patch.object(self.balloon, "require_environment"), patch.object( self.balloon, "require_model", side_effect=required.append, ), patch.object( self.balloon, "production_device", return_value="CUDA", ), ): operator.contour_analysis = True operator.depth_analysis = False contour_request = operator.request(None) operator.contour_analysis = False operator.depth_analysis = True depth_request = operator.request(None) self.assertEqual( required, [ self.addon.properties.BEN2_MODEL_KEY, "MOGE2_VITB_NORMAL", ], ) self.assertTrue(contour_request["contour_analysis"]) self.assertFalse(contour_request["depth_analysis"]) self.assertFalse(depth_request["contour_analysis"]) self.assertTrue(depth_request["depth_analysis"]) self.assertEqual(depth_request["depth_model"], "MOGE2_VITB_NORMAL") def test_balloon_depth_fields_and_shape_nodes(self): import inspect import numpy as np depth_data = { "depth_signal": np.asarray( ((1.0, -2.0), (0.5, 0.0)), dtype=np.float32, ), "baseline_vector": np.zeros(3, dtype=np.float32), "valid": np.ones((2, 2), dtype=bool), "origin": (0, 0), "crop_size": (2, 2), "source_size": (2, 2), } vertices = ( (0.0, 0.0, 2.0), (0.0, 0.0, 2.0), (0.0, 0.0, -2.0), (0.0, 0.0, -2.0), ) uv = ((0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0)) values, local_plane = self.balloon_depth.balloon_depth_fields( vertices, uv, depth_data, ) np.testing.assert_allclose(values, (1.5, -4.5, 0.5, -0.5)) self.assertAlmostEqual(float(np.median(values)), 0.0) np.testing.assert_allclose(local_plane, (0.0, 0.0, 0.0)) self.assertEqual( self.balloon.orient_balloon_vertices(((1.0, 2.0, 3.0),)), [(-3.0, -1.0, 2.0)], ) restored = np.asarray(self.balloon.BALLOON_AXIS_TO_SOURCE) @ np.asarray( (-3.0, -1.0, 2.0, 1.0) ) np.testing.assert_allclose(restored, (1.0, 2.0, 3.0, 1.0)) self.assertEqual( self.balloon.orient_balloon_base_plane((0.2, 0.3, 0.4)), (0.2, -0.3, -0.4), ) source = inspect.getsource(self.balloon_depth.balloon_shape_node_group) controller_source = inspect.getsource( self.balloon_depth.create_balloon_base_plane ) self.assertIn('"Thickness"', source) self.assertIn('"Depth Strength"', source) self.assertNotIn('"Far Clamp"', source) self.assertIn('"Double Sided Depth"', source) self.assertIn('"Base Plane"', source) self.assertIn('nodes.new("GeometryNodeObjectInfo")', source) self.assertIn("controller.parent = balloon", controller_source) self.assertIn( 'controller.empty_display_type = "SINGLE_ARROW"', controller_source ) self.assertIn('profile_factor.operation = "ABSOLUTE"', source) self.assertIn('profile_statistics.data_type = "FLOAT"', source) self.assertIn('profile_max_safe.operation = "MAXIMUM"', source) self.assertIn('profile_normalized.operation = "DIVIDE"', source) self.assertIn('profile_smooth.interpolation_type = "SMOOTHERSTEP"', source) self.assertIn('profile_active.operation = "MULTIPLY"', source) self.assertIn('effective_normal.input_type = "VECTOR"', source) self.assertNotIn("o_balloon_depth_mix", source) self.assertIn('front.operation = "LESS_THAN"', source) self.assertIn('depth_residual.operation = "ADD"', source) self.assertIn('depth_calibration.inputs[1].default_value = 0.5', source) self.assertIn('corrected_depth.operation = "ADD"', source) self.assertIn( 'profile_statistics.outputs["Max"], corrected_depth.inputs[1]', source, ) self.assertNotIn("baseline_thickness", source) self.assertIn('separate.outputs["X"], base_scale.inputs[0]', source) self.assertIn('double_choice.outputs["Output"], combine.inputs["X"]', source) self.assertIn('depth_mix = nodes.new("ShaderNodeMix")', source) self.assertIn('depth_mix.data_type = "FLOAT"', source) self.assertIn('depth_mix.clamp_factor = True', source) self.assertIn('base_magnitude.outputs[0], depth_mix.inputs["A"]', source) self.assertIn('depth_scale.outputs[0], depth_mix.inputs["B"]', source) self.assertNotIn("profile_inverse", source) self.assertNotIn("thickness_component", source) self.assertNotIn("depth_component", source) self.assertNotIn("blended_magnitude", source) self.assertIn('plane_offset.operation = "DOT_PRODUCT"', source) self.assertIn("controller.location = (0.0, 0.0, 0.0)", controller_source) self.assertNotIn("clamp_distance", source) self.assertNotIn("safety_clamped", source) self.assertNotIn("crossing_choice", source) self.assertIn('front_choice.input_type = "FLOAT"', source) self.assertIn('double_choice.input_type = "FLOAT"', source) self.assertNotIn("double_inverse", source) self.assertNotIn("depth_limited", source) self.assertNotIn("side_gate", source) self.assertIn('base_frame.label = "Base Thickness"', source) self.assertIn('plane_frame.label = "Middle Plane"', source) self.assertIn('mix_frame.label = "Depth Mix"', source) self.assertIn('output_frame.label = "Side Output"', source) self.assertIn('side.operation = "SIGN"', source) self.assertIn("BALLOON_DEPTH_ATTRIBUTE", source) self.assertEqual( self.balloon_depth.BALLOON_DEPTH_ATTRIBUTE, "o_balloon_depth", ) def test_balloon_redo_properties_use_mode_and_meter_resolution(self): import inspect spacing, inflation = self.balloon.balloon_geometry_parameters(2.0, 0.2) scene = SimpleNamespace( unit_settings=SimpleNamespace(scale_length=0.01), ) self.assertEqual(spacing, 0.2) self.assertEqual(inflation, 0.9) self.assertEqual( self.balloon.meters_to_world_distance(scene, 0.1), 10.0, ) operator = self.balloon.ExtractBalloon self.assertEqual(operator.bl_options, {"REGISTER", "UNDO"}) self.assertIn("mode", operator.__annotations__) self.assertIn("thickness", operator.__annotations__) self.assertIn("resolution", operator.__annotations__) self.assertIn("reuse_material", operator.__annotations__) self.assertIn("source_matrix_data", operator.__annotations__) settings = self.addon.properties.AnyImageSettings.__annotations__ self.assertIn("balloon_mode", settings) self.assertIn("balloon_thickness", settings) self.assertIn("balloon_resolution", settings) self.assertIn("balloon_reuse_material", settings) self.assertIn("balloon_thickness_radial_zoom", settings) self.assertIn("balloon_resolution_radial_zoom", settings) self.assertEqual( settings["balloon_thickness_radial_zoom"]["default"], (100.0, 100.0), ) self.assertEqual( settings["balloon_resolution_radial_zoom"]["default"], (1000.0, 1000.0), ) resolution_source = inspect.getsource( self.addon.properties.balloon_resolution_property ) thickness_source = inspect.getsource( self.addon.properties.balloon_thickness_property ) self.assertIn('name="Resolution (m)"', resolution_source) self.assertNotIn("subtype=", resolution_source) self.assertNotIn("unit=", resolution_source) self.assertIn("soft_max=5.0", thickness_source) self.assertNotIn(" max=", thickness_source) def test_balloon_workspace_tool_uses_one_top_level_operator_per_stroke(self): import inspect execute_source = inspect.getsource(self.balloon.ExtractBalloon.execute) invoke_source = inspect.getsource(self.balloon.ExtractBalloon.invoke) modal_source = inspect.getsource(self.balloon.ExtractBalloon.modal) self.assertIn("json.loads(self.selection_data)", execute_source) self.assertIn("deserialize_matrix(self.source_matrix_data)", execute_source) self.assertIn("extract_balloon_object(", execute_source) self.assertIn("select_result=False", execute_source) self.assertIn("reuse_material=self.reuse_material", execute_source) self.assertIn("meters_to_world_distance(", execute_source) self.assertIn("serialize_matrix(source_object.matrix_world)", invoke_source) self.assertIn("settings.balloon_thickness", invoke_source) self.assertIn("settings.balloon_resolution", invoke_source) self.assertIn("settings.balloon_reuse_material", invoke_source) self.assertIn("return self.execute(context)", modal_source) self.assertIn('return {"RUNNING_MODAL"}', modal_source) self.assertIn('return {"PASS_THROUGH"}', modal_source) self.assertNotIn("bpy.ops.anyimage.extract_balloon", modal_source) tool = self.balloon.BalloonTool self.assertEqual(tool.bl_space_type, "VIEW_3D") self.assertEqual(tool.bl_context_mode, "OBJECT") self.assertEqual(tool.bl_operator, self.balloon.ExtractBalloon.bl_idname) self.assertEqual(tool.bl_icon, "ops.mesh.polybuild_hover") self.assertEqual(tool.bl_cursor, "PAINT_BRUSH") keymap_operators = tuple(item[0] for item in tool.bl_keymap) self.assertEqual(keymap_operators[0], self.balloon.ExtractBalloon.bl_idname) self.assertEqual( keymap_operators[1:], ("wm.radial_control", "wm.radial_control"), ) self.assertEqual( tool.bl_keymap[1][2]["properties"], [ ( "data_path_primary", "scene.anyimage_settings.balloon_thickness", ), ( "zoom_path", "scene.anyimage_settings.balloon_thickness_radial_zoom", ), ], ) self.assertEqual( tool.bl_keymap[2][2]["properties"], [ ( "data_path_primary", "scene.anyimage_settings.balloon_resolution", ), ( "zoom_path", "scene.anyimage_settings.balloon_resolution_radial_zoom", ), ], ) def test_balloon_draw_handlers_are_filtered_to_the_invoking_viewport(self): import inspect source = inspect.getsource(self.balloon.drawing_in_region) operator_source = inspect.getsource(self.balloon.ExtractBalloon) self.assertIn("area.as_pointer() == area_pointer", source) self.assertIn("region.as_pointer() == region_pointer", source) self.assertIn("drawing_in_region(", operator_source) self.assertNotIn("wireframe_opacity", operator_source) self.assertNotIn("shading.type", operator_source) def test_balloon_matrix_serialization_keeps_all_components(self): import json matrix = tuple( tuple(row * 4 + column for column in range(4)) for row in range(4) ) values = json.loads(self.balloon.serialize_matrix(matrix)) self.assertEqual(values, list(range(16))) def test_extract_balloon_preserves_the_source_object(self): import inspect source = inspect.getsource(self.balloon.extract_balloon_object) self.assertNotIn("objects.remove(source_object", source) self.assertIn("balloon_name = source_name", source) self.assertIn( "selected_object = balloon if select_result else source_object", source ) self.assertIn( "balloon.matrix_world = orient_balloon_world_matrix(matrix_world)", source, ) self.assertIn("create_material(", source) self.assertIn("material_name = material_data_name(source_object)", source) self.assertIn( "normal_required=uses_normal", source, ) self.assertIn("mark_balloon_material(material, image, normal_image)", source) self.assertIn("merge_balloon_normal_image(normal_image, update_normal)", source) self.assertIn("if reuse_material", source) self.assertIn("color_image = image.copy()", source) self.assertIn("color_image.name = color_name", source) self.assertIn( "material_name,\n color_image,\n 1.0,", source, ) self.assertIn( "else TEXTURE_MATERIAL_NODE_GROUP_NAME", source, ) self.assertIn("owns_material", source) self.assertIn("owns_color_image", source) self.assertIn("balloon_depth_fields(", source) self.assertIn("assign_balloon_shape_attributes(", source) self.assertIn("inflation=INFLATION", source) self.assertIn("create_balloon_base_plane(", source) self.assertIn( "add_balloon_shape_modifier(balloon, thickness, base_plane)", source ) self.assertIn("BALLOON_MATERIAL_NODE_GROUP_NAME", source) self.assertIn("normal_path=", source) self.assertEqual( self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME, "O Texture Layer", ) def test_balloon_material_reuse_requires_matching_image_and_node_group(self): class MarkedMaterial(dict): def __init__(self, name, **properties): super().__init__(properties) self.name = name self.node_tree = None color_image = SimpleNamespace(name="cow.color") other_image = SimpleNamespace(name="other") texture_node = SimpleNamespace(type="TEX_IMAGE", image=color_image) texture_layer = SimpleNamespace( type="GROUP", node_tree=SimpleNamespace(name="O Texture Layer"), ) compatible = SimpleNamespace( name="cow.001", node_tree=SimpleNamespace(nodes=[texture_node, texture_layer]), ) marked = MarkedMaterial( "unrelated-name", anyimage_balloon_material=True, anyimage_source_image=color_image, ) marked_other = MarkedMaterial( "cow", anyimage_balloon_material=True, anyimage_source_image=other_image, ) normal_image = SimpleNamespace(name="cow.normal") marked_normal = MarkedMaterial( "depth-material", anyimage_balloon_material=True, anyimage_source_image=color_image, anyimage_balloon_normal_image=normal_image, ) wrong_group = SimpleNamespace( name="cow", node_tree=SimpleNamespace( nodes=[ texture_node, SimpleNamespace( type="GROUP", node_tree=SimpleNamespace(name="O DA3 Surface Layer"), ), ] ), ) self.fake_bpy.data = SimpleNamespace( objects=SimpleNamespace(get=lambda _name: None), materials=[ marked_other, wrong_group, compatible, marked_normal, marked, ], images=[color_image], ) self.assertIs( self.balloon.reusable_balloon_material( "cow", "cow.color", color_image, ), marked, ) self.assertIs( self.balloon.reusable_balloon_material("cow", "cow.color"), compatible, ) self.assertIs( self.balloon.reusable_balloon_material( "cow", "cow.color", color_image, normal_required=True, ), marked_normal, ) self.assertIs( self.balloon.reusable_color_image("cow.color"), color_image, ) def test_surface_and_depth_plane_material_names_use_image_data(self): import inspect surface_source = inspect.getsource(self.image_mesh.build_image_mesh) original_depth_source = inspect.getsource( self.depth_build.build_image_depth_plane ) depth_source = inspect.getsource(self.depth_build.build_depth_plane) expected = "material_data_name(source_object)" self.assertIn(expected, surface_source) self.assertIn(expected, original_depth_source) self.assertIn(expected, depth_source) def test_image_operators_require_environment_and_required_models(self): context = SimpleNamespace( object=SimpleNamespace(data=SimpleNamespace()), ) operators = ( (self.depth_operator, self.depth_operator.ConvertToDepthPlane), (self.image_mesh, self.image_mesh.ConvertToSurface), (self.image_background, self.image_background.RemoveImageBackground), ) states = ( (False, True, False), (True, False, False), (True, True, True), ) for environment_ready, models_ready, expected in states: with ( self.subTest( environment_ready=environment_ready, models_ready=models_ready, expected=expected, ), patch.object( self.addon.runtime, "environment_ready", return_value=environment_ready, ), patch.object( self.addon.runtime, "server_busy", return_value=False, ), patch( "anyimage.properties.required_models_ready", return_value=models_ready, ), ): for module, operator in operators: with ( self.subTest(operator=operator.__name__), patch.object( module, "is_image_empty", return_value=True, ), patch.object( module, "video_total_frames", return_value=1, ), ): self.assertEqual(operator.poll(context), expected) def test_ui_reads_required_model_files_without_starting_server(self): properties = importlib.import_module("anyimage.properties") with tempfile.TemporaryDirectory() as directory: root = Path(directory) moge = root / "models" / "moge-2-vits-normal-onnx" ben2 = root / "models" / "BEN2-ONNX" / "onnx" moge.mkdir(parents=True) ben2.mkdir(parents=True) (moge / "model.onnx").touch() (ben2 / "model_fp16.onnx").touch() with ( patch.object( self.addon.runtime, "storage_root", return_value=root, ), patch.object( self.addon.runtime, "resource", side_effect=AssertionError("UI must not start the Server"), ), ): self.assertTrue(properties.required_models_ready()) def test_runtime_request_uses_request_response_shape(self): result = object() responses = [] with patch.object( self.addon.runtime.server, "request", return_value=result, ) as request: returned = self.addon.runtime.request( "example", {"value": 3}, response=responses.append, ) self.assertIs(returned, result) request.assert_called_once_with( "example", {"value": 3}, response=responses.append, progress=None, cancel_check=None, ) def test_server_request_returns_job_result_and_calls_response(self): controller_type = importlib.import_module( "blendjob.controller" ).ServerController controller = controller_type.__new__(controller_type) statuses = iter( ( {"state": "running", "progress": 0.5}, { "state": "succeeded", "progress": 1.0, "result": {"value": 7}, }, ) ) controller.submit = lambda _job_type, _parameters: { "job_id": "job", "directory": ".", } controller.status = lambda _job_id: next(statuses) controller.mark_job_complete = lambda _job_id: None responses = [] result = controller.request( "example", {"input": 3}, response=responses.append, poll_interval=0.01, ) self.assertEqual(result.value, {"value": 7}) self.assertEqual(responses, [result]) def test_runtime_keeps_server_as_an_entrypoint_string(self): path, attribute = self.addon.runtime.server_entrypoint.rsplit(":", 1) self.assertEqual(Path(path).name, "__init__.py") self.assertEqual(Path(path).parent.name, "server") self.assertEqual(attribute, "server") def test_runtime_registers_system_operators_and_status_bar(self): self.addon.register() system_classes = self.addon.runtime.operator_classes() self.assertTrue(set(system_classes) <= set(self.registered)) self.assertEqual( self.registered_tools, [ ( self.balloon.BalloonTool, { "separator": False, }, ) ], ) self.assertEqual(len(self.status_bar_draws), 1) self.assertEqual(self.status_bar_draws[0]._owner, "anyimage") self.addon.unregister() self.assertEqual(self.registered, []) self.assertEqual(self.registered_tools, []) self.assertEqual(self.status_bar_draws, []) def test_status_bar_reads_runtime_state(self): calls = [] class Layout: def row(self, **_options): return self def progress(self, **options): calls.append(("progress", options)) def operator(self, identifier, **_options): calls.append(("operator", identifier)) runtime = self.addon.runtime runtime.active_job = object() runtime.progress = 0.4 runtime.message = "Working" runtime._draw_status_bar(SimpleNamespace(layout=Layout()), None) runtime.active_job = None self.assertEqual(calls[0][1]["factor"], 0.4) self.assertEqual(calls[0][1]["text"], "Working") self.assertEqual(calls[1], ("operator", "anyimage.cancel_job")) def test_moge_depth_request_is_written_on_the_operator(self): operator = self.addon.operators.GenerateMoge2Depth() operator.input_path = __file__ operator.source_object_name = "" operator.model = "MOGE2_VITS_NORMAL" operator.resolution_level = 6 operator.remove_background = False settings = SimpleNamespace(video_frames=12) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) with ( patch.object(self.depth_operator, "require_environment"), patch.object(self.depth_operator, "require_model"), ): parameters = operator.request(context) self.assertEqual(parameters["input"], __file__) self.assertEqual(parameters["model"], "MOGE2_VITS_NORMAL") self.assertEqual(parameters["resolution_level"], 6) self.assertNotIn("job_type", parameters) def test_moge_depth_response_calls_an_ordinary_function(self): operator = self.addon.operators.GenerateMoge2Depth() operator.source_object_name = "Reference" operator.remove_background = False operator.delete_source = True operator.shadeless = False result = object() context = object() with patch.object(self.depth_operator, "build_moge2_depth_response") as build: operator.response(context, result) build.assert_called_once_with( context, result, source_object_name="Reference", remove_background=False, delete_source=True, shadeless=False, ) def test_entry_operator_dispatches_plain_properties(self): operator = self.depth_operator.ConvertToDepthPlane() operator.model = "MOGE2_VITS_NORMAL" operator.resolution_level = 7 operator.keep_original = False operator.shadeless = True operator.remove_background = False source_object = SimpleNamespace( name="Reference", data=SimpleNamespace(source="FILE"), ) settings = SimpleNamespace(video_frames=1) context = SimpleNamespace( object=source_object, scene=SimpleNamespace(anyimage_settings=settings), ) calls = [] self.fake_bpy.ops = SimpleNamespace( anyimage=SimpleNamespace( generate_moge2_depth=lambda **options: ( calls.append(options) or {"RUNNING_MODAL"} ) ) ) with patch.object( self.depth_operator, "prepare_image_input", return_value=(Path(__file__), False), ): self.assertEqual(operator.execute(context), {"FINISHED"}) self.assertEqual(calls[0]["resolution_level"], 7) self.assertEqual(calls[0]["source_object_name"], "Reference") self.assertFalse(any(key.startswith("anyimage_") for key in calls[0])) if __name__ == "__main__": unittest.main()