Phase 2: Medien-Engine – Transport, Preload, Library, Content-Manifest
- PlaybackController (§12.5): Play/Pause/Stop/Retrigger, Loop/Once/ Ping-Pong mit In/Out-Fenstern, variable Geschwindigkeit ±4x (§16.6), Ende-Ereignis genau einmal je Pass, monotone Ereignis-Zeitbasis (§12.2) - PreloadSlot (§12.2): atomarer Clipwechsel preload->ready->commit; nicht bereites Medium wechselt nie (Policy beim Aufrufer) - MediaLibrary (§13.2/§13.3): Import mit SHA-256-Duplikaterkennung, Bank-Slots als explizite Show-Metadaten, fehlende Dateien markiert, Relink; Import laeuft im Control-Core-Worker, nie im Renderthread - ContentManifest (§6.4/§10.1): SHA-256 je Datei + resumierbare Chunk-Hashes, Verifikation gegen Veraenderung, Diff fuer Sync-Plan, deterministische Serialisierung mit Revision - 35 neue Unit-Tests; Gesamtsuite 258 gruen, Ruff gruen
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Unit-Tests Content-Manifest (PLAN.md §6.4, §10.1, §29.1).
|
||||
|
||||
Manifest-Chunks/-Hashes, Diff für Sync, Verifikation gegen Fälschung
|
||||
und Rundreise – Grundlage für Preflight und resumierbare Übertragung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hms_content_sync import CHUNK_SIZE, ContentManifest, hash_file
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def content_tree(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "content"
|
||||
(root / "clips").mkdir(parents=True)
|
||||
(root / "clips" / "intro.mp4").write_bytes(b"A" * 1000)
|
||||
(root / "clips" / "loop.mp4").write_bytes(b"B" * 500)
|
||||
(root / "presets").mkdir()
|
||||
(root / "presets" / "look.json").write_text("{}", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def test_hash_file_chunks_match_content(tmp_path: Path) -> None:
|
||||
f = tmp_path / "f.bin"
|
||||
f.write_bytes(b"x" * (CHUNK_SIZE + 100)) # > 1 Chunk
|
||||
file_hash, chunks = hash_file(f)
|
||||
assert len(chunks) == 2 # voller + Rest-Chunk
|
||||
assert len(file_hash) == 64 # SHA-256 Hex
|
||||
|
||||
|
||||
def test_hash_file_empty_file_yields_empty_chunk(tmp_path: Path) -> None:
|
||||
f = tmp_path / "empty.bin"
|
||||
f.write_bytes(b"")
|
||||
file_hash, chunks = hash_file(f)
|
||||
assert len(chunks) == 1
|
||||
assert file_hash # auch leere Dateien haben einen definierten Hash
|
||||
|
||||
|
||||
def test_manifest_build_covers_all_files(content_tree: Path) -> None:
|
||||
manifest = ContentManifest.build(content_tree, revision=7)
|
||||
assert len(manifest) == 3
|
||||
assert manifest.revision == 7
|
||||
assert "clips/intro.mp4" in manifest.entries
|
||||
assert "presets/look.json" in manifest.entries
|
||||
entry = manifest.entries["clips/intro.mp4"]
|
||||
assert entry.size_bytes == 1000
|
||||
assert len(entry.sha256) == 64
|
||||
assert entry.kind == "media" # Default-Art
|
||||
|
||||
|
||||
def test_manifest_kind_classification(content_tree: Path) -> None:
|
||||
manifest = ContentManifest.build(
|
||||
content_tree,
|
||||
kind_of=lambda rel: "plugin" if rel.endswith(".json") else "media",
|
||||
)
|
||||
assert manifest.entries["presets/look.json"].kind == "plugin"
|
||||
assert manifest.entries["clips/intro.mp4"].kind == "media"
|
||||
|
||||
|
||||
def test_manifest_roundtrip_preserves_entries(content_tree: Path) -> None:
|
||||
manifest = ContentManifest.build(content_tree, revision=3)
|
||||
restored = ContentManifest.from_dict(json.loads(json.dumps(manifest.to_dict())))
|
||||
assert restored.revision == 3
|
||||
assert set(restored.entries) == set(manifest.entries)
|
||||
orig = manifest.entries["clips/intro.mp4"]
|
||||
copy = restored.entries["clips/intro.mp4"]
|
||||
assert copy.sha256 == orig.sha256
|
||||
assert copy.chunk_hashes == orig.chunk_hashes
|
||||
|
||||
|
||||
def test_manifest_is_deterministic_across_builds(content_tree: Path) -> None:
|
||||
"""Gleicher Inhalt → gleiche Einträge (nur manifest_id ist neu)."""
|
||||
a = ContentManifest.build(content_tree)
|
||||
b = ContentManifest.build(content_tree)
|
||||
assert a.to_dict()["entries"] == b.to_dict()["entries"]
|
||||
assert a.manifest_id != b.manifest_id # Instanz-ID ist zeitlich unique
|
||||
|
||||
|
||||
def test_verify_detects_tampered_file(content_tree: Path) -> None:
|
||||
manifest = ContentManifest.build(content_tree)
|
||||
assert manifest.verify_file(content_tree, "clips/intro.mp4")
|
||||
(content_tree / "clips" / "intro.mp4").write_bytes(b"TAMPERED" * 50)
|
||||
assert not manifest.verify_file(content_tree, "clips/intro.mp4") # §6.4 Hashprüfung
|
||||
|
||||
|
||||
def test_verify_detects_missing_and_unknown(content_tree: Path) -> None:
|
||||
manifest = ContentManifest.build(content_tree)
|
||||
(content_tree / "clips" / "loop.mp4").unlink()
|
||||
assert not manifest.verify_file(content_tree, "clips/loop.mp4")
|
||||
assert not manifest.verify_file(content_tree, "gibts/nicht.mp4")
|
||||
|
||||
|
||||
def test_diff_drives_sync_plan(content_tree: Path) -> None:
|
||||
"""Diff liefert exakt die Übertragungsliste (§6.4: added/removed/changed)."""
|
||||
old = ContentManifest.build(content_tree, revision=1)
|
||||
# Ändern + Hinzufügen + Entfernen
|
||||
(content_tree / "clips" / "intro.mp4").write_bytes(b"GEAENDERT")
|
||||
(content_tree / "clips" / "neu.mp4").write_bytes(b"N")
|
||||
(content_tree / "clips" / "loop.mp4").unlink()
|
||||
new = ContentManifest.build(content_tree, revision=2)
|
||||
|
||||
diff = old.diff(new)
|
||||
assert diff.added == frozenset({"clips/neu.mp4"})
|
||||
assert diff.removed == frozenset({"clips/loop.mp4"})
|
||||
assert diff.changed == frozenset({"clips/intro.mp4"})
|
||||
assert not diff.empty
|
||||
|
||||
# identische Manifeste: leerer Diff
|
||||
same = old.diff(ContentManifest.from_dict(old.to_dict()))
|
||||
assert same.empty
|
||||
|
||||
|
||||
def test_build_rejects_missing_root(tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
ContentManifest.build(tmp_path / "gibts-nicht")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Unit-Tests Medienbibliothek (PLAN.md §13.2, §13.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hms_media import ImportStatus, MediaLibrary
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def media_root(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "media"
|
||||
(root / "clips").mkdir(parents=True)
|
||||
(root / "clips" / "intro.mp4").write_bytes(b"video-bytes-1" * 20)
|
||||
(root / "clips" / "loop.mp4").write_bytes(b"video-bytes-2" * 20)
|
||||
return root
|
||||
|
||||
|
||||
def test_import_creates_asset_with_hash(media_root: Path) -> None:
|
||||
lib = MediaLibrary(media_root)
|
||||
outcome = lib.import_file(media_root / "clips" / "intro.mp4")
|
||||
assert outcome.status is ImportStatus.IMPORTED
|
||||
assert outcome.asset is not None
|
||||
assert outcome.asset.content_hash # §13.2: Hash vorhanden
|
||||
assert outcome.asset.rel_path == "clips/intro.mp4"
|
||||
assert len(lib) == 1
|
||||
|
||||
|
||||
def test_duplicate_content_detected(media_root: Path) -> None:
|
||||
"""Gleicher Inhalt unter anderem Namen = Duplikat (§13.3)."""
|
||||
lib = MediaLibrary(media_root)
|
||||
first = lib.import_file(media_root / "clips" / "intro.mp4")
|
||||
copy = media_root / "clips" / "kopie.mp4"
|
||||
copy.write_bytes((media_root / "clips" / "intro.mp4").read_bytes())
|
||||
outcome = lib.import_file(copy)
|
||||
assert outcome.status is ImportStatus.DUPLICATE
|
||||
assert outcome.duplicate_of == first.asset.id # verweist auf das Original
|
||||
assert len(lib) == 1 # kein zweiter Eintrag
|
||||
|
||||
|
||||
def test_import_missing_file_reports_cleanly(media_root: Path) -> None:
|
||||
lib = MediaLibrary(media_root)
|
||||
outcome = lib.import_file(media_root / "clips" / "fehlt.mp4")
|
||||
assert outcome.status is ImportStatus.MISSING_FILE
|
||||
assert outcome.asset is None
|
||||
|
||||
|
||||
def test_bank_slots_are_explicit_metadata(media_root: Path) -> None:
|
||||
"""Bank/Index als Show-Metadaten, unabhängig vom Dateinamen (§13.2)."""
|
||||
lib = MediaLibrary(media_root)
|
||||
asset = lib.import_file(media_root / "clips" / "intro.mp4").asset
|
||||
lib.register_bank_slot(asset.id, bank=1, index=5)
|
||||
found = lib.asset_at(bank=1, index=5)
|
||||
assert found is not None and found.id == asset.id
|
||||
assert lib.asset_at(bank=1, index=6) is None
|
||||
with pytest.raises(KeyError):
|
||||
lib.register_bank_slot("unbekannt", 1, 1)
|
||||
|
||||
|
||||
def test_missing_detection_and_relink(media_root: Path) -> None:
|
||||
"""Fehlende Dateien werden markiert; Relink bindet neu (§13.3)."""
|
||||
lib = MediaLibrary(media_root)
|
||||
asset = lib.import_file(media_root / "clips" / "loop.mp4").asset
|
||||
(media_root / "clips" / "loop.mp4").unlink() # Datei verschwindet
|
||||
assert lib.mark_missing() == [asset.id]
|
||||
assert asset.id in lib.missing_asset_ids
|
||||
|
||||
# Relink auf eine neue Datei mit gleichem Namen
|
||||
(media_root / "clips" / "loop.mp4").write_bytes(b"neuer-inhalt")
|
||||
updated = lib.relink(asset.id, media_root / "clips" / "loop.mp4")
|
||||
assert updated.content_hash != asset.content_hash # neuer Inhalt → neuer Hash
|
||||
assert asset.id not in lib.missing_asset_ids
|
||||
|
||||
|
||||
def test_relink_rejects_missing_target(media_root: Path) -> None:
|
||||
lib = MediaLibrary(media_root)
|
||||
asset = lib.import_file(media_root / "clips" / "loop.mp4").asset
|
||||
with pytest.raises(FileNotFoundError):
|
||||
lib.relink(asset.id, media_root / "nirgendwo.mp4")
|
||||
|
||||
|
||||
def test_library_all_and_get(media_root: Path) -> None:
|
||||
lib = MediaLibrary(media_root)
|
||||
a = lib.import_file(media_root / "clips" / "intro.mp4").asset
|
||||
b = lib.import_file(media_root / "clips" / "loop.mp4").asset
|
||||
assert {x.id for x in lib.all()} == {a.id, b.id}
|
||||
assert lib.get(a.id).rel_path == "clips/intro.mp4"
|
||||
assert lib.get("gibts-nicht") is None
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Unit-Tests Playback-Transport (PLAN.md §12.2, §12.5, §16.6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from hms_domain import LoopMode, TransportState
|
||||
from hms_media import PlaybackController, PlaybackEventKind, PreloadSlot
|
||||
|
||||
|
||||
def _controller(**overrides) -> PlaybackController:
|
||||
defaults = dict(
|
||||
source_id="src-1",
|
||||
in_point=0.0,
|
||||
out_point=1.0,
|
||||
loop_mode=LoopMode.LOOP,
|
||||
speed=1.0,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return PlaybackController(**defaults)
|
||||
|
||||
|
||||
# ---------- Konstruktion und Invarianten ----------
|
||||
|
||||
|
||||
def test_rejects_invalid_in_out_points() -> None:
|
||||
with pytest.raises(ValueError, match="in_point"):
|
||||
_controller(in_point=0.9, out_point=0.1)
|
||||
with pytest.raises(ValueError, match="in_point"):
|
||||
_controller(in_point=0.5, out_point=0.5)
|
||||
|
||||
|
||||
def test_initial_state_stopped_at_in_point() -> None:
|
||||
c = _controller(in_point=0.2)
|
||||
assert c.state is TransportState.STOPPED
|
||||
assert c.position == pytest.approx(0.2)
|
||||
|
||||
|
||||
# ---------- Transportbefehle (§12.5) ----------
|
||||
|
||||
|
||||
def test_play_pause_resume_cycle() -> None:
|
||||
c = _controller()
|
||||
c.play()
|
||||
c.advance(0.1)
|
||||
pos_after_play = c.position
|
||||
c.pause()
|
||||
assert c.state is TransportState.PAUSED
|
||||
events = c.advance(1.0) # pausiert: keine Bewegung
|
||||
assert events == []
|
||||
assert c.position == pytest.approx(pos_after_play)
|
||||
c.play()
|
||||
c.advance(0.1)
|
||||
assert c.position > pos_after_play
|
||||
|
||||
|
||||
def test_stop_resets_position_and_direction() -> None:
|
||||
c = _controller(in_point=0.1)
|
||||
c.play()
|
||||
c.advance(0.3)
|
||||
c.stop()
|
||||
assert c.state is TransportState.STOPPED
|
||||
assert c.position == pytest.approx(0.1)
|
||||
assert c.direction == 1
|
||||
|
||||
|
||||
def test_retrigger_restarts_from_in_point() -> None:
|
||||
c = _controller()
|
||||
c.play()
|
||||
c.advance(0.4)
|
||||
c.retrigger() # §16.5: flankenbasierter Neustart
|
||||
assert c.position == pytest.approx(0.0)
|
||||
assert c.state is TransportState.PLAYING
|
||||
|
||||
|
||||
def test_seek_bounds_enforced() -> None:
|
||||
c = _controller(in_point=0.2, out_point=0.8)
|
||||
c.seek(0.5)
|
||||
assert c.position == pytest.approx(0.5)
|
||||
with pytest.raises(ValueError, match="In/Out"):
|
||||
c.seek(0.9)
|
||||
with pytest.raises(ValueError, match="In/Out"):
|
||||
c.seek(0.1)
|
||||
|
||||
|
||||
def test_speed_zero_rejected_pause_is_the_way() -> None:
|
||||
c = _controller()
|
||||
with pytest.raises(ValueError, match="speed 0"):
|
||||
c.set_speed(0.0)
|
||||
|
||||
|
||||
def test_negative_speed_plays_backward() -> None:
|
||||
"""§16.6: negative Geschwindigkeit zugelassen; Rückwärts am In-Point
|
||||
wrappt im Loop-Modus korrekt ans Ende des Fensters (§12.5)."""
|
||||
c = _controller()
|
||||
c.play()
|
||||
c.set_speed(-1.0)
|
||||
events = c.advance(0.05)
|
||||
assert c.position == pytest.approx(0.95) # Wrap nach hinten: out - 0.05
|
||||
assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP
|
||||
# danach läuft es weiter rückwärts Richtung In-Point
|
||||
c.advance(0.05)
|
||||
assert c.position == pytest.approx(0.9)
|
||||
|
||||
|
||||
# ---------- Loop-Verhalten ----------
|
||||
|
||||
|
||||
def test_loop_wraps_with_event() -> None:
|
||||
c = _controller(out_point=1.0)
|
||||
c.play()
|
||||
events = c.advance(1.25) # über das Ende hinaus
|
||||
assert c.position == pytest.approx(0.25) # Wrap: Überschuss am Anfang
|
||||
assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP
|
||||
assert c.state is TransportState.PLAYING # Loop läuft weiter
|
||||
|
||||
|
||||
def test_once_stops_at_out_point_with_end_event() -> None:
|
||||
c = _controller(loop_mode=LoopMode.ONCE)
|
||||
c.play()
|
||||
events = c.advance(0.5)
|
||||
assert events == [] # noch nicht am Ende
|
||||
events = c.advance(0.6) # über das Ende hinaus
|
||||
assert c.state is TransportState.STOPPED # §12.5: Ende-Ereignis
|
||||
assert c.position == pytest.approx(1.0)
|
||||
assert events and events[0].kind is PlaybackEventKind.END_OF_MEDIA
|
||||
# weiteres Advancement bleibt gestoppt
|
||||
assert c.advance(0.1) == []
|
||||
|
||||
|
||||
def test_ping_pong_reverses_direction() -> None:
|
||||
c = _controller(loop_mode=LoopMode.PING_PONG)
|
||||
c.play()
|
||||
events = c.advance(1.2) # Ende erreicht → Richtungsumkehr
|
||||
assert c.direction == -1
|
||||
assert c.position == pytest.approx(0.8) # 0.2 zurückgeprallt
|
||||
assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE
|
||||
events = c.advance(1.6) # zurück zum Anfang → wieder vorwärts
|
||||
assert c.direction == 1
|
||||
assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE
|
||||
assert c.state is TransportState.PLAYING # Ping-Pong läuft endlos
|
||||
|
||||
|
||||
def test_in_out_window_respected() -> None:
|
||||
"""Loop beachtet In/Out-Fenster, nicht nur 0..1 (§12.5)."""
|
||||
c = _controller(in_point=0.2, out_point=0.8)
|
||||
c.play()
|
||||
c.advance(0.7) # 0.2 + 0.7 = 0.9 > out 0.8
|
||||
assert c.position == pytest.approx(0.3) # Wrap innerhalb des Fensters
|
||||
|
||||
|
||||
def test_advance_rejects_negative_dt() -> None:
|
||||
c = _controller()
|
||||
c.play()
|
||||
with pytest.raises(ValueError, match="negativ"):
|
||||
c.advance(-0.1)
|
||||
|
||||
|
||||
def test_events_carry_source_and_monotonic_time() -> None:
|
||||
c = _controller()
|
||||
c.play()
|
||||
events = c.advance(1.5, now_ns=123456789)
|
||||
assert events[0].source_id == "src-1"
|
||||
assert events[0].monotonic_ns == 123456789
|
||||
|
||||
|
||||
# ---------- PreloadSlot: atomarer Clipwechsel (§12.2, §16.5) ----------
|
||||
|
||||
|
||||
def test_preload_commit_atomic_flow() -> None:
|
||||
slot = PreloadSlot()
|
||||
assert slot.commit() is None # nichts geladen → kein Wechsel
|
||||
slot.preload("asset-a")
|
||||
assert slot.pending == "asset-a"
|
||||
assert slot.commit() is None # noch nicht bereit → alter Clip bleibt
|
||||
slot.mark_ready("asset-a")
|
||||
assert slot.commit() == "asset-a" # atomar umgeschaltet
|
||||
assert slot.pending is None
|
||||
assert slot.commit() is None # Slot ist wieder leer
|
||||
|
||||
|
||||
def test_preload_cancel_discards() -> None:
|
||||
slot = PreloadSlot()
|
||||
slot.preload("asset-a")
|
||||
slot.mark_ready("asset-a")
|
||||
slot.cancel()
|
||||
assert slot.pending is None
|
||||
assert slot.commit() is None # abgebrochen wird nie committed
|
||||
|
||||
|
||||
def test_preload_rejects_foreign_ready() -> None:
|
||||
slot = PreloadSlot()
|
||||
slot.preload("asset-a")
|
||||
with pytest.raises(ValueError, match="anderes Asset"):
|
||||
slot.mark_ready("asset-b")
|
||||
|
||||
|
||||
def test_preload_new_preload_resets_ready() -> None:
|
||||
slot = PreloadSlot()
|
||||
slot.preload("asset-a")
|
||||
slot.mark_ready("asset-a")
|
||||
slot.preload("asset-b") # umgeladen: a wird verworfen
|
||||
assert slot.ready is False
|
||||
assert slot.commit() is None
|
||||
Reference in New Issue
Block a user