Files
HartOMat/backend/tests/domains/test_workflow_schema.py
T

441 lines
14 KiB
Python

import pytest
from pydantic import ValidationError
from app.core.process_steps import StepName
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_rejects_unregistered_nodes_from_registry(monkeypatch):
from app.domains.rendering import workflow_schema as schema_module
original = schema_module.get_node_definition
def fake_get_node_definition(step):
if step == StepName.GLB_BBOX:
return None
return original(step)
monkeypatch.setattr(schema_module, "get_node_definition", fake_get_node_definition)
with pytest.raises(ValidationError, match="is not registered in workflow_node_registry"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "bbox",
"step": StepName.GLB_BBOX.value,
"params": {},
},
],
"edges": [],
"ui": {"family": "order_line"},
}
)
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_invalid_glb_path_format():
with pytest.raises(ValidationError, match="must point to a .glb file"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "bbox",
"step": "glb_bbox",
"params": {"glb_path": "/tmp/model.gltf"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_invalid_template_id_override_format():
with pytest.raises(ValidationError, match="must be a valid UUID"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "template",
"step": "resolve_template",
"params": {"template_id_override": "not-a-uuid"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_invalid_material_library_path_format():
with pytest.raises(ValidationError, match="must point to a .blend file"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "template",
"step": "resolve_template",
"params": {"material_library_path": "/tmp/library.txt"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_invalid_noise_threshold_format():
with pytest.raises(ValidationError, match="must be a valid numeric string"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "render",
"step": "blender_still",
"params": {"noise_threshold": "fast"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_invalid_bg_color_format():
with pytest.raises(ValidationError, match="must be a hex color"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "turntable",
"step": "blender_turntable",
"params": {"bg_color": "blue"},
},
],
"edges": [],
}
)
def test_workflow_schema_rejects_invalid_output_name_suffix_format():
with pytest.raises(ValidationError, match="may only contain letters, numbers"):
WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "blend",
"step": "export_blend",
"params": {"output_name_suffix": "../unsafe"},
},
],
"edges": [],
}
)
def test_workflow_schema_accepts_empty_optional_text_overrides():
config = WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{
"id": "template",
"step": "resolve_template",
"params": {
"template_id_override": "",
"material_library_path": "",
},
},
{
"id": "render",
"step": "blender_still",
"params": {
"noise_threshold": "",
"material_override": "",
},
},
{
"id": "turntable",
"step": "blender_turntable",
"params": {"bg_color": ""},
},
{
"id": "blend",
"step": "export_blend",
"params": {"output_name_suffix": ""},
},
],
"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_accepts_cad_intake_contract_wiring_with_shared_bbox_node():
config = WorkflowConfig.model_validate(
{
"version": 1,
"nodes": [
{"id": "resolve_step", "step": "resolve_step_path", "params": {}},
{"id": "export_glb", "step": "occ_glb_export", "params": {}},
{"id": "bbox", "step": "glb_bbox", "params": {}},
{"id": "threejs_thumb", "step": "threejs_render", "params": {}},
{"id": "save", "step": "thumbnail_save", "params": {}},
],
"edges": [
{"from": "resolve_step", "to": "export_glb"},
{"from": "export_glb", "to": "bbox"},
{"from": "export_glb", "to": "threejs_thumb"},
{"from": "bbox", "to": "threejs_thumb"},
{"from": "threejs_thumb", "to": "save"},
],
"ui": {"family": "cad_file", "execution_mode": "graph"},
}
)
assert config.ui is not None
assert config.ui.family == "cad_file"
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"},
}
)