Phase 1: SQLite-Persistenz (WAL, Migrationen, Backup, Integritätscheck)
- hms_persistence: Database-Klasse nach §24 - WAL-Modus, Foreign Keys, kurze Transaktionen, Busy-Timeout - Migrationen mit Backup vor Schemaänderung (§24.4) - Integritätscheck beim Öffnen (§24.1) - Projekt-/Einstellungs-/Plugin-Status-CRUD - 18 Unit-Tests inkl. Altdaten-Migration und Reopen-Roundtrips
This commit is contained in:
@@ -19,6 +19,7 @@ _PACKAGE_DIRS = [
|
||||
"packages/adaptive_quality",
|
||||
"packages/capabilities",
|
||||
"packages/plugin_sdk",
|
||||
"packages/persistence",
|
||||
"apps/renderer",
|
||||
"apps/control_server",
|
||||
"apps/launcher",
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Unit-Tests SQLite-Persistenz (PLAN.md §24).
|
||||
|
||||
Deckung von §29.1 (Schema- und Projektmigrationen) und §24.1/§24.4:
|
||||
WAL, Foreign Keys, kurze Transaktionen, Backup vor Migration,
|
||||
Integritätscheck, Projekt-Roundtrip, Plugin-Status-Zyklus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hms_persistence import SCHEMA_VERSION, Database
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path: Path) -> Database:
|
||||
database = Database(tmp_path / "userdata" / "database" / "hms.db")
|
||||
database.open()
|
||||
yield database
|
||||
database.close()
|
||||
|
||||
|
||||
def _project(name: str = "Test Show", version: int = 1) -> dict:
|
||||
return {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"schema_version": version,
|
||||
"created_at": "2026-09-11T00:00:00+00:00",
|
||||
"updated_at": "2026-09-11T00:00:00+00:00",
|
||||
"compositions": [],
|
||||
}
|
||||
|
||||
|
||||
# ---------- Öffnen & PRAGMA (§24.1) ----------
|
||||
|
||||
|
||||
def test_open_sets_wal_mode(db: Database) -> None:
|
||||
mode = db.connection.execute("PRAGMA journal_mode").fetchone()
|
||||
assert mode and mode[0].lower() == "wal"
|
||||
|
||||
|
||||
def test_open_enables_foreign_keys(db: Database) -> None:
|
||||
fk = db.connection.execute("PRAGMA foreign_keys").fetchone()
|
||||
assert fk and fk[0] == 1
|
||||
|
||||
|
||||
def test_open_creates_parent_directories(tmp_path: Path) -> None:
|
||||
deep = tmp_path / "userdata" / "database"
|
||||
database = Database(deep / "hms.db")
|
||||
database.open()
|
||||
database.close()
|
||||
assert (deep / "hms.db").is_file()
|
||||
|
||||
|
||||
def test_integrity_check_failure_raises(tmp_path: Path) -> None:
|
||||
"""Beschädigte Datei darf nicht still geöffnet werden (§24.1)."""
|
||||
bad = tmp_path / "corrupt.db"
|
||||
bad.write_bytes(b"this is definitely not a sqlite database" * 10)
|
||||
database = Database(bad)
|
||||
with pytest.raises(sqlite3.DatabaseError):
|
||||
database.open()
|
||||
|
||||
|
||||
# ---------- Migrationen (§24.4) ----------
|
||||
|
||||
|
||||
def test_migrate_from_empty_sets_schema_version(db: Database) -> None:
|
||||
result = db.migrate()
|
||||
assert result.from_version == 0
|
||||
assert result.to_version == SCHEMA_VERSION
|
||||
assert result.integrity_ok
|
||||
assert db.schema_version == SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_migrate_is_idempotent(db: Database) -> None:
|
||||
db.migrate()
|
||||
second = db.migrate()
|
||||
assert second.from_version == SCHEMA_VERSION
|
||||
assert second.backup_path is None # kein erneutes Backup ohne Änderung
|
||||
|
||||
|
||||
def test_migrate_creates_backup_of_existing_data(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "hms.db"
|
||||
database = Database(db_path)
|
||||
database.open()
|
||||
database.migrate()
|
||||
proj = _project("Altdaten")
|
||||
database.save_project(proj)
|
||||
database.close()
|
||||
|
||||
# zweites Öffnen: Migration bereits aktuell → kein Backup nötig
|
||||
database2 = Database(db_path)
|
||||
database2.open(integrity_check=False) # Backup-Szenario simulieren
|
||||
result = database2.migrate(backup_dir=tmp_path / "backups")
|
||||
assert result.backup_path is None # Version identisch
|
||||
database2.close()
|
||||
|
||||
# Backup-Pfad existsiert nur bei echter Migration; hier simulieren wir
|
||||
# eine Vorwärtsmigration über eine Versionserhöhung nicht (SCHEMA_VERSION
|
||||
# ist 1) – stattdessen prüfen wir, dass Altdaten nach Reopen lesbar bleiben.
|
||||
database3 = Database(db_path)
|
||||
database3.open()
|
||||
loaded = database3.load_project(proj["id"])
|
||||
assert loaded is not None and loaded["name"] == "Altdaten" # §24.4: Test mit Altdaten
|
||||
database3.close()
|
||||
|
||||
|
||||
def test_migration_backup_before_change(monkeypatch, tmp_path: Path) -> None:
|
||||
"""Backup wird vor Schemaänderung angelegt, wenn Migration läuft."""
|
||||
import hms_persistence as persistence
|
||||
|
||||
db_path = tmp_path / "hms.db"
|
||||
database = Database(db_path)
|
||||
database.open()
|
||||
# Schema v1 anlegen und befüllen
|
||||
database.migrate()
|
||||
proj = _project("Vor-Backup")
|
||||
database.save_project(proj)
|
||||
# Simulation: eine neue Version existiert → Migration greift
|
||||
monkeypatch.setattr(persistence, "SCHEMA_VERSION", 2)
|
||||
monkeypatch.setitem(
|
||||
persistence._MIGRATIONS,
|
||||
2,
|
||||
"CREATE TABLE IF NOT EXISTS future_table (id TEXT PRIMARY KEY);",
|
||||
)
|
||||
backups = tmp_path / "backups"
|
||||
result = database.migrate(backup_dir=backups)
|
||||
assert result.backup_path is not None and result.backup_path.is_file()
|
||||
assert result.to_version == 2
|
||||
# Altdaten weiterhin vorhanden (Vorwärtsmigration verliert nichts)
|
||||
loaded = database.load_project(proj["id"])
|
||||
assert loaded is not None and loaded["name"] == "Vor-Backup"
|
||||
database.close()
|
||||
|
||||
|
||||
# ---------- Projekte (§24.2) ----------
|
||||
|
||||
|
||||
def test_project_save_load_roundtrip(db: Database) -> None:
|
||||
db.migrate()
|
||||
proj = _project("Meine Show")
|
||||
proj["compositions"] = [{"id": str(uuid.uuid4()), "layers": []}]
|
||||
db.save_project(proj)
|
||||
loaded = db.load_project(proj["id"])
|
||||
assert loaded == proj
|
||||
|
||||
|
||||
def test_project_list_sorted_by_update(db: Database) -> None:
|
||||
db.migrate()
|
||||
older = _project("Alt")
|
||||
newer = _project("Neu")
|
||||
newer["updated_at"] = "2026-09-11T12:00:00+00:00"
|
||||
db.save_project(older)
|
||||
db.save_project(newer)
|
||||
names = [p["name"] for p in db.list_projects()]
|
||||
assert names[0] == "Neu" # jüngstes zuerst
|
||||
|
||||
|
||||
def test_project_delete(db: Database) -> None:
|
||||
db.migrate()
|
||||
proj = _project()
|
||||
db.save_project(proj)
|
||||
db.delete_project(proj["id"])
|
||||
assert db.load_project(proj["id"]) is None
|
||||
|
||||
|
||||
def test_project_save_validates_required_fields(db: Database) -> None:
|
||||
db.migrate()
|
||||
with pytest.raises(ValueError, match="missing field"):
|
||||
db.save_project({"id": "x"})
|
||||
|
||||
|
||||
def test_project_save_overwrites_same_id(db: Database) -> None:
|
||||
db.migrate()
|
||||
proj = _project("Version A")
|
||||
db.save_project(proj)
|
||||
proj["name"] = "Version B"
|
||||
proj["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
db.save_project(proj)
|
||||
loaded = db.load_project(proj["id"])
|
||||
assert loaded is not None and loaded["name"] == "Version B"
|
||||
assert len(db.list_projects()) == 1
|
||||
|
||||
|
||||
# ---------- Einstellungen ----------
|
||||
|
||||
|
||||
def test_settings_roundtrip_and_default(db: Database) -> None:
|
||||
db.migrate()
|
||||
assert db.get_setting("artnet.bind") is None
|
||||
assert db.get_setting("artnet.bind", "0.0.0.0") == "0.0.0.0"
|
||||
db.set_setting("artnet.bind", "127.0.0.1")
|
||||
db.set_setting("artnet.bind", "10.0.0.5") # Überschreiben
|
||||
assert db.get_setting("artnet.bind") == "10.0.0.5"
|
||||
|
||||
|
||||
# ---------- Plugin-Status (§14.5) ----------
|
||||
|
||||
|
||||
def test_plugin_status_lifecycle(db: Database) -> None:
|
||||
db.migrate()
|
||||
db.upsert_plugin_status(
|
||||
"com.hms.fx.example_passthrough", "1.0.0", enabled=False, state="discovered"
|
||||
)
|
||||
db.upsert_plugin_status(
|
||||
"com.hms.fx.example_passthrough", "1.0.0", enabled=True, state="active"
|
||||
)
|
||||
status = db.get_plugin_status()
|
||||
entry = status["com.hms.fx.example_passthrough"]
|
||||
assert entry["enabled"] is True
|
||||
assert entry["state"] == "active"
|
||||
|
||||
|
||||
def test_plugin_status_rejects_invalid_state(db: Database) -> None:
|
||||
db.migrate()
|
||||
with pytest.raises(ValueError, match="invalid plugin state"):
|
||||
db.upsert_plugin_status("com.x.y", "1.0.0", enabled=True, state="exploded")
|
||||
|
||||
|
||||
def test_plugin_status_persists_across_reopen(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "hms.db"
|
||||
database = Database(db_path)
|
||||
database.open()
|
||||
database.migrate()
|
||||
database.upsert_plugin_status(
|
||||
"com.hms.fx.gaussian_blur", "1.0.0", enabled=True, state="compiled",
|
||||
package_hash="deadbeef",
|
||||
)
|
||||
database.close()
|
||||
|
||||
database2 = Database(db_path)
|
||||
database2.open()
|
||||
entry = database2.get_plugin_status()["com.hms.fx.gaussian_blur"]
|
||||
assert entry["package_hash"] == "deadbeef"
|
||||
assert entry["state"] == "compiled"
|
||||
database2.close()
|
||||
|
||||
|
||||
# ---------- Verbindungsschutz ----------
|
||||
|
||||
|
||||
def test_connection_property_requires_open(tmp_path: Path) -> None:
|
||||
database = Database(tmp_path / "hms.db")
|
||||
with pytest.raises(RuntimeError, match="not opened"):
|
||||
_ = database.connection
|
||||
Reference in New Issue
Block a user