This commit is contained in:
team3
2026-09-07 07:44:00 +02:00
parent 5d42b4cfc5
commit c6bbed4049
250 changed files with 13372 additions and 6203 deletions

View File

@@ -0,0 +1,418 @@
"""Agenten-Übergabe: der Auftrag beschreibt den Diff, der Start braucht ein Verzeichnis."""
import asyncio
import pytest
import auftrag
import features
import planung
import projekte
import verzeichnis
T = "-" * 40
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("A")
features.anlegen("A", f"<!-- titel -->\nGalerie\n{T}\n<!-- funktion -->\nBild wählen\n")
features.anlegen("A", f"<!-- titel -->\nShop\n{T}\n<!-- funktion -->\nKarte kaufen\n")
yield "A"
auftrag._laeufe.clear()
def test_ohne_planung_kein_auftrag(projekt):
assert auftrag.bau_auftrag(projekt) == ("", [])
def test_leere_planung_kein_auftrag(projekt):
planung.starte(projekt)
assert auftrag.bau_auftrag(projekt) == ("", [])
def test_auftrag_nennt_diff_und_sollzustand(projekt):
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.loeschen(projekt, f["id"])
text, schritte = auftrag.bau_auftrag(projekt)
texte = [s["text"] for s in schritte]
assert "Merkliste / Produkt merken: PRÜFEN" in texte
assert "Karte „Shop“ streichen" in texte
assert all(s in text for s in texte)
# Die Zuordnung steht daneben, nicht nur im Satz
assert {"text": "Merkliste / Produkt merken: PRÜFEN", "karte": "Merkliste",
"funktion": "Produkt merken"} in schritte
assert {"text": "Karte „Shop“ streichen", "karte": "Shop",
"funktion": ""} in schritte
# Sollzustand: die neue Karte steht voll drin, die gestrichene nicht
assert "<!-- funktion -->\nProdukt merken" in text
assert "Karte kaufen" not in text.split("Die betroffenen Karten")[1]
def test_start_braucht_verzeichnis(projekt):
assert auftrag.starte(projekt, "tu was", ["x"]) is False
def test_start_braucht_auftrag(projekt, tmp_path):
verzeichnis.setze(projekt, str(tmp_path))
assert auftrag.starte(projekt, "", []) is False
class Strom:
"""Attrappe des Claude-Prozesses: gibt vorbereitete Stream-Zeilen aus."""
def __init__(self, zeilen, rc=0, gesehen=None):
import json as _json
self._zeilen = [(_json.dumps(z) + "\n").encode() for z in zeilen]
self.returncode = rc
self._gesehen = gesehen if gesehen is not None else {}
class Stdin:
def write(s, daten): self._gesehen["eingabe"] = daten.decode()
async def drain(s): pass
def close(s): pass
self.stdin = Stdin()
class Stdout:
async def readline(s):
return self._zeilen.pop(0) if self._zeilen else b""
self.stdout = Stdout()
class Stderr:
async def read(s): return b""
self.stderr = Stderr()
async def wait(self):
return self.returncode
def text_ereignis(text):
return {"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}}
def test_start_fuehrt_claude_im_verzeichnis_aus(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
gesehen = {}
async def unecht(*kommando, cwd, **k):
gesehen["kommando"] = kommando
gesehen["cwd"] = cwd
return Strom([{"type": "result", "result": "fertig"}], gesehen=gesehen)
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
assert auftrag.starte(projekt, "Setze um: X",
[{"text": "S1", "karte": "", "funktion": ""}]) is True
assert auftrag.status(projekt)["laeuft"] is True
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
assert gesehen["kommando"][0] == "claude"
assert "--output-format" in gesehen["kommando"]
assert gesehen["cwd"] == str(tmp_path)
assert gesehen["eingabe"] == "Setze um: X"
stand = auftrag.status(projekt)
assert stand["text"] == "fertig"
assert stand["laeuft"] is False
def test_schritte_werden_abgehakt_und_planung_uebernommen(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
anweisung, schritte = auftrag.bau_auftrag(projekt)
async def unecht(*a, **k):
return Strom([
{"type": "assistant", "message": {"content": [
{"type": "tool_use", "name": "Edit", "input": {"file_path": "x.py"}}]}},
text_ereignis(f'ERLEDIGT: {schritte[0]["text"]}'),
{"type": "result", "result": "alles gebaut"},
])
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
auftrag.starte(projekt, anweisung, schritte)
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
stand = auftrag.status(projekt)
assert stand["schritte"] == [{**schritte[0], "fertig": True}]
assert stand["uebernommen"] is True
assert planung.laeuft(projekt) is False
assert "Merkliste" in [f["sektionen"]["titel"] for f in features.liste(projekt)]
def test_fehlschlag_laesst_die_planung_stehen(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
anweisung, schritte = auftrag.bau_auftrag(projekt)
async def unecht(*a, **k):
return Strom([], rc=1)
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
auftrag.starte(projekt, anweisung, schritte)
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
stand = auftrag.status(projekt)
assert stand["uebernommen"] is False
assert stand["fehler"]
assert planung.laeuft(projekt) is True
assert "Merkliste" not in [f["sektionen"]["titel"] for f in features.liste(projekt, nur_stand=True)]
def test_ungefaehre_meldung_hakt_den_naechsten_schritt_ab():
lauf = {"schritte": [{"text": "Galerie / Bild wählen: NEU", "fertig": False},
{"text": "Karte „Shop“ streichen", "fertig": False}]}
auftrag._hake_ab("Test", lauf, "ERLEDIGT: Galerie / Bild wählen (NEU)")
assert lauf["schritte"][0]["fertig"] is True
auftrag._hake_ab("Test", lauf, "ERLEDIGT: völlig anderes Zeug")
assert lauf["schritte"][1]["fertig"] is False
def test_fehlender_claude_wird_gemeldet(projekt, tmp_path, monkeypatch):
verzeichnis.setze(projekt, str(tmp_path))
async def kaputt(*a, **k):
raise FileNotFoundError("claude")
monkeypatch.setattr(asyncio, "create_subprocess_exec", kaputt)
async def lauf():
auftrag.starte(projekt, "X", [{"text": "S", "karte": "", "funktion": ""}])
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
assert "claude-CLI nicht gefunden" in auftrag.status(projekt)["fehler"]
assert auftrag.status(projekt)["laeuft"] is False
def test_zweiter_start_wird_abgelehnt(projekt, tmp_path):
verzeichnis.setze(projekt, str(tmp_path))
auftrag._laeufe[projekt] = {"laeuft": True, "fehler": "", "text": "",
"schritte": [], "aktivitaet": "", "uebernommen": False}
with pytest.raises(ValueError):
auftrag.starte(projekt, "X", [{"text": "S", "karte": "", "funktion": ""}])
# ------------------------------------------------------------- Verzeichnis
def test_verzeichnis_wird_gemerkt(projekt, tmp_path):
verzeichnis.setze(projekt, str(tmp_path))
assert verzeichnis.lies(projekt) == str(tmp_path)
verzeichnis.loesche(projekt)
assert verzeichnis.lies(projekt) == ""
def test_verzeichnis_lehnt_wurzel_und_relatives_ab(projekt):
with pytest.raises(ValueError):
verzeichnis.pruefe("/")
with pytest.raises(ValueError):
verzeichnis.pruefe("relativ/pfad")
with pytest.raises(FileNotFoundError):
verzeichnis.pruefe("/gibt/es/nicht")
def test_verzeichnis_liegt_im_stand_nicht_in_der_kopie(projekt, tmp_path):
planung.starte(projekt)
verzeichnis.setze(projekt, str(tmp_path))
planung.brich_ab(projekt)
assert verzeichnis.lies(projekt) == str(tmp_path)
# ------------------------------------------------------------- Durchblättern
def test_ordner_listet_nur_sichtbare_verzeichnisse(tmp_path):
(tmp_path / "b").mkdir()
(tmp_path / "A").mkdir()
(tmp_path / ".versteckt").mkdir()
(tmp_path / "datei.txt").write_text("x")
antwort = verzeichnis.ordner(str(tmp_path))
assert antwort["ordner"] == ["A", "b"]
assert antwort["pfad"] == str(tmp_path)
assert antwort["eltern"] == str(tmp_path.parent)
def test_ordner_ohne_pfad_beginnt_daheim():
from pathlib import Path
assert verzeichnis.ordner("")["pfad"] == str(Path.home())
def test_ordner_lehnt_datei_und_relatives_ab(tmp_path):
(tmp_path / "d.txt").write_text("x")
with pytest.raises(FileNotFoundError):
verzeichnis.ordner(str(tmp_path / "d.txt"))
with pytest.raises(ValueError):
verzeichnis.ordner("relativ")
def test_vision_gehoert_nie_zum_auftrag(projekt):
features.anlegen(projekt, "<!-- titel -->\nVision\n<!-- art -->\nvision\n<!-- ziel -->\nAlt\n")
planung.starte(projekt)
v = next(f for f in features.liste(projekt) if f["sektionen"]["art"] == "vision")
features.speichern(projekt, v["id"], v["roh"].replace("Alt", "Neu"))
assert auftrag.bau_auftrag(projekt) == ("", [])
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\nProdukt merken\n")
text, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Merkliste / Produkt merken: PRÜFEN"]
assert "Vision" not in text
# ------------------------------------------------- Stand: bestellt wird gebaut
def _stand(projekt, karte, funktion):
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == karte)
return next(x["stand"] for x in f["sektionen"]["funktionen"] if x["text"] == funktion)
def test_abgehakte_funktion_gilt_als_gebaut(projekt, tmp_path, monkeypatch):
"""Der Agent hat es gebaut — dann darf die Karte nicht „bestellt“ behaupten."""
verzeichnis.setze(projekt, str(tmp_path))
planung.starte(projekt)
features.anlegen(projekt, f"<!-- titel -->\nMerkliste\n{T}\n<!-- funktion -->\n"
f"Produkt merken\n<!-- stand -->\noffen\n{T}\n"
f"<!-- funktion -->\nProdukt entfernen\n<!-- stand -->\noffen\n")
anweisung, schritte = auftrag.bau_auftrag(projekt)
merken = next(s for s in schritte if s["funktion"] == "Produkt merken")
async def unecht(*a, **k):
return Strom([text_ereignis(f'ERLEDIGT: {merken["text"]}'),
{"type": "result", "result": "eins gebaut"}])
monkeypatch.setattr(asyncio, "create_subprocess_exec", unecht)
async def lauf():
auftrag.starte(projekt, anweisung, schritte)
await asyncio.gather(*auftrag._aufgaben)
asyncio.run(lauf())
# Nur der abgehakte Schritt wandert um; der andere bleibt bestellt
assert _stand(projekt, "Merkliste", "Produkt merken") == "gebaut"
assert _stand(projekt, "Merkliste", "Produkt entfernen") == "offen"
def test_gestrichene_funktion_wird_nicht_umgestellt(projekt, tmp_path):
"""Was weg ist, ist nicht gebaut — der Schritt trägt darum keinen Namen."""
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], "<!-- titel -->\nGalerie\n")
_, schritte = auftrag.bau_auftrag(projekt)
weg = next(s for s in schritte if "Bild wählen" in s["text"])
assert weg["funktion"] == ""
def test_offen_vermerk_bleibt_bei_anderer_funktion(projekt):
roh = ("<!-- titel -->\nGalerie\n"
"<!-- funktion -->\nBild wählen\n<!-- stand -->\noffen\n"
"<!-- funktion -->\nBild schließen\n<!-- stand -->\noffen\n")
neu = auftrag._als_gebaut(roh, "Bild schließen")
assert neu.count("offen") == 1
assert "Bild wählen\n<!-- stand -->\noffen" in neu
def test_unbekannte_bloecke_bleiben_erhalten(projekt):
"""Textoperation statt Neuschreiben: der Rest der Datei bleibt Zeichen für Zeichen."""
roh = ("<!-- titel -->\nGalerie\n<!-- eigenes -->\nnicht anfassen\n"
"<!-- funktion -->\nBild wählen\n<!-- stand -->\noffen\n"
"<!-- regeln -->\n- Eine Regel\n")
neu = auftrag._als_gebaut(roh, "Bild wählen")
assert "<!-- eigenes -->\nnicht anfassen" in neu
assert "<!-- regeln -->\n- Eine Regel" in neu
assert "offen" not in neu
# ---------------------------------------- offen heißt: noch nicht bewiesen
def _auf_offen(projekt, karte, funktion):
"""Wie ein Klick auf den Stand: gebaut zurück auf offen."""
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == karte)
roh = f["roh"].replace(f"<!-- funktion -->\n{funktion}\n",
f"<!-- funktion -->\n{funktion}\n<!-- stand -->\noffen\n")
features.speichern(projekt, f["id"], roh)
def test_zurueckgesetzte_funktion_kommt_in_die_arbeitsliste(projekt):
"""Eine kaputte Umsetzung meldet man, indem man die Funktion auf offen setzt.
Die Karte ändert sich dadurch, aber sonst nichts — vorher blieb der Klick folgenlos.
"""
planung.starte(projekt)
_auf_offen(projekt, "Galerie", "Bild wählen")
text, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Galerie / Bild wählen: PRÜFEN"]
# Der Sollzustand der Karte gehört dazu, sonst weiß der Agent nicht, wogegen er prüft
assert "<!-- funktion -->\nBild wählen" in text
assert auftrag.PRUEFEN in text
def test_offene_funktion_ohne_kartenaenderung(projekt, tmp_path):
"""Offen in der Kopie und im Stand: kein Diff, trotzdem Arbeit."""
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nKarte kaufen\n",
"<!-- funktion -->\nKarte kaufen\n<!-- stand -->\noffen\n"))
planung.starte(projekt)
assert planung.aenderungen(projekt) == {}
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Shop / Karte kaufen: PRÜFEN"]
def test_offen_schlaegt_geaendert(projekt):
"""Eine offene Funktion wird geprüft, nicht bloß angepasst."""
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nBild wählen\n",
"<!-- funktion -->\nBild wählen\n<!-- stand -->\noffen\n"
"<!-- regeln -->\n- Eine neue Regel\n"))
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Galerie / Bild wählen: PRÜFEN"]
def test_gebaut_und_geaendert_bleibt_geaendert(projekt):
planung.starte(projekt)
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nBild wählen\n",
"<!-- funktion -->\nBild wählen\n<!-- regeln -->\n- Eine neue Regel\n"))
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Galerie / Bild wählen: GEÄNDERT"]
def test_gestrichene_karte_zieht_ihre_offenen_nicht_nach(projekt):
"""Eine gestrichene Karte darf nicht zusätzlich als „prüfen“ auftauchen."""
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.speichern(projekt, f["id"], f["roh"].replace(
"<!-- funktion -->\nKarte kaufen\n",
"<!-- funktion -->\nKarte kaufen\n<!-- stand -->\noffen\n"))
planung.starte(projekt)
g = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
features.loeschen(projekt, g["id"])
_, schritte = auftrag.bau_auftrag(projekt)
assert [s["text"] for s in schritte] == ["Karte „Shop“ streichen"]
def test_ohne_planung_kein_lauf(projekt):
"""Der Agent arbeitet auf der Planung; ohne sie gibt es keinen Auftrag."""
_auf_offen(projekt, "Galerie", "Bild wählen")
assert auftrag.bau_auftrag(projekt) == ("", [])
def test_auftrag_verlangt_offenlegung_des_ungeprueften(projekt):
planung.starte(projekt)
_auf_offen(projekt, "Galerie", "Bild wählen")
text, _ = auftrag.bau_auftrag(projekt)
assert "nicht prüfen konntest" in text
assert "Hake nur ab, was du geprüft hast" in text

