SQLite-Ereignisse und Personenprofile vorbereiten

This commit is contained in:
marcinator_2 committed 2026-10-01 03:50:26 +02:00
1 parent 643146599d
commit f15ab1d399
9 files changed
+637 -21

No files matched your search

+3
View File
@@ -5,6 +5,9 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
- `test_controller.py` prüft Zustandswechsel, Stopp, Neustartvoraussetzungen
und gespeicherten Modus mit kontrollierter Uhr und temporären Daten.
- `test_web.py` prüft Demo-Bild und Webendpunkte über einen ASGI-Client.
- `test_event_store.py` prüft dauerhafte Ereignisse, Profilzuordnung,
Profil-Löschen, 30-Tage-Ablauf und die Erweiterung einer älteren
Ereignistabelle.
- Lokal ausführen: `python3 -m unittest discover -s tests -v`; für Webtests
wird zusätzlich `requirements-dev.txt` benötigt.
- Hardwareverhalten, echte Pi-Kamera, WLAN, SSH-Tunnel und Außeneinsatz nicht
+129
View File
@@ -0,0 +1,129 @@
import asyncio
from datetime import datetime, timedelta, timezone
import sqlite3
import stat
import tempfile
import unittest
from pathlib import Path
import httpx
from halloween.event_store import EventStore
from halloween.web import create_app
class EventStoreTest(unittest.TestCase):
def test_events_and_profiles_persist_and_delete_keeps_events(self):
async def scenario(folder: Path):
headers = {"X-Halloween-UI": "1"}
app = create_app(demo=True, state_dir=folder)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://local") as client:
self.assertEqual((await client.get("/api/events")).json()["events"], [])
self.assertEqual((await client.post("/api/profiles", json={"label": "Gast"})).status_code, 403)
profile_response = await client.post(
"/api/profiles", headers=headers, json={"label": " Gast "}
)
self.assertEqual(profile_response.status_code, 200)
profile_id = profile_response.json()["profile"]["id"]
self.assertEqual(profile_response.json()["profile"]["label"], "Gast")
await client.post("/api/zone-clear", headers=headers)
triggered = await client.post("/api/test-trigger", headers=headers)
self.assertTrue(triggered.json()["accepted"])
self.assertFalse((await client.post("/api/test-trigger", headers=headers)).json()["accepted"])
events = (await client.get("/api/events")).json()["events"]
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["kind"], "test_trigger")
event_id = events[0]["id"]
self.assertEqual(
(await client.patch(
f"/api/events/{event_id}/profile", headers=headers,
json={"profile_id": profile_id},
)).status_code, 200,
)
self.assertEqual(
(await client.get("/api/profiles")).json()["profiles"][0]["event_count"], 1
)
restarted = create_app(demo=True, state_dir=folder)
transport = httpx.ASGITransport(app=restarted)
async with httpx.AsyncClient(transport=transport, base_url="http://local") as client:
event = (await client.get("/api/events")).json()["events"][0]
self.assertEqual(event["profile_id"], profile_id)
self.assertEqual(event["profile_label"], "Gast")
self.assertEqual(
(await client.delete(f"/api/profiles/{profile_id}", headers=headers)).status_code,
200,
)
remaining = (await client.get("/api/events")).json()["events"]
self.assertEqual(len(remaining), 1)
self.assertIsNone(remaining[0]["profile_id"])
with tempfile.TemporaryDirectory() as folder:
asyncio.run(scenario(Path(folder)))
mode = stat.S_IMODE((Path(folder) / "events.sqlite3").stat().st_mode)
self.assertEqual(mode, 0o600)
def test_existing_event_table_is_extended_without_losing_rows(self):
with tempfile.TemporaryDirectory() as folder:
db_path = Path(folder) / "events.sqlite3"
with sqlite3.connect(db_path) as connection:
connection.execute(
"CREATE TABLE events (id INTEGER PRIMARY KEY, occurred_at TEXT NOT NULL, "
"kind TEXT NOT NULL, mode TEXT NOT NULL)"
)
connection.execute(
"INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)",
("2026-10-01T00:00:00+00:00", "test_trigger", "voll"),
)
store = EventStore(Path(folder))
self.assertEqual(len(store.recent()), 1)
self.assertIsNone(store.recent()[0]["profile_id"])
def test_auto_profiles_expire_after_30_days_and_manual_profiles_remain(self):
with tempfile.TemporaryDirectory() as folder:
store = EventStore(Path(folder))
manual = store.create_profile("Bekannter Gast")
now = datetime.now(timezone.utc)
vector = [1.0] + [0.0] * 31
auto = store.create_auto_profile(vector, "test-model", now=now)
self.assertEqual(
store.best_template_match(vector, "test-model", now=now)[0], auto["id"]
)
self.assertIsNone(store.best_template_match(vector, "other-model", now=now)[0])
event_id = store.record_test_trigger("voll")
store.assign_profile(event_id, auto["id"])
self.assertEqual(store.cleanup_expired(now + timedelta(days=29)), 0)
self.assertEqual(store.cleanup_expired(now + timedelta(days=31)), 1)
self.assertEqual([profile["id"] for profile in store.profiles()], [manual["id"]])
self.assertIsNone(store.recent()[0]["profile_id"])
self.assertIsNone(
store.best_template_match(vector, "test-model", now=now + timedelta(days=31))[0]
)
def test_expired_profiles_are_removed_during_read_without_restart(self):
with tempfile.TemporaryDirectory() as folder:
store = EventStore(Path(folder))
past = datetime.now(timezone.utc) - timedelta(days=31)
auto = store.create_auto_profile([1.0] + [0.0] * 31, "test-model", now=past)
self.assertEqual(store.profiles(), [])
with sqlite3.connect(store.path) as connection:
self.assertEqual(
connection.execute(
"SELECT COUNT(*) FROM profile_templates WHERE profile_id = ?",
(auto["id"],),
).fetchone()[0], 0,
)
def test_repeat_sighting_extends_retention_from_last_seen(self):
with tempfile.TemporaryDirectory() as folder:
store = EventStore(Path(folder))
now = datetime.now(timezone.utc)
auto = store.create_auto_profile([1.0] + [0.0] * 31, "test-model", now=now)
store.touch_auto_profile(auto["id"], now=now + timedelta(days=20))
self.assertEqual(store.cleanup_expired(now + timedelta(days=31)), 0)
self.assertEqual(store.cleanup_expired(now + timedelta(days=50)), 1)
if __name__ == "__main__":
unittest.main()