"""Unit-Tests Domänenmodell (PLAN.md §10.1, §12, §13.2, §18.1).""" from __future__ import annotations import pytest from hms_domain import ( BlendMode, Composition, EffectInstance, Layer, LayerType, MediaAsset, OutputSurface, PresetScene, Project, Source, SourceType, TransitionType, ) from pydantic import ValidationError def _media_source(asset_id: str | None = None) -> Source: return Source( plugin_id="hms.source.video", source_type=SourceType.VIDEO, asset_id=asset_id or "00000000-0000-0000-0000-000000000001", ) def _generator_source() -> Source: return Source( plugin_id="hms.generator.solid", source_type=SourceType.GENERATOR, ) def _media_layer(**overrides) -> Layer: defaults = dict(name="Video A", layer_type=LayerType.MEDIA, source=_media_source()) defaults.update(overrides) return Layer(**defaults) # ---------- Source (§10.1, §12.5) ---------- def test_media_source_requires_asset() -> None: with pytest.raises(ValidationError, match="asset_id"): Source(plugin_id="hms.source.video", source_type=SourceType.VIDEO) def test_generator_source_needs_no_asset() -> None: src = _generator_source() assert src.asset_id is None assert src.speed == 1.0 def test_in_point_must_be_before_out_point() -> None: with pytest.raises(ValidationError, match="in_point"): Source( plugin_id="hms.source.video", source_type=SourceType.VIDEO, asset_id="00000000-0000-0000-0000-000000000001", in_point=0.8, out_point=0.2, ) def test_speed_bounds_per_plan() -> None: """§16.6: bis maximal 4x, negative Geschwindigkeit zugelassen.""" src = _media_source() assert src.speed == 1.0 with pytest.raises(ValidationError): Source( plugin_id="hms.source.video", source_type=SourceType.VIDEO, asset_id="00000000-0000-0000-0000-000000000001", speed=8.0, ) # ---------- Layer (§10.1, §4.1, §12.3) ---------- def test_media_layer_defaults() -> None: layer = _media_layer() assert layer.opacity == 1.0 assert layer.blend_mode is BlendMode.NORMAL assert layer.enabled is True assert layer.effects == [] assert layer.transform.scale_x == 1.0 def test_layer_requires_source_for_media_types() -> None: with pytest.raises(ValidationError, match="Quelle"): Layer(name="Ohne Quelle", layer_type=LayerType.MEDIA) def test_adjustment_layer_rejects_source() -> None: with pytest.raises(ValidationError, match="keine eigene Quelle"): Layer( name="Adj", layer_type=LayerType.ADJUSTMENT, source=_generator_source(), ) def test_max_two_effect_slots() -> None: def fx(i: int) -> EffectInstance: return EffectInstance(plugin_id="com.hms.fx.x", order_index=i) with pytest.raises(ValidationError, match="zwei Effekt"): _media_layer(effects=[fx(0), fx(1), fx(2)]) def test_effect_order_index_unique() -> None: fx = EffectInstance(plugin_id="com.hms.fx.x", order_index=0) with pytest.raises(ValidationError, match="eindeutig"): _media_layer(effects=[fx, fx.model_copy()]) def test_opacity_bounds() -> None: with pytest.raises(ValidationError): _media_layer(opacity=1.5) # ---------- Composition (§10.1) ---------- def test_composition_layer_ids_unique() -> None: base = _media_layer() duplicate = base.model_copy() with pytest.raises(ValidationError, match="eindeutig"): Composition(name="C", layers=[base, duplicate]) def test_composition_max_64_layers() -> None: layers = [ _media_layer(name=f"L{i}", source=_media_source()) for i in range(65) ] with pytest.raises(ValidationError, match="64"): Composition(name="C", layers=layers) def test_composition_canvas_bounds() -> None: comp = Composition(name="HD", width=1920, height=1080, fps=60.0) assert comp.width == 1920 uhd = Composition(name="UHD", width=3840, height=2160) # Desktop-Zielprofil (§3.3) assert uhd.width == 3840 with pytest.raises(ValidationError): Composition(name="Zu groß", width=9000) def test_layer_by_id_lookup() -> None: layer = _media_layer() comp = Composition(name="C", layers=[layer]) assert comp.layer_by_id(layer.id) is layer assert comp.layer_by_id("unbekannt") is None # ---------- MediaAsset (§13.2, §9.1) ---------- def test_media_asset_relative_path_required() -> None: asset = MediaAsset(rel_path="clips/intro.mp4", container="mp4", video_codec="h264") assert asset.content_hash is None # optional bis Projektpaket (§13.2) assert asset.seek_suitability == "unknown" # ungeprüft, nie optimistisch def test_media_asset_rejects_absolute_path() -> None: with pytest.raises(ValidationError, match="portabel-relativ"): MediaAsset(rel_path="/absolut/clip.mp4") def test_media_asset_rejects_traversal() -> None: with pytest.raises(ValidationError, match="portabel-relativ"): MediaAsset(rel_path="../outside/clip.mp4") def test_project_rejects_duplicate_asset_paths() -> None: a = MediaAsset(rel_path="clips/a.mp4") b = MediaAsset(rel_path="clips/a.mp4") with pytest.raises(ValidationError, match="Pfade müssen eindeutig"): Project(media_assets=[a, b]) # §13.3: Duplikate erkennen def test_project_rejects_duplicate_asset_ids() -> None: a = MediaAsset(id="1", rel_path="clips/a.mp4") b = MediaAsset(id="1", rel_path="clips/b.mp4") with pytest.raises(ValidationError, match="MediaAsset-IDs"): Project(media_assets=[a, b]) # ---------- OutputSurface (§10.1, §22.2) ---------- def test_output_surface_defaults_hold_last_frame() -> None: out = OutputSurface(node_id="00000000-0000-0000-0000-000000000009") assert out.fallback_policy == "hold_last_frame" # §26.1 assert out.slice_w == 1.0 and out.slice_h == 1.0 # volle Canvas als Default def test_output_surface_slice_bounds() -> None: with pytest.raises(ValidationError): OutputSurface( node_id="00000000-0000-0000-0000-000000000009", slice_w=1.5 ) # ---------- PresetScene (§18.1, §18.3) ---------- def test_preset_scene_snapshot_and_transition() -> None: comp = Composition(name="Show", layers=[_media_layer()]) scene = PresetScene( name="Look 1", composition_snapshot=comp.model_dump(), transition_type=TransitionType.CROSSFADE, transition_duration_s=2.0, ) assert scene.composition_snapshot["name"] == "Show" assert scene.transition_type is TransitionType.CROSSFADE with pytest.raises(ValidationError): PresetScene(name="X", composition_snapshot={}, transition_duration_s=99.0) # ---------- Project (§10.1, §24.4) ---------- def test_project_schema_version_and_roundtrip() -> None: asset = MediaAsset(rel_path="clips/intro.mp4") comp = Composition(name="Main", layers=[_media_layer()]) project = Project(name="Meine Show", media_assets=[asset], compositions=[comp]) assert project.schema_version == 1 data = project.model_dump() restored = Project.model_validate(data) assert restored == project # Persistenz-Roundtrip (SQLite speichert JSON, §24.2) restored_comp = restored.composition_by_id(comp.id) restored_asset = restored.asset_by_id(asset.id) assert restored_comp is not None and restored_comp.id == comp.id assert restored_asset is not None and restored_asset.rel_path == asset.rel_path def test_project_updated_at_changes_on_mutation() -> None: """Timestamps verwaltet der Control Core explizit, nicht das Modell.""" project = Project(name="P") before = project.updated_at project.name = "Neuer Name" assert project.updated_at == before