chore: snapshot workflow migration progress
This commit is contained in:
@@ -6,10 +6,10 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select, text
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import Base
|
||||
from app.core.render_paths import build_order_line_export_path, build_order_line_step_render_path
|
||||
from app.core.process_steps import StepName
|
||||
from app.domains.auth.models import User, UserRole
|
||||
from app.domains.materials.models import AssetLibrary
|
||||
@@ -27,25 +27,13 @@ from app.domains.rendering.workflow_graph_runtime import (
|
||||
from app.domains.rendering.workflow_run_service import create_workflow_run
|
||||
from app.domains.rendering.workflow_runtime_services import OrderLineRenderSetupResult
|
||||
|
||||
import app.models # noqa: F401
|
||||
from tests.db_test_utils import reset_public_schema_sync, resolve_test_db_url
|
||||
from tests.db_test_utils import sync_test_session as sync_test_session_ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sync_session():
|
||||
engine = create_engine(resolve_test_db_url(async_driver=False))
|
||||
with engine.begin() as conn:
|
||||
reset_public_schema_sync(conn)
|
||||
Base.metadata.create_all(conn)
|
||||
|
||||
session = Session(engine)
|
||||
try:
|
||||
with sync_test_session_ctx() as session:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
with engine.begin() as conn:
|
||||
reset_public_schema_sync(conn)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _seed_renderable_order_line(
|
||||
@@ -137,6 +125,19 @@ def _seed_renderable_order_line(
|
||||
target_collection="Product",
|
||||
material_replace_enabled=True,
|
||||
lighting_only=False,
|
||||
workflow_input_schema=[
|
||||
{
|
||||
"key": "studio_variant",
|
||||
"label": "Studio Variant",
|
||||
"type": "select",
|
||||
"section": "Template Inputs",
|
||||
"default": "default",
|
||||
"options": [
|
||||
{"value": "default", "label": "Default"},
|
||||
{"value": "warm", "label": "Warm"},
|
||||
],
|
||||
}
|
||||
],
|
||||
is_active=True,
|
||||
output_types=[output_type],
|
||||
)
|
||||
@@ -329,6 +330,193 @@ def test_execute_graph_workflow_routes_cad_thumbnail_save_using_upstream_threejs
|
||||
assert node_results["save"].output["predicted_output_path"].endswith(f"{cad_file.id}.png")
|
||||
|
||||
|
||||
def test_execute_graph_workflow_serializes_template_schema_and_template_inputs(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
template = sync_session.execute(select(RenderTemplate)).unique().scalar_one()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.domains.rendering.workflow_runtime_services.resolve_material_map",
|
||||
lambda raw_map: {key: f"resolved:{value}" for key, value in raw_map.items()},
|
||||
)
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{
|
||||
"id": "template",
|
||||
"step": "resolve_template",
|
||||
"params": {
|
||||
"template_id_override": str(template.id),
|
||||
"template_input__studio_variant": "warm",
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="graph",
|
||||
)
|
||||
run = create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
refreshed_run = sync_session.execute(
|
||||
select(WorkflowRun)
|
||||
.where(WorkflowRun.id == run.id)
|
||||
.options(selectinload(WorkflowRun.node_results))
|
||||
).scalar_one()
|
||||
node_results = {node_result.node_name: node_result for node_result in refreshed_run.node_results}
|
||||
|
||||
assert dispatch_result.task_ids == []
|
||||
assert node_results["template"].status == "completed"
|
||||
assert node_results["template"].output["workflow_input_schema"] == template.workflow_input_schema
|
||||
assert node_results["template"].output["template_inputs"] == {"studio_variant": "warm"}
|
||||
assert node_results["template"].output["template_input_count"] == 1
|
||||
|
||||
|
||||
def test_execute_graph_workflow_passes_template_inputs_to_still_task(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
template = sync_session.execute(select(RenderTemplate)).unique().scalar_one()
|
||||
|
||||
send_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def _fake_send_task(task_name: str, args: list[str], kwargs: dict[str, object]):
|
||||
send_calls.append((task_name, args, kwargs))
|
||||
return SimpleNamespace(id="task-still-template-inputs")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.celery_app.celery_app.send_task",
|
||||
_fake_send_task,
|
||||
)
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{
|
||||
"id": "template",
|
||||
"step": "resolve_template",
|
||||
"params": {
|
||||
"template_id_override": str(template.id),
|
||||
"template_input__studio_variant": "warm",
|
||||
},
|
||||
},
|
||||
{"id": "render", "step": "blender_still", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="graph",
|
||||
)
|
||||
create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
assert dispatch_result.task_ids == ["task-still-template-inputs"]
|
||||
assert len(send_calls) == 1
|
||||
assert send_calls[0][0] == "app.domains.rendering.tasks.render_order_line_still_task"
|
||||
assert send_calls[0][1] == [str(line.id)]
|
||||
assert send_calls[0][2]["template_inputs"] == {"studio_variant": "warm"}
|
||||
|
||||
|
||||
def test_execute_graph_workflow_passes_template_inputs_and_duration_to_turntable_task(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
template = sync_session.execute(select(RenderTemplate)).unique().scalar_one()
|
||||
|
||||
send_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def _fake_send_task(task_name: str, args: list[str], kwargs: dict[str, object]):
|
||||
send_calls.append((task_name, args, kwargs))
|
||||
return SimpleNamespace(id="task-turntable-template-inputs")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.celery_app.celery_app.send_task",
|
||||
_fake_send_task,
|
||||
)
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{
|
||||
"id": "template",
|
||||
"step": "resolve_template",
|
||||
"params": {
|
||||
"template_id_override": str(template.id),
|
||||
"template_input__studio_variant": "warm",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "render",
|
||||
"step": "blender_turntable",
|
||||
"params": {
|
||||
"fps": 12,
|
||||
"duration_s": 7,
|
||||
"frame_count": 999,
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="graph",
|
||||
)
|
||||
create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
assert dispatch_result.task_ids == ["task-turntable-template-inputs"]
|
||||
assert len(send_calls) == 1
|
||||
assert send_calls[0][0] == "app.domains.rendering.tasks.render_turntable_task"
|
||||
assert send_calls[0][1] == [str(line.id)]
|
||||
assert send_calls[0][2]["template_inputs"] == {"studio_variant": "warm"}
|
||||
assert send_calls[0][2]["duration_s"] == 7.0
|
||||
assert send_calls[0][2]["fps"] == 12
|
||||
assert send_calls[0][2]["frame_count"] == 84
|
||||
|
||||
|
||||
def test_execute_graph_workflow_completes_cad_bridge_only_nodes_without_queueing(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
@@ -660,6 +848,108 @@ def test_build_task_kwargs_autoscales_default_samples_via_shared_render_invocati
|
||||
assert kwargs["samples"] == 64
|
||||
|
||||
|
||||
def test_build_task_kwargs_ignores_authoritative_still_overrides_without_opt_in(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
step_path = tmp_path / "cad" / "bearing.step"
|
||||
step_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
step_path.write_text("STEP", encoding="utf-8")
|
||||
|
||||
output_type = OutputType(
|
||||
id=uuid.uuid4(),
|
||||
name="Still Preview",
|
||||
renderer="blender",
|
||||
output_format="png",
|
||||
render_settings={
|
||||
"width": 2048,
|
||||
"height": 1536,
|
||||
"engine": "cycles",
|
||||
"samples": 128,
|
||||
"noise_threshold": "0.05",
|
||||
},
|
||||
transparent_bg=True,
|
||||
cycles_device="cuda",
|
||||
)
|
||||
cad_file = CadFile(
|
||||
id=uuid.uuid4(),
|
||||
original_name="bearing.step",
|
||||
stored_path=str(step_path),
|
||||
file_hash="hash-graph-2",
|
||||
parsed_objects={"objects": ["InnerRing", "OuterRing"]},
|
||||
)
|
||||
product = Product(
|
||||
id=uuid.uuid4(),
|
||||
pim_id="P-graph-2",
|
||||
name="Bearing G2",
|
||||
category_key="bearings",
|
||||
cad_file_id=cad_file.id,
|
||||
cad_file=cad_file,
|
||||
)
|
||||
line = OrderLine(
|
||||
id=uuid.uuid4(),
|
||||
order_id=uuid.uuid4(),
|
||||
product_id=product.id,
|
||||
product=product,
|
||||
output_type_id=output_type.id,
|
||||
output_type=output_type,
|
||||
)
|
||||
state = WorkflowGraphState(
|
||||
setup=OrderLineRenderSetupResult(
|
||||
status="ready",
|
||||
order_line=line,
|
||||
cad_file=cad_file,
|
||||
part_colors={"InnerRing": "Steel raw"},
|
||||
)
|
||||
)
|
||||
workflow_context = SimpleNamespace(
|
||||
workflow_run_id=uuid.uuid4(),
|
||||
execution_mode="graph",
|
||||
ordered_nodes=[],
|
||||
edges=[],
|
||||
)
|
||||
node = SimpleNamespace(
|
||||
id="render",
|
||||
step=StepName.BLENDER_STILL,
|
||||
params={
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"samples": 16,
|
||||
"render_engine": "eevee",
|
||||
"transparent_bg": False,
|
||||
"cycles_device": "cpu",
|
||||
"noise_threshold": "0.2",
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.domains.rendering.workflow_graph_runtime.resolve_render_position_context",
|
||||
lambda _session, _line: SimpleNamespace(
|
||||
rotation_x=0.0,
|
||||
rotation_y=0.0,
|
||||
rotation_z=0.0,
|
||||
focal_length_mm=None,
|
||||
sensor_width_mm=None,
|
||||
),
|
||||
)
|
||||
|
||||
kwargs = _build_task_kwargs(
|
||||
session=object(),
|
||||
workflow_context=workflow_context,
|
||||
state=state,
|
||||
node=node,
|
||||
)
|
||||
|
||||
assert kwargs["width"] == 2048
|
||||
assert kwargs["height"] == 1536
|
||||
assert kwargs["engine"] == "cycles"
|
||||
assert kwargs["samples"] == 128
|
||||
assert kwargs["transparent_bg"] is True
|
||||
assert kwargs["cycles_device"] == "cuda"
|
||||
assert kwargs["noise_threshold"] == "0.05"
|
||||
assert "render_engine" not in kwargs
|
||||
|
||||
|
||||
def test_execute_graph_workflow_respects_custom_render_settings_opt_in_for_still_task(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
@@ -838,6 +1128,221 @@ def test_execute_graph_workflow_preserves_turntable_timing_without_custom_render
|
||||
assert kwargs["output_name_suffix"].startswith("shadow-")
|
||||
|
||||
|
||||
def test_execute_graph_workflow_respects_custom_render_settings_opt_in_for_turntable_task(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
assert line.output_type is not None
|
||||
line.output_type.render_settings = {
|
||||
"width": 2048,
|
||||
"height": 2048,
|
||||
"engine": "cycles",
|
||||
"samples": 128,
|
||||
"fps": 30,
|
||||
"frame_count": 180,
|
||||
}
|
||||
sync_session.commit()
|
||||
|
||||
send_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def _fake_send_task(task_name: str, args: list[str], kwargs: dict[str, object]):
|
||||
send_calls.append((task_name, args, kwargs))
|
||||
return SimpleNamespace(id="task-custom-turntable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.celery_app.celery_app.send_task",
|
||||
_fake_send_task,
|
||||
)
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "template", "step": "resolve_template", "params": {}},
|
||||
{
|
||||
"id": "render",
|
||||
"step": "blender_turntable",
|
||||
"params": {
|
||||
"use_custom_render_settings": True,
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"samples": 32,
|
||||
"render_engine": "eevee",
|
||||
"fps": 12,
|
||||
"duration_s": 6,
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="graph",
|
||||
)
|
||||
create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
assert dispatch_result.task_ids == ["task-custom-turntable"]
|
||||
assert len(send_calls) == 1
|
||||
|
||||
task_name, args, kwargs = send_calls[0]
|
||||
assert task_name == "app.domains.rendering.tasks.render_turntable_task"
|
||||
assert args == [str(line.id)]
|
||||
assert kwargs["width"] == 1024
|
||||
assert kwargs["height"] == 768
|
||||
assert kwargs["samples"] == 32
|
||||
assert kwargs["render_engine"] == "eevee"
|
||||
assert kwargs["engine"] == "cycles"
|
||||
assert kwargs["fps"] == 12
|
||||
assert kwargs["duration_s"] == 6.0
|
||||
assert kwargs["frame_count"] == 72
|
||||
|
||||
|
||||
def test_execute_graph_workflow_preserves_template_camera_orbit_without_custom_render_settings(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
template = sync_session.execute(select(RenderTemplate)).unique().scalar_one()
|
||||
template.camera_orbit = False
|
||||
assert line.output_type is not None
|
||||
line.output_type.render_settings = {
|
||||
"width": 2048,
|
||||
"height": 2048,
|
||||
"engine": "cycles",
|
||||
"samples": 128,
|
||||
"fps": 30,
|
||||
"frame_count": 180,
|
||||
}
|
||||
sync_session.commit()
|
||||
|
||||
send_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
def _fake_send_task(task_name: str, args: list[str], kwargs: dict[str, object]):
|
||||
send_calls.append((task_name, args, kwargs))
|
||||
return SimpleNamespace(id="task-turntable-camera-orbit")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.celery_app.celery_app.send_task",
|
||||
_fake_send_task,
|
||||
)
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "template", "step": "resolve_template", "params": {}},
|
||||
{
|
||||
"id": "render",
|
||||
"step": "blender_turntable",
|
||||
"params": {
|
||||
"fps": 24,
|
||||
"frame_count": 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="graph",
|
||||
)
|
||||
create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
assert dispatch_result.task_ids == ["task-turntable-camera-orbit"]
|
||||
assert len(send_calls) == 1
|
||||
assert send_calls[0][2]["camera_orbit"] is False
|
||||
|
||||
|
||||
def test_execute_graph_workflow_serializes_template_override_modes(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
template = sync_session.execute(select(RenderTemplate)).unique().scalar_one()
|
||||
template.target_collection = "TemplateCollection"
|
||||
template.material_replace_enabled = False
|
||||
template.lighting_only = False
|
||||
template.shadow_catcher_enabled = False
|
||||
template.camera_orbit = True
|
||||
sync_session.commit()
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{
|
||||
"id": "template",
|
||||
"step": "resolve_template",
|
||||
"params": {
|
||||
"target_collection": "NodeCollection",
|
||||
"material_library_path": "/libraries/materials.blend",
|
||||
"material_replace_mode": "enabled",
|
||||
"lighting_only_mode": "enabled",
|
||||
"shadow_catcher_mode": "enabled",
|
||||
"camera_orbit_mode": "disabled",
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "template"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="graph",
|
||||
)
|
||||
run = create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
refreshed_run = sync_session.execute(
|
||||
select(WorkflowRun)
|
||||
.where(WorkflowRun.id == run.id)
|
||||
.options(selectinload(WorkflowRun.node_results))
|
||||
).scalar_one()
|
||||
node_results = {node_result.node_name: node_result for node_result in refreshed_run.node_results}
|
||||
|
||||
assert dispatch_result.task_ids == []
|
||||
assert node_results["template"].status == "completed"
|
||||
assert node_results["template"].output["target_collection"] == "NodeCollection"
|
||||
assert node_results["template"].output["use_materials"] is True
|
||||
assert node_results["template"].output["lighting_only"] is True
|
||||
assert node_results["template"].output["shadow_catcher"] is True
|
||||
assert node_results["template"].output["camera_orbit"] is False
|
||||
|
||||
|
||||
def test_execute_graph_workflow_retries_bridge_node_and_persists_attempt_metadata(
|
||||
sync_session,
|
||||
monkeypatch,
|
||||
@@ -1010,16 +1515,22 @@ def test_execute_graph_workflow_supports_output_save_bridge_node(
|
||||
assert send_calls[0][2]["graph_authoritative_output_enabled"] is True
|
||||
assert send_calls[0][2]["graph_output_node_ids"] == ["output"]
|
||||
assert node_results["render"].status == "queued"
|
||||
assert node_results["output"].status == "completed"
|
||||
assert node_results["output"].status == "pending"
|
||||
assert node_results["output"].output["publication_mode"] == "awaiting_graph_authoritative_save"
|
||||
assert node_results["output"].output["order_line_id"] == str(line.id)
|
||||
assert node_results["output"].output["handoff_state"] == "armed"
|
||||
assert node_results["output"].output["handoff_node_ids"] == ["render"]
|
||||
assert node_results["output"].output["artifact_count"] == 1
|
||||
assert node_results["output"].output["upstream_artifacts"] == [
|
||||
{
|
||||
"node_id": "render",
|
||||
"artifact_role": "render_output",
|
||||
"predicted_output_path": str(
|
||||
tmp_path / "cad" / "renders" / f"line_{line.id}.png"
|
||||
build_order_line_step_render_path(
|
||||
line.product.cad_file.stored_path,
|
||||
str(line.id),
|
||||
f"line_{line.id}.png",
|
||||
)
|
||||
),
|
||||
"predicted_asset_type": "still",
|
||||
"publish_asset_enabled": False,
|
||||
@@ -1086,14 +1597,16 @@ def test_execute_graph_workflow_arms_output_save_handoff_for_export_blend(
|
||||
assert send_calls[0][2]["graph_authoritative_output_enabled"] is True
|
||||
assert send_calls[0][2]["graph_output_node_ids"] == ["output"]
|
||||
assert node_results["blend"].status == "queued"
|
||||
assert node_results["output"].status == "completed"
|
||||
assert node_results["output"].status == "pending"
|
||||
assert node_results["output"].output["publication_mode"] == "awaiting_graph_authoritative_save"
|
||||
assert node_results["output"].output["handoff_state"] == "armed"
|
||||
assert node_results["output"].output["handoff_node_ids"] == ["blend"]
|
||||
assert node_results["output"].output["artifact_count"] == 1
|
||||
assert node_results["output"].output["upstream_artifacts"] == [
|
||||
{
|
||||
"node_id": "blend",
|
||||
"artifact_role": "blend_export",
|
||||
"predicted_output_path": str(tmp_path / "cad" / "bearing_production.blend"),
|
||||
"predicted_output_path": str(build_order_line_export_path(str(line.id), "bearing_production.blend")),
|
||||
"predicted_asset_type": "blend_production",
|
||||
"publish_asset_enabled": False,
|
||||
"graph_authoritative_output_enabled": True,
|
||||
@@ -1160,14 +1673,18 @@ def test_execute_graph_workflow_arms_output_save_handoff_for_turntable(
|
||||
assert send_calls[0][2]["graph_output_node_ids"] == ["output"]
|
||||
assert send_calls[0][2]["workflow_node_id"] == "turntable"
|
||||
assert node_results["turntable"].status == "queued"
|
||||
assert node_results["output"].status == "completed"
|
||||
assert node_results["output"].status == "pending"
|
||||
assert node_results["output"].output["publication_mode"] == "awaiting_graph_authoritative_save"
|
||||
assert node_results["output"].output["handoff_state"] == "armed"
|
||||
assert node_results["output"].output["handoff_node_ids"] == ["turntable"]
|
||||
assert node_results["output"].output["artifact_count"] == 1
|
||||
assert node_results["output"].output["upstream_artifacts"] == [
|
||||
{
|
||||
"node_id": "turntable",
|
||||
"artifact_role": "turntable_output",
|
||||
"predicted_output_path": str(tmp_path / "cad" / "renders" / "turntable.mp4"),
|
||||
"predicted_output_path": str(
|
||||
build_order_line_step_render_path(line.product.cad_file.stored_path, str(line.id), "turntable.mp4")
|
||||
),
|
||||
"predicted_asset_type": "turntable",
|
||||
"publish_asset_enabled": False,
|
||||
"graph_authoritative_output_enabled": True,
|
||||
@@ -1178,6 +1695,150 @@ def test_execute_graph_workflow_arms_output_save_handoff_for_turntable(
|
||||
]
|
||||
|
||||
|
||||
def test_execute_graph_workflow_arms_shadow_output_save_handoff_for_turntable(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
send_calls: list[tuple[str, list[str], dict[str, object]]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.celery_app.celery_app.send_task",
|
||||
lambda task_name, args, kwargs: send_calls.append((task_name, args, kwargs))
|
||||
or SimpleNamespace(id="task-shadow-turntable-output-save"),
|
||||
)
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "turntable", "step": "blender_turntable", "params": {"fps": 24, "frame_count": 96}},
|
||||
{"id": "output", "step": "output_save", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "turntable"},
|
||||
{"from": "turntable", "to": "output"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="shadow",
|
||||
)
|
||||
run = create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
refreshed_run = sync_session.execute(
|
||||
select(WorkflowRun)
|
||||
.where(WorkflowRun.id == run.id)
|
||||
.options(selectinload(WorkflowRun.node_results))
|
||||
).scalar_one()
|
||||
node_results = {node_result.node_name: node_result for node_result in refreshed_run.node_results}
|
||||
|
||||
assert dispatch_result.task_ids == ["task-shadow-turntable-output-save"]
|
||||
assert len(send_calls) == 1
|
||||
assert send_calls[0][0] == "app.domains.rendering.tasks.render_turntable_task"
|
||||
assert send_calls[0][1] == [str(line.id)]
|
||||
assert send_calls[0][2]["publish_asset_enabled"] is False
|
||||
assert send_calls[0][2]["observer_output_enabled"] is True
|
||||
assert send_calls[0][2]["graph_output_node_ids"] == ["output"]
|
||||
assert "graph_authoritative_output_enabled" not in send_calls[0][2]
|
||||
assert node_results["turntable"].status == "queued"
|
||||
assert node_results["output"].status == "pending"
|
||||
assert node_results["output"].output["publication_mode"] == "shadow_observer_only"
|
||||
assert node_results["output"].output["handoff_state"] == "armed"
|
||||
assert node_results["output"].output["handoff_node_ids"] == ["turntable"]
|
||||
assert node_results["output"].output["artifact_count"] == 1
|
||||
assert node_results["output"].output["upstream_artifacts"] == [
|
||||
{
|
||||
"node_id": "turntable",
|
||||
"artifact_role": "turntable_output",
|
||||
"predicted_output_path": str(
|
||||
build_order_line_step_render_path(
|
||||
line.product.cad_file.stored_path,
|
||||
str(line.id),
|
||||
f"turntable_shadow-{str(run.id)[:8]}.mp4",
|
||||
)
|
||||
),
|
||||
"predicted_asset_type": "turntable",
|
||||
"publish_asset_enabled": False,
|
||||
"graph_authoritative_output_enabled": False,
|
||||
"graph_output_node_ids": ["output"],
|
||||
"notify_handoff_enabled": False,
|
||||
"task_id": "task-shadow-turntable-output-save",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_execute_graph_workflow_routes_shadow_render_tasks_to_light_queue_when_available(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
line = _seed_renderable_order_line(sync_session, tmp_path)
|
||||
send_calls: list[tuple[str, list[str], dict[str, object], dict[str, object]]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.domains.rendering.workflow_graph_runtime._inspect_active_worker_queues",
|
||||
lambda timeout=1.0: {"asset_pipeline", "asset_pipeline_light"},
|
||||
)
|
||||
|
||||
def _fake_send_task(task_name: str, args: list[str], kwargs: dict[str, object], **task_options):
|
||||
send_calls.append((task_name, args, kwargs, task_options))
|
||||
return SimpleNamespace(id="task-shadow-light-queue")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.tasks.celery_app.celery_app.send_task",
|
||||
_fake_send_task,
|
||||
)
|
||||
|
||||
workflow_context = prepare_workflow_context(
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{"id": "setup", "step": "order_line_setup", "params": {}},
|
||||
{"id": "turntable", "step": "blender_turntable", "params": {"fps": 24, "frame_count": 96}},
|
||||
{"id": "output", "step": "output_save", "params": {}},
|
||||
],
|
||||
"edges": [
|
||||
{"from": "setup", "to": "turntable"},
|
||||
{"from": "turntable", "to": "output"},
|
||||
],
|
||||
},
|
||||
context_id=str(line.id),
|
||||
execution_mode="shadow",
|
||||
)
|
||||
run = create_workflow_run(
|
||||
sync_session,
|
||||
workflow_def_id=None,
|
||||
order_line_id=line.id,
|
||||
workflow_context=workflow_context,
|
||||
)
|
||||
|
||||
dispatch_result = execute_graph_workflow(sync_session, workflow_context)
|
||||
sync_session.commit()
|
||||
|
||||
refreshed_run = sync_session.execute(
|
||||
select(WorkflowRun)
|
||||
.where(WorkflowRun.id == run.id)
|
||||
.options(selectinload(WorkflowRun.node_results))
|
||||
).scalar_one()
|
||||
node_results = {node_result.node_name: node_result for node_result in refreshed_run.node_results}
|
||||
|
||||
assert dispatch_result.task_ids == ["task-shadow-light-queue"]
|
||||
assert len(send_calls) == 1
|
||||
assert send_calls[0][0] == "app.domains.rendering.tasks.render_turntable_task"
|
||||
assert send_calls[0][3]["queue"] == "asset_pipeline_light"
|
||||
assert node_results["turntable"].output["task_queue"] == "asset_pipeline_light"
|
||||
|
||||
|
||||
def test_execute_graph_workflow_routes_output_save_handoffs_per_connected_branch(
|
||||
sync_session,
|
||||
tmp_path,
|
||||
@@ -1240,12 +1901,21 @@ def test_execute_graph_workflow_routes_output_save_handoffs_per_connected_branch
|
||||
assert send_calls[0][2]["graph_output_node_ids"] == ["still_output"]
|
||||
assert send_calls[1][0] == "app.domains.rendering.tasks.render_turntable_task"
|
||||
assert send_calls[1][2]["graph_output_node_ids"] == ["turntable_output"]
|
||||
assert node_results["still_output"].status == "pending"
|
||||
assert node_results["still_output"].output["handoff_state"] == "armed"
|
||||
assert node_results["still_output"].output["handoff_node_ids"] == ["still"]
|
||||
assert node_results["still_output"].output["artifact_count"] == 1
|
||||
assert node_results["still_output"].output["upstream_artifacts"] == [
|
||||
{
|
||||
"node_id": "still",
|
||||
"artifact_role": "render_output",
|
||||
"predicted_output_path": str(tmp_path / "cad" / "renders" / f"line_{line.id}.png"),
|
||||
"predicted_output_path": str(
|
||||
build_order_line_step_render_path(
|
||||
line.product.cad_file.stored_path,
|
||||
str(line.id),
|
||||
f"line_{line.id}.png",
|
||||
)
|
||||
),
|
||||
"predicted_asset_type": "still",
|
||||
"publish_asset_enabled": False,
|
||||
"graph_authoritative_output_enabled": True,
|
||||
@@ -1254,12 +1924,17 @@ def test_execute_graph_workflow_routes_output_save_handoffs_per_connected_branch
|
||||
"task_id": "task-branch-1",
|
||||
}
|
||||
]
|
||||
assert node_results["turntable_output"].status == "pending"
|
||||
assert node_results["turntable_output"].output["handoff_state"] == "armed"
|
||||
assert node_results["turntable_output"].output["handoff_node_ids"] == ["turntable"]
|
||||
assert node_results["turntable_output"].output["artifact_count"] == 1
|
||||
assert node_results["turntable_output"].output["upstream_artifacts"] == [
|
||||
{
|
||||
"node_id": "turntable",
|
||||
"artifact_role": "turntable_output",
|
||||
"predicted_output_path": str(tmp_path / "cad" / "renders" / "turntable.mp4"),
|
||||
"predicted_output_path": str(
|
||||
build_order_line_step_render_path(line.product.cad_file.stored_path, str(line.id), "turntable.mp4")
|
||||
),
|
||||
"predicted_asset_type": "turntable",
|
||||
"publish_asset_enabled": False,
|
||||
"graph_authoritative_output_enabled": True,
|
||||
@@ -1379,9 +2054,10 @@ def test_execute_graph_workflow_arms_notify_handoff_for_graph_render_task(
|
||||
assert send_calls[0][2]["emit_legacy_notifications"] is True
|
||||
assert send_calls[0][2]["graph_notify_node_ids"] == ["notify"]
|
||||
assert node_results["render"].output["graph_notify_node_ids"] == ["notify"]
|
||||
assert node_results["notify"].status == "completed"
|
||||
assert node_results["notify"].status == "pending"
|
||||
assert node_results["notify"].output["notification_mode"] == "deferred_to_render_task"
|
||||
assert node_results["notify"].output["armed_node_ids"] == ["render"]
|
||||
assert node_results["notify"].output["handoff_state"] == "armed"
|
||||
|
||||
|
||||
def test_execute_graph_workflow_routes_notify_handoffs_per_connected_branch(
|
||||
@@ -1451,10 +2127,14 @@ def test_execute_graph_workflow_routes_notify_handoffs_per_connected_branch(
|
||||
assert send_calls[1][2]["graph_notify_node_ids"] == ["turntable_notify"]
|
||||
assert node_results["still"].output["graph_notify_node_ids"] == ["still_notify"]
|
||||
assert node_results["turntable"].output["graph_notify_node_ids"] == ["turntable_notify"]
|
||||
assert node_results["still_notify"].status == "completed"
|
||||
assert node_results["still_notify"].status == "pending"
|
||||
assert node_results["still_notify"].output["notification_mode"] == "deferred_to_render_task"
|
||||
assert node_results["still_notify"].output["armed_node_ids"] == ["still"]
|
||||
assert node_results["turntable_notify"].status == "completed"
|
||||
assert node_results["still_notify"].output["handoff_state"] == "armed"
|
||||
assert node_results["turntable_notify"].status == "pending"
|
||||
assert node_results["turntable_notify"].output["notification_mode"] == "deferred_to_render_task"
|
||||
assert node_results["turntable_notify"].output["armed_node_ids"] == ["turntable"]
|
||||
assert node_results["turntable_notify"].output["handoff_state"] == "armed"
|
||||
|
||||
|
||||
def test_execute_graph_workflow_suppresses_notify_node_in_shadow_mode(
|
||||
|
||||
Reference in New Issue
Block a user