init
This commit is contained in:
66
backend/tests/conftest.py
Normal file
66
backend/tests/conftest.py
Normal 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)
|
||||
7
backend/tests/fixtures/mini_repo/anzeige.py
vendored
Normal file
7
backend/tests/fixtures/mini_repo/anzeige.py
vendored
Normal 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)}"
|
||||
23
backend/tests/fixtures/mini_repo/rechner.py
vendored
Normal file
23
backend/tests/fixtures/mini_repo/rechner.py
vendored
Normal 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
|
||||
50
backend/tests/test_agents.py
Normal file
50
backend/tests/test_agents.py
Normal 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)
|
||||
65
backend/tests/test_artefakte.py
Normal file
65
backend/tests/test_artefakte.py
Normal 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
|
||||
57
backend/tests/test_chunker.py
Normal file
57
backend/tests/test_chunker.py
Normal 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"}
|
||||
31
backend/tests/test_config_rollen.py
Normal file
31
backend/tests/test_config_rollen.py
Normal 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"}
|
||||
33
backend/tests/test_database.py
Normal file
33
backend/tests/test_database.py
Normal 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
159
backend/tests/test_gates.py
Normal 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"]
|
||||
128
backend/tests/test_kanban.py
Normal file
128
backend/tests/test_kanban.py
Normal 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
|
||||
32
backend/tests/test_modell_ids.py
Normal file
32
backend/tests/test_modell_ids.py
Normal 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}"
|
||||
102
backend/tests/test_rescan.py
Normal file
102
backend/tests/test_rescan.py
Normal 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)
|
||||
87
backend/tests/test_routes.py
Normal file
87
backend/tests/test_routes.py
Normal 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"]
|
||||
119
backend/tests/test_scan_fake.py
Normal file
119
backend/tests/test_scan_fake.py
Normal 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
|
||||
114
backend/tests/test_viewer.py
Normal file
114
backend/tests/test_viewer.py
Normal 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
|
||||
Reference in New Issue
Block a user