111
backend/tests/test_chat.py Normal file
View File

@@ -0,0 +1,111 @@
"""Was das Planungsgespräch darf — und was es nicht sieht.
Geprüft wird nicht die Antwort des Modells, sondern womit es losgeschickt wird:
Werkzeugsatz, Arbeitsverzeichnis und der Absatz im Systemprompt gehören zusammen.
Fällt eines auseinander, behauptet das Modell nachgesehen zu haben, ohne zu können.
"""
import json
import pytest
import agents
import chat
import projekte
import verzeichnis
KARTE = ("<!-- titel -->\nUpload\n<!-- art -->\nkern\n"
"<!-- ziel -->\nDateien landen im Speicher.\n"
"<!-- funktion -->\nDatei wählen\n<!-- testen -->\n- Dialog öffnen\n")
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("P")
(datenordner / "P" / "upload.md").write_text(KARTE)
return "P"
@pytest.fixture
def mitschrift(monkeypatch):
"""Fängt den Aufruf ab und gibt eine gültige Antwort zurück."""
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen.update(system=system, inhalt=inhalt, kommando=kommando, ordner=ordner)
return json.dumps({"antwort": "Verstanden.", "karten": []})
monkeypatch.setattr(agents, "frage_lokal", antworte)
return gesehen
async def test_ohne_verzeichnis_kein_lesen(projekt, mitschrift):
await chat.frage(projekt, "Was kann das Projekt?")
assert mitschrift["kommando"] == agents.PLANUNG_OHNE_CODE
assert mitschrift["ordner"] is None
assert chat.CODE_OHNE in mitschrift["system"]
async def test_mit_verzeichnis_darf_lesen(projekt, mitschrift, tmp_path):
code = tmp_path / "quelltext"
code.mkdir()
verzeichnis.setze(projekt, str(code))
await chat.frage(projekt, "Gibt es den Upload schon?")
assert mitschrift["kommando"] == agents.PLANUNG_KOMMANDO
assert mitschrift["ordner"] == str(code.resolve())
assert chat.CODE_MIT in mitschrift["system"]
async def test_toter_pfad_zaehlt_wie_keiner(projekt, mitschrift, tmp_path):
"""Sonst startet der Prozess nicht, und die Meldung zeigt auf die CLI."""
code = tmp_path / "weg"
code.mkdir()
verzeichnis.setze(projekt, str(code))
code.rmdir()
await chat.frage(projekt, "Und jetzt?")
assert mitschrift["kommando"] == agents.PLANUNG_OHNE_CODE
assert mitschrift["ordner"] is None
async def test_planung_sucht_nicht_im_web(projekt, mitschrift, tmp_path):
"""Fremdes Wissen kommt aus der Recherche, nicht nebenbei in einen Entwurf."""
code = tmp_path / "quelltext"
code.mkdir()
verzeichnis.setze(projekt, str(code))
await chat.frage(projekt, "Wie macht man das üblicherweise?")
for satz in (agents.PLANUNG_KOMMANDO, agents.PLANUNG_OHNE_CODE):
assert "WebSearch" not in _erlaubt(satz)
assert "WebFetch" not in _erlaubt(satz)
def test_recherche_sieht_keinen_quelltext():
"""Umgekehrt: aus dem Projekt darf nichts in eine Suchanfrage geraten."""
erlaubt = _erlaubt(agents.RECHERCHE_KOMMANDO)
assert "WebSearch" in erlaubt and "WebFetch" in erlaubt
for werkzeug in ("Read", "Glob", "Grep"):
assert werkzeug not in erlaubt
@pytest.mark.parametrize("kommando", [
agents.PLANUNG_KOMMANDO, agents.PLANUNG_OHNE_CODE, agents.RECHERCHE_KOMMANDO])
def test_kein_satz_kann_etwas_anstossen(kommando):
"""Fremder Text im Kontext darf nichts auslösen, egal woher er kommt."""
gesperrt = _gesperrt(kommando)
for werkzeug in ("Write", "Edit", "Bash", "CronCreate", "PushNotification",
"SendMessage", "Workflow", "Task"):
assert werkzeug in gesperrt
assert "--safe-mode" in kommando
def _abschnitt(kommando: tuple, flagge: str) -> list:
rest = list(kommando[kommando.index(flagge) + 1:])
ende = next((i for i, w in enumerate(rest) if w.startswith("--")), len(rest))
return rest[:ende]
def _erlaubt(kommando: tuple) -> list:
return _abschnitt(kommando, "--allowed-tools")
def _gesperrt(kommando: tuple) -> list:
return _abschnitt(kommando, "--disallowed-tools")

