This commit is contained in:
Team3
2026-07-22 16:12:23 +02:00
commit f07ef9653d
851 changed files with 501480 additions and 0 deletions

66
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,66 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import config # noqa: E402
import database # noqa: E402
import fake_agents # noqa: E402
@pytest.fixture
async def testdb(tmp_path, monkeypatch):
"""Frische SQLite-Datei je Test; setzt die modul-globale Connection zurück."""
monkeypatch.setattr(config, "DB_PATH", tmp_path / "test.db")
database._db = None
await database.init_db()
yield database
await database.close_db()
@pytest.fixture
async def gescanntes_repo(tmp_path, monkeypatch, testdb):
"""mini_repo als echtes git-Repo mit echtem .planer/ (Fake-Scan, echter HEAD-Hash) —
Grundlage für Viewer-/Drift-/Routen-Tests."""
import shutil
import subprocess
import fake_agents
import kanban
import repo as repo_mod
import scan_board
wurzel = tmp_path / "mini"
shutil.copytree(Path(__file__).parent / "fixtures" / "mini_repo", wurzel)
env = {"PATH": "/usr/bin:/bin", "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", "HOME": str(wurzel)}
def git(*args):
subprocess.run(["git", "-C", str(wurzel), *args], check=True, capture_output=True, env=env)
git("init", "-b", "main")
git("add", "-A")
git("commit", "-m", "start")
welt = fake_agents.Welt()
fake_agents.set_welt(welt)
fake_agents.standard_antworten(welt)
monkeypatch.setenv("PLANER_FAKE_AGENTS", "1")
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.05)
ergebnis = await scan_board.run_scan(wurzel, "mini", stand=repo_mod.head_hash(wurzel))
assert ergebnis["status"] == "ok", ergebnis
git("add", "-A")
git("commit", "-m", "artefakte")
yield wurzel, git
fake_agents.set_welt(None)
@pytest.fixture
def fake_welt(monkeypatch):
"""Frische Fake-Welt + PLANER_FAKE_AGENTS aktiv; Tests hängen Antworten/Störungen an."""
welt = fake_agents.Welt()
fake_agents.set_welt(welt)
monkeypatch.setenv("PLANER_FAKE_AGENTS", "1")
yield welt
fake_agents.set_welt(None)

View File

@@ -0,0 +1,7 @@
"""Mini-Fixture: Ausgabe."""
from rechner import addiere
def zeige(a, b):
"""Formatiert die Summe."""
return f"Ergebnis: {addiere(a, b)}"

View File

@@ -0,0 +1,23 @@
"""Mini-Fixture: kleiner Rechner."""
BASIS = 10
def addiere(a, b):
"""Summe zweier Zahlen."""
return a + b
def skaliere(wert):
"""Skaliert mit der Modul-Basis."""
return wert * BASIS
class Speicher:
"""Merkt sich das letzte Ergebnis."""
def __init__(self):
self.letztes = None
def merke(self, wert):
self.letztes = wert

View File

@@ -0,0 +1,50 @@
"""Provider-Layer: Fake-Umschaltung, harter Stopp ohne Rollen-Config, kein Fallback."""
import re
from pathlib import Path
import pytest
import agents
async def test_fake_umschaltung(fake_welt):
fake_welt.antwort_auf(r"-schneiden-", lambda prompt: "ANTWORT")
rc, out, err = await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
assert (rc, out) == (0, "ANTWORT")
assert fake_welt.calls == ["scan-x-schneiden-1"]
async def test_stoerung_dann_fehler(fake_welt):
fake_welt.antwort_auf(r".", lambda prompt: "ok")
fake_welt.stoerungen.append({"muster": r"-schneiden-", "modus": "fehler", "mal": 1})
rc1, _, err1 = await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
rc2, out2, _ = await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
assert (rc1, err1) == (1, "fake-stoerung")
assert (rc2, out2) == (0, "ok")
async def test_infra_stoerung_wirft(fake_welt):
fake_welt.stoerungen.append({"muster": r".", "modus": "infra", "mal": 1})
with pytest.raises(agents.AgentInfraError):
await agents.run_agent("scan-x-schneiden-1", "egal", 5, role="schneiden")
async def test_unkonfigurierte_rolle_pausiert(monkeypatch):
"""Ohne Fake-Modus und ohne ROLLE_-Env: AgentInfraError (Lauf pausiert), kein Default."""
monkeypatch.delenv("PLANER_FAKE_AGENTS", raising=False)
monkeypatch.delenv("ROLLE_JUDGE", raising=False)
with pytest.raises(agents.AgentInfraError, match="kein Modell konfiguriert"):
await agents.run_agent("scan-x-judge-1", "egal", 5, role="judge")
def test_kein_fallback_codepfad():
"""Der Creator-Fallback (_available_cached / 'routed provider unavailable') darf
nicht mitportiert worden sein."""
quelle = Path(agents.__file__).read_text(encoding="utf-8")
assert "_available_cached" not in quelle
assert not re.search(r"run_provider", quelle)
def test_prioritaet_der_stufen():
assert agents._agent_priority("scan-p-sichten-1") < agents._agent_priority("scan-p-schneiden-9")
assert agents._agent_priority("irgendwas") == len(agents._STAGE_PRIORITY)

