update
This commit is contained in:
418
backend/tests/test_auftrag.py
Normal file
418
backend/tests/test_auftrag.py
Normal 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
111
backend/tests/test_chat.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
193
backend/tests/test_recherche.py
Normal file
193
backend/tests/test_recherche.py
Normal 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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user