Files
hms-mediaengine/tests/unit/test_content_manifest.py
T
HMS MediaEngine Agent d24fe3a963 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
2026-09-11 01:25:23 +02:00

120 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")