View File

@@ -22,13 +22,16 @@ def projekt(datenordner):
def karte(projekt, titel, *funktionen):
"""funktionen sind (name, regel) oder (name, regel, [testschritte])."""
"""funktionen sind (name, regel) oder (name, regel, [testschritte]).
Ohne eigene Schritte bekommt die Funktion einen Platzhalter-Ablauf: Testschritte
sind Pflicht, und ohne sie bricht die Messung vor allem anderen ab.
"""
text = f"<!-- titel -->\n{titel}\n"
for eintrag in funktionen:
name, regel, schritte = (*eintrag, [])[:3]
name, regel, schritte = (*eintrag, ["Karte öffnen", "Karte offen"])[:3]
text += f"<!-- funktion -->\n{name}\n<!-- regeln -->\n- {regel}\n"
if schritte:
text += "<!-- testen -->\n" + "".join(f"- {s}\n" for s in schritte)
text += "<!-- testen -->\n" + "".join(f"- {s}\n" for s in schritte)
return features.anlegen(projekt, text)
@@ -116,14 +119,14 @@ async def test_stilurteil_wird_gemerkt_und_nicht_neu_gefragt(projekt, monkeypatc
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
assert await arbeite(a, None) == []
assert await arbeite(a) == []
abschluss()
erste = modell.rufe
assert erste >= len(pruefung.TEMPERATUREN)
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
assert await arbeite(a, None) == []
assert await arbeite(a) == []
assert modell.rufe == erste # keine einzige neue Anfrage
@@ -136,7 +139,7 @@ async def test_geaenderter_text_wird_neu_gefragt(projekt, monkeypatch):
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
for a in liste:
await arbeite(a, None)
await arbeite(a)
abschluss()
vorher = modell.rufe
@@ -144,7 +147,7 @@ async def test_geaenderter_text_wird_neu_gefragt(projekt, monkeypatch):
datei.write_text(datei.read_text().replace("- Knopf", "- Ganz anderer Text"))
liste, arbeite, _ = await pruefung._stil_aufgaben(projekt)
for a in liste:
await arbeite(a, None)
await arbeite(a)
assert modell.rufe > vorher
@@ -159,7 +162,7 @@ async def test_gemessener_verstoss_kommt_nie_aus_dem_gedaechtnis(projekt, monkey
for lauf in range(2):
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
gefunden = [p for a in liste for p in await arbeite(a, None)]
gefunden = [p for a in liste for p in await arbeite(a)]
abschluss()
assert [p["vorschlag"] for p in gefunden] == ["Passwort prüfen"], lauf
assert modell.rufe > len(pruefung.TEMPERATUREN) # zweimal wirklich gefragt

View File

@@ -199,3 +199,33 @@ def test_gespraech_und_gedaechtnis_wandern_mit(projekt):
def test_die_kopie_kopiert_sich_nicht_selbst(projekt):
planung.starte(projekt)
assert not (projekte.wurzel(projekt) / projekte.PLANUNG).exists()
def test_arbeitsverzeichnis_ueberlebt_das_umsetzen(projekt, tmp_path):
"""Erst nach dem Planungsstart festgelegt — dann fehlt es in der Kopie.
Vorher räumte das Umsetzen es mit dem alten Stand weg, und der nächste Lauf
fand kein Verzeichnis mehr.
"""
import verzeichnis
planung.starte(projekt)
verzeichnis.setze(projekt, str(tmp_path))
assert not (planung._kopie(projekt) / verzeichnis.DATEI).exists()
planung.setze_um(projekt)
assert verzeichnis.lies(projekt) == str(tmp_path)
def test_geplantes_verzeichnis_gewinnt(projekt, tmp_path):
"""Steht es in beiden, gilt die Kopie — sie ist der neuere Stand."""
import verzeichnis
alt = tmp_path / "alt"
alt.mkdir()
verzeichnis.setze(projekt, str(alt))
planung.starte(projekt)
neu = tmp_path / "neu"
neu.mkdir()
(planung._kopie(projekt) / verzeichnis.DATEI).write_text(str(neu))
planung.setze_um(projekt)
assert verzeichnis.lies(projekt) == str(neu)

View File

@@ -0,0 +1,193 @@
import pytest
import agents
import projekte
import recherche
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("P")
return "P"
def test_leeres_projekt_hat_keine_faeden(projekt):
assert recherche.liste(projekt) == []
def test_anlegen_gibt_faden_mit_id(projekt):
faden = recherche.anlegen(projekt)
assert faden["id"] and faden["verlauf"] == []
assert [f["id"] for f in recherche.liste(projekt)] == [faden["id"]]
def test_faeden_haben_eigene_ids(projekt):
a, b = recherche.anlegen(projekt), recherche.anlegen(projekt)
assert a["id"] != b["id"]
assert len(recherche.liste(projekt)) == 2
def test_loeschen_trifft_nur_den_einen(projekt):
a, b = recherche.anlegen(projekt), recherche.anlegen(projekt)
recherche.loeschen(projekt, a["id"])
assert [f["id"] for f in recherche.liste(projekt)] == [b["id"]]
def test_loeschen_ohne_faden_ist_404(projekt):
with pytest.raises(FileNotFoundError):
recherche.loeschen(projekt, "gibtsnicht")
def test_faeden_liegen_im_projektstand_nicht_in_der_planung(projekt):
"""Nachgeschlagenes überlebt einen Planungsabbruch — es gehört zum Projekt."""
recherche.anlegen(projekt)
(projekte.stand(projekt) / projekte.PLANUNG).mkdir()
assert len(recherche.liste(projekt)) == 1
assert (projekte.stand(projekt) / recherche.DATEI).is_file()
def _antwort(satz="Laut Quelle X: ja."):
"""Eine Modellantwort — Markdown, nicht JSON."""
async def antworte(system, inhalt, kommando=None, ordner=None):
return satz
return antworte
async def test_frage_haengt_beide_zeilen_an(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort(" Laut Quelle X: ja. "))
erg = await recherche.frage(projekt, faden["id"], "Wie geht das?")
assert [(e["rolle"], e["text"]) for e in erg["verlauf"]] == [
("person", "Wie geht das?"), ("ki", "Laut Quelle X: ja.")]
async def test_erste_frage_benennt_den_faden(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort("ok"))
erg = await recherche.frage(projekt, faden["id"], "Wie lädt man\nzu S3 hoch?")
assert erg["titel"] == "Wie lädt man zu S3 hoch?"
# Die zweite Frage lässt den Namen stehen: der Reiter soll nicht wandern
erg = await recherche.frage(projekt, faden["id"], "Und die Grenzen?")
assert erg["titel"] == "Wie lädt man zu S3 hoch?"
async def test_langer_titel_wird_gekuerzt(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort("ok"))
erg = await recherche.frage(projekt, faden["id"], "w" * 80)
assert len(erg["titel"]) == recherche.TITELLAENGE + 1
assert erg["titel"].endswith("…")
async def test_recherche_bekommt_die_websuche(projekt, monkeypatch):
"""Der Werkzeugsatz ist der ganze Unterschied zum Planungsgespräch."""
faden = recherche.anlegen(projekt)
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen.update(kommando=kommando, inhalt=inhalt)
return "ok"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, faden["id"], "Was gilt draußen?")
assert gesehen["kommando"] == agents.RECHERCHE_KOMMANDO
async def test_die_karten_liegen_im_prompt(projekt, monkeypatch):
"""Ohne Bestand ist „was fehlt noch" nicht zu beantworten — dann rät das Modell."""
(projekte.stand(projekt) / "gold.md").write_text(
"<!-- titel -->\nGold\n<!-- art -->\nkern\n"
"<!-- ziel -->\nSpieler verdient Gold pro Runde.\n"
"<!-- funktion -->\nZinsen erhalten\n<!-- stand -->\noffen\n"
"<!-- testen -->\n- Runde spielen\n")
faden = recherche.anlegen(projekt)
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen["system"] = system
return "ok"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, faden["id"], "Was fehlt noch?")
assert "Zinsen erhalten" in gesehen["system"]
# Der Stand ist der Punkt: er unterscheidet Gebautes von Bestelltem
assert '"stand": "offen"' in gesehen["system"]
# Testabläufe blähen den Prompt und tragen keine Recherche
assert "Runde spielen" not in gesehen["system"]
async def test_vision_kommt_mit(projekt, monkeypatch):
(projekte.stand(projekt) / "vision.md").write_text(
"<!-- titel -->\nVision\n<!-- art -->\nvision\n"
"<!-- ziel -->\nTFT im Browser nachbauen.\n")
faden = recherche.anlegen(projekt)
gesehen = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
gesehen["system"] = system
return "ok"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, faden["id"], "Und nun?")
assert "TFT im Browser nachbauen." in gesehen["system"]
async def test_markdown_bleibt_wie_es_kam(projekt, monkeypatch):
"""Die Antwort wird als Markdown angezeigt — der Server fasst sie nicht an."""
roh = "Kurz gesagt.\n\n## Festgelegt\n- **5 Gold** pro Runde\n\n## Quellen\n- [Wiki](http://x)"
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort(f"\n{roh}\n"))
erg = await recherche.frage(projekt, faden["id"], "Wie geht Gold?")
assert erg["verlauf"][-1]["text"] == roh
async def test_leere_frage_wird_abgelehnt(projekt):
faden = recherche.anlegen(projekt)
with pytest.raises(ValueError):
await recherche.frage(projekt, faden["id"], " ")
async def test_geschlossener_faden_verwirft_die_antwort(projekt, monkeypatch):
"""Antworten dauern Minuten. Wer inzwischen schließt, will das Ergebnis nicht."""
faden = recherche.anlegen(projekt)
async def antworte(system, inhalt, kommando=None, ordner=None):
recherche.loeschen(projekt, faden["id"])
return "zu spät"
monkeypatch.setattr(agents, "frage_lokal", antworte)
with pytest.raises(FileNotFoundError):
await recherche.frage(projekt, faden["id"], "Was gilt?")
assert recherche.liste(projekt) == []
async def test_paralleler_faden_bleibt_erhalten(projekt, monkeypatch):
"""Der zweite Faden entsteht, während der erste noch wartet."""
a = recherche.anlegen(projekt)
neu = {}
async def antworte(system, inhalt, kommando=None, ordner=None):
neu["b"] = recherche.anlegen(projekt)
return "fertig"
monkeypatch.setattr(agents, "frage_lokal", antworte)
await recherche.frage(projekt, a["id"], "Frage")
faeden = recherche.liste(projekt)
assert [f["id"] for f in faeden] == [a["id"], neu["b"]["id"]]
# Was der eine Faden erfährt, bleibt beim einen
assert len(faeden[0]["verlauf"]) == 2 and faeden[1]["verlauf"] == []
def test_kaputte_datei_gilt_als_leer(projekt):
(projekte.stand(projekt) / recherche.DATEI).write_text("{kein json")
assert recherche.liste(projekt) == []
async def test_tiefe_haelt_den_faden_kurz(projekt, monkeypatch):
faden = recherche.anlegen(projekt)
monkeypatch.setattr(agents, "frage_lokal", _antwort("ok"))
for i in range(recherche.TIEFE):
await recherche.frage(projekt, faden["id"], f"Frage {i}")
verlauf = recherche.liste(projekt)[0]["verlauf"]
assert len(verlauf) == recherche.TIEFE
assert verlauf[-2]["text"] == f"Frage {recherche.TIEFE - 1}"

