d24fe3a963
- 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
120 lines
4.5 KiB
Python
120 lines
4.5 KiB
Python
"""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")
|