chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
@@ -1,7 +1,9 @@
import pytest
from app.core.process_steps import StepName
from app.domains.rendering.models import WorkflowDefinition
from app.domains.rendering.models import OutputType, WorkflowDefinition, WorkflowRun
from app.domains.rendering.workflow_config_utils import build_preset_workflow_config
from app.domains.rendering.workflow_graph_runtime import _STILL_TASK_KEYS, _TURNTABLE_TASK_KEYS
from app.domains.rendering.workflow_node_registry import (
get_node_definition,
list_node_definitions,
@@ -14,11 +16,55 @@ def test_node_registry_covers_all_step_names():
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.family in {"cad_file", "order_line", "shared"} for definition in definitions)
assert all(definition.module_key for definition in definitions)
assert all(definition.legacy_source for definition in definitions)
def test_node_registry_module_keys_are_unique():
definitions = list_node_definitions()
module_keys = [definition.module_key for definition in definitions]
assert len(module_keys) == len(set(module_keys))
def test_node_registry_defaults_match_declared_fields():
definitions = list_node_definitions()
for definition in definitions:
field_keys = {field.key for field in definition.fields}
default_keys = set(definition.defaults)
assert default_keys <= field_keys
def test_node_registry_contracts_have_valid_shape():
definitions = list_node_definitions()
for definition in definitions:
input_context = definition.input_contract.get("context")
output_context = definition.output_contract.get("context")
if definition.family == "shared":
assert input_context is None
assert output_context is None
else:
assert input_context == definition.family
assert output_context == definition.family
required = definition.input_contract.get("requires", [])
required_any = definition.input_contract.get("requires_any", [])
provides = definition.output_contract.get("provides", [])
assert len(required) == len(set(required))
assert len(required_any) == len(set(required_any))
assert len(provides) == len(set(provides))
assert len(definition.artifact_roles_consumed) == len(set(definition.artifact_roles_consumed))
assert len(definition.artifact_roles_produced) == len(set(definition.artifact_roles_produced))
field_keys = [field.key for field in definition.fields]
assert len(field_keys) == len(set(field_keys))
def test_turntable_node_definition_exposes_expected_schema():
definition = get_node_definition(StepName.BLENDER_TURNTABLE)
@@ -27,7 +73,11 @@ def test_turntable_node_definition_exposes_expected_schema():
assert definition.module_key == "render.production.turntable"
assert definition.node_type == "renderFramesNode"
assert definition.defaults["fps"] == 24
assert definition.defaults["frame_count"] == 120
assert definition.defaults["duration_s"] == 5
assert definition.defaults["turntable_degrees"] == 360
assert definition.defaults["turntable_axis"] == "world_z"
assert definition.defaults["camera_orbit"] is True
assert definition.input_contract["context"] == "order_line"
assert definition.output_contract["provides"] == ["rendered_frames", "rendered_video"]
assert "material_assignments" in definition.artifact_roles_consumed
@@ -55,6 +105,22 @@ def test_turntable_node_definition_exposes_expected_schema():
}
def test_graph_render_node_fields_are_supported_by_runtime_dispatch():
still_definition = get_node_definition(StepName.BLENDER_STILL)
turntable_definition = get_node_definition(StepName.BLENDER_TURNTABLE)
assert still_definition is not None
assert turntable_definition is not None
still_runtime_fields = {field.key for field in still_definition.fields if field.key != "use_custom_render_settings"}
turntable_runtime_fields = {
field.key for field in turntable_definition.fields if field.key != "use_custom_render_settings"
}
assert still_runtime_fields <= _STILL_TASK_KEYS
assert turntable_runtime_fields <= _TURNTABLE_TASK_KEYS
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)
@@ -87,12 +153,40 @@ def test_order_line_setup_and_template_contracts_expose_runtime_outputs():
"use_materials",
"override_material",
}
assert {field.key for field in bbox.fields} == {"glb_path"}
assert {field.key for field in bbox.fields} == {"glb_path", "source_preference"}
assert bbox.family == "shared"
assert bbox.input_contract == {"requires": ["glb_preview"]}
assert bbox.output_contract == {"provides": ["bbox"]}
assert {field.key for field in template.fields} == {
"template_id_override",
"require_template",
"material_library_path",
"disable_materials",
"target_collection",
"material_replace_mode",
"lighting_only_mode",
"shadow_catcher_mode",
"camera_orbit_mode",
}
assert {field.key for field in get_node_definition(StepName.MATERIAL_MAP_RESOLVE).fields} == {
"disable_materials",
"material_override",
}
assert {field.key for field in get_node_definition(StepName.AUTO_POPULATE_MATERIALS).fields} == {
"persist_updates",
"refresh_material_source",
"include_populated_products",
}
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 {field.key for field in output.fields} == {
"expected_artifact_role",
"require_upstream_artifact",
}
assert export_blend.defaults["output_name_suffix"] == ""
assert {field.key for field in export_blend.fields} == {"output_name_suffix"}
assert notify.defaults == {"channel": "audit_log", "require_armed_render": False}
assert notify.input_contract["requires"] == ["order_line_context"]
assert notify.input_contract["requires_any"] == [
"rendered_image",
@@ -100,6 +194,58 @@ def test_order_line_setup_and_template_contracts_expose_runtime_outputs():
"rendered_video",
"workflow_result",
]
assert {field.key for field in notify.fields} == {"channel", "require_armed_render"}
def test_cad_and_export_contract_nodes_only_expose_supported_settings():
occ_glb_export = get_node_definition(StepName.OCC_GLB_EXPORT)
thumbnail_save = get_node_definition(StepName.THUMBNAIL_SAVE)
export_blend = get_node_definition(StepName.EXPORT_BLEND)
stl_cache_generate = get_node_definition(StepName.STL_CACHE_GENERATE)
assert occ_glb_export is not None
assert thumbnail_save is not None
assert export_blend is not None
assert stl_cache_generate is not None
assert occ_glb_export.family == "cad_file"
assert occ_glb_export.fields == []
assert occ_glb_export.defaults == {}
assert occ_glb_export.input_contract == {"context": "cad_file", "requires": ["step_path"]}
assert occ_glb_export.output_contract == {"context": "cad_file", "provides": ["glb_preview"]}
assert occ_glb_export.artifact_roles_consumed == ["step_path"]
assert occ_glb_export.artifact_roles_produced == ["glb_preview"]
assert "does not expose per-node overrides yet" in occ_glb_export.description
assert thumbnail_save.family == "cad_file"
assert thumbnail_save.fields == []
assert thumbnail_save.defaults == {}
assert thumbnail_save.input_contract == {"context": "cad_file", "requires": ["rendered_image"]}
assert thumbnail_save.output_contract == {"context": "cad_file", "provides": ["cad_thumbnail_media"]}
assert thumbnail_save.artifact_roles_consumed == ["rendered_image"]
assert thumbnail_save.artifact_roles_produced == ["cad_thumbnail_media"]
assert "connected upstream thumbnail request node" in thumbnail_save.description
assert export_blend.family == "order_line"
assert export_blend.defaults == {"output_name_suffix": ""}
assert {field.key for field in export_blend.fields} == {"output_name_suffix"}
assert export_blend.input_contract == {
"context": "order_line",
"requires": ["order_line_context", "render_template"],
}
assert export_blend.output_contract == {"context": "order_line", "provides": ["blend_asset"]}
assert export_blend.artifact_roles_consumed == ["order_line_context", "render_template"]
assert export_blend.artifact_roles_produced == ["blend_asset"]
assert "Only the optional filename suffix is workflow-configurable today." in export_blend.description
assert stl_cache_generate.family == "cad_file"
assert stl_cache_generate.fields == []
assert stl_cache_generate.defaults == {}
assert stl_cache_generate.input_contract == {"context": "cad_file", "requires": ["step_path"]}
assert stl_cache_generate.output_contract == {"context": "cad_file", "provides": ["stl_cache"]}
assert stl_cache_generate.artifact_roles_consumed == ["step_path"]
assert stl_cache_generate.artifact_roles_produced == ["stl_cache"]
assert "Compatibility node for legacy CAD flows." in stl_cache_generate.description
@pytest.mark.asyncio
@@ -146,6 +292,16 @@ async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
"material_override",
}
blender_turntable = next(
definition for definition in body["definitions"] if definition["step"] == StepName.BLENDER_TURNTABLE.value
)
assert blender_turntable["defaults"]["fps"] == 24
assert blender_turntable["defaults"]["frame_count"] == 120
assert blender_turntable["defaults"]["duration_s"] == 5
assert blender_turntable["defaults"]["turntable_degrees"] == 360
assert blender_turntable["defaults"]["turntable_axis"] == "world_z"
assert blender_turntable["defaults"]["camera_orbit"] is True
glb_bbox = next(
definition for definition in body["definitions"] if definition["step"] == StepName.GLB_BBOX.value
)
@@ -162,7 +318,30 @@ async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
"step": None,
"unit": None,
"options": [],
}
"allow_blank": True,
"max_length": None,
"text_format": "absolute_glb_path",
},
{
"key": "source_preference",
"label": "Source Preference",
"type": "select",
"description": "Prefer a prepared GLB, force STEP fallback, or fail when no GLB artifact is available.",
"section": "Inputs",
"default": "auto",
"min": None,
"max": None,
"step": None,
"unit": None,
"options": [
{"value": "auto", "label": "Auto"},
{"value": "step_only", "label": "STEP Only"},
{"value": "glb_only", "label": "GLB Only"},
],
"allow_blank": True,
"max_length": None,
"text_format": "plain",
},
]
@@ -203,6 +382,85 @@ async def test_workflow_crud_roundtrip_preserves_execution_mode(client, auth_hea
assert fetched["config"]["ui"]["execution_mode"] == "shadow"
@pytest.mark.asyncio
async def test_workflow_crud_exposes_supported_artifact_kinds(client, auth_headers):
create_response = await client.post(
"/api/workflows",
headers=auth_headers,
json={
"name": "Still Workflow Contract",
"config": build_preset_workflow_config("still_graph"),
"is_active": True,
},
)
assert create_response.status_code == 201, create_response.text
created = create_response.json()
assert created["family"] == "order_line"
assert created["supported_artifact_kinds"] == ["still_image"]
get_response = await client.get(f"/api/workflows/{created['id']}", headers=auth_headers)
assert get_response.status_code == 200
fetched = get_response.json()
assert fetched["supported_artifact_kinds"] == ["still_image"]
@pytest.mark.asyncio
async def test_workflow_crud_exposes_rollout_summary(client, db, auth_headers):
workflow = WorkflowDefinition(
name="Shadow Rollout Workflow",
config=build_preset_workflow_config("still_graph") | {
"ui": {
**(build_preset_workflow_config("still_graph").get("ui") or {}),
"execution_mode": "shadow",
}
},
is_active=True,
)
db.add(workflow)
await db.flush()
output_type = OutputType(
name="Shadow Still Output",
workflow_definition_id=workflow.id,
workflow_family="order_line",
artifact_kind="still_image",
workflow_rollout_mode="shadow",
render_backend="celery",
)
db.add(output_type)
await db.flush()
workflow_run = WorkflowRun(
workflow_def_id=workflow.id,
execution_mode="shadow",
status="completed",
)
db.add(workflow_run)
await db.commit()
response = await client.get(f"/api/workflows/{workflow.id}", headers=auth_headers)
assert response.status_code == 200, response.text
body = response.json()
assert body["rollout_summary"]["linked_output_type_count"] == 1
assert body["rollout_summary"]["linked_output_type_names"] == ["Shadow Still Output"]
assert body["rollout_summary"]["linked_output_types"] == [
{
"id": str(output_type.id),
"name": "Shadow Still Output",
"is_active": True,
"artifact_kind": "still_image",
"workflow_rollout_mode": "shadow",
}
]
assert body["rollout_summary"]["rollout_modes"] == ["shadow"]
assert body["rollout_summary"]["has_blocking_contracts"] is False
assert body["rollout_summary"]["latest_shadow_run"]["workflow_run_id"] == str(workflow_run.id)
assert body["rollout_summary"]["latest_shadow_run"]["execution_mode"] == "shadow"
@pytest.mark.asyncio
async def test_admin_backfill_workflows_rewrites_legacy_configs(client, db, auth_headers):
legacy = WorkflowDefinition(