View File

@@ -72,3 +72,41 @@ async def test_pruefung_ohne_konfiguration(client, monkeypatch):
async def test_unbekanntes_projekt(client):
assert (await client.get("/api/projekte/Fremd/features")).status_code == 404
assert (await client.post("/api/projekte/Fremd/features", json={"roh": ROH})).status_code == 404
assert (await client.get("/api/projekte/Fremd/recherche")).status_code == 404
async def test_recherche_faeden(client, monkeypatch):
import agents
await client.post("/api/projekte", json={"name": "Alpha"})
assert (await client.get("/api/projekte/Alpha/recherche")).json()["faeden"] == []
a = (await client.post("/api/projekte/Alpha/recherche")).json()
b = (await client.post("/api/projekte/Alpha/recherche")).json()
assert a["id"] != b["id"]
async def antworte(system, inhalt, kommando=None, ordner=None):
return "## Festgelegt\n- **5 GB** je Sendung"
monkeypatch.setattr(agents, "frage_lokal", antworte)
res = await client.post(f"/api/projekte/Alpha/recherche/{a['id']}",
json={"text": "Was gilt?"})
assert res.json()["titel"] == "Was gilt?"
assert len(res.json()["verlauf"]) == 2
assert res.json()["verlauf"][-1]["text"] == "## Festgelegt\n- **5 GB** je Sendung"
# Der zweite Faden bleibt leer — Fäden teilen sich nichts
faeden = (await client.get("/api/projekte/Alpha/recherche")).json()["faeden"]
assert [len(f["verlauf"]) for f in faeden] == [2, 0]
assert (await client.delete(f"/api/projekte/Alpha/recherche/{b['id']}")).status_code == 200
assert len((await client.get("/api/projekte/Alpha/recherche")).json()["faeden"]) == 1
assert (await client.delete(f"/api/projekte/Alpha/recherche/{b['id']}")).status_code == 404
async def test_recherche_leere_frage(client):
await client.post("/api/projekte", json={"name": "Alpha"})
faden = (await client.post("/api/projekte/Alpha/recherche")).json()
res = await client.post(f"/api/projekte/Alpha/recherche/{faden['id']}",
json={"text": " "})
assert res.status_code == 400

