Files
hms-mediaengine/tests/unit/test_domain_model.py
T
HMS MediaEngine Agent 402ff7268c Phase 2: Domänenmodell nach §10.1 (plattformneutral, Pydantic)
- Project/Composition/Layer/Source/EffectInstance/MediaAsset/OutputSurface/
  PresetScene mit schema_version 1 (§10, §24.4)
- Validierungen: 2 Effekt-Slots (§4.1), eindeutige Layer-IDs/Z-Reihenfolge,
  max 64 Layer, in<out-Punkte, speed ±4x (§16.6), portable relative Pfade
  ohne Traversal (§9.1), eindeutige Asset-Pfade (§13.3), Canvas-Bounds bis
  8192 (§3.3), Adjustment/Group ohne Quelle (§12.3)
- OutputSurface mit Canvas-Slice-Modell (§22.2 Mapping-Datenmodell ab V1)
- PresetScene: normalisierter Snapshot + Übergänge V1 (§18.1, §18.3)
- keine Backend-Typen im Modell (§12.6)
- 24 Unit-Tests; Gesamtsuite 223 gruen, Ruff gruen
2026-09-11 01:15:11 +02:00

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