250 lines
7.7 KiB
Python
250 lines
7.7 KiB
Python
|
|
"""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
|