umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -0,0 +1,108 @@
"""Tests fuer app/recordings/recorder.py (Umsetzungsauftrag Teil A D2 /
Teil E E2): Puffer/Executor-Auslagerung, Hash-Kette, Rotation und
Groessenbegrenzung."""
import asyncio
import json
import pytest
from app.recordings import recorder as rec_mod
@pytest.fixture(autouse=True)
def _isolated_recordings_dir(tmp_path, monkeypatch):
"""settings ist ein Prozess-Singleton (siehe conftest.py) -- fuer
Testisolation wird recordings_dir direkt umgebogen statt ueber die
Umgebungsvariable (die nach dem ersten Import wirkungslos waere)."""
from app.config import settings
recordings_dir = tmp_path / "recordings"
recordings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(settings, "recordings_dir", recordings_dir)
monkeypatch.setattr(rec_mod.SessionRecorder, "FLUSH_INTERVAL_S", 0.02)
yield
async def test_record_is_non_blocking_and_flushes_via_background_task():
rec = rec_mod.SessionRecorder(session_id=1)
for i in range(10):
rec.record("output", f"frame-{i}")
with open(rec.path, encoding="utf-8") as fh:
assert fh.read() == "" # record() darf nicht sofort schreiben
await asyncio.sleep(0.2)
with open(rec.path, encoding="utf-8") as fh:
lines = [l for l in fh if l.strip()]
assert len(lines) == 10
assert rec_mod.verify_recording(rec.path)
await rec.aclose()
async def test_aclose_flushes_pending_buffer_even_with_long_interval():
rec_mod.SessionRecorder.FLUSH_INTERVAL_S = 100
rec = rec_mod.SessionRecorder(session_id=2)
for i in range(5):
rec.record("input", f"x{i}")
await rec.aclose()
with open(rec.path, encoding="utf-8") as fh:
lines = [l for l in fh if l.strip()]
assert len(lines) == 5
assert rec_mod.verify_recording(rec.path)
async def test_concurrent_recorders_do_not_interfere():
recs = [rec_mod.SessionRecorder(session_id=100 + i) for i in range(5)]
for i, r in enumerate(recs):
for j in range(20):
r.record("output", f"s{i}-{j}")
await asyncio.sleep(0.3)
for r in recs:
with open(r.path, encoding="utf-8") as fh:
lines = [l for l in fh if l.strip()]
assert len(lines) == 20
assert rec_mod.verify_recording(r.path)
await r.aclose()
async def test_rotation_splits_into_multiple_valid_parts():
from app.config import settings
settings.recording_max_part_bytes = 400
settings.recording_max_total_bytes = 10_000_000
rec = rec_mod.SessionRecorder(session_id=3)
rec._max_part_bytes = 400
rec._max_total_bytes = 10_000_000
for i in range(60):
rec.record("output", f"payload-{i:04d}-" + ("x" * 20))
await rec.aclose()
parts = list(rec_mod._iter_part_paths(rec.path))
assert len(parts) > 1
assert rec_mod.verify_recording_set(rec.path)
assert rec_mod.count_recording_entries(rec.path) == 60
for p in parts:
with open(p, encoding="utf-8") as fh:
first_line = next((l for l in fh if l.strip()), None)
assert first_line is not None
assert json.loads(first_line)["prev_hash"] == rec_mod.GENESIS_HASH
async def test_total_size_limit_truncates_and_stops_accepting_entries():
rec = rec_mod.SessionRecorder(session_id=4)
rec._max_part_bytes = 10_000_000
rec._max_total_bytes = 500
for i in range(50):
rec.record("output", f"payload-{i:04d}-" + ("y" * 20))
await rec.aclose()
total_entries = rec_mod.count_recording_entries(rec.path)
assert 0 < total_entries < 50
assert rec_mod.verify_recording_set(rec.path)
entries = list(rec_mod.iter_recording_entries(rec.path))
assert "truncated" in entries[-1]["data"]
rec.record("output", "should-be-dropped")
await asyncio.sleep(0.1)
assert rec_mod.count_recording_entries(rec.path) == total_entries