View File

@@ -103,7 +103,8 @@ async def test_messung_ist_auftrag_und_tuersteher(datenordner, monkeypatch):
f = features.anlegen(
"Test",
"<!-- titel -->\nLogin\n<!-- funktion -->\nAnmelden\n"
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
"<!-- regeln -->\n- Auch Passwort prüfen.\n"
"<!-- testen -->\n- Anmelden\n- Angemeldet\n",
)
gesehen = []
# Jede Stimme liefert erst einen halbherzigen Vorschlag: Punkt weg, Füllwort bleibt
@@ -141,7 +142,8 @@ async def test_gemessener_verstoss_wird_notfalls_selbst_behoben(datenordner, mon
f = features.anlegen(
"Test",
"<!-- titel -->\nLogin\n<!-- funktion -->\nAnmelden\n"
"<!-- regeln -->\n- Auch Passwort prüfen.\n",
"<!-- regeln -->\n- Auch Passwort prüfen.\n"
"<!-- testen -->\n- Anmelden\n- Angemeldet\n",
)
async def sturr(system, inhalt, temperatur=0.0):
@@ -226,18 +228,24 @@ def test_zeilenfehler_haben_vorrang():
== ["Satzzeichen am Zeilenende, Füllwort „auch“"]
def test_funktion_ohne_jedes_feld_ist_leer():
"""Ein bloßer Titel beschreibt nichts — der Prüfer stülpt ihn über alles."""
def test_funktion_ohne_testschritte():
"""Ohne Ablauf ist der Titel eine Behauptung — der Prüfer stülpt ihn über alles."""
b = struktur.befunde("funktion", {"titel": "Modal steuern", "ziel": "",
"regeln": "", "testen": ""})
assert _probleme(b) == ["Funktion ohne Ziel, Regeln und Testen"]
assert _probleme(b) == ["Funktion ohne Testschritte"]
def test_ein_einziges_feld_genuegt():
for feld in ("ziel", "regeln", "testen"):
def test_ziel_und_regeln_ersetzen_die_testschritte_nicht():
for feld in ("ziel", "regeln"):
felder = {"titel": "Modal steuern", "ziel": "", "regeln": "", "testen": ""}
felder[feld] = "Etwas tun" if feld == "ziel" else "- Etwas tun"
assert struktur.befunde("funktion", felder) == [], feld
assert _probleme(struktur.befunde("funktion", felder)) \
== ["Funktion ohne Testschritte"], feld
def test_testschritte_genuegen():
assert struktur.befunde("funktion", {"titel": "Modal steuern", "ziel": "",
"regeln": "", "testen": "- Etwas tun"}) == []
def test_einzelnes_feld_loest_nichts_aus():
@@ -259,3 +267,59 @@ def test_platzhalter_ist_kein_inhalt():
def test_kleiner_als_ist_kein_platzhalter():
assert not struktur._umschreiben("funktion", "regeln", "Wert 3 < 5")
def test_kommando_als_testschritt():
"""Ein Setup wird über die Kommandozeile bedient — das muss zulässig sein."""
assert _pruefe("funktion", "testen", '- Befehl "make install" ausführen') == []
assert _pruefe("funktion", "titel", "Anwendung installieren") == []
def test_zitat_zaehlt_als_ein_wort():
"""Sonst sprengt jedes Kommando die Wortgrenze."""
lang = '- Befehl "uv run python -m tft.cli refresh" ausführen'
assert _pruefe("funktion", "testen", lang) == []
# Ohne Zitat greift die Grenze weiter
assert _probleme(_pruefe("funktion", "testen",
"- Ein sehr langer Schritt mit deutlich zu vielen Wörtern")) \
== ["Mehr als 8 Wörter"]
def test_schraegstrich_im_zitat_ist_erlaubt():
assert _pruefe("funktion", "testen", '- Befehl "npm run build/dev" ausführen') == []
assert _probleme(_pruefe("funktion", "regeln", "- Gilt für Bank und/oder Brett")) \
== ["Klammer oder Schrägstrich"]
def test_fuellwort_im_zitat_bleibt_stehen():
"""Der Menüeintrag heißt so — gestrichen zeigte der Schritt auf nichts."""
assert _pruefe("funktion", "testen", '- Klick auf "Auch Vorlagen zeigen"') == []
def test_kommandoschritt_ist_eine_handlung():
"""Nach dem Ergebnis darf keine Handlung mehr stehen — auch keine mit Kommando."""
zwei = ('- Befehl "make install" ausführen\n- Anwendung installiert\n'
'- Befehl "make test" ausführen')
assert _probleme(_pruefe("funktion", "testen", zwei)) == \
["Zweiter Ablauf in einer Funktion"]
def test_ausloeser_ist_kein_funktionstitel():
"""Der Titel nennt die Wirkung; der Griff steht in den Testschritten."""
for titel in ('Befehl "make install" ausführen', "LMB auf Speichern drücken",
"Klick auf Anlegen ausführen"):
assert "Auslöser statt Wirkung im Titel" in _probleme(
_pruefe("funktion", "titel", titel)), titel
assert _pruefe("funktion", "titel", "Anwendung installieren") == []
def test_vorbedingung_ist_kein_zweiter_ablauf():
"""Der Katalog erlaubt eine Vorbedingung zuerst — sie ist kein Endzustand."""
text = "- Badge ist sichtbar\n- RMB auf Badge\n- Text ändern\n- Neuer Text sichtbar"
assert _pruefe("funktion", "testen", text) == []
def test_zweiter_ablauf_faellt_weiter_auf():
text = "- Karte öffnen\n- Karte sichtbar\n- Karte schließen"
assert _probleme(_pruefe("funktion", "testen", text)) \
== ["Zweiter Ablauf in einer Funktion"]