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
@@ -5,6 +5,7 @@ import uuid
from pathlib import Path
import pytest
from PIL import Image, PngImagePlugin
from sqlalchemy import select, text
from sqlalchemy.orm import Session
@@ -15,6 +16,7 @@ from app.domains.orders.models import Order, OrderLine, OrderStatus
from app.domains.products.models import CadFile, Product
from app.domains.rendering.models import OutputType, RenderTemplate
from app.domains.rendering.workflow_runtime_services import (
_build_effective_material_lookup,
auto_populate_materials_for_cad,
build_order_line_render_invocation,
emit_order_line_render_notifications,
@@ -101,6 +103,75 @@ def _seed_order_line_graph(session: Session, tmp_path: Path) -> OrderLine:
return line
def _write_png_with_metadata(path: Path, *, rgba: tuple[int, int, int, int], date_text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
image = Image.new("RGBA", (8, 8), rgba)
metadata = PngImagePlugin.PngInfo()
metadata.add_text("Date", date_text)
metadata.add_text("Software", "Blender")
image.save(path, pnginfo=metadata)
def test_effective_material_lookup_keeps_product_assignments_authoritative_and_adds_manifest_aliases():
cad_file = CadFile(
id=uuid.uuid4(),
original_name="bearing.step",
stored_path="/tmp/bearing.step",
file_hash=f"hash-{uuid.uuid4().hex}",
resolved_material_assignments={
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "HARTOMAT_010101_Steel-Bare",
},
"usd_only_part": {
"source_name": "UsdOnlyPart",
"prim_path": "/Root/Assembly/usd_only_part",
"canonical_material": "HARTOMAT_050101_Elastomer-Black",
},
},
)
effective = _build_effective_material_lookup(
cad_file,
[
{"part_name": "InnerRing", "material": "Steel raw"},
],
)
assert effective["InnerRing"] == "Steel raw"
assert effective["inner_ring"] == "Steel raw"
assert effective["UsdOnlyPart"] == "HARTOMAT_050101_Elastomer-Black"
assert effective["usd_only_part"] == "HARTOMAT_050101_Elastomer-Black"
def test_effective_material_lookup_backfills_manifest_part_keys_from_legacy_serialized_names():
cad_file = CadFile(
id=uuid.uuid4(),
original_name="bearing.step",
stored_path="/tmp/bearing.step",
file_hash=f"hash-{uuid.uuid4().hex}",
resolved_material_assignments={
"rwdr_b_f_802044_tr4_h122bk": {
"source_name": "RWDR_B_F-802044_TR4_H122BK",
"prim_path": "/Root/Assembly/rwdr_b_f_802044_tr4_h122bk",
"canonical_material": "HARTOMAT_010101_Steel-Bare",
},
},
)
effective = _build_effective_material_lookup(
cad_file,
[
{"part_name": "RWDR_B_F-802044_TR4_H122B-69186", "material": "Steel--Stahl"},
],
)
assert effective["RWDR_B_F-802044_TR4_H122B-69186"] == "Steel--Stahl"
assert effective["RWDR_B_F-802044_TR4_H122BK"] == "Steel--Stahl"
assert effective["rwdr_b_f_802044_tr4_h122bk"] == "Steel--Stahl"
def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd(sync_session, tmp_path, monkeypatch):
from app.config import settings
@@ -118,7 +189,10 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text("USD", encoding="utf-8")
usd_asset_path.write_text(
"hartomat:canonicalMaterialName\nhartomat:partKey\n",
encoding="utf-8",
)
sync_session.add(
MediaAsset(
@@ -127,6 +201,9 @@ def test_prepare_order_line_render_context_marks_line_processing_and_prefers_usd
product_id=line.product_id,
asset_type=MediaAssetType.usd_master,
storage_key="usd/bearing.usd",
render_config={
"cache_key": "stephash:0.03:0.05:20.0:materialhash:scriptfingerprint",
},
)
)
sync_session.commit()
@@ -230,6 +307,264 @@ def test_prepare_order_line_render_context_queues_refresh_for_legacy_usd(sync_se
assert line.render_status == "processing"
def test_prepare_order_line_render_context_queues_refresh_for_legacy_usd_cache_key(sync_session, tmp_path, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "HARTOMAT_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text(
"hartomat:canonicalMaterialName\nhartomat:partKey\n",
encoding="utf-8",
)
glb_asset_path = upload_dir / "step_files" / "bearing_thumbnail.glb"
glb_asset_path.parent.mkdir(parents=True, exist_ok=True)
glb_asset_path.write_text("GLB", encoding="utf-8")
sync_session.add_all(
[
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.usd_master,
storage_key="usd/bearing.usd",
render_config={
"cache_key": "stephash:0.03:0.05:20.0:materialhash",
},
),
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.gltf_geometry,
storage_key="step_files/bearing_thumbnail.glb",
),
]
)
sync_session.commit()
queued: list[str] = []
class _Task:
@staticmethod
def delay(cad_file_id: str) -> None:
queued.append(cad_file_id)
monkeypatch.setattr(
"app.tasks.step_tasks.generate_usd_master_task",
_Task(),
)
result = prepare_order_line_render_context(sync_session, str(line.id))
expected_glb = tmp_path / "parts" / "bearing_thumbnail.glb"
assert result.is_ready
assert result.usd_render_path is None
assert result.glb_reuse_path == expected_glb
assert expected_glb.exists()
assert queued == [str(line.product.cad_file_id)]
def test_prepare_order_line_render_context_accepts_binary_usd_without_literal_hartomat_markers(sync_session, tmp_path, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "HARTOMAT_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_bytes(b"PXR-USDC\x00binary-usd-with-customdata-not-greppable")
sync_session.add(
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.usd_master,
storage_key="usd/bearing.usd",
render_config={
"cache_key": "stephash:0.03:0.05:20.0:materialhash:scriptfingerprint",
},
)
)
sync_session.commit()
queued: list[str] = []
class _Task:
@staticmethod
def delay(cad_file_id: str) -> None:
queued.append(cad_file_id)
monkeypatch.setattr(
"app.tasks.step_tasks.generate_usd_master_task",
_Task(),
)
result = prepare_order_line_render_context(sync_session, str(line.id))
assert result.is_ready
assert result.usd_render_path == usd_asset_path
assert result.glb_reuse_path is None
assert queued == []
def test_prepare_order_line_render_context_queues_refresh_for_legacy_usd_file_markers(sync_session, tmp_path, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"canonical_material": "HARTOMAT_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text("legacy-usd-without-hartomat-markers", encoding="utf-8")
glb_asset_path = upload_dir / "step_files" / "bearing_thumbnail.glb"
glb_asset_path.parent.mkdir(parents=True, exist_ok=True)
glb_asset_path.write_text("GLB", encoding="utf-8")
sync_session.add_all(
[
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.usd_master,
storage_key="usd/bearing.usd",
render_config={
"cache_key": "stephash:0.03:0.05:20.0:materialhash:scriptfingerprint",
},
),
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.gltf_geometry,
storage_key="step_files/bearing_thumbnail.glb",
),
]
)
sync_session.commit()
queued: list[str] = []
class _Task:
@staticmethod
def delay(cad_file_id: str) -> None:
queued.append(cad_file_id)
monkeypatch.setattr(
"app.tasks.step_tasks.generate_usd_master_task",
_Task(),
)
result = prepare_order_line_render_context(sync_session, str(line.id))
expected_glb = tmp_path / "parts" / "bearing_thumbnail.glb"
assert result.is_ready
assert result.usd_render_path is None
assert result.glb_reuse_path == expected_glb
assert expected_glb.exists()
assert queued == [str(line.product.cad_file_id)]
def test_prepare_order_line_render_context_queues_refresh_for_legacy_usd_material_field(sync_session, tmp_path, monkeypatch):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
upload_dir = Path(settings.upload_dir)
upload_dir.mkdir(parents=True, exist_ok=True)
line = _seed_order_line_graph(sync_session, tmp_path)
line.product.cad_file.resolved_material_assignments = {
"inner_ring": {
"source_name": "InnerRing",
"prim_path": "/Root/Assembly/inner_ring",
"material": "SCHAEFFLER_010101_Steel-Bare",
}
}
usd_asset_path = upload_dir / "usd" / "bearing.usd"
usd_asset_path.parent.mkdir(parents=True, exist_ok=True)
usd_asset_path.write_text("USD", encoding="utf-8")
glb_asset_path = upload_dir / "step_files" / "bearing_thumbnail.glb"
glb_asset_path.parent.mkdir(parents=True, exist_ok=True)
glb_asset_path.write_text("GLB", encoding="utf-8")
sync_session.add_all(
[
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.usd_master,
storage_key="usd/bearing.usd",
),
MediaAsset(
id=uuid.uuid4(),
cad_file_id=line.product.cad_file_id,
product_id=line.product_id,
asset_type=MediaAssetType.gltf_geometry,
storage_key="step_files/bearing_thumbnail.glb",
),
]
)
sync_session.commit()
queued: list[str] = []
class _Task:
@staticmethod
def delay(cad_file_id: str) -> None:
queued.append(cad_file_id)
monkeypatch.setattr(
"app.tasks.step_tasks.generate_usd_master_task",
_Task(),
)
result = prepare_order_line_render_context(sync_session, str(line.id))
expected_glb = tmp_path / "parts" / "bearing_thumbnail.glb"
assert result.is_ready
assert result.usd_render_path is None
assert result.glb_reuse_path == expected_glb
assert expected_glb.exists()
assert queued == [str(line.product.cad_file_id)]
def test_prepare_order_line_render_context_skips_closed_orders(sync_session, tmp_path, monkeypatch):
from app.config import settings
@@ -322,6 +657,11 @@ def test_build_order_line_render_invocation_applies_output_and_line_overrides(tm
material_map={"InnerRing": "SteelPolished"},
use_materials=True,
override_material="Studio White",
target_collection="Assembly",
lighting_only=True,
shadow_catcher=True,
camera_orbit=False,
template_inputs={"studio_variant": "warm"},
category_key="bearings",
output_type_id=str(output_type.id),
),
@@ -357,6 +697,7 @@ def test_build_order_line_render_invocation_applies_output_and_line_overrides(tm
assert invocation.part_names_ordered == ["InnerRing", "OuterRing"]
assert invocation.rotation_x == 12.0
assert invocation.focal_length_mm == 50.0
assert invocation.template_inputs == {"studio_variant": "warm"}
still_kwargs = invocation.as_still_renderer_kwargs(
step_path=str(step_path),
@@ -374,6 +715,7 @@ def test_build_order_line_render_invocation_applies_output_and_line_overrides(tm
assert still_kwargs["cycles_device"] == "cuda"
assert still_kwargs["material_library_path"] == "/libraries/materials.blend"
assert still_kwargs["material_override"] == "Studio White"
assert still_kwargs["template_inputs"] == {"studio_variant": "warm"}
assert still_kwargs["job_id"] == "job-1"
assert still_kwargs["order_line_id"] == "line-1"
@@ -437,6 +779,11 @@ def test_build_order_line_render_invocation_autoscales_samples_and_prefers_mater
material_map={"InnerRing": "TemplateSteel"},
use_materials=True,
override_material="Template White",
target_collection="Product",
lighting_only=False,
shadow_catcher=False,
camera_orbit=True,
template_inputs={"studio_variant": "warm"},
category_key="bearings",
output_type_id=str(output_type.id),
),
@@ -480,11 +827,13 @@ def test_build_order_line_render_invocation_autoscales_samples_and_prefers_mater
assert turntable_kwargs["samples"] == 64
assert turntable_kwargs["material_map"] == {"InnerRing": "ResolvedSteel"}
assert turntable_kwargs["material_library_path"] is None
assert turntable_kwargs["template_inputs"] == {"studio_variant": "warm"}
assert cinematic_kwargs["width"] == 1024
assert cinematic_kwargs["height"] == 512
assert cinematic_kwargs["engine"] == "eevee"
assert cinematic_kwargs["samples"] == 64
assert cinematic_kwargs["material_override"] == "Resolved White"
assert cinematic_kwargs["template_inputs"] == {"studio_variant": "warm"}
def test_resolve_order_line_template_context_uses_exact_template_and_override(sync_session, tmp_path, monkeypatch):
@@ -584,6 +933,153 @@ def test_resolve_order_line_template_context_supports_explicit_template_and_libr
"InnerRing": "resolved:Steel raw",
"OuterRing": "resolved:Steel raw",
}
assert result.target_collection == "ForcedCollection"
assert result.lighting_only is False
assert result.shadow_catcher is False
assert result.camera_orbit is True
def test_resolve_order_line_template_context_applies_template_override_modes(
sync_session,
tmp_path,
monkeypatch,
):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
line = _seed_order_line_graph(sync_session, tmp_path)
template = RenderTemplate(
id=uuid.uuid4(),
name="Overrideable Template",
category_key="bearings",
blend_file_path="/templates/overrideable.blend",
original_filename="overrideable.blend",
target_collection="TemplateCollection",
material_replace_enabled=False,
lighting_only=False,
shadow_catcher_enabled=False,
camera_orbit=True,
is_active=True,
output_types=[line.output_type],
)
sync_session.add(template)
sync_session.add(
AssetLibrary(
id=uuid.uuid4(),
name="Default Library",
blend_file_path="/libraries/materials.blend",
is_active=True,
)
)
sync_session.commit()
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()},
)
setup = prepare_order_line_render_context(sync_session, str(line.id))
result = resolve_order_line_template_context(
sync_session,
setup,
template_id_override=str(template.id),
material_library_path_override="/libraries/materials.blend",
target_collection_override="NodeCollection",
material_replace_mode="enabled",
lighting_only_mode="enabled",
shadow_catcher_mode="enabled",
camera_orbit_mode="disabled",
)
assert result.template is not None
assert result.use_materials is True
assert result.material_map == {
"InnerRing": "resolved:Steel raw",
"OuterRing": "resolved:Steel raw",
}
assert result.target_collection == "NodeCollection"
assert result.lighting_only is True
assert result.shadow_catcher is True
assert result.camera_orbit is False
def test_resolve_order_line_template_context_exposes_template_schema_and_invocation_inputs(
sync_session,
tmp_path,
monkeypatch,
):
from app.config import settings
monkeypatch.setattr(settings, "upload_dir", str(tmp_path / "uploads"))
line = _seed_order_line_graph(sync_session, tmp_path)
template = RenderTemplate(
id=uuid.uuid4(),
name="Schema Template",
category_key="bearings",
blend_file_path="/templates/schema-template.blend",
original_filename="schema-template.blend",
target_collection="Product",
material_replace_enabled=True,
lighting_only=False,
shadow_catcher_enabled=False,
camera_orbit=True,
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"},
],
},
{
"key": "camera_profile",
"label": "Camera Profile",
"type": "text",
"section": "Template Inputs",
"default": "macro",
},
],
is_active=True,
output_types=[line.output_type],
)
sync_session.add(template)
sync_session.add(
AssetLibrary(
id=uuid.uuid4(),
name="Default Library",
blend_file_path="/libraries/materials.blend",
is_active=True,
)
)
sync_session.commit()
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()},
)
setup = prepare_order_line_render_context(sync_session, str(line.id))
template_context = resolve_order_line_template_context(
sync_session,
setup,
template_id_override=str(template.id),
template_input_overrides={"studio_variant": "warm"},
)
invocation = build_order_line_render_invocation(setup, template_context=template_context)
assert template_context.workflow_input_schema == template.workflow_input_schema
assert template_context.template_inputs == {
"studio_variant": "warm",
"camera_profile": "macro",
}
assert invocation.template_inputs == {
"studio_variant": "warm",
"camera_profile": "macro",
}
def test_resolve_order_line_template_context_can_disable_material_resolution(sync_session, tmp_path, monkeypatch):
@@ -1077,6 +1573,56 @@ def test_persist_order_line_output_canonicalizes_step_file_outputs(sync_session,
assert asset.storage_key == f"renders/{line.id}/{expected_path.name}"
def test_png_persistence_strips_volatile_metadata_for_primary_and_observer_outputs(
sync_session,
tmp_path,
monkeypatch,
):
from app.config import settings
upload_dir = tmp_path / "uploads"
monkeypatch.setattr(settings, "upload_dir", str(upload_dir))
line = _seed_order_line_graph(sync_session, tmp_path)
primary_source = upload_dir / "step_files" / "renders" / f"line_{line.id}.png"
observer_source = upload_dir / "step_files" / "renders" / f"line_{line.id}_shadow.png"
_write_png_with_metadata(
primary_source,
rgba=(12, 34, 56, 255),
date_text="2026/04/10 17:05:27",
)
_write_png_with_metadata(
observer_source,
rgba=(12, 34, 56, 255),
date_text="2026/04/10 17:06:30",
)
primary_result = persist_order_line_output(
sync_session,
line,
success=True,
output_path=str(primary_source),
render_log={"renderer": "blender", "engine_used": "cycles"},
)
observer_result = persist_order_line_media_asset(
sync_session,
line,
success=True,
output_path=str(observer_source),
asset_type=MediaAssetType.still,
render_log={"renderer": "blender", "engine_used": "cycles"},
)
primary_bytes = Path(primary_result.result_path or "").read_bytes()
observer_bytes = Path(observer_result.result_path or "").read_bytes()
assert primary_bytes == observer_bytes
assert b"Date" not in primary_bytes
assert b"Date" not in observer_bytes
assert Image.open(primary_result.result_path).getpixel((0, 0)) == (12, 34, 56, 255)
assert Image.open(observer_result.result_path).getpixel((0, 0)) == (12, 34, 56, 255)
def test_persist_order_line_output_classifies_blend_outputs_as_blend_assets(sync_session, tmp_path, monkeypatch):
from app.config import settings