View File

@@ -0,0 +1,65 @@
"""Artefakt-Schicht: Merge, ergaenze-anker, Writer, Prüfungs-Gerüst."""
from pathlib import Path
import artefakte
FRAG_A = """## a.py::eins
beschreibung: Erste.
input: x
output: y
## a.py::zwei
beschreibung: Zweite.
"""
FRAG_NACHFASS = """ergaenze-anker: a.py::_helfer zu a.py::eins
## a.py::drei
beschreibung: Dritte.
"""
def test_merge_mit_nachfass():
rumpf = artefakte.merge_fragmente([FRAG_A, FRAG_NACHFASS])
assert rumpf.index("## a.py::eins") < rumpf.index("## a.py::zwei") < rumpf.index("## a.py::drei")
assert "anker: a.py::_helfer" in rumpf.split("## a.py::zwei")[0] # im eins-Block
assert "ergaenze-anker" not in rumpf
def test_merge_dubletten_fallen_weg():
rumpf = artefakte.merge_fragmente([FRAG_A, FRAG_A])
assert rumpf.count("## a.py::eins") == 1
def test_einheiten_datei_und_stand():
text = artefakte.einheiten_datei("a.py", "## a.py::eins\nbeschreibung: X.", "abc123")
assert text.startswith("# Einheiten: a.py\nstand: abc123\n")
assert artefakte.lese_stand(text) == "abc123"
def test_alle_ids():
text = "## a.py::eins\nanker: a.py::_helfer\n\n## a.py::zwei\n"
assert artefakte.alle_ids(text) == {"a.py::eins", "a.py::_helfer", "a.py::zwei"}
def test_datei_pfad():
assert artefakte.datei_pfad("backend/kanban.py") == "einheiten/backend__kanban.py.md"
def test_schreibe_planer(tmp_path):
pfade = artefakte.schreibe_planer(tmp_path, {"kern.md": "# Kern\n",
"einheiten/a.py.md": "# Einheiten: a.py\n"})
assert (tmp_path / ".planer" / "kern.md").read_text() == "# Kern\n"
assert len(pfade) == 2
def test_pruefung_geruest_mit_makefile(tmp_path):
(tmp_path / "Makefile").write_text("test:\n\tpytest\ndev:\n\tuvicorn\n")
text = artefakte.pruefung_geruest(tmp_path, "demo")
assert "tests: make test" in text and "start: make dev" in text
assert "lint: (keins vorhanden)" in text
def test_pruefung_geruest_ohne_alles(tmp_path):
text = artefakte.pruefung_geruest(tmp_path, "demo")
assert "tests: (keins vorhanden)" in text

View File

@@ -0,0 +1,57 @@
"""Chunker: Ausschlussliste, Symbol-Schnitt, Chunk-Größen."""
from pathlib import Path
import chunker
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def test_mini_repo_dateien():
rel = [str(p.relative_to(MINI)) for p in chunker.dateien(MINI)]
assert rel == ["anzeige.py", "rechner.py"]
def test_ausschluss(tmp_path):
(tmp_path / "ok.py").write_text("X = 1\n")
(tmp_path / ".env").write_text("GEHEIM=1\n")
(tmp_path / ".env.local").write_text("GEHEIM=2\n")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "paket.py").write_text("Y = 1\n")
(tmp_path / ".planer").mkdir()
(tmp_path / ".planer" / "alt.py").write_text("Z = 1\n")
rel = [str(p.relative_to(tmp_path)) for p in chunker.dateien(tmp_path)]
assert rel == ["ok.py"]
def test_schnitt_mini():
text = (MINI / "rechner.py").read_text()
chunks = chunker.schneide("rechner.py", text)
assert len(chunks) == 1 # klein → ein Chunk
assert chunks[0]["symbole"] == ["addiere", "skaliere", "Speicher"]
assert chunks[0]["text"] == text.rstrip("\n") # Kopf (Docstring, BASIS) im ersten Chunk
def test_schnitt_grosse_datei():
"""Symbole à 60 Zeilen → Chunks bleiben um CHUNK_ZEILEN, Schnitt nie mitten im Symbol."""
bloecke = [f"def f{i}():\n" + "".join(f" x{j} = {j}\n" for j in range(60)) for i in range(20)]
text = "\n".join(bloecke)
chunks = chunker.schneide("gross.py", text)
assert len(chunks) > 1
assert all(c["groesse"] <= chunker.CHUNK_ZEILEN + 61 for c in chunks)
alle = [s for c in chunks for s in c["symbole"]]
assert alle == [f"f{i}" for i in range(20)] # jedes Symbol genau einmal, in Reihenfolge
for c in chunks:
assert c["text"].splitlines()[0].startswith(("def ", "\n", '"')) # Schnitt an Blockgrenze
def test_creator_smoke():
"""Read-only gegen das echte Creator-Backend: board_inventory.py (2344 Z.) wird zerlegt."""
wurzel = Path("/home/arbeit/projects/creator")
chunks = [c for c in chunker.chunks_fuer_repo(wurzel, {"backend/board_inventory.py"})]
assert len(chunks) >= 6
assert all(c["groesse"] <= 700 for c in chunks) # kein Riesen-Chunk
def test_top_level_symbole():
text = (MINI / "rechner.py").read_text()
assert chunker.top_level_symbole(text) == {"BASIS", "addiere", "skaliere", "Speicher"}

