feat: harden workflow graph contracts
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from app.core.process_steps import StepName
|
||||
from app.domains.rendering.models import WorkflowDefinition
|
||||
from app.domains.rendering.workflow_node_registry import (
|
||||
get_node_definition,
|
||||
list_node_definitions,
|
||||
@@ -8,30 +9,99 @@ from app.domains.rendering.workflow_node_registry import (
|
||||
|
||||
|
||||
def test_node_registry_covers_all_step_names():
|
||||
registered_steps = {definition.step for definition in list_node_definitions()}
|
||||
definitions = list_node_definitions()
|
||||
registered_steps = {definition.step for definition in definitions}
|
||||
expected_steps = {step.value for step in StepName}
|
||||
|
||||
assert registered_steps == expected_steps
|
||||
assert all(definition.family in {"cad_file", "order_line"} for definition in definitions)
|
||||
assert all(definition.module_key for definition in definitions)
|
||||
assert all(definition.legacy_source for definition in definitions)
|
||||
|
||||
|
||||
def test_turntable_node_definition_exposes_expected_schema():
|
||||
definition = get_node_definition(StepName.BLENDER_TURNTABLE)
|
||||
|
||||
assert definition is not None
|
||||
assert definition.family == "order_line"
|
||||
assert definition.module_key == "render.production.turntable"
|
||||
assert definition.node_type == "renderFramesNode"
|
||||
assert definition.defaults["fps"] == 24
|
||||
assert definition.defaults["duration_s"] == 5
|
||||
assert definition.input_contract["context"] == "order_line"
|
||||
assert definition.output_contract["provides"] == ["rendered_frames", "rendered_video"]
|
||||
assert "material_assignments" in definition.artifact_roles_consumed
|
||||
assert "rendered_video" in definition.artifact_roles_produced
|
||||
assert definition.legacy_source == f"legacy_step:{StepName.BLENDER_TURNTABLE.value}"
|
||||
assert {field.key for field in definition.fields} >= {
|
||||
"render_engine",
|
||||
"cycles_device",
|
||||
"samples",
|
||||
"transparent_bg",
|
||||
"bg_color",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
"duration_s",
|
||||
"turntable_degrees",
|
||||
"turntable_axis",
|
||||
"camera_orbit",
|
||||
"rotation_x",
|
||||
"rotation_y",
|
||||
"rotation_z",
|
||||
"focal_length_mm",
|
||||
"sensor_width_mm",
|
||||
"material_override",
|
||||
}
|
||||
|
||||
|
||||
def test_order_line_setup_and_template_contracts_expose_runtime_outputs():
|
||||
setup = get_node_definition(StepName.ORDER_LINE_SETUP)
|
||||
template = get_node_definition(StepName.RESOLVE_TEMPLATE)
|
||||
bbox = get_node_definition(StepName.GLB_BBOX)
|
||||
output = get_node_definition(StepName.OUTPUT_SAVE)
|
||||
export_blend = get_node_definition(StepName.EXPORT_BLEND)
|
||||
notify = get_node_definition(StepName.NOTIFY)
|
||||
|
||||
assert setup is not None
|
||||
assert template is not None
|
||||
assert bbox is not None
|
||||
assert output is not None
|
||||
assert export_blend is not None
|
||||
assert notify is not None
|
||||
|
||||
assert set(setup.output_contract["provides"]) >= {
|
||||
"order_line_context",
|
||||
"cad_file_ref",
|
||||
"step_path",
|
||||
"cad_materials",
|
||||
"glb_preview",
|
||||
"bbox",
|
||||
}
|
||||
assert set(template.output_contract["provides"]) >= {
|
||||
"render_template",
|
||||
"output_profile",
|
||||
"template_path",
|
||||
"material_library",
|
||||
"material_map",
|
||||
"use_materials",
|
||||
"override_material",
|
||||
}
|
||||
assert {field.key for field in bbox.fields} == {"glb_path"}
|
||||
assert output.input_contract["requires"] == ["order_line_context"]
|
||||
assert output.input_contract["requires_any"] == ["rendered_image", "rendered_frames", "rendered_video"]
|
||||
assert set(output.output_contract["provides"]) >= {"media_asset", "workflow_result"}
|
||||
assert export_blend.defaults["output_name_suffix"] == ""
|
||||
assert {field.key for field in export_blend.fields} == {"output_name_suffix"}
|
||||
assert notify.input_contract["requires"] == ["order_line_context"]
|
||||
assert notify.input_contract["requires_any"] == [
|
||||
"rendered_image",
|
||||
"rendered_frames",
|
||||
"rendered_video",
|
||||
"workflow_result",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
|
||||
response = await client.get("/api/workflows/node-definitions", headers=auth_headers)
|
||||
@@ -43,8 +113,57 @@ async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
|
||||
blender_still = next(
|
||||
definition for definition in body["definitions"] if definition["step"] == StepName.BLENDER_STILL.value
|
||||
)
|
||||
assert blender_still["family"] == "order_line"
|
||||
assert blender_still["module_key"] == "render.production.still"
|
||||
assert blender_still["node_type"] == "renderNode"
|
||||
assert blender_still["defaults"]["render_engine"] == "cycles"
|
||||
assert blender_still["defaults"]["use_custom_render_settings"] is False
|
||||
assert blender_still["input_contract"]["context"] == "order_line"
|
||||
assert "bbox" in blender_still["artifact_roles_consumed"]
|
||||
assert blender_still["output_contract"]["provides"] == ["rendered_image"]
|
||||
assert blender_still["legacy_source"] == f"legacy_step:{StepName.BLENDER_STILL.value}"
|
||||
assert {field["key"] for field in blender_still["fields"]} >= {
|
||||
"use_custom_render_settings",
|
||||
"render_engine",
|
||||
"cycles_device",
|
||||
"samples",
|
||||
"width",
|
||||
"height",
|
||||
"transparent_bg",
|
||||
"noise_threshold",
|
||||
"denoiser",
|
||||
"denoising_input_passes",
|
||||
"denoising_prefilter",
|
||||
"denoising_quality",
|
||||
"denoising_use_gpu",
|
||||
"target_collection",
|
||||
"lighting_only",
|
||||
"shadow_catcher",
|
||||
"rotation_x",
|
||||
"rotation_y",
|
||||
"rotation_z",
|
||||
"focal_length_mm",
|
||||
"sensor_width_mm",
|
||||
"material_override",
|
||||
}
|
||||
|
||||
glb_bbox = next(
|
||||
definition for definition in body["definitions"] if definition["step"] == StepName.GLB_BBOX.value
|
||||
)
|
||||
assert glb_bbox["fields"] == [
|
||||
{
|
||||
"key": "glb_path",
|
||||
"label": "GLB Path Override",
|
||||
"type": "text",
|
||||
"description": "Optional absolute path to a specific GLB file. Leave empty to reuse the prepared preview/export artifact automatically.",
|
||||
"section": "Inputs",
|
||||
"default": "",
|
||||
"min": None,
|
||||
"max": None,
|
||||
"step": None,
|
||||
"unit": None,
|
||||
"options": [],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -82,3 +201,52 @@ async def test_workflow_crud_roundtrip_preserves_execution_mode(client, auth_hea
|
||||
assert get_response.status_code == 200
|
||||
fetched = get_response.json()
|
||||
assert fetched["config"]["ui"]["execution_mode"] == "shadow"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_backfill_workflows_rewrites_legacy_configs(client, db, auth_headers):
|
||||
legacy = WorkflowDefinition(
|
||||
name="Legacy Still Workflow",
|
||||
config={
|
||||
"type": "still",
|
||||
"params": {"width": 1280, "height": 720},
|
||||
"ui": {"execution_mode": "graph"},
|
||||
},
|
||||
is_active=True,
|
||||
)
|
||||
canonical = WorkflowDefinition(
|
||||
name="Canonical Workflow",
|
||||
config={
|
||||
"version": 1,
|
||||
"ui": {"preset": "custom", "execution_mode": "legacy"},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "setup",
|
||||
"step": StepName.ORDER_LINE_SETUP.value,
|
||||
"params": {},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
is_active=True,
|
||||
)
|
||||
db.add_all([legacy, canonical])
|
||||
await db.commit()
|
||||
|
||||
response = await client.post("/api/admin/settings/backfill-workflows", headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["scanned"] == 2
|
||||
assert body["updated"] == 1
|
||||
assert body["invalid"] == []
|
||||
assert body["workflows"] == [{"id": str(legacy.id), "name": "Legacy Still Workflow"}]
|
||||
|
||||
await db.refresh(legacy)
|
||||
await db.refresh(canonical)
|
||||
|
||||
assert legacy.config["version"] == 1
|
||||
assert legacy.config["ui"]["preset"] == "still"
|
||||
assert legacy.config["ui"]["execution_mode"] == "graph"
|
||||
assert any(node["step"] == StepName.BLENDER_STILL.value for node in legacy.config["nodes"])
|
||||
assert canonical.config["ui"]["preset"] == "custom"
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domains.rendering.workflow_schema import WorkflowConfig
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_duplicate_edges():
|
||||
with pytest.raises(ValidationError, match="duplicate edge is not allowed"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "render"},
|
||||
{"from": "setup", "to": "render"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_self_referential_edges():
|
||||
with pytest.raises(ValidationError, match="self-referential edge is not allowed"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "render", "to": "render"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_cycles():
|
||||
with pytest.raises(ValidationError, match="workflow graph must be acyclic"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "template", "step": "resolve_template", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
{"from": "render", "to": "setup"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_unknown_node_params():
|
||||
with pytest.raises(ValidationError, match="uses unknown param key"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bbox",
|
||||
"step": "glb_bbox",
|
||||
"params": {"glb_path": "/tmp/model.glb", "bogus": "value"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_accepts_known_node_params():
|
||||
config = WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bbox",
|
||||
"step": "glb_bbox",
|
||||
"params": {"glb_path": "/tmp/model.glb"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
"ui": {"family": "order_line"},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.ui is not None
|
||||
assert config.ui.family == "order_line"
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_ui_family_mismatch():
|
||||
with pytest.raises(ValidationError, match="ui.family"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "render"},
|
||||
],
|
||||
"ui": {"family": "cad_file"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_accepts_explicit_mixed_family_when_declared():
|
||||
config = WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "cad", "step": "resolve_step_path", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [],
|
||||
"ui": {"family": "mixed"},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.ui is not None
|
||||
assert config.ui.family == "mixed"
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_invalid_select_value():
|
||||
with pytest.raises(ValidationError, match="must be one of"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "notify",
|
||||
"step": "notify",
|
||||
"params": {"channel": "email"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_invalid_number_value():
|
||||
with pytest.raises(ValidationError, match="must be a number"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "render",
|
||||
"step": "blender_still",
|
||||
"params": {"samples": "high"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_invalid_boolean_value():
|
||||
with pytest.raises(ValidationError, match="must be a boolean"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "render",
|
||||
"step": "blender_still",
|
||||
"params": {"use_custom_render_settings": "yes"},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_missing_required_upstream_artifact():
|
||||
with pytest.raises(ValidationError, match="missing required input artifact"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "render"},
|
||||
],
|
||||
"ui": {"execution_mode": "graph", "family": "order_line"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_schema_accepts_transitive_contract_wiring():
|
||||
config = WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "template", "step": "resolve_template", "params": {}},
|
||||
{"id": "populate_materials", "step": "auto_populate_materials", "params": {}},
|
||||
{"id": "bbox", "step": "glb_bbox", "params": {}},
|
||||
{"id": "resolve_materials", "step": "material_map_resolve", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
{"id": "output", "step": "output_save", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "setup", "to": "populate_materials"},
|
||||
{"from": "setup", "to": "bbox"},
|
||||
{"from": "template", "to": "resolve_materials"},
|
||||
{"from": "populate_materials", "to": "resolve_materials"},
|
||||
{"from": "template", "to": "render"},
|
||||
{"from": "bbox", "to": "render"},
|
||||
{"from": "resolve_materials", "to": "render"},
|
||||
{"from": "render", "to": "output"},
|
||||
],
|
||||
"ui": {"family": "order_line", "execution_mode": "graph"},
|
||||
}
|
||||
)
|
||||
|
||||
assert config.ui is not None
|
||||
assert config.ui.execution_mode == "graph"
|
||||
|
||||
|
||||
def test_workflow_schema_rejects_mixed_family_graph_execution():
|
||||
with pytest.raises(ValidationError, match="single-family"):
|
||||
WorkflowConfig.model_validate(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "cad", "step": "resolve_step_path", "params": {}},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [],
|
||||
"ui": {"execution_mode": "graph", "family": "mixed"},
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user