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", "\nEins\n") features.anlegen("Test", "\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 = ( "\nLogin\n\noptional\n\nAnmelden\n" "\n- Kartenregel\n" "\nAnmelden\n\n- Formular öffnen\n" "\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 "\noptional" in inhalt[""] assert "\n- Kartenregel" in inhalt[""] assert "\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 "\nAnmelden" in inhalt["Anmelden"] assert "\n- Formular öffnen" in inhalt["Anmelden"] assert "Kartenregel" not in inhalt["Anmelden"] # Leere Felder werden gar nicht erst mitgeschickt assert inhalt["Abmelden"] == "\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") def test_gemessenes_feld_braucht_keine_einstimmigkeit(): """Ein gezählter Verstoß steht fest, bevor gefragt wird. Vorher verschwand er, sobald eine einzige Stimme das Feld nicht anfasste.""" eins = {"titel": {"neu": "Kurz", "grund": "x"}} proben = [eins, {}, {}, {}] assert pruefung._mehrheit(proben) == {} assert pruefung._mehrheit(proben, ("titel",)) == eins