View File

@@ -0,0 +1,31 @@
"""Rollen-Auflösung: explizit konfiguriert oder harter Stopp — nie ein Default."""
import pytest
import config
def test_konfigurierte_rolle(monkeypatch):
monkeypatch.setenv("ROLLE_SCHNEIDEN", "minimax:minimax/MiniMax-M3")
assert config.resolve_role("schneiden") == ("minimax", "minimax/MiniMax-M3")
def test_unkonfigurierte_rolle_stoppt(monkeypatch):
monkeypatch.delenv("ROLLE_JUDGE", raising=False)
with pytest.raises(RuntimeError, match="kein Modell konfiguriert"):
config.resolve_role("judge")
def test_unbekannter_provider_stoppt(monkeypatch):
monkeypatch.setenv("ROLLE_SICHTEN", "openai:gpt-4")
with pytest.raises(RuntimeError, match="unbekannter Provider"):
config.resolve_role("sichten")
def test_fehlendes_modell_stoppt(monkeypatch):
monkeypatch.setenv("ROLLE_NACHFASS", "minimax")
with pytest.raises(RuntimeError, match="Modell fehlt"):
config.resolve_role("nachfass")
def test_alle_rollen_deklariert():
assert set(config.ROLLEN) == {"schneiden", "nachfass", "sichten", "judge"}

View File

@@ -0,0 +1,33 @@
"""DB-Roundtrip: Karten + Telemetrie."""
import json
async def test_karten_roundtrip(testdb):
await testdb.kanban_upsert_card("p", "scan", "c1", "chunk", "schneiden", {"groesse": 10})
await testdb.kanban_upsert_card("p", "scan", "c2", "chunk", "schneiden", {"groesse": 99})
karten = await testdb.kanban_pull("p", "scan", "schneiden", 10)
assert [k["card_id"] for k in karten] == ["c2", "c1"] # LPT: größte zuerst
assert karten[0]["payload"]["groesse"] == 99
await testdb.kanban_advance_many("p", "scan", [("c1", "sammeln")])
assert await testdb.kanban_count("p", "schneiden") == 1
assert (await testdb.kanban_stage_counts("p"))["scan"] == {"schneiden": 1, "sammeln": 1}
async def test_payload_erhalt_bei_upsert_ohne_payload(testdb):
await testdb.kanban_upsert_card("p", "scan", "c1", "chunk", "schneiden", {"datei": "a.py"})
await testdb.kanban_upsert_card("p", "scan", "c1", "chunk", "nachfass", None)
karte = await testdb.kanban_get_card("p", "scan", "c1")
assert karte["stage"] == "nachfass" and karte["payload"]["datei"] == "a.py"
async def test_events_mit_run_id(testdb):
testdb.set_current_run("p", "lauf-1")
await testdb.add_event("p", "agent", key="scan:c1", status="ok", dur_ms=1200,
meta={"tokens": {"output": 500}})
db = await testdb.get_db()
cursor = await db.execute("SELECT kind, status, run_id, meta FROM events WHERE topic='p'")
kind, status, run_id, meta = await cursor.fetchone()
assert (kind, status, run_id) == ("agent", "ok", "lauf-1")
assert json.loads(meta)["tokens"]["output"] == 500
testdb.set_current_run("p", None)

159
backend/tests/test_gates.py Normal file
View File

