Files
planer2/backend/tests/test_pruefung.py
2026-07-27 16:43:11 +02:00

433 lines
16 KiB
Python

import asyncio
import json
import pytest
import agents
import features
import projekte
import pruefung
import struktur
ANTWORT = """```json
{"felder": {"titel": {"regel": "Sprache: Deutsch, Präsens, keine Anrede",
"neu": "Anmeldung", "grund": "Titel nennt Tätigkeit"}}}
```"""
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("Test")
features.anlegen("Test", "<!-- titel -->\nEins\n")
features.anlegen("Test", "<!-- titel -->\nZwei\n")
return "Test"
@pytest.fixture(autouse=True)
def kein_lauf():
pruefung._laeufe.clear()
yield
pruefung._laeufe.clear()
async def warte_bis_fertig(projekt):
for _ in range(50):
if not pruefung.status(projekt)["laeuft"]:
return
await asyncio.sleep(0.01)
raise AssertionError("Prüfung wurde nicht fertig")
async def test_lauf_sammelt_probleme(projekt, monkeypatch):
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
monkeypatch.setattr(agents, "frage", lambda system, inhalt, temperatur=0.0: _antwort())
await pruefung.starte(projekt)
await warte_bis_fertig(projekt)
s = pruefung.status(projekt)
assert s["gesamt"] == 2
assert s["fertig"] == 2
assert s["meldung"] == ""
assert len(s["probleme"]) == 2
assert s["probleme"][0]["feature"] in ("eins", "zwei")
assert s["probleme"][0]["vorschlag"] == "Anmeldung"
assert s["probleme"][0]["problem"] == "Titel nennt Tätigkeit"
async def _antwort():
return ANTWORT
async def test_ohne_konfiguration_kein_lauf(projekt, monkeypatch):
monkeypatch.setattr(agents.config, "MINIMAX_API_KEY", "")
with pytest.raises(ValueError):
await pruefung.starte(projekt)
assert pruefung.status(projekt)["laeuft"] is False
async def test_providerfehler_landet_in_meldung(projekt, monkeypatch):
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
async def kaputt(system, inhalt, temperatur=0.0):
raise RuntimeError("Provider antwortet mit HTTP 500")
monkeypatch.setattr(agents, "frage", kaputt)
await pruefung.starte(projekt)
await warte_bis_fertig(projekt)
s = pruefung.status(projekt)
assert s["fehler"] == 2
assert s["meldung"] == (
"2 von 2 Prüfungen fehlgeschlagen: Provider antwortet mit HTTP 500"
)
async def test_verworfener_lauf_stoppt(projekt, monkeypatch):
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
monkeypatch.setattr(agents.config, "PRUEFUNG_PARALLEL", 1)
aufrufe = []
async def zaehle(system, inhalt, temperatur=0.0):
aufrufe.append(inhalt)
pruefung.beende("Test") # Panel wird nach der ersten Karte geschlossen
return '{"felder": {}}'
monkeypatch.setattr(agents, "frage", zaehle)
await pruefung.starte(projekt)
for _ in range(50):
await asyncio.sleep(0.01)
# Nur die erste Karte wird angefragt — mit ihren drei Stimmen
assert len(aufrufe) == len(pruefung.TEMPERATUREN)
async def test_parallel_begrenzt(projekt, monkeypatch):
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
monkeypatch.setattr(agents.config, "PRUEFUNG_PARALLEL", 1)
gleichzeitig = 0
hoechststand = 0
async def langsam(system, inhalt, temperatur=0.0):
nonlocal gleichzeitig, hoechststand
gleichzeitig += 1
hoechststand = max(hoechststand, gleichzeitig)
await asyncio.sleep(0.02)
gleichzeitig -= 1
return '{"felder": {}}'
monkeypatch.setattr(agents, "frage", langsam)
await pruefung.starte(projekt)
await warte_bis_fertig(projekt)
# Die Schranke gilt je Prüfstelle; deren Stimmen laufen gemeinsam
assert hoechststand == len(pruefung.TEMPERATUREN)
assert pruefung.status(projekt)["fertig"] == 2
async def test_status_ohne_lauf():
assert pruefung.status("Fremd") == {
"laeuft": False, "art": "", "fertig": 0, "gesamt": 0, "fehler": 0,
"probleme": [], "meldung": "",
}
KARTE = (
"<!-- titel -->\nLogin\n<!-- art -->\noptional\n<!-- ziel -->\nAnmelden\n"
"<!-- regeln -->\n- Kartenregel\n"
"<!-- funktion -->\nAnmelden\n<!-- testen -->\n- Formular öffnen\n"
"<!-- funktion -->\nAbmelden\n"
)
def test_aufgaben_eine_je_ebene(datenordner):
projekte.anlegen("Test")
f = features.anlegen("Test", KARTE)
liste = pruefung.aufgaben(f)
# Dateireihenfolge, nicht alphabetisch — sortiert wird erst in der Anzeige
assert [a["funktion"] for a in liste] == ["", "Anmelden", "Abmelden"]
assert [a["bereich"] for a in liste] == ["feature", "funktion", "funktion"]
assert all(a["feature"] == "login" for a in liste)
def test_aufgabe_bekommt_nur_ihren_ausschnitt(datenordner):
projekte.anlegen("Test")
f = features.anlegen("Test", KARTE)
karte, anmelden, abmelden = pruefung.aufgaben(f)
inhalt = {a["funktion"]: pruefung.inhalt(a) for a in (karte, anmelden, abmelden)}
# Kartenebene: badge statt art, Funktionstitel nur als Kontext für den Badge
assert "<!-- badge -->\noptional" in inhalt[""]
assert "<!-- regeln -->\n- Kartenregel" in inhalt[""]
assert "<!-- funktionen -->\n- Anmelden\n- Abmelden" in inhalt[""]
assert "Formular öffnen" not in inhalt[""] # keine Details der Funktionen
# Funktion: eigener Titel, eigenes testen, nichts von der Karte
assert "<!-- titel -->\nAnmelden" in inhalt["Anmelden"]
assert "<!-- testen -->\n- Formular öffnen" in inhalt["Anmelden"]
assert "Kartenregel" not in inhalt["Anmelden"]
# Leere Felder werden gar nicht erst mitgeschickt
assert inhalt["Abmelden"] == "<!-- titel -->\nAbmelden\n"
async def test_lauf_zaehlt_alle_stellen(datenordner, monkeypatch):
projekte.anlegen("Test")
features.anlegen("Test", KARTE)
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
gesehen = []
async def merke(system, inhalt, temperatur=0.0):
gesehen.append((system, inhalt))
return '{"felder": {}}'
monkeypatch.setattr(agents, "frage", merke)
await pruefung.starte("Test")
await warte_bis_fertig("Test")
assert pruefung.status("Test")["gesamt"] == 3 # 1 Karte + 2 Funktionen
assert len(gesehen) == 3 * len(pruefung.TEMPERATUREN)
# Der Kartenprüfer kennt die Badge-Regeln, der Funktionsprüfer nicht
karten_system = next(s for s, i in gesehen if "badge" in i)
assert "## Badge der Karte" in karten_system
assert "## Testen einer Funktion" not in karten_system
async def test_herkunft_kommt_vom_code_nicht_vom_modell(datenordner, monkeypatch):
projekte.anlegen("Test")
features.anlegen("Test", KARTE)
monkeypatch.setattr(agents, "pruefe_konfiguration", lambda: None)
async def luegt(system, inhalt, temperatur=0.0):
# Das Modell behauptet eine fremde Funktion — das muss ignoriert werden
return ('{"felder": {"titel": {"regel": "Sprache: Deutsch, Präsens, keine Anrede",'
' "neu": "Anders", "grund": "x"},'
' "funktion": {"neu": "Erfunden", "grund": "y"}}}')
monkeypatch.setattr(agents, "frage", luegt)
await pruefung.starte("Test")
await warte_bis_fertig("Test")
zuordnung = sorted(p["funktion"] for p in pruefung.status("Test")["probleme"])
assert zuordnung == ["", "Abmelden", "Anmelden"]
def test_stil_katalog_je_bereich():
feature = pruefung._stil_lesen("feature")
funktion = pruefung._stil_lesen("funktion")
# allgemein steckt in beiden und steht vorn
for stil in (feature, funktion):
assert stil.startswith("# Stil-Katalog")
assert "## Allgemein" in stil
for stil in (feature, funktion):
assert pruefung.GEMESSEN in stil # messbare Vorgaben stehen beiden zur Verfügung
for ueberschrift in (
"## Titel der Karte", "## Badge der Karte",
"## Ziel der Karte", "## Regeln der Karte",
):
assert ueberschrift in feature
assert ueberschrift not in funktion # der Funktionsprüfer sieht das nicht
for ueberschrift in (
"## Titel einer Funktion", "## Ziel einer Funktion",
"## Regeln einer Funktion", "## Testen einer Funktion",
):
assert ueberschrift in funktion
assert ueberschrift not in feature
def test_neue_stil_datei_kommt_ohne_code_dazu(tmp_path, monkeypatch):
(tmp_path / "allgemein.md").write_text("## Allgemein\n")
(tmp_path / "feature-neu.md").write_text("## Neu\n")
monkeypatch.setattr(pruefung.config, "STIL_DIR", tmp_path)
stil = pruefung._stil_lesen("feature")
assert "## Neu" in stil
assert stil.index("## Allgemein") < stil.index("## Neu") # alphabetisch
assert "## Neu" not in pruefung._stil_lesen("funktion")
def test_stil_ordner_leer_bricht_ab(tmp_path, monkeypatch):
monkeypatch.setattr(pruefung.config, "STIL_DIR", tmp_path)
with pytest.raises(ValueError):
pruefung._stil_lesen("feature")
def _stellen(feld, alt, neu):
return [(s["original"], s["vorschlag"])
for s in pruefung._stellen(feld, alt, neu, "grund")]
TESTEN = "- RMB auf Hauptbereich\n- Klick auf \"Feature erstellen\"\n- Bestätigen mit LMB"
def test_ergaenzte_zeile_haengt_an_der_davor():
# Der Endzustand fehlt: die letzte Zeile bleibt, der neue Schritt kommt dahinter
assert _stellen("testen", TESTEN, TESTEN + "\n- Feature erstellt") == [
("Bestätigen mit LMB", "Bestätigen mit LMB\nFeature erstellt")]
def test_ergaenzung_am_anfang_haengt_an_der_dahinter():
assert _stellen("testen", TESTEN, "- Vollmodus öffnen\n" + TESTEN) == [
("RMB auf Hauptbereich", "Vollmodus öffnen\nRMB auf Hauptbereich")]
def test_ersetzte_zeile():
neu = TESTEN.replace("- Bestätigen mit LMB", "- Bestätigen")
assert _stellen("testen", TESTEN, neu) == [("Bestätigen mit LMB", "Bestätigen")]
def test_gestrichene_zeile():
neu = "- RMB auf Hauptbereich\n- Bestätigen mit LMB"
assert _stellen("testen", TESTEN, neu) == [('Klick auf "Feature erstellen"', "")]
def test_mehrere_aenderungen_werden_ein_umbau():
# Einzeln angenommen ergäben sie Dubletten oder Lücken — also nur zusammen
neu = "- RMB auf Hauptbereich\n- Feature anlegen"
stellen = pruefung._stellen("testen", TESTEN, neu, "grund")
assert len(stellen) == 1
assert stellen[0]["ganzes_feld"] is True
assert stellen[0]["original"] == "\n".join(struktur._zeilen("testen", TESTEN))
assert stellen[0]["vorschlag"] == "RMB auf Hauptbereich\nFeature anlegen"
def test_unveraendert_ergibt_keine_stelle():
assert _stellen("testen", TESTEN, TESTEN) == []
assert _stellen("titel", "Login", "Login") == []
assert _stellen("titel", "Login", " Login ") == []
def test_leeres_listenfeld_wird_ganz_gesetzt():
assert _stellen("testen", "", "- Eins\n- Zwei") == [("", "Eins\nZwei")]
def test_einzelfeld_wird_ganz_ersetzt():
assert _stellen("titel", "Login", "Anmeldung") == [("Login", "Anmeldung")]
assert _stellen("ziel", "Alt", "") == [("Alt", "")]
def test_einzelne_aenderung_zeigt_auf_eine_echte_zeile():
stellen = pruefung._stellen("testen", TESTEN, TESTEN + "\n- Fertig", "grund")
assert len(stellen) == 1
assert stellen[0]["ganzes_feld"] is False
assert stellen[0]["original"] in struktur._zeilen("testen", TESTEN)
def test_erfundenes_feld_wird_verworfen():
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
antwort = ('{"felder": {"struktur": {"neu": "x", "grund": "y"},'
' "testen": {"neu": "- a", "grund": "z"}}}')
assert list(pruefung._felder_lesen(antwort, erlaubt)) == ["testen"]
def test_feld_darf_nicht_leer_werden_wenn_es_pflicht_ist():
erlaubt = pruefung.BEREICHE["feature"]["felder"]
leer = '{"felder": {"titel": {"neu": "", "grund": "x"}, "ziel": {"neu": "", "grund": "y"}}}'
assert list(pruefung._felder_lesen(leer, erlaubt)) == ["ziel"]
def test_mehrzeilig_nur_bei_listenfeldern():
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
antwort = ('{"felder": {"titel": {"neu": "a\\nb", "grund": "x"},'
' "regeln": {"neu": "- a\\n- b", "grund": "y"}}}')
assert list(pruefung._felder_lesen(antwort, erlaubt)) == ["regeln"]
def test_neu_darf_liste_oder_string_sein():
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
als_liste = '{"felder": {"testen": {"neu": ["- a", "- b"], "grund": "x"}}}'
als_string = '{"felder": {"testen": {"neu": "- a\\n- b", "grund": "x"}}}'
assert pruefung._felder_lesen(als_liste, erlaubt) == \
pruefung._felder_lesen(als_string, erlaubt)
def test_feld_ohne_huelle_wird_angenommen():
# Manche Antworten kürzen {"neu": ...} auf den reinen Wert ab
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
gelesen = pruefung._felder_lesen('{"felder": {"titel": "Anmelden"}}', erlaubt)
assert gelesen == {"titel": {"neu": "Anmelden", "grund": "", "regel": ""}}
KATALOG = pruefung.katalogzeilen(pruefung._stil_lesen("funktion"))
def _mit_regel(regel):
antwort = ('{"felder": {"titel": {"regel": "%s", "neu": "Anmelden", "grund": "x"}}}'
% regel)
return pruefung._felder_lesen(
antwort, pruefung.BEREICHE["funktion"]["felder"], KATALOG)
def test_aenderung_braucht_eine_echte_katalogzeile():
assert _mit_regel("Genau eine Tätigkeit") != {} # steht so im Katalog
assert _mit_regel("genau eine tätigkeit") != {} # Groß-/Kleinschreibung egal
assert _mit_regel("- Genau eine Tätigkeit") != {} # Listenstrich egal
def test_gemessene_regeln_sind_nicht_zitierbar():
# Sonst beruft sich das Modell auf die Füllwortliste und wendet sie frei an
assert _mit_regel("Titel einer Funktion: höchstens 5 Wörter") == {}
assert _mit_regel("Keine Füllwörter. Die Liste ist abschließend:"
" auch, noch, einfach, jeweils, ggf.") == {}
assert "## Vom System gemessen" in pruefung._stil_lesen("funktion")
def test_erfundene_regel_wird_verworfen():
# Genau daran ist der Lauf vorher nicht konvergiert
assert _mit_regel("Platzhaltertext ist nicht erlaubt") == {}
assert _mit_regel("Ziel muss konkret sein") == {}
assert _mit_regel("") == {}
assert _mit_regel("Kein Geschmack") == {}
def test_gemessener_verstoss_braucht_keine_regel():
antwort = '{"felder": {"titel": {"neu": "Anmelden", "grund": "zu lang"}}}'
erlaubt = pruefung.BEREICHE["funktion"]["felder"]
assert pruefung._felder_lesen(antwort, erlaubt, KATALOG) == {}
assert pruefung._felder_lesen(antwort, erlaubt, KATALOG, ("titel",)) != {}
def test_katalogzeilen_sind_nur_die_aufzaehlung():
zeilen = pruefung.katalogzeilen("## Titel\n\nEinleitung\n\n- Erste Regel\n- Zweite\n")
assert zeilen == {"erste regel", "zweite"}
def test_json_mit_nachsatz_und_zaun():
for text in (
'{"felder": {}}\nDas war alles.',
'```json\n{"felder": {}}\n```',
'Hier: {"felder": {}} {"noch": "was"}',
):
assert pruefung._json_lesen(text) == {"felder": {}}
def test_json_ohne_objekt():
with pytest.raises(ValueError):
pruefung._json_lesen("keine Ahnung")
def _proben(*fassungen):
"""Eine Stimme je Eintrag; None heißt „diese Stimme sieht nichts"."""
return [f or {} for f in fassungen]
def test_uneinigkeit_faellt_weg():
# Grenzfälle beurteilt ein Modell mal so, mal so — die fliegen raus
eins = {"titel": {"neu": "A", "grund": "x"}}
assert pruefung._mehrheit(_proben(eins, eins, eins, None)) == {}
assert pruefung._mehrheit(_proben(eins, None, None, None)) == {}
def test_einstimmiges_bleibt():
eins = {"titel": {"neu": "A", "grund": "x"}}
assert pruefung._mehrheit(_proben(eins, eins, eins, eins)) == eins
def test_konsens_nimmt_die_mittlere_fassung():
# Der Ausreißer zieht das Ergebnis nicht mit
def f(text):
return {"titel": {"neu": text, "grund": "x"}}
proben = _proben(f("Anmeldung starten"), f("Anmeldung startet"),
f("Anmeldung beginnen"), f("Völlig anderer Text"))
assert pruefung._mehrheit(proben)["titel"]["neu"].startswith("Anmeldung")