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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user