@@ -0,0 +1,159 @@
"""Gates gegen das mini_repo-Fixture: gültiges Fragment passiert, jede Manipulation
wird mit genau dem erwarteten Fehler gefangen."""
from pathlib import Path
import gates
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
GUT = '''# Einheiten: rechner.py
stand: UNBEKANNT
## rechner.py::addiere
beschreibung: Addiert zwei Zahlen.
input: zwei Zahlen a, b
output: die Summe
entscheidungen:
- Reine Funktion ohne Zustand.
beleg: "return a + b"
kanten:
- wird-genutzt-von: anzeige.py::zeige
## rechner.py::Speicher
beschreibung: Merkt sich das letzte Ergebnis.
input: Werte über merke()
output: letzter Wert in self.letztes
entscheidungen:
- Startet leer.
beleg: "self.letztes = None"
kanten:
- nutzt: rechner.py::addiere
'''
def test_gutes_fragment_passiert():
fehler, warnungen, einheiten = gates.pruefe_fragment(GUT, MINI, "rechner.py")
assert fehler == []
assert [e["id"] for e in einheiten] == ["rechner.py::addiere", "rechner.py::Speicher"]
def test_falsches_zitat():
kaputt = GUT.replace('beleg: "return a + b"', 'beleg: "return a - b"')
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("Zitat nicht wörtlich" in f for f in fehler)
def test_erfundener_anker():
kaputt = GUT.replace("## rechner.py::addiere", "## rechner.py::subtrahiere")
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("Symbol 'subtrahiere' nicht in" in f for f in fehler)
def test_kaputte_kante():
kaputt = GUT.replace("- nutzt: rechner.py::addiere",
"- nutzt: rechner.py::addiere (wichtig!)")
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("Kante unlesbar" in f for f in fehler)
def test_unbekannter_kanten_typ():
kaputt = GUT.replace("- nutzt:", "- verwendet:")
fehler, _, _ = gates.pruefe_fragment(kaputt, MINI, "rechner.py")
assert any("unbekannter Kanten-Typ" in f for f in fehler)
def test_fremde_datei_abgelehnt():
fehler, _, _ = gates.pruefe_fragment(GUT, MINI, "anzeige.py")
assert any("gehört nicht zur gescannten Datei" in f for f in fehler)
def test_leere_antwort():
fehler, _, _ = gates.pruefe_fragment("", MINI, "rechner.py")
assert any("keine Einheit geparst" in f for f in fehler)
def test_escaping_normalisierung():
frag = GUT.replace('beleg: "return a + b"', 'beleg: "return f\\"Ergebnis: {addiere(a, b)}\\""')
frag = frag.replace("## rechner.py::addiere", "## anzeige.py::zeige").replace(
"# Einheiten: rechner.py", "# Einheiten: anzeige.py")
# nur den umgebauten zeige-Block prüfen
block = frag.split("\n\n")[1]
fehler, _, _ = gates.pruefe_fragment(block, MINI, "anzeige.py")
assert not any("Zitat" in f for f in fehler)
def test_fehlende_symbole():
_, _, einheiten = gates.pruefe_fragment(GUT, MINI, "rechner.py")
fehlt = gates.fehlende_symbole(["addiere", "skaliere", "Speicher", "BASIS"], einheiten, [])
assert fehlt == ["skaliere", "BASIS"]
fehlt2 = gates.fehlende_symbole(["skaliere"], einheiten, ["rechner.py::skaliere"])
assert fehlt2 == []
def test_repariere_kanten():
"""Erfundene Kanten-Ziele werden gestrichen (Datei erfunden, Symbol erfunden,
Prefix fehlt) — gültige Kanten und alle anderen Zeilen bleiben unangetastet."""
kaputt = GUT.replace(
"- nutzt: rechner.py::addiere",
"- nutzt: rechner.py::addiere\n- ruft-auf: erfunden.py::x\n"
"- nutzt: rechner.py::gibtsnicht\n- löst-aus: nur_symbol")
bereinigt, gestrichen = gates.repariere_kanten(kaputt, MINI, "rechner.py")
assert len(gestrichen) == 3
assert "- nutzt: rechner.py::addiere" in bereinigt
assert "erfunden.py" not in bereinigt and "gibtsnicht" not in bereinigt
fehler, _, _ = gates.pruefe_fragment(bereinigt, MINI, "rechner.py")
assert fehler == []
def test_repariere_belege_schnappt_fast_richtige_zitate():
"""Ein-Zeichen-Abweichung (`=` statt Original) wird auf die echte Quellzeile
geschnappt; frei erfundene Zitate bleiben stehen und fallen im Gate durch."""
kaputt = GUT.replace('beleg: "return a + b"', 'beleg: "return a+b"')
repariert, korrigiert = gates.repariere_belege(kaputt, MINI, "rechner.py")
assert len(korrigiert) == 1
fehler, _, _ = gates.pruefe_fragment(repariert, MINI, "rechner.py")
assert fehler == []
erfunden = GUT.replace('beleg: "return a + b"', 'beleg: "raise TotalErfunden(xyz)"')
repariert2, korrigiert2 = gates.repariere_belege(erfunden, MINI, "rechner.py")
assert korrigiert2 == []
fehler2, _, _ = gates.pruefe_fragment(repariert2, MINI, "rechner.py")
assert any("Zitat nicht wörtlich" in f for f in fehler2)
def test_qualifizierte_methode_als_symbol():
frag = GUT.replace("## rechner.py::Speicher", "## rechner.py::Speicher.merke")
fehler, _, _ = gates.pruefe_fragment(frag, MINI, "rechner.py")
assert not any("Speicher.merke" in f for f in fehler)
def test_beleg_mit_anfuehrungszeichen():
"""Belege mit eingebetteten Quotes/Regex (Befund 17) parsen und passieren."""
frag = ('## anzeige.py::zeige\nbeschreibung: Formatiert.\ninput: a, b\noutput: Text\n'
'entscheidungen:\n- Nutzt einen f-String.\n'
' beleg: "return f"Ergebnis: {addiere(a, b)}""\nkanten:\n')
fehler, _, _ = gates.pruefe_fragment(frag, MINI, "anzeige.py")
assert fehler == []
def test_datei_einheit_ohne_symbole(tmp_path):
"""Skript ohne def/class: Datei-Einheit `## pfad` (Befund 18)."""
(tmp_path / "skript.py").write_text('import sys\nprint(sys.argv)\n')
frag = ('## skript.py\nbeschreibung: Gibt Argumente aus.\ninput: argv\noutput: stdout\n'
'entscheidungen:\n- Direkter Druck ohne Formatierung.\n beleg: "print(sys.argv)"\nkanten:\n')
fehler, _, einheiten = gates.pruefe_fragment(frag, tmp_path, "skript.py")
assert fehler == [] and einheiten[0]["id"] == "skript.py"
def test_referenz_korrektur_falscher_prefix():
"""Symbol existiert eindeutig unter anderer Datei → ID wird korrigiert (Befund 19)."""
sicht = "### Kann reparieren\neinheiten: falsch.py::_fix, kaputt.py::gibtsnirgends\n"
text, gestrichen = gates.repariere_referenzen(sicht, {"richtig.py::_fix", "a.py::x"})
assert "richtig.py::_fix" in text
assert gestrichen == ["kaputt.py::gibtsnirgends"]
def test_referenz_integritaet():
sicht = "### Kann rechnen\neinheiten: rechner.py::addiere, rechner.py::gibtsnicht\n"
fehler = gates.pruefe_referenzen(sicht, {"rechner.py::addiere"})
assert fehler == ["unbekannte Einheiten-Referenz: rechner.py::gibtsnicht"]

