diff --git a/blender/agent-harness/cli_anything/blender/tests/test_bpy_gen.py b/blender/agent-harness/cli_anything/blender/tests/test_bpy_gen.py new file mode 100644 index 000000000..fa7a9c001 --- /dev/null +++ b/blender/agent-harness/cli_anything/blender/tests/test_bpy_gen.py @@ -0,0 +1,120 @@ +"""Tests for bpy_gen.py — Blender Python script generation. + +Tests verify that generated scripts are syntactically valid Python, +especially with Windows-style paths that could trigger escape errors. +""" + +from typing import Any, Dict + +import pytest + +from cli_anything.blender.utils.bpy_gen import generate_full_script + + +def _minimal_project() -> Dict[str, Any]: + """Return a minimal project dict sufficient for script generation.""" + return { + "version": "1.0", + "scene": { + "unit_system": "METRIC", + "unit_scale": 1.0, + "frame_start": 1, + "frame_end": 250, + "frame_current": 1, + "fps": 24, + }, + "render": { + "engine": "CYCLES", + "resolution_x": 1920, + "resolution_y": 1080, + "resolution_percentage": 100, + "samples": 128, + "use_denoising": True, + "output_format": "PNG", + }, + "objects": [], + "cameras": [], + "lights": [], + "materials": [], + "world": { + "color": [0.0, 0.0, 0.0], + "use_hdri": False, + }, + "keyframes": [], + } + + +class TestGeneratedScriptSyntax: + """Generated scripts must produce valid Python, even on Windows.""" + + @pytest.mark.parametrize("output_path", [ + r"C:\Users\user\Desktop\render.png", + r"D:\data\scene\output.png", + r"\\server\share\render_0001.png", + r"C:\Users\test\AppData\Local\file.png", + "/home/user/render.png", + "./relative/render.png", + ]) + def test_windows_paths_produce_valid_python(self, output_path: str) -> None: + """Windows-style backslash paths must not cause SyntaxError.""" + project = _minimal_project() + script = generate_full_script(project, output_path) + + # compile() will raise SyntaxError if the script is invalid + try: + compile(script, "", "exec") + except SyntaxError as e: + pytest.fail( + f"Generated script has SyntaxError with path {output_path!r}:\n" + f"{e}\n" + f"--- script excerpt around error ---\n" + f"{_excerpt(e, script)}" + ) + + def test_trailing_backslash_path(self) -> None: + """Paths ending in backslash must be handled correctly (raw-string edge case).""" + project = _minimal_project() + script = generate_full_script(project, "C:\\Users\\user\\output_dir\\") + compile(script, "", "exec") + + def test_path_with_quotes(self) -> None: + """Paths containing single quotes must not break generated script.""" + project = _minimal_project() + script = generate_full_script(project, "C:\\Users\\o'brien\\output.png") + compile(script, "", "exec") + + def test_hdri_path_with_windows_slashes(self) -> None: + """HDRI paths on Windows must not cause SyntaxError.""" + project = _minimal_project() + project["world"]["use_hdri"] = True + project["world"]["hdri_path"] = r"C:\Users\user\assets\env.hdr" + project["world"]["hdri_strength"] = 1.0 + script = generate_full_script(project, r"C:\Users\user\output.png") + compile(script, "", "exec") + + def test_print_statement_with_windows_path(self) -> None: + """The 'Render complete:' print line must not cause Unicode escape error.""" + project = _minimal_project() + script = generate_full_script(project, r"C:\Users\u\output.png") + compile(script, "", "exec") + assert "Render complete:" in script + + def test_normal_unix_paths_still_work(self) -> None: + """Existing Unix path behavior must not regress.""" + project = _minimal_project() + script = generate_full_script(project, "/tmp/render.png") + compile(script, "", "exec") + + +def _excerpt(err: SyntaxError, script: str) -> str: + """Return a short excerpt of the script around the syntax error.""" + lines = script.splitlines() + lineno = err.lineno or 1 + start = max(0, lineno - 3) + end = min(len(lines), lineno + 2) + excerpt = lines[start:end] + result = f" Line {start + 1} to {end}:\n" + for i, line in enumerate(excerpt, start=start + 1): + marker = " >>> " if i == lineno else " " + result += f"{marker}{i}: {line}\n" + return result diff --git a/blender/agent-harness/cli_anything/blender/utils/bpy_gen.py b/blender/agent-harness/cli_anything/blender/utils/bpy_gen.py index d5e3344af..ee34134f5 100644 --- a/blender/agent-harness/cli_anything/blender/utils/bpy_gen.py +++ b/blender/agent-harness/cli_anything/blender/utils/bpy_gen.py @@ -144,7 +144,7 @@ def _gen_world_settings(project: Dict[str, Any]) -> List[str]: "", "# HDRI environment", "env_tex = world.node_tree.nodes.new('ShaderNodeTexEnvironment')", - f"env_tex.image = bpy.data.images.load(r'{hdri_path}')", + f"env_tex.image = bpy.data.images.load({hdri_path!r})", f"bg_node.inputs[1].default_value = {strength}", "world.node_tree.links.new(env_tex.outputs[0], bg_node.inputs[0])", ]) @@ -532,7 +532,7 @@ def _gen_render_output( lines = [ "# ── Render Output ───────────────────────────────────────────", f"scene.render.image_settings.file_format = '{bpy_format}'", - f"scene.render.filepath = r'{output_path}'", + f"scene.render.filepath = {output_path!r}", ] if animation: @@ -552,7 +552,7 @@ def _gen_render_output( lines.extend([ "", - f"print('Render complete: {output_path}')", + f"print('Render complete: ' + {output_path!r})", ]) return lines