Files
HartOMat/backend/tests/domains/test_rendering_service.py
T

1447 lines
50 KiB
Python

"""Tests for rendering domain — workflow builder + task helpers."""
import uuid
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from app.core.render_paths import build_order_line_export_path, build_order_line_step_render_path
# ---------------------------------------------------------------------------
# workflow_builder unit tests (no DB required)
# ---------------------------------------------------------------------------
def test_dispatch_workflow_unknown_type_raises():
from app.domains.rendering.workflow_builder import dispatch_workflow
with pytest.raises(ValueError, match="Unknown workflow type"):
dispatch_workflow("nonexistent_type", str(uuid.uuid4()))
def test_build_still_returns_chain():
"""_build_still returns a Celery chain wrapping render_order_line_still_task."""
from celery import chain
from app.domains.rendering.workflow_builder import _build_still
canvas = _build_still(str(uuid.uuid4()), {})
# A single-task chain is still a Celery Signature, not a plain chain, but
# it should be callable / have apply_async
assert hasattr(canvas, "apply_async")
def test_build_multi_angle_creates_group():
"""_build_multi_angle returns a Celery group with one sig per angle."""
from celery import group
from app.domains.rendering.workflow_builder import _build_multi_angle
order_line_id = str(uuid.uuid4())
canvas = _build_multi_angle(order_line_id, {"angles": [0, 90, 180]})
# group has tasks attribute
assert hasattr(canvas, "tasks")
assert len(canvas.tasks) == 3
def test_build_still_with_exports_is_chain():
"""_build_still_with_exports returns a chain."""
from app.domains.rendering.workflow_builder import _build_still_with_exports
canvas = _build_still_with_exports(str(uuid.uuid4()), {})
assert hasattr(canvas, "apply_async")
def test_build_turntable_raises_without_step_path():
"""_build_turntable raises ValueError if step_path missing in params."""
from app.domains.rendering.workflow_builder import _build_turntable
with pytest.raises(ValueError, match="step_path"):
_build_turntable(str(uuid.uuid4()), {})
def test_build_turntable_raises_without_output_dir():
from app.domains.rendering.workflow_builder import _build_turntable
with pytest.raises(ValueError, match="output_dir"):
_build_turntable(str(uuid.uuid4()), {"step_path": "/tmp/test.stp"})
# ---------------------------------------------------------------------------
# _resolve_step_path_for_order_line — unit-tests with DB (integration)
# ---------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.asyncio
async def test_resolve_step_path_returns_none_for_missing_line(db):
"""Returns (None, None) for a line_id that doesn't exist."""
from app.domains.rendering.tasks import _resolve_step_path_for_order_line
import asyncio
result = _resolve_step_path_for_order_line(str(uuid.uuid4()))
assert result == (None, None)
# ---------------------------------------------------------------------------
# publish_asset (unit test with mocked DB)
# ---------------------------------------------------------------------------
def test_publish_asset_signature():
"""publish_asset is importable and is a bound Celery task."""
from app.domains.rendering.tasks import publish_asset
assert callable(publish_asset)
assert hasattr(publish_asset, "delay")
# ---------------------------------------------------------------------------
# generate_gltf_geometry_task — smoke test (unit)
# ---------------------------------------------------------------------------
def test_generate_gltf_geometry_task_importable():
from app.tasks.step_tasks import generate_gltf_geometry_task
assert callable(generate_gltf_geometry_task)
assert hasattr(generate_gltf_geometry_task, "delay")
def test_build_ffmpeg_cmd_prefers_prefixed_frame_sequence(tmp_path):
from app.domains.rendering.tasks import _build_ffmpeg_cmd
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
(frames_dir / "frame_0001.png").write_text("png", encoding="utf-8")
cmd = _build_ffmpeg_cmd(frames_dir, tmp_path / "turntable.mp4")
assert any("frame_%04d.png" in part for part in cmd)
def test_build_ffmpeg_cmd_falls_back_to_legacy_frame_sequence(tmp_path):
from app.domains.rendering.tasks import _build_ffmpeg_cmd
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
cmd = _build_ffmpeg_cmd(frames_dir, tmp_path / "turntable.mp4")
assert any(part.endswith("%04d.png") for part in cmd)
assert not any("frame_%04d.png" in part for part in cmd)
def test_build_ffmpeg_cmd_limits_bg_color_overlay_to_frame_sequence(tmp_path):
from app.domains.rendering.tasks import _build_ffmpeg_cmd
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
(frames_dir / "frame_0001.png").write_text("png", encoding="utf-8")
cmd = _build_ffmpeg_cmd(
frames_dir,
tmp_path / "turntable.mp4",
fps=24,
bg_color="#ffffff",
width=512,
height=512,
)
assert "-filter_complex" in cmd
filter_index = cmd.index("-filter_complex") + 1
assert cmd[filter_index] == "[1:v][0:v]overlay=0:0:shortest=1"
assert "color=c=0xffffff:size=512x512:rate=24" in cmd
assert "-crf" in cmd
assert cmd[cmd.index("-crf") + 1] == "18"
# ---------------------------------------------------------------------------
# New order-line tasks are importable and correctly registered
# ---------------------------------------------------------------------------
def test_render_order_line_still_task_importable():
from app.domains.rendering.tasks import render_order_line_still_task
assert render_order_line_still_task.name == "app.domains.rendering.tasks.render_order_line_still_task"
assert render_order_line_still_task.queue == "asset_pipeline"
def test_export_blend_for_order_line_task_importable():
from app.domains.rendering.tasks import export_blend_for_order_line_task
assert export_blend_for_order_line_task.queue == "asset_pipeline"
def test_normalize_order_line_still_params_maps_legacy_editor_fields():
from app.domains.rendering.tasks import _normalize_order_line_still_params
normalized = _normalize_order_line_still_params(
{
"render_engine": "cycles",
"samples": 256,
"resolution": [1920, 1080],
"rotation_z": 45,
"usd_path": "/app/uploads/step_files/example_master.usd",
}
)
assert normalized["engine"] == "cycles"
assert "render_engine" not in normalized
assert normalized["width"] == 1920
assert normalized["height"] == 1080
assert "resolution" not in normalized
assert normalized["rotation_z"] == 45
assert str(normalized["usd_path"]) == "/app/uploads/step_files/example_master.usd"
def test_normalize_order_line_still_params_drops_graph_only_override_flag():
from app.domains.rendering.tasks import _normalize_order_line_still_params
normalized = _normalize_order_line_still_params(
{
"use_custom_render_settings": True,
"width": 640,
"height": 640,
}
)
assert "use_custom_render_settings" not in normalized
assert normalized["width"] == 640
assert normalized["height"] == 640
def test_normalize_order_line_still_params_drops_graph_control_params():
from app.domains.rendering.tasks import _normalize_order_line_still_params
normalized = _normalize_order_line_still_params(
{
"width": 640,
"graph_notify_node_ids": ["notify"],
"graph_output_node_ids": ["output"],
"workflow_run_id": "run-1",
"workflow_node_id": "render",
"emit_legacy_notifications": True,
}
)
assert normalized == {"width": 640}
def test_resolve_order_line_still_output_extension_uses_output_type_contract(monkeypatch):
from app.domains.rendering.tasks import _resolve_order_line_still_output_extension
line = SimpleNamespace(output_type=SimpleNamespace(output_format="webp"), render_overrides=None)
db = MagicMock()
db.execute.return_value.scalar_one_or_none.return_value = line
class _SessionCtx:
def __enter__(self):
return db
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("app.core.db_utils.get_sync_session", lambda: _SessionCtx())
assert _resolve_order_line_still_output_extension("line-1") == "webp"
def test_render_order_line_still_task_finalizes_non_png_outputs(tmp_path, monkeypatch):
from app.domains.rendering.tasks import render_order_line_still_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-webp-save"))
render_calls: list[dict] = []
def _fake_render_to_file(step_path, output_path, **kwargs):
render_calls.append(
{
"step_path": step_path,
"output_path": output_path,
**kwargs,
}
)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_text("WEBP", encoding="utf-8")
return True, {"renderer": "blender", "engine_used": "cycles", "total_duration_s": 0.5}
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_order_line_still_output_extension",
lambda order_line_id, params=None: "webp",
)
monkeypatch.setattr("app.services.step_processor.render_to_file", _fake_render_to_file)
monkeypatch.setattr("app.domains.rendering.tasks._update_workflow_run_status", lambda *args, **kwargs: None)
monkeypatch.setattr("app.domains.rendering.tasks._finalize_graph_still_output", lambda *args, **kwargs: None)
result = render_order_line_still_task.run.__func__(
task_self,
"line-webp",
job_document_enabled=False,
emit_events=False,
publish_asset_enabled=False,
)
expected_output = build_order_line_step_render_path(step_path, "line-webp", "line_line-webp.webp")
assert render_calls == [
{
"step_path": str(step_path),
"output_path": str(expected_output),
"order_line_id": "line-webp",
}
]
assert result["output_path"] == str(expected_output)
def test_finalise_image_converts_png_to_jpg(tmp_path):
from PIL import Image
from app.services.step_processor import _finalise_image
src = tmp_path / "source.png"
dst = tmp_path / "final.jpg"
Image.new("RGBA", (4, 4), (10, 20, 30, 255)).save(src, "PNG")
result = _finalise_image(src, dst)
assert result == dst
assert dst.exists()
assert not src.exists()
with Image.open(dst) as img:
assert img.format == "JPEG"
assert img.mode == "RGB"
def test_finalise_image_flattens_transparency_for_jpg(tmp_path):
from PIL import Image
from app.services.step_processor import _finalise_image
src = tmp_path / "source.png"
dst = tmp_path / "final.jpg"
Image.new("RGBA", (2, 2), (0, 0, 0, 0)).save(src, "PNG")
result = _finalise_image(src, dst)
assert result == dst
with Image.open(dst) as img:
assert img.format == "JPEG"
assert img.getpixel((0, 0)) == (255, 255, 255)
assert img.getpixel((1, 1)) == (255, 255, 255)
def test_render_order_line_still_task_uses_graph_authoritative_output_handoff(tmp_path, monkeypatch):
from app.domains.rendering.tasks import publish_asset, render_order_line_still_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-graph-save"))
finalize_calls: list[dict] = []
notify_finalize_calls: list[dict] = []
notify_emit_calls: list[dict] = []
publish_calls: list[tuple] = []
render_calls: list[dict] = []
def _fake_render_to_file(step_path, output_path, **kwargs):
render_calls.append(
{
"step_path": step_path,
"output_path": output_path,
**kwargs,
}
)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_text("PNG", encoding="utf-8")
return True, {"renderer": "blender", "engine_used": "cycles", "total_duration_s": 0.5}
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_order_line_still_output_extension",
lambda order_line_id, params=None: "png",
)
monkeypatch.setattr("app.services.step_processor.render_to_file", _fake_render_to_file)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_still_output",
lambda order_line_id, **kwargs: finalize_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_notify_nodes",
lambda **kwargs: notify_finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._emit_graph_render_notifications",
lambda order_line_id, **kwargs: notify_emit_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
result = render_order_line_still_task.run.__func__(
task_self,
"line-1",
workflow_run_id="run-1",
workflow_node_id="render",
publish_asset_enabled=False,
graph_authoritative_output_enabled=True,
graph_output_node_ids=["output"],
graph_notify_node_ids=["notify"],
job_document_enabled=False,
emit_events=False,
emit_legacy_notifications=True,
width=640,
height=480,
)
expected_output = build_order_line_step_render_path(step_path, "line-1", "line_line-1.png")
assert result["renderer"] == "blender"
assert publish_calls == []
assert render_calls == [
{
"step_path": str(step_path),
"output_path": str(expected_output),
"order_line_id": "line-1",
"width": 640,
"height": 480,
}
]
assert finalize_calls == [
{
"order_line_id": "line-1",
"success": True,
"output_path": str(expected_output),
"render_log": result,
"workflow_run_id": "run-1",
"output_node_ids": ["output"],
"render_node_id": "render",
}
]
assert notify_emit_calls == [
{
"order_line_id": "line-1",
"success": True,
"render_log": result,
}
]
assert notify_finalize_calls == [
{
"workflow_run_id": "run-1",
"notify_node_ids": ["notify"],
"success": True,
"render_node_id": "render",
}
]
def test_render_order_line_still_task_uses_shadow_observer_output_handoff(tmp_path, monkeypatch):
from app.domains.rendering.tasks import publish_asset, render_order_line_still_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-shadow-save"))
observer_finalize_calls: list[dict] = []
publish_calls: list[tuple] = []
def _fake_render_to_file(step_path, output_path, **kwargs):
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_text("PNG", encoding="utf-8")
return True, {"renderer": "blender", "engine_used": "cycles", "total_duration_s": 0.5}
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_order_line_still_output_extension",
lambda order_line_id, params=None: "png",
)
monkeypatch.setattr("app.services.step_processor.render_to_file", _fake_render_to_file)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_shadow_still_output",
lambda order_line_id, **kwargs: observer_finalize_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
result = render_order_line_still_task.run.__func__(
task_self,
"line-shadow",
workflow_run_id="run-shadow",
workflow_node_id="render",
publish_asset_enabled=False,
observer_output_enabled=True,
graph_output_node_ids=["output"],
job_document_enabled=False,
emit_events=False,
)
expected_output = build_order_line_step_render_path(step_path, "line-shadow", "line_line-shadow.png")
assert result["renderer"] == "blender"
assert publish_calls == []
assert observer_finalize_calls == [
{
"order_line_id": "line-shadow",
"success": True,
"output_path": str(expected_output),
"render_log": result,
"workflow_run_id": "run-shadow",
"output_node_ids": ["output"],
"render_node_id": "render",
}
]
def test_render_order_line_still_task_publishes_asset_without_graph_authoritative_handoff(
tmp_path,
monkeypatch,
):
from app.domains.rendering.tasks import publish_asset, render_order_line_still_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-publish"))
finalize_calls: list[dict] = []
publish_calls: list[tuple] = []
def _fake_render_to_file(step_path, output_path, **kwargs):
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_text("PNG", encoding="utf-8")
return True, {
"renderer": "blender",
"engine_used": "cycles",
"total_duration_s": 0.5,
}
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr("app.services.step_processor.render_to_file", _fake_render_to_file)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_still_output",
lambda *args, **kwargs: finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
render_order_line_still_task.run.__func__(
task_self,
"line-2",
workflow_run_id="run-2",
workflow_node_id="render",
publish_asset_enabled=True,
graph_authoritative_output_enabled=False,
job_document_enabled=False,
emit_events=False,
width=640,
height=480,
)
expected_output = build_order_line_step_render_path(step_path, "line-2", "line_line-2.png")
assert finalize_calls == []
assert publish_calls == [
(
(
"line-2",
"still",
str(expected_output),
),
{
"render_config": {
"renderer": "blender",
"engine_used": "cycles",
"total_duration_s": 0.5,
"output_path": str(expected_output),
},
"workflow_run_id": "run-2",
},
)
]
def test_export_blend_for_order_line_task_uses_graph_authoritative_output_handoff(
tmp_path,
monkeypatch,
):
from app.config import settings
from app.domains.rendering.tasks import export_blend_for_order_line_task, publish_asset
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
glb_path = step_path.parent / "bearing_render.glb"
glb_path.write_text("GLB", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-blend-graph"))
finalize_calls: list[dict] = []
notify_finalize_calls: list[dict] = []
notify_emit_calls: list[dict] = []
publish_calls: list[tuple] = []
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr(
"app.services.render_blender.build_tessellated_glb_path",
lambda *args, **kwargs: glb_path,
)
monkeypatch.setattr(
"subprocess.run",
lambda *args, **kwargs: SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_blend_output",
lambda order_line_id, **kwargs: finalize_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_notify_nodes",
lambda **kwargs: notify_finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._emit_graph_render_notifications",
lambda order_line_id, **kwargs: notify_emit_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
result = export_blend_for_order_line_task.run.__func__(
task_self,
"line-3",
workflow_run_id="run-3",
workflow_node_id="blend",
publish_asset_enabled=False,
graph_authoritative_output_enabled=True,
graph_output_node_ids=["output"],
graph_notify_node_ids=["notify"],
emit_legacy_notifications=True,
)
expected_blend_path = build_order_line_export_path("line-3", "bearing_production.blend")
assert result == {
"blend_path": str(expected_blend_path),
"artifact_type": "blend_production",
}
assert publish_calls == []
assert finalize_calls == [
{
"order_line_id": "line-3",
"success": True,
"output_path": str(expected_blend_path),
"render_log": result,
"workflow_run_id": "run-3",
"output_node_ids": ["output"],
"render_node_id": "blend",
}
]
assert notify_emit_calls == [
{
"order_line_id": "line-3",
"success": True,
"render_log": result,
}
]
assert notify_finalize_calls == [
{
"workflow_run_id": "run-3",
"notify_node_ids": ["notify"],
"success": True,
"render_node_id": "blend",
}
]
def test_export_blend_for_order_line_task_publishes_without_graph_authoritative_handoff(
tmp_path,
monkeypatch,
):
from app.config import settings
from app.domains.rendering.tasks import export_blend_for_order_line_task, publish_asset
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
glb_path = step_path.parent / "bearing_render.glb"
glb_path.write_text("GLB", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-blend-publish"))
finalize_calls: list[dict] = []
publish_calls: list[tuple] = []
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr(
"app.services.render_blender.build_tessellated_glb_path",
lambda *args, **kwargs: glb_path,
)
monkeypatch.setattr(
"subprocess.run",
lambda *args, **kwargs: SimpleNamespace(returncode=0, stderr=""),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_blend_output",
lambda *args, **kwargs: finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
export_blend_for_order_line_task.run.__func__(
task_self,
"line-4",
workflow_run_id="run-4",
workflow_node_id="blend",
publish_asset_enabled=True,
graph_authoritative_output_enabled=False,
)
expected_blend_path = build_order_line_export_path("line-4", "bearing_production.blend")
assert finalize_calls == []
assert publish_calls == [
(
(
"line-4",
"blend_production",
str(expected_blend_path),
),
{
"workflow_run_id": "run-4",
},
)
]
def test_render_turntable_task_uses_graph_authoritative_output_handoff(
tmp_path,
monkeypatch,
):
from app.domains.rendering.tasks import publish_asset, render_turntable_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-turntable-graph"))
expected_output_mp4 = build_order_line_step_render_path(step_path, "line-5", "preview.mp4")
finalize_calls: list[dict] = []
notify_finalize_calls: list[dict] = []
notify_emit_calls: list[dict] = []
publish_calls: list[tuple] = []
workflow_status_calls: list[tuple] = []
blender_calls: list[list[str]] = []
def _fake_subprocess_run(cmd, *args, **kwargs):
cmd_text = " ".join(str(part) for part in cmd)
if "export_step_to_gltf.py" in cmd_text:
glb_path = step_path.parent / "bearing_thumbnail.glb"
glb_path.write_text("GLB", encoding="utf-8")
elif "turntable_render.py" in cmd_text:
blender_calls.append([str(part) for part in cmd])
frames_dir = Path(cmd[cmd.index("--") + 2])
frames_dir.mkdir(parents=True, exist_ok=True)
(frames_dir / "frame_0001.png").write_text("PNG", encoding="utf-8")
if "ffmpeg" in cmd_text:
output_mp4 = expected_output_mp4
output_mp4.parent.mkdir(parents=True, exist_ok=True)
output_mp4.write_text("MP4", encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr(
"app.domains.rendering.tasks._build_ffmpeg_cmd",
lambda *args, **kwargs: ["ffmpeg", str(expected_output_mp4)],
)
monkeypatch.setattr(
"subprocess.run",
_fake_subprocess_run,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_turntable_output",
lambda order_line_id, **kwargs: finalize_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: workflow_status_calls.append((args, kwargs)),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_notify_nodes",
lambda **kwargs: notify_finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._emit_graph_render_notifications",
lambda order_line_id, **kwargs: notify_emit_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
result = render_turntable_task.run.__func__(
task_self,
"line-5",
output_name="preview",
workflow_run_id="run-5",
workflow_node_id="turntable",
publish_asset_enabled=False,
graph_authoritative_output_enabled=True,
graph_output_node_ids=["output"],
graph_notify_node_ids=["notify"],
emit_legacy_notifications=True,
emit_events=False,
)
assert result == {
"output_mp4": str(expected_output_mp4),
"frame_count": 120,
"fps": 30,
}
assert publish_calls == []
assert finalize_calls == [
{
"order_line_id": "line-5",
"success": True,
"output_path": str(expected_output_mp4),
"render_log": result,
"workflow_run_id": "run-5",
"output_node_ids": ["output"],
"render_node_id": "turntable",
}
]
assert workflow_status_calls == [
(
("line-5", "completed"),
{"workflow_run_id": "run-5", "workflow_node_id": "turntable"},
)
]
assert notify_emit_calls == [
{
"order_line_id": "line-5",
"success": True,
"render_log": result,
}
]
assert notify_finalize_calls == [
{
"workflow_run_id": "run-5",
"notify_node_ids": ["notify"],
"success": True,
"render_node_id": "turntable",
}
]
assert len(blender_calls) == 1
args = blender_calls[0][blender_calls[0].index("--") + 1 :]
assert Path(args[0]).parent == step_path.parent
assert Path(args[0]).suffix == ".glb"
expected_frames_dir = expected_output_mp4.parent / "_frames_preview"
assert args[1:9] == [
str(expected_frames_dir),
"120",
"360",
"1920",
"1080",
"cycles",
"64",
"{}",
]
assert args[9:17] == [
"",
"Product",
"",
"{}",
"[]",
"0",
"gpu",
"0",
]
assert args[17:23] == [
"0.0",
"0.0",
"0.0",
"world_z",
"",
"0",
]
def test_render_turntable_task_uses_shadow_observer_output_handoff(
tmp_path,
monkeypatch,
):
from app.domains.rendering.tasks import publish_asset, render_turntable_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-turntable-shadow"))
expected_output_mp4 = build_order_line_step_render_path(
step_path,
"line-shadow",
"preview_shadow-abcd1234.mp4",
)
observer_finalize_calls: list[dict] = []
publish_calls: list[tuple] = []
def _fake_subprocess_run(cmd, *args, **kwargs):
cmd_text = " ".join(str(part) for part in cmd)
if "export_step_to_gltf.py" in cmd_text:
glb_path = step_path.parent / "bearing_thumbnail.glb"
glb_path.write_text("GLB", encoding="utf-8")
elif "turntable_render.py" in cmd_text:
frames_dir = Path(cmd[cmd.index("--") + 2])
frames_dir.mkdir(parents=True, exist_ok=True)
(frames_dir / "frame_0001.png").write_text("PNG", encoding="utf-8")
if "ffmpeg" in cmd_text:
output_mp4 = expected_output_mp4
output_mp4.parent.mkdir(parents=True, exist_ok=True)
output_mp4.write_text("MP4", encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr(
"app.domains.rendering.tasks._build_ffmpeg_cmd",
lambda *args, **kwargs: ["ffmpeg", str(expected_output_mp4)],
)
monkeypatch.setattr("subprocess.run", _fake_subprocess_run)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_shadow_turntable_output",
lambda order_line_id, **kwargs: observer_finalize_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
result = render_turntable_task.run.__func__(
task_self,
"line-shadow",
output_name="preview",
output_name_suffix="shadow-abcd1234",
workflow_run_id="run-shadow",
workflow_node_id="turntable",
publish_asset_enabled=False,
observer_output_enabled=True,
graph_output_node_ids=["output"],
emit_events=False,
)
assert result == {
"output_mp4": str(expected_output_mp4),
"frame_count": 120,
"fps": 30,
}
assert publish_calls == []
assert observer_finalize_calls == [
{
"order_line_id": "line-shadow",
"success": True,
"output_path": str(expected_output_mp4),
"render_log": result,
"workflow_run_id": "run-shadow",
"output_node_ids": ["output"],
"render_node_id": "turntable",
}
]
def test_render_turntable_task_uses_isolated_frames_dir_per_output_name(
tmp_path,
monkeypatch,
):
from app.domains.rendering.tasks import render_turntable_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-turntable-isolated-frames"))
expected_output_mp4 = build_order_line_step_render_path(
step_path,
"line-shadow",
"preview_shadow-run123.mp4",
)
expected_frames_dir = expected_output_mp4.parent / "_frames_preview_shadow-run123"
blender_calls: list[list[str]] = []
ffmpeg_calls: list[tuple[Path, Path, dict]] = []
def _fake_subprocess_run(cmd, *args, **kwargs):
cmd_text = " ".join(str(part) for part in cmd)
if "export_step_to_gltf.py" in cmd_text:
glb_path = step_path.parent / "bearing_thumbnail.glb"
glb_path.write_text("GLB", encoding="utf-8")
elif "turntable_render.py" in cmd_text:
blender_calls.append([str(part) for part in cmd])
frames_dir = Path(cmd[cmd.index("--") + 2])
frames_dir.mkdir(parents=True, exist_ok=True)
(frames_dir / "frame_0001.png").write_text("fresh", encoding="utf-8")
elif "ffmpeg" in cmd_text:
output_mp4 = expected_output_mp4
output_mp4.parent.mkdir(parents=True, exist_ok=True)
output_mp4.write_text("MP4", encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
def _fake_build_ffmpeg_cmd(frames_dir, output_mp4, **kwargs):
ffmpeg_calls.append((frames_dir, output_mp4, kwargs))
return ["ffmpeg", str(output_mp4)]
stale_frame_dir = expected_frames_dir
stale_frame_dir.mkdir(parents=True, exist_ok=True)
(stale_frame_dir / "frame_0001.png").write_text("stale", encoding="utf-8")
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr("app.domains.rendering.tasks._build_ffmpeg_cmd", _fake_build_ffmpeg_cmd)
monkeypatch.setattr("subprocess.run", _fake_subprocess_run)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_shadow_turntable_output",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
render_turntable_task.run.__func__(
task_self,
"line-shadow",
output_name="preview",
output_name_suffix="shadow-run123",
workflow_run_id="run-shadow",
workflow_node_id="turntable",
publish_asset_enabled=False,
observer_output_enabled=True,
emit_events=False,
)
assert len(blender_calls) == 1
args = blender_calls[0][blender_calls[0].index("--") + 1 :]
assert args[1] == str(stale_frame_dir)
assert stale_frame_dir.exists()
assert list(stale_frame_dir.iterdir()) == [stale_frame_dir / "frame_0001.png"]
assert (stale_frame_dir / "frame_0001.png").read_text(encoding="utf-8") == "fresh"
assert ffmpeg_calls == [
(
stale_frame_dir,
expected_output_mp4,
{"fps": 30, "bg_color": "", "width": 1920, "height": 1080},
)
]
def test_render_turntable_task_preserves_legacy_step_path_signature(
tmp_path,
monkeypatch,
):
from app.domains.rendering.tasks import publish_asset, render_turntable_task
step_path = tmp_path / "cad" / "bearing.step"
output_dir = step_path.parent / "legacy-renders"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(request=SimpleNamespace(id="task-turntable-legacy"))
finalize_calls: list[dict] = []
publish_calls: list[tuple] = []
workflow_status_calls: list[tuple] = []
blender_calls: list[list[str]] = []
def _fake_subprocess_run(cmd, *args, **kwargs):
cmd_text = " ".join(str(part) for part in cmd)
if "export_step_to_gltf.py" in cmd_text:
glb_path = step_path.parent / "bearing_thumbnail.glb"
glb_path.write_text("GLB", encoding="utf-8")
elif "turntable_render.py" in cmd_text:
blender_calls.append([str(part) for part in cmd])
frames_dir = Path(cmd[cmd.index("--") + 2])
frames_dir.mkdir(parents=True, exist_ok=True)
(frames_dir / "frame_0001.png").write_text("PNG", encoding="utf-8")
if "ffmpeg" in cmd_text:
output_mp4 = output_dir / "turntable.mp4"
output_mp4.parent.mkdir(parents=True, exist_ok=True)
output_mp4.write_text("MP4", encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr(
"app.domains.rendering.tasks._build_ffmpeg_cmd",
lambda *args, **kwargs: ["ffmpeg", str(output_dir / "turntable.mp4")],
)
monkeypatch.setattr(
"subprocess.run",
_fake_subprocess_run,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_turntable_output",
lambda *args, **kwargs: finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: workflow_status_calls.append((args, kwargs)),
)
monkeypatch.setattr(
publish_asset,
"delay",
lambda *args, **kwargs: publish_calls.append((args, kwargs)),
)
result = render_turntable_task.run.__func__(
task_self,
str(step_path),
str(output_dir),
emit_events=False,
)
assert result == {
"output_mp4": str(output_dir / "turntable.mp4"),
"frame_count": 120,
"fps": 30,
}
assert len(blender_calls) == 1
assert "--camera-orbit" in blender_calls[0]
assert finalize_calls == []
assert publish_calls == []
assert workflow_status_calls == []
def test_render_order_line_still_task_finalizes_notify_handoff_on_failure(tmp_path, monkeypatch):
from app.domains.rendering.tasks import render_order_line_still_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(
request=SimpleNamespace(id="task-still-failure"),
retry=lambda *, exc, countdown: (_ for _ in ()).throw(exc),
)
notify_finalize_calls: list[dict] = []
notify_emit_calls: list[dict] = []
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.step_processor.render_to_file",
lambda **kwargs: (_ for _ in ()).throw(RuntimeError("still boom")),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_still_output",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_notify_nodes",
lambda **kwargs: notify_finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._emit_graph_render_notifications",
lambda order_line_id, **kwargs: notify_emit_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
with pytest.raises(RuntimeError, match="still boom"):
render_order_line_still_task.run.__func__(
task_self,
"line-still-failure",
workflow_run_id="run-still-failure",
workflow_node_id="render",
graph_authoritative_output_enabled=True,
graph_output_node_ids=["output"],
graph_notify_node_ids=["notify"],
emit_legacy_notifications=True,
job_document_enabled=False,
emit_events=False,
)
assert notify_emit_calls == [
{
"order_line_id": "line-still-failure",
"success": False,
"render_log": {"error": "still boom"},
}
]
assert notify_finalize_calls == [
{
"workflow_run_id": "run-still-failure",
"notify_node_ids": ["notify"],
"success": False,
"render_node_id": "render",
"error": "still boom",
}
]
def test_export_blend_for_order_line_task_finalizes_notify_handoff_on_failure(
tmp_path,
monkeypatch,
):
from app.config import settings
from app.domains.rendering.tasks import export_blend_for_order_line_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
glb_path = step_path.parent / "bearing_render.glb"
glb_path.write_text("GLB", encoding="utf-8")
task_self = SimpleNamespace(
request=SimpleNamespace(id="task-blend-failure"),
retry=lambda *, exc, countdown: (_ for _ in ()).throw(exc),
)
notify_finalize_calls: list[dict] = []
notify_emit_calls: list[dict] = []
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr(
"app.services.render_blender.build_tessellated_glb_path",
lambda *args, **kwargs: glb_path,
)
monkeypatch.setattr(
"subprocess.run",
lambda *args, **kwargs: SimpleNamespace(returncode=1, stderr="blend boom"),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_blend_output",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_notify_nodes",
lambda **kwargs: notify_finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._emit_graph_render_notifications",
lambda order_line_id, **kwargs: notify_emit_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
with pytest.raises(RuntimeError, match="export_blend.py exited 1"):
export_blend_for_order_line_task.run.__func__(
task_self,
"line-blend-failure",
workflow_run_id="run-blend-failure",
workflow_node_id="blend",
graph_authoritative_output_enabled=True,
graph_output_node_ids=["output"],
graph_notify_node_ids=["notify"],
emit_legacy_notifications=True,
)
assert notify_emit_calls == [
{
"order_line_id": "line-blend-failure",
"success": False,
"render_log": {"error": "export_blend.py exited 1:\nblend boom"},
}
]
assert notify_finalize_calls == [
{
"workflow_run_id": "run-blend-failure",
"notify_node_ids": ["notify"],
"success": False,
"render_node_id": "blend",
"error": "export_blend.py exited 1:\nblend boom",
}
]
def test_render_turntable_task_finalizes_notify_handoff_on_failure(
tmp_path,
monkeypatch,
):
from app.domains.rendering.tasks import render_turntable_task
step_path = tmp_path / "cad" / "bearing.step"
step_path.parent.mkdir(parents=True, exist_ok=True)
step_path.write_text("STEP", encoding="utf-8")
task_self = SimpleNamespace(
request=SimpleNamespace(id="task-turntable-failure"),
retry=lambda *, exc, countdown: (_ for _ in ()).throw(exc),
)
notify_finalize_calls: list[dict] = []
notify_emit_calls: list[dict] = []
def _fake_subprocess_run(cmd, *args, **kwargs):
cmd_text = " ".join(str(part) for part in cmd)
if "export_step_to_gltf.py" in cmd_text:
glb_path = step_path.parent / "bearing_thumbnail.glb"
glb_path.write_text("GLB", encoding="utf-8")
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "turntable_render.py" in cmd_text:
return SimpleNamespace(returncode=1, stdout="turntable boom", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(
"app.domains.rendering.tasks._resolve_step_path_for_order_line",
lambda order_line_id: (str(step_path), "cad-1"),
)
monkeypatch.setattr(
"app.services.render_blender.find_blender",
lambda: "/usr/bin/blender",
)
monkeypatch.setattr(
"subprocess.run",
_fake_subprocess_run,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_turntable_output",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._update_workflow_run_status",
lambda *args, **kwargs: None,
)
monkeypatch.setattr(
"app.domains.rendering.tasks._finalize_graph_notify_nodes",
lambda **kwargs: notify_finalize_calls.append(kwargs),
)
monkeypatch.setattr(
"app.domains.rendering.tasks._emit_graph_render_notifications",
lambda order_line_id, **kwargs: notify_emit_calls.append(
{"order_line_id": order_line_id, **kwargs}
),
)
with pytest.raises(RuntimeError, match="Blender turntable exited 1"):
render_turntable_task.run.__func__(
task_self,
"line-turntable-failure",
workflow_run_id="run-turntable-failure",
workflow_node_id="turntable",
graph_authoritative_output_enabled=True,
graph_output_node_ids=["output"],
graph_notify_node_ids=["notify"],
emit_legacy_notifications=True,
emit_events=False,
)
assert notify_emit_calls == [
{
"order_line_id": "line-turntable-failure",
"success": False,
"render_log": {"error": "Blender turntable exited 1:\nturntable boom"},
}
]
assert notify_finalize_calls == [
{
"workflow_run_id": "run-turntable-failure",
"notify_node_ids": ["notify"],
"success": False,
"render_node_id": "turntable",
"error": "Blender turntable exited 1:\nturntable boom",
}
]