View File

@@ -0,0 +1,128 @@
"""Engine tests with fake processors (no LLM): flow, barrier, retry/dead-letter, producer race."""
import asyncio
import pytest
import kanban
from kanban import Flow, Stage, chain_stages, run_flow
TOPIC = "t"
BOARD = "inventory"
def _advance_proc(db, to_stage):
async def proc(cards):
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], to_stage) for c in cards])
return proc
async def _seed(db, n, stage="s1"):
for i in range(n):
await db.kanban_upsert_card(TOPIC, BOARD, f"card-{i}", "title", stage, {"title": f"T{i}"})
async def test_cards_flow_through_stages(testdb):
db = testdb
await _seed(db, 7)
flow = Flow(TOPIC)
stages = chain_stages([
Stage(BOARD, "s1", _advance_proc(db, "s2")),
Stage(BOARD, "s2", _advance_proc(db, "done")),
])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 7
assert await db.kanban_count(TOPIC, ["s1", "s2"], board=BOARD) == 0
async def test_barrier_waits_for_upstream(testdb):
db = testdb
await _seed(db, 6)
upstream_left: list[int] = []
async def slow_s1(cards):
await asyncio.sleep(0.05) # keep upstream busy so an eager barrier would see queued cards
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "gate") for c in cards])
async def barrier_proc(cards):
upstream_left.append(await db.kanban_count(TOPIC, ["s1"], board=BOARD))
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "done") for c in cards])
flow = Flow(TOPIC)
stages = chain_stages([
Stage(BOARD, "s1", slow_s1),
Stage(BOARD, "gate", barrier_proc, barrier=True),
])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 6
assert upstream_left and all(n == 0 for n in upstream_left) # barrier never ran with s1 queued
async def test_retry_backoff_then_dead(testdb, monkeypatch):
db = testdb
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.02)
await _seed(db, 1)
attempts = []
async def failing(cards):
attempts.append(cards[0]["retries"])
raise RuntimeError("kaputt")
flow = Flow(TOPIC)
stages = chain_stages([Stage(BOARD, "s1", failing)])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
card = await db.kanban_get_card(TOPIC, BOARD, "card-0")
assert card["stage"] == "dead"
assert card["retries"] == kanban.MAX_CARD_RETRIES
assert "kaputt" in card["last_error"]
assert attempts == [0, 1, 2] # backoff between attempts, then dead-letter
async def test_requeue_dead(testdb):
db = testdb
await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1")
for _ in range(kanban.MAX_CARD_RETRIES):
await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", kanban.MAX_CARD_RETRIES, 0.0)
assert (await db.kanban_get_card(TOPIC, BOARD, "card-0"))["stage"] == "dead"
assert await db.kanban_requeue_dead(TOPIC, BOARD, "s1") == 1
card = await db.kanban_get_card(TOPIC, BOARD, "card-0")
assert card["stage"] == "s1" and card["retries"] == 0
async def test_producer_attach_in_idle_lull(testdb):
"""Fix-6 regression: a producer attached while workers sit in the exit grace poll
must keep the flow alive and its cards must still be processed."""
db = testdb
flow = Flow(TOPIC)
stages = chain_stages([Stage(BOARD, "s1", _advance_proc(db, "done"))])
async def producer_a():
await db.kanban_upsert_card(TOPIC, BOARD, "card-a", "title", "s1")
flow.wake.set()
flow.done_producer()
async def attacher():
while await db.kanban_count(TOPIC, "done", board=BOARD) == 0: # wait for card-a done
await asyncio.sleep(0.01)
flow.add_producer() # synchronous BEFORE the work — the grace poll must see it
async def producer_b():
await db.kanban_upsert_card(TOPIC, BOARD, "card-b", "title", "s1")
flow.wake.set()
flow.done_producer()
await producer_b()
flow.add_producer() # producer_a, counted before run_flow (sync add)
asyncio.get_event_loop().create_task(attacher())
await asyncio.wait_for(run_flow(flow, stages, producers=[producer_a()]), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 2
async def test_backoff_delays_pull(testdb, monkeypatch):
db = testdb
await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1")
await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", 5, 0.2)
assert await db.kanban_pull(TOPIC, BOARD, "s1", 10) == [] # in backoff → not pullable
assert await db.kanban_count(TOPIC, "s1", board=BOARD) == 1 # but still counts as queued
await asyncio.sleep(0.25)
assert len(await db.kanban_pull(TOPIC, BOARD, "s1", 10)) == 1

View File

@@ -0,0 +1,32 @@
"""Wächter für Leitprinzip 2: Modell-IDs stehen NUR in der .env, nie im Code.
Greppt den gesamten backend-Quellcode (ohne tests/) nach Modell-ID-Mustern.
Schlägt dieser Test an, hat jemand ein Modell hart verdrahtet.
"""
import re
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
MUSTER = re.compile(r"claude-\w+-\d|MiniMax-M\d|\bopus\b|\bsonnet\b|\bhaiku\b|gpt-\d",
re.IGNORECASE)
def test_keine_modell_ids_im_code():
treffer = []
for pfad in BACKEND.rglob("*.py"):
if "tests" in pfad.parts:
continue
for nr, zeile in enumerate(pfad.read_text(encoding="utf-8").splitlines(), 1):
if MUSTER.search(zeile):
treffer.append(f"{pfad.relative_to(BACKEND)}:{nr}: {zeile.strip()}")
assert not treffer, "Modell-IDs im Code gefunden (gehören in die .env):\n" + "\n".join(treffer)
def test_keine_modell_ids_in_templates():
templates = BACKEND / "templates"
if not templates.is_dir():
return
treffer = [f"{p.name}:{nr}" for p in templates.rglob("*.md")
for nr, zeile in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
if MUSTER.search(zeile)]
assert not treffer, f"Modell-IDs in Templates: {treffer}"

View File

@@ -0,0 +1,102 @@
"""Re-Scan über git-Diff: nur berührte Dateien werden neu gescannt, Löschungen räumen
auf, sauberer Tree ist Pflicht. Läuft komplett in der Fake-Welt."""
import shutil
import subprocess
from pathlib import Path
import pytest
import fake_agents
import kanban
import repo as repo_mod
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def _git(wurzel, *args):
subprocess.run(["git", "-C", str(wurzel), *args], check=True, capture_output=True,
env={"PATH": "/usr/bin:/bin", "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"HOME": str(wurzel)})
def _repo(tmp_path: Path) -> Path:
ziel = tmp_path / "repo"
shutil.copytree(MINI, ziel)
_git(ziel, "init", "-b", "main")
_git(ziel, "add", "-A")
_git(ziel, "commit", "-m", "start")
return ziel
@pytest.fixture
def cli(monkeypatch):
import cli as cli_mod
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.05)
return cli_mod
async def test_scan_dann_rescan_nur_geaenderte(cli, fake_welt, testdb, tmp_path):
fake_agents.standard_antworten(fake_welt)
wurzel = _repo(tmp_path)
assert await cli._scan(wurzel) == 0
stand1 = repo_mod.head_hash(wurzel)
assert f"stand: {stand1}" in (wurzel / ".planer" / "einheiten" / "rechner.py.md").read_text()
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "scan-artefakte")
(wurzel / "rechner.py").write_text((wurzel / "rechner.py").read_text() + "\n\ndef neu():\n return 1\n")
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "neue funktion")
stand2 = repo_mod.head_hash(wurzel)
fake_welt.calls.clear()
assert await cli._rescan(wurzel) == 0
# Nur rechner.py wurde neu gescannt …
assert all("anzeige" not in k for k in fake_welt.calls if "-schneiden-" in k)
rechner = (wurzel / ".planer" / "einheiten" / "rechner.py.md").read_text()
anzeige = (wurzel / ".planer" / "einheiten" / "anzeige.py.md").read_text()
assert f"stand: {stand2}" in rechner and "## rechner.py::neu" in rechner
assert f"stand: {stand1}" in anzeige # unverändert übernommen
# … aber die Sichten wurden neu aggregiert und referenzieren auch den Bestand
assert any("-sichten-" in k for k in fake_welt.calls)
kern = (wurzel / ".planer" / "kern.md").read_text()
assert kern.startswith("# Kern:")
async def test_rescan_ohne_aenderung_ruft_kein_llm(cli, fake_welt, testdb, tmp_path):
fake_agents.standard_antworten(fake_welt)
wurzel = _repo(tmp_path)
assert await cli._scan(wurzel) == 0
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "scan-artefakte")
fake_welt.calls.clear()
assert await cli._rescan(wurzel) == 0
assert fake_welt.calls == []
async def test_rescan_geloeschte_datei_raeumt_auf(cli, fake_welt, testdb, tmp_path):
fake_agents.standard_antworten(fake_welt)
wurzel = _repo(tmp_path)
assert await cli._scan(wurzel) == 0
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "scan-artefakte")
(wurzel / "anzeige.py").unlink()
_git(wurzel, "add", "-A")
_git(wurzel, "commit", "-m", "anzeige weg")
assert await cli._rescan(wurzel) == 0
assert not (wurzel / ".planer" / "einheiten" / "anzeige.py.md").exists()
async def test_unsauberer_tree_bricht_ab(cli, fake_welt, testdb, tmp_path):
wurzel = _repo(tmp_path)
(wurzel / "schmutz.py").write_text("X = 1\n")
with pytest.raises(SystemExit):
await cli._scan(wurzel)
async def test_kein_git_repo_bricht_ab(cli, fake_welt, testdb, tmp_path):
kein_repo = tmp_path / "kein-repo"
kein_repo.mkdir()
with pytest.raises(SystemExit):
await cli._scan(kein_repo)

