Files
planer/backend/tests/test_scan_fake.py
2026-07-22 16:12:23 +02:00

120 lines
5.3 KiB
Python

"""Fake-E2E: kompletter Scan-Lauf (Chunker → Board → .planer/) in Sekunden, ohne LLM.
Die Fake-Antworten durchlaufen die ECHTEN Gates — getestet wird der volle Pfad
inkl. Nachfass, Retry, Dead-Letter und Infra-Pause."""
import asyncio
import shutil
from pathlib import Path
import fake_agents
import kanban
import scan_board
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def _repo(tmp_path: Path) -> Path:
ziel = tmp_path / "repo"
shutil.copytree(MINI, ziel)
return ziel
async def _scan(repo: Path, monkeypatch) -> dict:
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.05)
return await asyncio.wait_for(
scan_board.run_scan(repo, "mini", stand="deadbeef"), timeout=30)
async def test_e2e_voller_scan(fake_welt, testdb, tmp_path, monkeypatch):
fake_agents.standard_antworten(fake_welt)
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok", ergebnis
planer = repo / ".planer"
assert (planer / "einheiten" / "rechner.py.md").is_file()
assert (planer / "einheiten" / "anzeige.py.md").is_file()
assert (planer / "kern.md").read_text().startswith("# Kern: mini")
assert (planer / "architektur.md").is_file()
assert (planer / "flows.md").is_file()
assert (planer / "pruefung.md").is_file()
assert list((planer / "features").glob("*.md"))
einheiten = (planer / "einheiten" / "rechner.py.md").read_text()
assert "stand: deadbeef" in einheiten
assert "## rechner.py::addiere" in einheiten and "## rechner.py::Speicher" in einheiten
async def test_e2e_nachfass_pfad(fake_welt, testdb, tmp_path, monkeypatch):
"""Erst-Scan lässt `skaliere` aus → Abdeckungs-Gate schickt den Chunk in den Nachfass."""
fake_agents.standard_antworten(fake_welt, lasse_aus={"skaliere"})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok", ergebnis
assert any("-nachfass-" in k for k in fake_welt.calls)
assert "## rechner.py::skaliere" in (repo / ".planer" / "einheiten" / "rechner.py.md").read_text()
async def test_e2e_stoerung_retry(fake_welt, testdb, tmp_path, monkeypatch):
"""Einmal Garbage → Gates lehnen ab → Engine-Backoff → zweiter Versuch gelingt."""
fake_agents.standard_antworten(fake_welt)
fake_welt.stoerungen.append({"muster": r"-schneiden-chunk-0000", "modus": "garbage", "mal": 1})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok", ergebnis
async def test_e2e_dauerfehler_dead(fake_welt, testdb, tmp_path, monkeypatch):
"""Dauerhafter Fehler auf einem Chunk → Karte landet im Dead-Letter, Lauf endet."""
fake_agents.standard_antworten(fake_welt)
fake_welt.stoerungen.append({"muster": r"-schneiden-chunk-0000", "modus": "fehler", "mal": 99})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "fehler"
assert ergebnis["dead"] == ["chunk-0000"]
async def test_e2e_infra_pause(fake_welt, testdb, tmp_path, monkeypatch):
"""AgentInfraError → Lauf pausiert sofort (kein Dead-Letter, kein Raten)."""
fake_agents.standard_antworten(fake_welt)
fake_welt.stoerungen.append({"muster": r"-schneiden-", "modus": "infra", "mal": 1})
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "pausiert"
assert "fake-infra" in ergebnis["infra_error"]
async def test_e2e_fortsetzen_eine_sichten_runde(fake_welt, testdb, tmp_path, monkeypatch):
"""Fortsetzung mit Mix aus toten und fertigen Chunks: die Sammeln-Barriere darf erst
öffnen, wenn der Producer ALLE Karten requeued hat — genau eine Sichten-Runde, und
die Sichten sehen die volle Übersicht (Abnahme-Befund: Teil-Übersicht durch frühes
Drainen)."""
fake_agents.standard_antworten(fake_welt)
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok"
# Zustand eines abgebrochenen Laufs nachstellen: ein Chunk tot, Rest fertig
karten = await testdb.kanban_cards("mini", scan_board.BOARD, kind="chunk")
tot = karten[0]["card_id"]
await testdb.kanban_upsert_card("mini", scan_board.BOARD, tot, "chunk", "dead", None)
fake_welt.calls.clear()
ergebnis2 = await asyncio.wait_for(
scan_board.run_scan(repo, "mini", stand="deadbeef", fortsetzen=True), timeout=30)
assert ergebnis2["status"] == "ok", ergebnis2
sichten_calls = [k for k in fake_welt.calls if "-sichten-" in k]
assert len(sichten_calls) == 3 # genau EINE Runde, kein Doppel-Drain
features = (repo / ".planer" / "features" / "fake-bereich.md").read_text()
assert "rechner.py::addiere" in features and "anzeige.py::zeige" in features # volle Übersicht
async def test_e2e_events_geschrieben(fake_welt, testdb, tmp_path, monkeypatch):
"""Telemetrie: Agent-Events mit run_id landen in der DB."""
import agents
import database
monkeypatch.setattr(agents, "on_event", database.add_event)
fake_agents.standard_antworten(fake_welt)
repo = _repo(tmp_path)
ergebnis = await _scan(repo, monkeypatch)
assert ergebnis["status"] == "ok"
db = await testdb.get_db()
cursor = await db.execute(
"SELECT count(*) FROM events WHERE topic='mini' AND kind='agent' AND run_id LIKE 'scan-%'")
assert (await cursor.fetchone())[0] >= 5 # 2 Chunks + 3 Sichten