diff --git a/backend/tests/domains/test_workflow_smoke_harness.py b/backend/tests/domains/test_workflow_smoke_harness.py index 5041769..8a75ccb 100644 --- a/backend/tests/domains/test_workflow_smoke_harness.py +++ b/backend/tests/domains/test_workflow_smoke_harness.py @@ -227,6 +227,63 @@ def test_workflow_smoke_template_name_maps_supported_modes_to_blenderstudio(): assert module.workflow_smoke_template_name("turntable") is None +def test_smoke_turntable_output_type_name_includes_variant_and_mode(): + module = _load_render_pipeline_script() + + name = module.smoke_turntable_output_type_name("graph") + + assert "Turntable" in name + assert "Graph" in name + assert name.startswith("[Workflow Smoke]") + + +def test_workflow_smoke_turntable_template_name_returns_animation_template_for_graph(): + module = _load_render_pipeline_script() + + assert module.workflow_smoke_turntable_template_name("graph") == "Blender_Studio_Schadowcatcher_Anim" + assert module.workflow_smoke_turntable_template_name("shadow") == "Blender_Studio_Schadowcatcher_Anim" + assert module.workflow_smoke_turntable_template_name("legacy") is None + + +def test_workflow_smoke_blend_template_name_returns_blenderstudio(): + module = _load_render_pipeline_script() + + assert module.workflow_smoke_blend_template_name() == "BlenderStudio" + + +def test_build_graph_turntable_config_accepts_render_params_override(): + module = _load_render_pipeline_script() + + config = module.build_graph_turntable_config( + execution_mode="graph", + render_params={"width": 512, "height": 512, "fps": 12, "duration_s": 2, "samples": 16}, + ) + + turntable_node = next(node for node in config["nodes"] if node["id"] == "turntable") + assert turntable_node["params"]["width"] == 512 + assert turntable_node["params"]["height"] == 512 + assert turntable_node["params"]["fps"] == 12 + assert turntable_node["params"]["duration_s"] == 2 + assert turntable_node["params"]["samples"] == 16 + + +def test_build_graph_turntable_config_defaults_unchanged_without_render_params(): + module = _load_render_pipeline_script() + + config = module.build_graph_turntable_config(execution_mode="graph") + + turntable_node = next(node for node in config["nodes"] if node["id"] == "turntable") + assert turntable_node["params"]["width"] == 768 + assert turntable_node["params"]["height"] == 768 + + +def test_smoke_blend_output_type_name_is_stable(): + module = _load_render_pipeline_script() + + assert module.smoke_blend_output_type_name() == "[Workflow Smoke] Blend Export" + assert module.smoke_blend_workflow_name() == "[Workflow Smoke] Blend Export" + + def test_ensure_output_type_render_template_binding_moves_output_type_to_preferred_template(): module = _load_render_pipeline_script() diff --git a/scripts/test_render_pipeline.py b/scripts/test_render_pipeline.py index a44b1a3..f28a44e 100644 --- a/scripts/test_render_pipeline.py +++ b/scripts/test_render_pipeline.py @@ -319,7 +319,19 @@ def build_graph_still_config( } -def build_graph_turntable_config(*, execution_mode: str = "graph") -> dict: +def build_graph_turntable_config(*, execution_mode: str = "graph", render_params: dict | None = None) -> dict: + turntable_params: dict = { + "width": 768, + "height": 768, + "render_engine": "cycles", + "samples": 16, + "fps": 12, + "duration_s": 2, + } + if render_params: + normalized = _normalize_render_params(render_params) + turntable_params.update(normalized) + return { "version": 1, "ui": {"preset": "turntable", "execution_mode": execution_mode}, @@ -357,14 +369,7 @@ def build_graph_turntable_config(*, execution_mode: str = "graph") -> dict: { "id": "turntable", "step": "blender_turntable", - "params": { - "width": 768, - "height": 768, - "render_engine": "cycles", - "samples": 16, - "fps": 12, - "duration_s": 2, - }, + "params": turntable_params, "ui": {"type": "renderFramesNode", "label": "Turntable Render", "position": {"x": 440, "y": 100}}, }, { @@ -607,6 +612,33 @@ def workflow_smoke_template_name(execution_mode: str) -> str | None: return None +def smoke_turntable_output_type_name(execution_mode: str) -> str: + return f"[Workflow Smoke] Turntable {execution_mode.title()}" + + +def smoke_turntable_workflow_name(execution_mode: str) -> str: + return f"[Workflow Smoke] Turntable {execution_mode.title()}" + + +def workflow_smoke_turntable_template_name(execution_mode: str) -> str | None: + normalized = str(execution_mode or "").strip().lower() + if normalized in {"graph", "shadow"}: + return "Blender_Studio_Schadowcatcher_Anim" + return None + + +def smoke_blend_output_type_name() -> str: + return "[Workflow Smoke] Blend Export" + + +def smoke_blend_workflow_name() -> str: + return "[Workflow Smoke] Blend Export" + + +def workflow_smoke_blend_template_name() -> str: + return "BlenderStudio" + + def build_workflow_golden_cases() -> list[dict]: still_invocation = { "width": 1024, @@ -856,6 +888,236 @@ def ensure_workflow_still_smoke_resources( } +def ensure_workflow_turntable_smoke_resources( + client: APIClient, + *, + execution_mode: str = "graph", +) -> dict: + output_type_name = smoke_turntable_output_type_name(execution_mode) + workflow_name = smoke_turntable_workflow_name(execution_mode) + output_types = get_output_types(client, include_inactive=True) + output_type = find_named(output_types, output_type_name) + + smoke_render_params = { + "width": 512, + "height": 512, + "fps": 12, + "duration_s": 2, + "samples": 16, + "engine": "cycles", + } + output_type_payload = { + "name": output_type_name, + "description": f"Turntable workflow smoke profile ({execution_mode}) — 512x512, 24 frames", + "renderer": "blender", + "render_settings": smoke_render_params, + "output_format": "mp4", + "sort_order": 0, + "is_active": True, + "compatible_categories": [], + "render_backend": "celery", + "is_animation": True, + "transparent_bg": False, + "workflow_family": "order_line", + "artifact_kind": "turntable_video", + "invocation_overrides": smoke_render_params, + "workflow_definition_id": None, + } + + if output_type is None: + resp = client.post("/output-types", json=output_type_payload) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Turntable smoke output type create failed: {resp.status_code} {resp.text[:400]}" + ) + output_type = resp.json() + ok(f"Provisioned turntable smoke output type: {output_type_name}") + else: + resp = client.patch(f"/output-types/{output_type['id']}", json=output_type_payload) + if resp.status_code != 200: + raise RuntimeError( + f"Turntable smoke output type update failed: {resp.status_code} {resp.text[:400]}" + ) + output_type = resp.json() + info(f"Reusing turntable smoke output type: {output_type_name}") + + preferred_template_name = workflow_smoke_turntable_template_name(execution_mode) + if preferred_template_name: + bound_templates = ensure_output_type_render_template_binding( + client, + output_type=output_type, + preferred_template_name=preferred_template_name, + ) + info( + "Turntable smoke template candidates: " + + ", ".join(template.get("name") or "" for template in bound_templates) + ) + + workflow = None + if execution_mode != "legacy": + workflows = get_workflows(client) + workflow = find_named(workflows, workflow_name) + workflow_config = build_graph_turntable_config( + execution_mode=execution_mode, + render_params=smoke_render_params, + ) + workflow_payload = { + "name": workflow_name, + "output_type_id": output_type["id"], + "config": workflow_config, + "is_active": True, + } + if workflow is None: + resp = client.post("/workflows", json=workflow_payload) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Turntable smoke workflow create failed: {resp.status_code} {resp.text[:400]}" + ) + workflow = resp.json() + ok(f"Provisioned turntable smoke workflow: {workflow_name}") + else: + resp = client.put( + f"/workflows/{workflow['id']}", + json={ + "name": workflow_payload["name"], + "config": workflow_payload["config"], + "is_active": workflow_payload["is_active"], + }, + ) + if resp.status_code != 200: + raise RuntimeError( + f"Turntable smoke workflow update failed: {resp.status_code} {resp.text[:400]}" + ) + workflow = resp.json() + info(f"Reusing turntable smoke workflow: {workflow_name}") + + resp = client.patch( + f"/output-types/{output_type['id']}", + json=build_output_type_workflow_link_payload( + workflow_definition_id=workflow["id"], + execution_mode=execution_mode, + ), + ) + if resp.status_code != 200: + raise RuntimeError( + f"Turntable smoke output type link failed: {resp.status_code} {resp.text[:400]}" + ) + output_type = resp.json() + + return { + "output_type": output_type, + "workflow": workflow, + "execution_mode": execution_mode, + } + + +def ensure_workflow_blend_smoke_resources( + client: APIClient, +) -> dict: + output_type_name = smoke_blend_output_type_name() + workflow_name = smoke_blend_workflow_name() + output_types = get_output_types(client, include_inactive=True) + output_type = find_named(output_types, output_type_name) + + output_type_payload = { + "name": output_type_name, + "description": "Blend export workflow smoke profile — graph-authoritative .blend file export", + "renderer": "blender", + "render_settings": {}, + "output_format": "blend", + "sort_order": 0, + "is_active": True, + "compatible_categories": [], + "render_backend": "celery", + "is_animation": False, + "transparent_bg": False, + "workflow_family": "order_line", + "artifact_kind": "blend_asset", + "invocation_overrides": {}, + "workflow_definition_id": None, + } + + if output_type is None: + resp = client.post("/output-types", json=output_type_payload) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Blend smoke output type create failed: {resp.status_code} {resp.text[:400]}" + ) + output_type = resp.json() + ok(f"Provisioned blend smoke output type: {output_type_name}") + else: + resp = client.patch(f"/output-types/{output_type['id']}", json=output_type_payload) + if resp.status_code != 200: + raise RuntimeError( + f"Blend smoke output type update failed: {resp.status_code} {resp.text[:400]}" + ) + output_type = resp.json() + info(f"Reusing blend smoke output type: {output_type_name}") + + preferred_template_name = workflow_smoke_blend_template_name() + bound_templates = ensure_output_type_render_template_binding( + client, + output_type=output_type, + preferred_template_name=preferred_template_name, + ) + info( + "Blend smoke template candidates: " + + ", ".join(template.get("name") or "" for template in bound_templates) + ) + + workflows = get_workflows(client) + workflow = find_named(workflows, workflow_name) + workflow_config = build_graph_blend_export_config(execution_mode="graph") + workflow_payload = { + "name": workflow_name, + "output_type_id": output_type["id"], + "config": workflow_config, + "is_active": True, + } + if workflow is None: + resp = client.post("/workflows", json=workflow_payload) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Blend smoke workflow create failed: {resp.status_code} {resp.text[:400]}" + ) + workflow = resp.json() + ok(f"Provisioned blend smoke workflow: {workflow_name}") + else: + resp = client.put( + f"/workflows/{workflow['id']}", + json={ + "name": workflow_payload["name"], + "config": workflow_payload["config"], + "is_active": workflow_payload["is_active"], + }, + ) + if resp.status_code != 200: + raise RuntimeError( + f"Blend smoke workflow update failed: {resp.status_code} {resp.text[:400]}" + ) + workflow = resp.json() + info(f"Reusing blend smoke workflow: {workflow_name}") + + resp = client.patch( + f"/output-types/{output_type['id']}", + json=build_output_type_workflow_link_payload( + workflow_definition_id=workflow["id"], + execution_mode="graph", + ), + ) + if resp.status_code != 200: + raise RuntimeError( + f"Blend smoke output type link failed: {resp.status_code} {resp.text[:400]}" + ) + output_type = resp.json() + + return { + "output_type": output_type, + "workflow": workflow, + "execution_mode": "graph", + } + + def ensure_workflow_golden_resources( client: APIClient, *, @@ -1582,6 +1844,171 @@ def test_workflow_still_smoke( return success +def test_workflow_turntable_smoke( + client: APIClient, + cad_file_id: str, + *, + execution_mode: str = "graph", +) -> bool: + section(f"3b. Workflow Turntable Smoke — {execution_mode}") + + smoke_resources = ensure_workflow_turntable_smoke_resources( + client, + execution_mode=execution_mode, + ) + output_type = smoke_resources["output_type"] + workflow = smoke_resources["workflow"] + + info( + f"Turntable smoke contract: output_type={output_type['name']} " + f"workflow={workflow['name'] if workflow else 'legacy-only'}" + ) + + product_id = get_or_create_test_product(client, cad_file_id) + if not product_id: + return False + + order = create_test_order( + client, + product_id=product_id, + output_type_ids=[output_type["id"]], + test_label=f"Workflow Turntable Smoke [{execution_mode}]", + ) + if order is None: + return False + + lines = order.get("lines", []) + if len(lines) != 1: + fail("Turntable smoke expects exactly one order line") + return False + line_id = lines[0]["id"] + + if workflow is not None: + resp_preflight = client.get( + f"/workflows/{workflow['id']}/preflight", + params={"context_id": line_id}, + ) + if resp_preflight.status_code != 200: + fail(f"Turntable workflow preflight failed: {resp_preflight.status_code} {resp_preflight.text[:300]}") + return False + preflight = resp_preflight.json() + info( + "Preflight: " + f"execution_mode={preflight.get('execution_mode')} " + f"allowed={preflight.get('graph_dispatch_allowed')}" + ) + if not preflight.get("graph_dispatch_allowed"): + fail(f"Turntable workflow preflight blocked dispatch: {preflight.get('summary')}") + for issue in preflight.get("issues", []): + info(f" {issue.get('code')}: {issue.get('message')}") + return False + ok(f"Turntable workflow preflight passed for {execution_mode} mode") + + success = _submit_and_wait( + client, + order, + [output_type["id"]], + use_graph_dispatch=False, + timeout_seconds=ANIMATION_RENDER_TIMEOUT_SECONDS, + ) + + if workflow is not None: + workflow_run = wait_for_workflow_run( + client, + workflow_id=workflow["id"], + line_id=line_id, + ) + if workflow_run is None: + warn("Turntable workflow run could not be resolved after dispatch") + else: + ok( + f"Turntable workflow run tracked: mode={workflow_run.get('execution_mode')} " + f"run={workflow_run.get('id')[:8]}..." + ) + + return success + + +def test_workflow_blend_smoke( + client: APIClient, + cad_file_id: str, +) -> bool: + section("3c. Workflow Blend Export Smoke — graph") + + smoke_resources = ensure_workflow_blend_smoke_resources(client) + output_type = smoke_resources["output_type"] + workflow = smoke_resources["workflow"] + + info( + f"Blend smoke contract: output_type={output_type['name']} " + f"workflow={workflow['name'] if workflow else 'legacy-only'}" + ) + + product_id = get_or_create_test_product(client, cad_file_id) + if not product_id: + return False + + order = create_test_order( + client, + product_id=product_id, + output_type_ids=[output_type["id"]], + test_label="Workflow Blend Export Smoke [graph]", + ) + if order is None: + return False + + lines = order.get("lines", []) + if len(lines) != 1: + fail("Blend smoke expects exactly one order line") + return False + line_id = lines[0]["id"] + + if workflow is not None: + resp_preflight = client.get( + f"/workflows/{workflow['id']}/preflight", + params={"context_id": line_id}, + ) + if resp_preflight.status_code != 200: + fail(f"Blend workflow preflight failed: {resp_preflight.status_code} {resp_preflight.text[:300]}") + return False + preflight = resp_preflight.json() + if not preflight.get("graph_dispatch_allowed"): + fail(f"Blend workflow preflight blocked dispatch: {preflight.get('summary')}") + for issue in preflight.get("issues", []): + info(f" {issue.get('code')}: {issue.get('message')}") + return False + ok("Blend workflow preflight passed") + + success = _submit_and_wait( + client, + order, + [output_type["id"]], + use_graph_dispatch=False, + ) + + if workflow is not None: + workflow_run = wait_for_workflow_run( + client, + workflow_id=workflow["id"], + line_id=line_id, + ) + if workflow_run is None: + warn("Blend workflow run could not be resolved after dispatch") + else: + node_result = _node_result_by_name(workflow_run, "blend") + if node_result and node_result.get("status") == "completed": + ok("Blend export node completed") + elif node_result: + fail(f"Blend export node status: {node_result.get('status')}") + success = False + ok( + f"Blend workflow run tracked: mode={workflow_run.get('execution_mode')} " + f"run={workflow_run.get('id')[:8]}..." + ) + + return success + + def _assert_workflow_run_contract(case: dict, workflow_run: dict) -> bool: success = True