View File

@@ -0,0 +1,87 @@
"""API-Routen: Lesen + Scan-Start über httpx/ASGITransport (gleicher Event-Loop wie
create_task und der DB-Write-Lock — fastapi.TestClient wäre die Loop-Falle)."""
import asyncio
import httpx
import pytest
import config
import scan_dienst
@pytest.fixture
async def client(gescanntes_repo, monkeypatch, tmp_path):
wurzel, git = gescanntes_repo
datei = tmp_path / "projekte.txt"
datei.write_text(f"{wurzel}\n{tmp_path}/tot\n")
monkeypatch.setattr(config, "PROJEKTE_DATEI", datei)
scan_dienst._laeufe.clear()
from server import app
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c, wurzel, git
async def test_projektliste(client):
c, wurzel, _ = client
r = await c.get("/api/projekte")
assert r.status_code == 200
eintraege = {p["name"]: p for p in r.json()}
assert eintraege["mini"]["hat_planer"] is True
assert eintraege["mini"]["drift"] == []
assert eintraege["mini"]["scan_laeuft"] is False
assert "fehler" in eintraege["tot"]
async def test_projekt_detail(client):
c, _, _ = client
r = await c.get("/api/projekte/mini")
daten = r.json()
assert daten["kern"]["titel"] == "mini"
assert daten["bereiche"] and daten["bereiche"][0]["features"]
assert "rechner.py::addiere" in daten["einheiten"]
assert daten["doku_ordnung"]
assert (await c.get("/api/projekte/gibtsnicht")).status_code == 404
async def test_einheit_mit_code(client):
c, _, _ = client
r = await c.get("/api/projekte/mini/einheit", params={"id": "rechner.py::addiere"})
e = r.json()
assert "return a + b" in e["code"]
assert e["veraltet"] is False
assert (await c.get("/api/projekte/mini/einheit", params={"id": "x.py::y"})).status_code == 404
async def test_drift_nach_commit(client):
c, wurzel, git = client
(wurzel / "rechner.py").write_text((wurzel / "rechner.py").read_text() + "\nX9 = 9\n")
git("add", "-A")
git("commit", "-m", "drift")
r = await c.get("/api/projekte")
mini = next(p for p in r.json() if p["name"] == "mini")
assert mini["drift"] == ["rechner.py"]
e = (await c.get("/api/projekte/mini/einheit", params={"id": "rechner.py::addiere"})).json()
assert e["veraltet"] is True
async def test_scan_starten_und_pollen(client):
c, _, _ = client
r = await c.post("/api/projekte/mini/scan", json={"art": "rescan"})
assert r.json()["status"] == "gestartet"
doppel = await c.post("/api/projekte/mini/scan", json={"art": "rescan"})
assert doppel.json()["status"] in ("laeuft", "gestartet") # idempotent, nie Doppellauf
for _ in range(100):
s = (await c.get("/api/projekte/mini/scan-status")).json()
if not s["laeuft"]:
break
await asyncio.sleep(0.1)
assert s["ergebnis"]["status"] in ("ok", "unveraendert")
async def test_scan_409_bei_dirty_tree(client):
c, wurzel, _ = client
(wurzel / "schmutz.py").write_text("X = 1\n")
r = await c.post("/api/projekte/mini/scan", json={"art": "scan"})
assert r.status_code == 409
assert "uncommittete" in r.json()["detail"]

