362e089be0
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
229 lines
8.4 KiB
Python
229 lines
8.4 KiB
Python
"""Unit-Tests Plugin-Lifecycle (PLAN.md §14.5, §14.6, §26.3, §29.2)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from hms_plugin_sdk import (
|
|
InvalidTransitionError,
|
|
LifecycleState,
|
|
PluginLifecycleManager,
|
|
)
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
EXAMPLES = REPO / "plugins" / "examples"
|
|
|
|
|
|
def _valid_plugin_dir(tmp_path: Path) -> Path:
|
|
"""Kopiert das gültige Passthrough-Beispiel in ein Test-Verzeichnis."""
|
|
target = tmp_path / "plugins"
|
|
target.mkdir()
|
|
for item in EXAMPLES.rglob("*"):
|
|
if item.is_file():
|
|
rel = item.relative_to(EXAMPLES)
|
|
dest = target / rel
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
dest.write_bytes(item.read_bytes())
|
|
return target
|
|
|
|
|
|
# ---------- Discovery & Validierung (§14.5, §29.2) ----------
|
|
|
|
|
|
def test_discover_validates_example_plugins(tmp_path: Path) -> None:
|
|
"""Externe Beispielplugins werden ohne Kernänderung entdeckt und
|
|
validiert (§29.2 / Gate 3 Vorbereitung)."""
|
|
plugin_dir = _valid_plugin_dir(tmp_path)
|
|
mgr = PluginLifecycleManager()
|
|
errors = mgr.discover(plugin_dir)
|
|
assert errors == []
|
|
by_id = {r.plugin_id: r for r in mgr.all()}
|
|
assert "com.hms.fx.example_passthrough" in by_id
|
|
assert "com.hms.fx.gaussian_blur" in by_id
|
|
assert all(r.state is LifecycleState.VALIDATED for r in by_id.values())
|
|
|
|
|
|
def test_discover_quarantines_invalid_manifest(tmp_path: Path) -> None:
|
|
"""Ungültiges Manifest → QUARANTINED mit dokumentierter Ursache (§3.5)."""
|
|
plugin_dir = _valid_plugin_dir(tmp_path)
|
|
broken = plugin_dir / "com.broken.plugin"
|
|
broken.mkdir()
|
|
(broken / "plugin.json").write_text(
|
|
json.dumps({"schema_version": 99, "id": "com.broken.plugin"}),
|
|
encoding="utf-8",
|
|
)
|
|
mgr = PluginLifecycleManager()
|
|
errors = mgr.discover(plugin_dir)
|
|
record = mgr.get("com.broken.plugin")
|
|
assert record is not None
|
|
assert record.state is LifecycleState.QUARANTINED
|
|
assert record.last_error # Ursache sichtbar, nicht geschluckt (§33)
|
|
assert any("com.broken.plugin" in e for e in errors)
|
|
# die gültigen Plugins bleiben unbeeinflusst
|
|
assert mgr.get("com.hms.fx.example_passthrough").state is LifecycleState.VALIDATED
|
|
|
|
|
|
def test_discover_missing_dir_reports_error(tmp_path: Path) -> None:
|
|
mgr = PluginLifecycleManager()
|
|
errors = mgr.discover(tmp_path / "gibts-nicht")
|
|
assert errors and "fehlt" in errors[0]
|
|
|
|
|
|
# ---------- Übergänge (§14.5) ----------
|
|
|
|
|
|
@pytest.fixture()
|
|
def manager_with_plugin(tmp_path: Path) -> PluginLifecycleManager:
|
|
mgr = PluginLifecycleManager()
|
|
mgr.discover(_valid_plugin_dir(tmp_path))
|
|
return mgr
|
|
|
|
|
|
def _advance_to(
|
|
mgr: PluginLifecycleManager, pid: str, *states: LifecycleState
|
|
) -> None:
|
|
"""Führt ein Plugin schrittweise durch die angegebenen Zustände."""
|
|
for state in states:
|
|
mgr.advance(pid, state)
|
|
|
|
|
|
_ACTIVE_CHAIN = (
|
|
LifecycleState.INSTALLED,
|
|
LifecycleState.ENABLED,
|
|
LifecycleState.COMPILED,
|
|
LifecycleState.ACTIVE,
|
|
)
|
|
|
|
|
|
def test_full_lifecycle_happy_path(manager_with_plugin: PluginLifecycleManager) -> None:
|
|
"""discovered→validated→installed→enabled→compiled→active (§14.5)."""
|
|
mgr = manager_with_plugin
|
|
pid = "com.hms.fx.example_passthrough"
|
|
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
|
|
record = mgr.get(pid)
|
|
assert record.state is LifecycleState.ACTIVE
|
|
assert record.last_error is None
|
|
assert mgr.active_plugins() == [record]
|
|
|
|
|
|
def test_skip_transition_rejected(manager_with_plugin: PluginLifecycleManager) -> None:
|
|
"""Sprünge im Graph sind Fehler: validated → active illegal."""
|
|
mgr = manager_with_plugin
|
|
with pytest.raises(InvalidTransitionError, match="nicht erlaubt"):
|
|
mgr.advance("com.hms.fx.example_passthrough", LifecycleState.ACTIVE)
|
|
|
|
|
|
def test_backward_transition_rejected(manager_with_plugin: PluginLifecycleManager) -> None:
|
|
"""Rücksprünge sind ebenfalls illegal (active → enabled)."""
|
|
mgr = manager_with_plugin
|
|
pid = "com.hms.fx.example_passthrough"
|
|
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
|
|
with pytest.raises(InvalidTransitionError):
|
|
mgr.advance(pid, LifecycleState.ENABLED)
|
|
|
|
|
|
def test_disable_and_reenable_cycle(manager_with_plugin: PluginLifecycleManager) -> None:
|
|
"""active → disabled → enabled: Live-Betrieb bleibt steuerbar (§17.7)."""
|
|
mgr = manager_with_plugin
|
|
pid = "com.hms.fx.example_passthrough"
|
|
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
|
|
mgr.advance(pid, LifecycleState.DISABLED)
|
|
mgr.advance(pid, LifecycleState.ENABLED) # Re-Enable: Rücksprung in Betrieb
|
|
assert mgr.get(pid).state is LifecycleState.ENABLED
|
|
|
|
|
|
def test_unknown_plugin_rejected() -> None:
|
|
mgr = PluginLifecycleManager()
|
|
with pytest.raises(KeyError):
|
|
mgr.advance("gibts-nicht", LifecycleState.INSTALLED)
|
|
|
|
|
|
def test_quarantine_from_active_documents_reason(
|
|
manager_with_plugin: PluginLifecycleManager,
|
|
) -> None:
|
|
"""Fehlerfall im Betrieb: Quarantäne mit Ursache, Projekt bleibt
|
|
nutzbar (§3.5, §12.2 Bypass)."""
|
|
mgr = manager_with_plugin
|
|
pid = "com.hms.fx.gaussian_blur"
|
|
_advance_to(mgr, pid, *_ACTIVE_CHAIN)
|
|
mgr.quarantine(pid, "shader compile failed")
|
|
record = mgr.get(pid)
|
|
assert record.state is LifecycleState.QUARANTINED
|
|
assert record.last_error == "shader compile failed"
|
|
assert mgr.by_state(LifecycleState.QUARANTINED) == [record]
|
|
# quarantined hat keine Folgezustände: Neustart nur über Neuinstallation
|
|
with pytest.raises(InvalidTransitionError):
|
|
mgr.advance(pid, LifecycleState.ENABLED)
|
|
|
|
|
|
# ---------- Show-Lock (§26.3) ----------
|
|
|
|
|
|
def test_show_lock_blocks_installation(manager_with_plugin: PluginLifecycleManager) -> None:
|
|
"""§26.3: Plugininstallation im Show-Lock gesperrt."""
|
|
mgr = manager_with_plugin
|
|
mgr.set_show_lock(True)
|
|
with pytest.raises(InvalidTransitionError, match="Show-Lock"):
|
|
mgr.advance("com.hms.fx.example_passthrough", LifecycleState.INSTALLED)
|
|
|
|
|
|
def test_show_lock_allows_enable_of_installed(manager_with_plugin: PluginLifecycleManager) -> None:
|
|
"""§17.7/§26.3: Bereits installiertes Plugin darf im Live-Betrieb
|
|
aktiviert werden (Layerparameter und Livefunktionen)."""
|
|
mgr = manager_with_plugin
|
|
pid = "com.hms.fx.example_passthrough"
|
|
mgr.advance(pid, LifecycleState.INSTALLED)
|
|
mgr.set_show_lock(True)
|
|
_advance_to(
|
|
mgr,
|
|
pid,
|
|
LifecycleState.ENABLED,
|
|
LifecycleState.COMPILED,
|
|
LifecycleState.ACTIVE,
|
|
)
|
|
assert mgr.get(pid).state is LifecycleState.ACTIVE
|
|
|
|
|
|
def test_show_lock_blocks_version_update(tmp_path: Path) -> None:
|
|
"""§26.3: Versionswechsel (= Update) im Show-Lock gesperrt."""
|
|
plugin_dir = _valid_plugin_dir(tmp_path)
|
|
mgr = PluginLifecycleManager()
|
|
mgr.discover(plugin_dir)
|
|
mgr.set_show_lock(True)
|
|
# gleiche Plugin-ID mit neuer Version einschleusen
|
|
manifest_path = plugin_dir / "com.hms.fx.example_passthrough" / "plugin.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["version"] = "2.0.0"
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
with pytest.raises(InvalidTransitionError, match="Versionswechsel"):
|
|
mgr.discover(plugin_dir)
|
|
|
|
|
|
def test_version_update_without_lock_starts_new_cycle(tmp_path: Path) -> None:
|
|
"""Update ohne Show-Lock: Plugin startet einen neuen Zyklus in DISCOVERED."""
|
|
plugin_dir = _valid_plugin_dir(tmp_path)
|
|
mgr = PluginLifecycleManager()
|
|
mgr.discover(plugin_dir)
|
|
mgr.advance("com.hms.fx.example_passthrough", LifecycleState.INSTALLED)
|
|
manifest_path = plugin_dir / "com.hms.fx.example_passthrough" / "plugin.json"
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
manifest["version"] = "2.0.0"
|
|
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
|
mgr.discover(plugin_dir) # Re-Scan erkennt neue Version
|
|
record = mgr.get("com.hms.fx.example_passthrough")
|
|
assert record.version == "2.0.0"
|
|
assert record.state is LifecycleState.VALIDATED # neuer Zyklus
|
|
|
|
|
|
# ---------- Backend-Kompatibilität (§14.5, §12.6) ----------
|
|
|
|
|
|
def test_compatible_backends_intersection(manager_with_plugin: PluginLifecycleManager) -> None:
|
|
"""Nur Backends mit Entrypoint UND supported_backends gelten (§12.6)."""
|
|
mgr = manager_with_plugin
|
|
backends = mgr.compatible_backends("com.hms.fx.example_passthrough")
|
|
assert backends == frozenset({"d3d11", "gl", "gles"})
|
|
assert mgr.compatible_backends("gibts-nicht") == frozenset()
|