feat: turntable + blend export smoke test coverage

- build_graph_turntable_config: accept render_params kwarg so smoke can
  use low settings (512x512, 24 frames) without touching golden case defaults
- add smoke naming + template functions for turntable and blend
- add ensure_workflow_turntable_smoke_resources() — provisions output type
  (mp4/turntable_video) + workflow graph, binds to
  Blender_Studio_Schadowcatcher_Anim template
- add ensure_workflow_blend_smoke_resources() — provisions output type
  (blend/blend_asset) + workflow graph, binds to BlenderStudio template
- add test_workflow_turntable_smoke() — preflight + dispatch + run tracking
- add test_workflow_blend_smoke() — preflight + dispatch + blend node check
- 6 new harness unit tests (17 total, all passing), using real asset:
  cad_file_id 8dd73335 (81113-l_cut.stp, has USD master + GLB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 10:17:47 +02:00
co-authored by Claude Sonnet 4.6
parent 44b52d05cc
commit c87857f836
2 changed files with 493 additions and 9 deletions
+436 -9
View File
@@ -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 "<unnamed>" 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 "<unnamed>" 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