View File

@@ -0,0 +1,119 @@
"""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

View File

@@ -0,0 +1,114 @@
"""Viewer-Service: Parser, Drift, Doku-Ordnung, Projektliste."""
from pathlib import Path
import config
import projekte
import viewer
MINI = Path(__file__).parent / "fixtures" / "mini_repo"
def test_lese_einheiten_mit_entscheidungen():
text = """# Einheiten: a.py
stand: abc
## a.py::eins
beschreibung: Macht eins.
input: x
output: y
entscheidungen:
- Erste Entscheidung.
beleg: "code hier"
- Zweite mit Umbruch
und Fortsetzung.
kanten:
- ruft-auf: a.py::zwei
## b.py
beschreibung: Datei-Einheit.
"""
e = viewer.lese_einheiten(text)
eins = e["a.py::eins"]
assert eins["entscheidungen"] == ["Erste Entscheidung.", "Zweite mit Umbruch und Fortsetzung."]
assert eins["kanten"] == [{"typ": "ruft-auf", "ziel": "a.py::zwei"}]
assert (eins["input"], eins["output"]) == ("x", "y")
assert e["b.py"]["beschreibung"] == "Datei-Einheit."
def test_lese_kern_und_pruefung():
kern = viewer.lese_kern("# Kern: demo\nZwei Sätze.\n\ntragende-einheiten: a.py::x, b.py::y\n")
assert kern["titel"] == "demo" and kern["tragende_einheiten"] == ["a.py::x", "b.py::y"]
pr = viewer.lese_pruefung("# Prüfung: demo\ntests: make test\nlint: (keins vorhanden)\n"
"start: make dev\nhinweise:\n- Vorsicht.\n")
assert pr["tests"] == "make test" and pr["hinweise"] == ["Vorsicht."]
def test_lese_features_architektur_flows():
b = viewer.lese_features("# Bereich: rechnen\nbeschreibung: Rechnet.\n\n"
"## Feature: Addieren [kern]\nbeschreibung: Summen.\n\n"
"### Kann addieren\neinheiten: a.py::eins\n")
assert b["features"][0]["flag"] == "kern"
assert b["features"][0]["teilfeatures"][0]["einheiten"] == ["a.py::eins"]
a = viewer.lese_architektur("# Architektur: demo\n\n## Komponente: kernstück\n"
"beschreibung: Trägt.\neinheiten: a.py::eins\n"
"beziehungen:\n- nutzt: anzeige\n")
assert a[0]["beziehungen"] == [{"typ": "nutzt", "ziel": "anzeige"}]
f = viewer.lese_flows("# Flows: demo\n\n## Flow: ablauf\n1. Schritt eins — a.py::eins\n"
"2. Schritt ohne Referenz\n")
assert f[0]["schritte"][0]["einheiten"] == ["a.py::eins"]
assert f[0]["schritte"][1]["einheiten"] == []
def test_code_extrahieren():
assert "return a + b" in viewer.code_extrahieren(MINI, "rechner.py::addiere")
assert viewer.code_extrahieren(MINI, "rechner.py::BASIS").startswith("BASIS = 10")
assert viewer.code_extrahieren(MINI, "rechner.py::gibtsnicht") == ""
assert viewer.code_extrahieren(MINI, "wegdatei.py::x") == ""
assert "def merke" in viewer.code_extrahieren(MINI, "rechner.py::Speicher.merke")
async def test_lade_projekt_und_drift(gescanntes_repo):
wurzel, git = gescanntes_repo
projekt = viewer.lade_projekt(wurzel)
assert "rechner.py::addiere" in projekt["einheiten"]
assert projekt["kern"]["titel"] == "mini"
assert projekt["staende"]["rechner.py"]
assert viewer.drift(wurzel, projekt["staende"])["veraltet"] == []
(wurzel / "rechner.py").write_text((wurzel / "rechner.py").read_text() + "\nX2 = 2\n")
git("add", "-A")
git("commit", "-m", "aenderung")
d = viewer.drift(wurzel, projekt["staende"])
assert d["veraltet"] == ["rechner.py"] # anzeige.py unverändert
def test_doku_ordnung_mit_zyklus():
projekt = {
"bereiche": [
{"name": "oben", "features": [{"teilfeatures": [{"einheiten": ["a.py::x"]}]}]},
{"name": "unten", "features": [{"teilfeatures": [{"einheiten": ["b.py::y"]}]}]},
],
"einheiten": {
"a.py::x": {"kanten": [{"typ": "ruft-auf", "ziel": "b.py::y"}]},
"b.py::y": {"kanten": []},
},
}
assert viewer.doku_ordnung(projekt) == ["unten", "oben"] # Fundament zuerst
# Zyklus: beide nutzen einander → deterministisch, beide enthalten
projekt["einheiten"]["b.py::y"]["kanten"] = [{"typ": "nutzt", "ziel": "a.py::x"}]
ordnung = viewer.doku_ordnung(projekt)
assert sorted(ordnung) == ["oben", "unten"] and len(ordnung) == 2
def test_projektliste(tmp_path, monkeypatch):
echt = tmp_path / "echt"
echt.mkdir()
(tmp_path / "kopie").mkdir()
datei = tmp_path / "projekte.txt"
datei.write_text(f"# Kommentar\n{echt}\n{tmp_path}/weg\n{tmp_path}/kopie\n{echt}\n")
monkeypatch.setattr(config, "PROJEKTE_DATEI", datei)
liste = projekte.lade()
assert [p.get("fehler") is None for p in liste] == [True, False, True, False]
assert "existiert nicht" in liste[1]["fehler"]
assert "Namenskollision" in liste[3]["fehler"]
assert projekte.finde("echt") == echt and projekte.finde("weg") is None