update
This commit is contained in:
@@ -32,6 +32,12 @@ def liste(projekt: str) -> list[dict]:
|
||||
return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower())
|
||||
|
||||
|
||||
def pruefbar(projekt: str) -> list[dict]:
|
||||
"""Alles außer der Vision. Die hält fest, wie es gedacht ist — sie ist die
|
||||
Vorlage, nicht der Prüfling, und keine Prüfung schlägt Änderungen daran vor."""
|
||||
return [f for f in liste(projekt) if f["sektionen"]["art"] != parser.VISION]
|
||||
|
||||
|
||||
def anlegen(projekt: str, roh: str) -> dict:
|
||||
ordner = projekte.wurzel(projekt)
|
||||
titel = parser.zerlege(roh)["titel"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen.
|
||||
|
||||
**Feature im Code** — steht hinter jeder Funktion einer Karte auch Code?
|
||||
**Feature im Code** — steht hinter jeder Funktion einer Karte auch Code? Dazu:
|
||||
beschreiben zwei Funktionen dieselbe Fähigkeit?
|
||||
**Code im Feature** — hat jede Stelle im Code auch eine Funktion auf einer Karte?
|
||||
|
||||
Beide arbeiten mit derselben Beweislast, nur andersherum.
|
||||
@@ -17,14 +18,22 @@ dieselbe Frage kippt auch bei Temperatur 0 gelegentlich.
|
||||
|
||||
Was der Code selbst weiß, entscheidet der Code: die Liste der Bedienstellen kommt
|
||||
aus `quellcode.interaktionen()`, jeder Beleg wird gegen die Datei geschlagen.
|
||||
|
||||
Jedes Urteil wandert in `ledger`. Der nächste Lauf fragt nur noch, was er nicht
|
||||
schon weiß — dadurch liefert derselbe Stand dasselbe Ergebnis. Auch das unentschiedene
|
||||
Urteil wird gemerkt: es galt als abgedeckt, und ein Münzwurf, der jede Runde neu
|
||||
fällt, ist schlimmer als einer, der einmal fällt. Ändert sich die Stelle im Code,
|
||||
wird sie ohnehin neu beurteilt.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
import agents
|
||||
import config
|
||||
import ledger
|
||||
import quellcode
|
||||
|
||||
# Ohne Drossel liefen alle Anfragen gleichzeitig los und der Provider antwortete
|
||||
@@ -86,7 +95,7 @@ JSON-Objekt."""
|
||||
ABDECKUNG_SYSTEM = """Du entscheidest, ob eine Stelle im Programm schon von einer
|
||||
beschriebenen Funktion abgedeckt ist. Antworte ausschließlich mit JSON."""
|
||||
|
||||
ABDECKUNG = """An dieser Stelle kann eine Person etwas auslösen:
|
||||
ABDECKUNG = """Diese Stelle im Programm lässt sich auslösen:
|
||||
|
||||
{art} in {datei}, Zeile {zeile}
|
||||
|
||||
@@ -100,7 +109,9 @@ Die Karten des Projekts beschreiben bisher das hier:
|
||||
|
||||
Beschreibt eine dieser Funktionen, was an der Stelle oben passiert?
|
||||
Wortwahl darf abweichen; entscheidend ist die Sache. Ziel, Regeln und Testschritte
|
||||
einer Funktion gehören mit zu dem, was sie abdeckt.
|
||||
einer Funktion gehören mit zu dem, was sie abdeckt, und die Regeln der Karte
|
||||
ebenso: eine Zusicherung wie „alphabetisch sortiert" ist beschrieben, auch wenn
|
||||
kein Knopf dazu gehört.
|
||||
|
||||
Antworte ausschließlich mit JSON:
|
||||
{{"abgedeckt": true, "funktion": "..."}}
|
||||
@@ -114,8 +125,40 @@ oder
|
||||
|
||||
Rein technische Aufhänger gelten als abgedeckt: Speichern beim Verlassen eines
|
||||
Feldes, Abbrechen mit Escape, Gesundheitsprüfungen, interne Weiterleitung.
|
||||
Fehlerbehandlung ebenso — eine Fehlermeldung ist kein eigener Auftrag, sondern
|
||||
gehört zu der Fähigkeit, bei der etwas schiefging.
|
||||
Im Zweifel abgedeckt. Nur was einer Person wirklich fehlt, soll dazukommen."""
|
||||
|
||||
DUBLETTE_SYSTEM = """Du entscheidest, ob zwei Funktionsbeschreibungen dieselbe
|
||||
Fähigkeit meinen. Antworte ausschließlich mit JSON."""
|
||||
|
||||
DUBLETTE = """Zwei Funktionen aus den Karten eines Projekts:
|
||||
|
||||
A
|
||||
{text_a}
|
||||
|
||||
B
|
||||
{text_b}
|
||||
|
||||
Aufgefallen sind sie so: {anlass}
|
||||
|
||||
Beantworte der Reihe nach, allein aus Titel und Testschritten:
|
||||
|
||||
1. Woran arbeitet A? Der Gegenstand, an dem sich etwas ändert.
|
||||
2. Woran arbeitet B?
|
||||
3. Meinen 1 und 2 denselben Gegenstand?
|
||||
4. Was ist anders, nachdem A ausgeführt wurde?
|
||||
5. Was ist anders, nachdem B ausgeführt wurde?
|
||||
6. Beschreiben 4 und 5 denselben Zustand? Es zählt das Ergebnis, nicht das Wort:
|
||||
verschiedene Wörter für dasselbe Ergebnis sind gleich, ein Gegenstück nicht.
|
||||
|
||||
Antworte ausschließlich mit JSON:
|
||||
{{"gegenstand_a": "...", "gegenstand_b": "...", "gegenstand_gleich": true,
|
||||
"ergebnis_a": "...", "ergebnis_b": "...", "ergebnis_gleich": true}}
|
||||
|
||||
Urteile nicht selbst über die Dublette. Ob es eine ist, wird aus deinen Antworten
|
||||
auf 3 und 6 errechnet."""
|
||||
|
||||
# Sechs unabhängige Versuche, den Beleg zu finden. Eine einzige Stimme, die etwas
|
||||
# findet, genügt zum Freispruch — die Anklage lautet auf Löschen, da wiegt ein
|
||||
# Fehlurteil schwerer als ein übersehener Rest.
|
||||
@@ -126,8 +169,34 @@ ANKLAGE_AB = 4
|
||||
# 0:6, Grenzfälle etwa 3:3 — die Schwelle bei fünf trennt beides.
|
||||
URTEIL_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
|
||||
FEHLT_AB = 5
|
||||
# So viele Stimmen müssen geantwortet haben, bevor überhaupt geurteilt wird. Das
|
||||
# Urteil wird gemerkt und gilt dann dauerhaft — dafür reichen zwei Überlebende nicht.
|
||||
URTEIL_AB = 4
|
||||
# Ab dieser Ähnlichkeit gelten zwei Titel als dieselbe Sache
|
||||
GLEICH = 0.75
|
||||
# Drei Stimmen über ein Paar, einstimmig für die Dublette. Ein Löschvorschlag darf
|
||||
# nicht an einer Einzelmeinung hängen.
|
||||
DUBLETTE_TEMPERATUREN = (0.0, 0.3, 0.6)
|
||||
# Kandidatenkanal „ähnlicher Titel". Höher als GLEICH: hier zahlt jedes Paar drei
|
||||
# Anfragen, und „Feature anlegen"/„Feature entfernen" liegen schon bei 0,75.
|
||||
TITEL_KANAL = 0.85
|
||||
# Kandidatenkanal „ähnlicher Inhalt". Anteil der Wörter, die die kürzere der beiden
|
||||
# Beschreibungen mit der längeren teilt. Überlappung statt Jaccard, weil eine knappe
|
||||
# Dublette sonst an der Länge der ausführlicheren scheitert.
|
||||
INHALT_KANAL = 0.7
|
||||
# Wörter, die in so vielen Funktionen vorkommen, tragen nichts zur Unterscheidung
|
||||
# bei. „Vollmodus öffnen", „Text ändern" stehen fast überall — ohne diesen Filter
|
||||
# ähnelt jede Bearbeiten-Funktion jeder anderen zu 90 %.
|
||||
FLOSKEL_ANTEIL = 0.2
|
||||
# Deckel gegen Explosion bei vielen Funktionen; was darüber liegt, meldet der Lauf
|
||||
DUBLETTEN_CAP = 40
|
||||
|
||||
# Ein gemerktes Urteil gehört zu der Frage, auf die es geantwortet hat. Wird die
|
||||
# Frage umgeschrieben, ist das Urteil von gestern — deshalb hängt der Fingerabdruck
|
||||
# am Wortlaut des Prompts. Sonst müsste man daran denken, eine Zahl hochzuzählen.
|
||||
_SUCHE_STAND = ledger.abdruck(SUCHE_SYSTEM, SUCHE_NACHRICHT)
|
||||
_ABDECKUNG_STAND = ledger.abdruck(ABDECKUNG_SYSTEM, ABDECKUNG)
|
||||
_DUBLETTE_STAND = ledger.abdruck(DUBLETTE_SYSTEM, DUBLETTE)
|
||||
|
||||
|
||||
def _normal(text: str) -> str:
|
||||
@@ -144,11 +213,34 @@ def _json(antwort: str) -> dict:
|
||||
return pruefung._json_lesen(antwort)
|
||||
|
||||
|
||||
async def _sammle(aufgaben: list, was: str, melde=None) -> list:
|
||||
"""Alle Stellen beurteilen, auch wenn einzelne platzen.
|
||||
|
||||
Vorher riss die erste kaputte Stelle den ganzen Lauf ab. Zwei Folgen, beide
|
||||
schlecht: die schon bezahlten Urteile der anderen waren weg, und das Gedächtnis
|
||||
wurde gespeichert, während die übrigen Anfragen noch liefen — es blieb leer.
|
||||
|
||||
Jetzt wird gewartet, bis alle durch sind. Was ging, zählt; was nicht ging, steht
|
||||
in der Meldung. Der nächste Lauf holt nur den Rest nach.
|
||||
"""
|
||||
ergebnisse = await asyncio.gather(*aufgaben, return_exceptions=True)
|
||||
kaputt = [e for e in ergebnisse if isinstance(e, BaseException)]
|
||||
if kaputt and melde:
|
||||
melde(f"{len(kaputt)} von {len(ergebnisse)} {was} nicht beurteilt: "
|
||||
f"{kaputt[0]}", len(kaputt))
|
||||
return [x for x in ergebnisse if x and not isinstance(x, BaseException)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Feature im Code
|
||||
|
||||
def _funktionstext(karte: str, f: dict) -> str:
|
||||
"""Alles, was die Funktion behauptet — danach wird im Code gesucht."""
|
||||
zeilen = [f"Karte: {karte}", f'Funktion: {f["text"]}']
|
||||
"""Alles, was die Funktion behauptet — danach wird im Code gesucht.
|
||||
|
||||
Ohne Karte, wenn die anderswo schon dasteht: doppelt genannt zog sie beim
|
||||
Dublettenurteil mehr Gewicht auf sich als Titel und Testschritte zusammen.
|
||||
"""
|
||||
zeilen = [f"Karte: {karte}"] if karte else []
|
||||
zeilen.append(f'Funktion: {f["text"]}')
|
||||
if f["ziel"]:
|
||||
zeilen.append(f'Ziel: {f["ziel"]}')
|
||||
for feld in ("regeln", "testen"):
|
||||
@@ -158,16 +250,47 @@ def _funktionstext(karte: str, f: dict) -> str:
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
async def feature_im_code(features: list, quelle: dict, quelltext: str,
|
||||
zaehl=None) -> list:
|
||||
"""Funktionen, für die keine Stimme einen nachprüfbaren Beleg findet."""
|
||||
def _quellabdruck(quelle: dict) -> str:
|
||||
return ledger.abdruck(_SUCHE_STAND,
|
||||
*(f"{p}\n{t}" for p, t in sorted(quelle.items())))
|
||||
|
||||
|
||||
async def feature_im_code(features: list, quelle: dict, quelltext: str, stand: dict,
|
||||
zaehl=None, melde=None) -> list:
|
||||
"""Funktionen, für die keine Stimme einen nachprüfbaren Beleg findet.
|
||||
|
||||
Ein gemerkter Beleg zählt weiter, solange die zitierte Zeile noch dasteht —
|
||||
dafür braucht es kein Modell, das prüft `quellcode.belegt()` nach. Eine gemerkte
|
||||
Fehlanzeige gilt nur, solange sich am Quelltext nichts geändert hat: sie fordert
|
||||
zum Löschen auf, und dafür muss der Befund frisch sein.
|
||||
"""
|
||||
quellstand = _quellabdruck(quelle)
|
||||
auftraege = [(f, x) for f in features for x in f["sektionen"]["funktionen"]]
|
||||
bekannt = {ledger.schluessel(f["id"], x["text"]) for f, x in auftraege}
|
||||
stand["belege"] = ledger.behalte(stand["belege"], bekannt)
|
||||
stand["ohne_code"] = ledger.behalte(stand["ohne_code"], bekannt)
|
||||
|
||||
async def eine(paar):
|
||||
feature, x = paar
|
||||
nachricht = SUCHE_NACHRICHT.format(
|
||||
quelle=quelltext,
|
||||
funktion=_funktionstext(feature["sektionen"]["titel"], x))
|
||||
schluessel = ledger.schluessel(feature["id"], x["text"])
|
||||
text = _funktionstext(feature["sektionen"]["titel"], x)
|
||||
wie_gehabt = ledger.abdruck(text)
|
||||
|
||||
gemerkt = stand["belege"].get(schluessel)
|
||||
if gemerkt and gemerkt["abdruck"] == wie_gehabt and quellcode.belegt(
|
||||
quelle, gemerkt["datei"], gemerkt["beleg"]):
|
||||
if zaehl:
|
||||
zaehl()
|
||||
return None
|
||||
gemerkt = stand["ohne_code"].get(schluessel)
|
||||
if gemerkt and gemerkt["abdruck"] == wie_gehabt \
|
||||
and gemerkt["quelle"] == quellstand:
|
||||
if zaehl:
|
||||
zaehl()
|
||||
return {"feature": feature["id"], "titel": x["text"],
|
||||
"art": "ohne_code", "grund": gemerkt["grund"]}
|
||||
|
||||
nachricht = SUCHE_NACHRICHT.format(quelle=quelltext, funktion=text)
|
||||
proben = await asyncio.gather(*(
|
||||
_urteil(SUCHE_SYSTEM, nachricht, t) for t in SUCHE_TEMPERATUREN
|
||||
), return_exceptions=True)
|
||||
@@ -175,7 +298,8 @@ async def feature_im_code(features: list, quelle: dict, quelltext: str,
|
||||
if zaehl:
|
||||
zaehl()
|
||||
gruende = []
|
||||
brauchbar = belegt = behauptet = 0
|
||||
brauchbar = behauptet = 0
|
||||
beleg = None
|
||||
for u in proben:
|
||||
if not isinstance(u, dict):
|
||||
continue
|
||||
@@ -185,26 +309,196 @@ async def feature_im_code(features: list, quelle: dict, quelltext: str,
|
||||
# verschreibt sich beim Abschreiben von Backticks — daraus eine
|
||||
# Löschempfehlung zu machen, wäre grob falsch.
|
||||
behauptet += 1
|
||||
if quellcode.belegt(quelle, str(u.get("datei") or "").strip(),
|
||||
str(u.get("beleg") or "").strip()):
|
||||
belegt += 1
|
||||
datei = str(u.get("datei") or "").strip()
|
||||
zitat = str(u.get("beleg") or "").strip()
|
||||
if beleg is None and quellcode.belegt(quelle, datei, zitat):
|
||||
beleg = {"abdruck": wie_gehabt, "datei": datei, "beleg": zitat}
|
||||
elif u.get("grund"):
|
||||
gruende.append(str(u["grund"]).strip())
|
||||
|
||||
if belegt:
|
||||
stand["belege"].pop(schluessel, None)
|
||||
stand["ohne_code"].pop(schluessel, None)
|
||||
if beleg:
|
||||
stand["belege"][schluessel] = beleg
|
||||
return None # geprüfter Beleg: die Funktion steht im Code
|
||||
if behauptet:
|
||||
return None # widersprüchliche Stimmen: nichts behaupten
|
||||
return None # widersprüchliche Stimmen: nichts behaupten und nichts merken
|
||||
if brauchbar < ANKLAGE_AB:
|
||||
# Zu wenige Stimmen für eine Löschempfehlung. Lieber laut scheitern
|
||||
# als eine gültige Funktion wegen eines Ausfalls anzuklagen.
|
||||
raise RuntimeError(
|
||||
f'{x["text"]}: nur {brauchbar} von '
|
||||
f"{len(SUCHE_TEMPERATUREN)} Stimmen brauchbar")
|
||||
grund = gruende[0] if gruende else "Kein Beleg im Code gefunden"
|
||||
stand["ohne_code"][schluessel] = {"abdruck": wie_gehabt, "quelle": quellstand,
|
||||
"grund": grund}
|
||||
return {"feature": feature["id"], "titel": x["text"],
|
||||
"grund": gruende[0] if gruende else "Kein Beleg im Code gefunden"}
|
||||
"art": "ohne_code", "grund": grund}
|
||||
|
||||
return [x for x in await asyncio.gather(*(eine(a) for a in auftraege)) if x]
|
||||
return await _sammle([eine(a) for a in auftraege], "Funktionen", melde)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Dubletten
|
||||
|
||||
def _funktionen(features: list) -> dict:
|
||||
"""schluessel -> alles, was zur Funktion gehört."""
|
||||
raus = {}
|
||||
for f in features:
|
||||
for x in f["sektionen"]["funktionen"]:
|
||||
raus[ledger.schluessel(f["id"], x["text"])] = {
|
||||
"feature": f["id"], "karte": f["sektionen"]["titel"],
|
||||
"titel": x["text"], "eintrag": x,
|
||||
"text": _funktionstext("", x),
|
||||
# Ohne die Feldnamen: „Regeln:" steht über jedem Block, und eine
|
||||
# Funktion namens „Regeln anpassen" verlöre sonst ihr eigenes Wort
|
||||
"worte": " ".join((f["sektionen"]["titel"], x["text"], x["ziel"],
|
||||
x["regeln"], x["testen"]))}
|
||||
return raus
|
||||
|
||||
|
||||
def _gewicht(f: dict) -> tuple:
|
||||
"""Wer mehr sagt, bleibt stehen. Bei Gleichstand entscheidet der Name — nicht
|
||||
schön, aber vorhersagbar; ein Zufallssieger machte den Vorschlag unstet."""
|
||||
x = f["eintrag"]
|
||||
zeilen = len([z for z in (x["regeln"] + "\n" + x["testen"]).splitlines() if z.strip()])
|
||||
return (zeilen, len(x["ziel"]), f["titel"])
|
||||
|
||||
|
||||
_WORT = re.compile(r"[a-zä-üß0-9]{4,}")
|
||||
# Beugung abschneiden statt richtig stemmen: „entfernen" und „entfernt" sind
|
||||
# dasselbe Wort, und ein Stemmer legte die Sprache im Code fest.
|
||||
STAMM = 5
|
||||
|
||||
|
||||
def _kerne(alle: dict) -> dict:
|
||||
"""Je Funktion die Wörter, die sie von den anderen unterscheiden.
|
||||
|
||||
Was in jeder fünften Funktion steht, fliegt raus. Sonst misst der Vergleich nur
|
||||
noch die gemeinsame Schrittfolge, und „Titel anpassen" und „Ziel anpassen"
|
||||
stimmen zu hundert Prozent überein.
|
||||
"""
|
||||
mengen = {k: set(_WORT.findall(v["worte"].lower())) for k, v in alle.items()}
|
||||
haeufig = Counter(wort for m in mengen.values() for wort in m)
|
||||
grenze = max(3, int(len(mengen) * FLOSKEL_ANTEIL))
|
||||
return {k: {w[:STAMM] for w in m if haeufig[w] < grenze} for k, m in mengen.items()}
|
||||
|
||||
|
||||
def _ueberlappung(a: set, b: set) -> float:
|
||||
return len(a & b) / min(len(a), len(b)) if a and b else 0.0
|
||||
|
||||
|
||||
def _kandidaten(alle: dict, belege: dict) -> list[tuple]:
|
||||
"""Paare, die überhaupt eine Dublette sein könnten, mit dem Anlass.
|
||||
|
||||
Drei Kanäle, weil eine Dublette auf drei Arten auffällt: dieselbe Codezeile als
|
||||
Beleg, fast derselbe Titel, fast dieselben Wörter. Der letzte fängt, was die
|
||||
ersten beiden durchlassen — „Funktion löschen" und „Funktion entfernen" haben
|
||||
weder denselben Titel noch zwingend denselben Beleg.
|
||||
|
||||
Jedes Paar zu fragen wäre quadratisch und träfe fast nie. Erst billig
|
||||
eingrenzen, dann einmal teuer urteilen.
|
||||
"""
|
||||
paare = {}
|
||||
|
||||
def merke(a, b, anlass):
|
||||
if a != b:
|
||||
paare.setdefault(ledger.paarschluessel(a, b), (a, b, anlass))
|
||||
|
||||
nach_stelle = {}
|
||||
for name, eintrag in belege.items():
|
||||
if name in alle:
|
||||
stelle = (eintrag["datei"], re.sub(r"\s+", " ", eintrag["beleg"]).strip())
|
||||
nach_stelle.setdefault(stelle, []).append(name)
|
||||
for (datei, zeile), gruppe in nach_stelle.items():
|
||||
gruppe = sorted(gruppe)
|
||||
for i, a in enumerate(gruppe):
|
||||
for b in gruppe[i + 1:]:
|
||||
merke(a, b, f"Beide belegen dieselbe Zeile in {datei}: {zeile}")
|
||||
|
||||
kern = _kerne(alle)
|
||||
namen = sorted(alle)
|
||||
for i, a in enumerate(namen):
|
||||
for b in namen[i + 1:]:
|
||||
if _aehnlich(alle[a]["titel"], alle[b]["titel"]) >= TITEL_KANAL:
|
||||
merke(a, b, "Die Titel sind fast gleich.")
|
||||
elif _ueberlappung(kern[a], kern[b]) >= INHALT_KANAL:
|
||||
merke(a, b, "Beide Beschreibungen bestehen fast aus denselben Wörtern.")
|
||||
return [(k, *paare[k]) for k in sorted(paare)]
|
||||
|
||||
|
||||
async def _ist_dublette(a: dict, b: dict, anlass: str) -> bool:
|
||||
"""Einstimmig gleicher Gegenstand und gleiches Ergebnis — sonst keine Dublette.
|
||||
|
||||
Das Gesamturteil rechnet der Code aus den Teilantworten. Gefragt nach der
|
||||
Dublette an sich, widersprach eine Stimme ihrer eigenen Analyse: derselbe
|
||||
Gegenstand, dasselbe Tun, und trotzdem „nein". Zwei kleine Fragen beantwortet
|
||||
sie verlässlich, die große nicht.
|
||||
|
||||
Gefragt wird nach dem Ergebnis, nicht nach dem Verb. „Löschen" und „entfernen"
|
||||
hielten zwei von drei Stimmen für verschiedenes Tun — was danach anders ist,
|
||||
beschrieben sie gleich.
|
||||
"""
|
||||
nachricht = DUBLETTE.format(text_a=a["text"], text_b=b["text"], anlass=anlass)
|
||||
proben = await asyncio.gather(*(
|
||||
_urteil(DUBLETTE_SYSTEM, nachricht, t) for t in DUBLETTE_TEMPERATUREN
|
||||
), return_exceptions=True)
|
||||
stimmen = [p for p in proben if isinstance(p, dict)]
|
||||
if len(stimmen) < len(DUBLETTE_TEMPERATUREN):
|
||||
raise RuntimeError(f'Dublette {a["titel"]}/{b["titel"]}: '
|
||||
f"nur {len(stimmen)} von "
|
||||
f"{len(DUBLETTE_TEMPERATUREN)} Stimmen brauchbar")
|
||||
return all(u.get("gegenstand_gleich") is True and u.get("ergebnis_gleich") is True
|
||||
for u in stimmen)
|
||||
|
||||
|
||||
async def dubletten(features: list, stand: dict, melde=None) -> list:
|
||||
"""Funktionen, die eine andere Funktion doppelt beschreiben.
|
||||
|
||||
Gemeldet wird immer die schwächere von beiden — die stärkere bleibt stehen.
|
||||
Ein einmal gefälltes Urteil bleibt gemerkt, auch das ablehnende: sonst zahlt
|
||||
jeder Lauf erneut für dieselbe Frage und antwortet womöglich anders.
|
||||
"""
|
||||
alle = _funktionen(features)
|
||||
paare = _kandidaten(alle, stand["belege"])
|
||||
stand["paare"] = ledger.behalte(stand["paare"], {p[0] for p in paare})
|
||||
if len(paare) > DUBLETTEN_CAP:
|
||||
if melde:
|
||||
melde(f"{len(paare)} Kandidatenpaare, geprüft wurden {DUBLETTEN_CAP}")
|
||||
paare = paare[:DUBLETTEN_CAP]
|
||||
|
||||
async def urteile(paar):
|
||||
schluessel, a, b, anlass = paar
|
||||
links, rechts = alle[a], alle[b]
|
||||
wie_gehabt = ledger.abdruck(_DUBLETTE_STAND, links["text"], rechts["text"])
|
||||
gemerkt = stand["paare"].get(schluessel)
|
||||
if gemerkt and gemerkt["abdruck"] == wie_gehabt:
|
||||
gleich = gemerkt["gleich"]
|
||||
else:
|
||||
gleich = await _ist_dublette(links, rechts, anlass)
|
||||
stand["paare"][schluessel] = {"abdruck": wie_gehabt, "gleich": gleich}
|
||||
if not gleich:
|
||||
return None
|
||||
schwach, stark = sorted((links, rechts), key=_gewicht)
|
||||
return {"feature": schwach["feature"], "titel": schwach["titel"],
|
||||
"art": "dublette",
|
||||
"grund": f'Beschreibt „{stark["titel"]}“ '
|
||||
f'(Karte {stark["karte"]}) ein zweites Mal'}
|
||||
|
||||
return await _sammle([urteile(p) for p in paare], "Funktionspaare", melde)
|
||||
|
||||
|
||||
def _wegfall_grund(betroffen: list) -> str:
|
||||
"""Was die Karte verliert, in einem Halbsatz. Ohne Trennung nach Anlass stand
|
||||
über einer Dublette „ohne eigenen Code" — beides heißt streichen, aber aus
|
||||
verschiedenen Gründen, und der Grund entscheidet, ob man zustimmt."""
|
||||
teile = []
|
||||
for art, eins, viele in (("dublette", "Funktion doppelt", "Funktionen doppelt"),
|
||||
("ohne_code", "Funktion ohne eigenen Code",
|
||||
"Funktionen ohne eigenen Code")):
|
||||
n = sum(x["art"] == art for x in betroffen)
|
||||
if n:
|
||||
teile.append(f"{n} {eins if n == 1 else viele}")
|
||||
return ", ".join(teile)
|
||||
|
||||
|
||||
def probleme_feature(features: list, ohne_code: list) -> list:
|
||||
@@ -214,13 +508,16 @@ def probleme_feature(features: list, ohne_code: list) -> list:
|
||||
raus = []
|
||||
for f in features:
|
||||
ist = [x["text"] for x in f["sektionen"]["funktionen"]]
|
||||
betroffen = [x for x in ohne_code if x["feature"] == f["id"]]
|
||||
betroffen, gesehen = [], set()
|
||||
for x in ohne_code:
|
||||
if x["feature"] == f["id"] and x["titel"] not in gesehen:
|
||||
gesehen.add(x["titel"])
|
||||
betroffen.append(x)
|
||||
if not betroffen:
|
||||
continue
|
||||
weg = [x["titel"] for x in betroffen]
|
||||
neu = [t for t in ist if t not in weg]
|
||||
grund = (f"{len(weg)} Funktion ohne Code" if len(weg) == 1
|
||||
else f"{len(weg)} Funktionen ohne Code")
|
||||
grund = _wegfall_grund(betroffen)
|
||||
beleg = " · ".join(f'{x["titel"]}: {x["grund"]}' for x in betroffen)
|
||||
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
|
||||
grund, beleg):
|
||||
@@ -237,6 +534,10 @@ def _funktionsliste(features: list) -> str:
|
||||
|
||||
Nur die Titel reichten nicht: der Richter musste raten, ob eine Funktion eine
|
||||
Stelle abdeckt, und urteilte von Runde zu Runde anders.
|
||||
|
||||
Die Regeln der Karte gehören dazu. Vieles, was ein Kunde bestellt, ist keine
|
||||
Bedienung, sondern eine Zusicherung — „eine Datei je Feature", „alphabetisch
|
||||
sortiert". Ohne sie sah der Richter den Code dahinter als unbeschrieben an.
|
||||
"""
|
||||
teile = []
|
||||
for f in features:
|
||||
@@ -244,6 +545,10 @@ def _funktionsliste(features: list) -> str:
|
||||
block = [f'## Karte: {s["titel"]}']
|
||||
if s["ziel"]:
|
||||
block.append(f'Ziel: {s["ziel"]}')
|
||||
if s["regeln"]:
|
||||
zeilen = ", ".join(z.strip("- ").strip()
|
||||
for z in s["regeln"].splitlines() if z.strip())
|
||||
block.append(f"Regeln der Karte: {zeilen}")
|
||||
for x in s["funktionen"]:
|
||||
block.append(f'\n### {x["text"]} (Karte: {s["titel"]})')
|
||||
if x["ziel"]:
|
||||
@@ -268,43 +573,118 @@ async def _abdeckung(punkt: dict, liste: str) -> list:
|
||||
return [p for p in proben if isinstance(p, dict)]
|
||||
|
||||
|
||||
def _naechste_karte(name: str, karten: dict):
|
||||
if not name:
|
||||
def _naechste_karte(name: str, karten: dict, schwelle: float = 0.6):
|
||||
if not name or not karten:
|
||||
return None
|
||||
treffer = max(karten, key=lambda k: _aehnlich(name, k))
|
||||
return karten[treffer] if _aehnlich(name, treffer) > 0.6 else None
|
||||
return karten[treffer] if _aehnlich(name, treffer) >= schwelle else None
|
||||
|
||||
|
||||
async def code_im_feature(features: list, punkte: list, zaehl=None) -> list:
|
||||
"""Bedienstellen, die von keiner Funktion abgedeckt sind."""
|
||||
def _zielkarte(urteile: list, karten: dict):
|
||||
"""Auf welche Karte gehört die fehlende Funktion?
|
||||
|
||||
Die Stimmen schlagen oft Karten vor, die es nicht gibt („Export",
|
||||
„Datenverwaltung"). Vorher fiel der Befund dann weg — eine Stelle, die
|
||||
einstimmig fehlte, wurde verschwiegen, weil man sich über die Ablage nicht
|
||||
einig war. Jetzt gewinnt der erste Vorschlag, der sich auflösen lässt, und
|
||||
zur Not der ähnlichste. Falsch abgelegt kann man verschieben; verschwiegen nicht.
|
||||
"""
|
||||
namen = Counter(str(u.get("karte") or "").strip() for u in urteile
|
||||
if u.get("abgedeckt") is False and u.get("karte"))
|
||||
for name, _ in namen.most_common():
|
||||
if treffer := karten.get(name) or _naechste_karte(name, karten):
|
||||
return treffer
|
||||
haeufigste = namen.most_common(1)
|
||||
return _naechste_karte(haeufigste[0][0], karten, schwelle=0.0) if haeufigste else None
|
||||
|
||||
|
||||
def _stellenabdruck(punkt: dict) -> str:
|
||||
return ledger.abdruck(_ABDECKUNG_STAND, punkt["beleg"], punkt["umfeld"],
|
||||
punkt["rumpf"])
|
||||
|
||||
|
||||
async def code_im_feature(features: list, punkte: list, stand: dict,
|
||||
zaehl=None, melde=None) -> list:
|
||||
"""Bedienstellen, die von keiner Funktion abgedeckt sind.
|
||||
|
||||
Ein gemerktes Urteil gilt, solange es tragen kann: „abgedeckt durch X", solange
|
||||
X noch auf einer Karte steht; „fehlt als Y", solange es kein Y gibt. Beides
|
||||
fällt weg, sobald sich die Codestelle selbst ändert.
|
||||
"""
|
||||
liste = _funktionsliste(features)
|
||||
karten = {f["sektionen"]["titel"]: f for f in features}
|
||||
kennungen = {f["id"] for f in features}
|
||||
titel_da = [x["text"] for f in features for x in f["sektionen"]["funktionen"]]
|
||||
normal_da = {_normal(t) for t in titel_da}
|
||||
stand["abdeckung"] = ledger.behalte(stand["abdeckung"],
|
||||
{p["schluessel"] for p in punkte})
|
||||
stand["ohne_karte"] = ledger.behalte(stand["ohne_karte"],
|
||||
{p["schluessel"] for p in punkte})
|
||||
|
||||
async def eine(punkt):
|
||||
schluessel = punkt["schluessel"]
|
||||
wie_gehabt = _stellenabdruck(punkt)
|
||||
|
||||
gemerkt = stand["abdeckung"].get(schluessel)
|
||||
if gemerkt and gemerkt["abdruck"] == wie_gehabt \
|
||||
and (not gemerkt["funktion"] or _normal(gemerkt["funktion"]) in normal_da):
|
||||
if zaehl:
|
||||
zaehl()
|
||||
return None
|
||||
gemerkt = stand["ohne_karte"].get(schluessel)
|
||||
if gemerkt and gemerkt["abdruck"] == wie_gehabt \
|
||||
and gemerkt["feature"] in kennungen \
|
||||
and not any(_aehnlich(gemerkt["titel"], t) > GLEICH for t in titel_da):
|
||||
if zaehl:
|
||||
zaehl()
|
||||
return {"feature": gemerkt["feature"], "titel": gemerkt["titel"],
|
||||
"beleg": gemerkt["beleg"]}
|
||||
|
||||
urteile = await _abdeckung(punkt, liste)
|
||||
if zaehl:
|
||||
zaehl()
|
||||
if len(urteile) < FEHLT_AB:
|
||||
# Nicht verschweigen: ausgefallene Stimmen sähen sonst aus wie „ist
|
||||
# abgedeckt" und der Lauf meldete stillschweigend zu wenig. Ein
|
||||
# einzelner Ausfall darf den Lauf aber nicht abreißen — es zählt, ob
|
||||
# noch genug Stimmen übrig sind, um die Schwelle zu erreichen.
|
||||
nein = sum(u.get("abgedeckt") is False for u in urteile)
|
||||
ausgefallen = len(URTEIL_TEMPERATUREN) - len(urteile)
|
||||
if len(urteile) < URTEIL_AB or nein < FEHLT_AB <= nein + ausgefallen:
|
||||
# Zu dünn oder offen. Dünn: zwei überlebende Stimmen genügen rechnerisch
|
||||
# für „abgedeckt", weil die Schwelle unerreichbar wird — als Dauerurteil
|
||||
# wäre das zu wenig. Offen: die ausgefallenen Stimmen hätten es kippen
|
||||
# können. Beides nicht verschweigen; sonst sähe der Ausfall aus wie „ist
|
||||
# abgedeckt" und der Lauf meldete stillschweigend zu wenig.
|
||||
raise RuntimeError(
|
||||
f'{punkt["schluessel"]}: nur {len(urteile)} von '
|
||||
f"{len(URTEIL_TEMPERATUREN)} Stimmen brauchbar")
|
||||
if sum(u.get("abgedeckt") is False for u in urteile) < FEHLT_AB:
|
||||
stand["abdeckung"].pop(schluessel, None)
|
||||
stand["ohne_karte"].pop(schluessel, None)
|
||||
|
||||
# Steht das Ergebnis schon fest, zählen die fehlenden Stimmen nicht mehr.
|
||||
# Vier Stimmen „abgedeckt" bei sechs möglichen können die Schwelle nicht
|
||||
# mehr reißen — daraus einen Fehlschlag zu machen, wäre Verschwendung.
|
||||
if nein < FEHLT_AB:
|
||||
# Abgedeckt — oder unentschieden, was dasselbe Ergebnis hat. Gemerkt
|
||||
# wird beides; der Name nur, wenn die Stimmen sich auf einen einigen.
|
||||
namen = Counter(str(u.get("funktion") or "").strip()
|
||||
for u in urteile if u.get("abgedeckt"))
|
||||
name, treffer = namen.most_common(1)[0] if namen else ("", 0)
|
||||
# Nur ein Name, den es wirklich gibt, taugt als Verfallsdatum. Deckt
|
||||
# eine Regel die Stelle ab, nennt die Stimme deren Wortlaut — der
|
||||
# stünde auf ewig ungültig in der Datei und würde jeden Lauf neu gefragt.
|
||||
gilt = treffer >= len(urteile) / 2 and _normal(name) in normal_da
|
||||
stand["abdeckung"][schluessel] = {"abdruck": wie_gehabt,
|
||||
"funktion": name if gilt else ""}
|
||||
return None
|
||||
ziele = [str(u.get("karte") or "").strip() for u in urteile]
|
||||
name = max(set(ziele), key=ziele.count)
|
||||
treffer = karten.get(name) or _naechste_karte(name, karten)
|
||||
|
||||
treffer = _zielkarte(urteile, karten)
|
||||
titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")]
|
||||
if not treffer or not titel:
|
||||
return None
|
||||
return {"feature": treffer["id"],
|
||||
"titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)),
|
||||
"beleg": f'{punkt["datei"]}:{punkt["zeile"]}'}
|
||||
gefunden = {"feature": treffer["id"],
|
||||
"titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)),
|
||||
"beleg": f'{punkt["datei"]}:{punkt["zeile"]}'}
|
||||
stand["ohne_karte"][schluessel] = {"abdruck": wie_gehabt, **gefunden}
|
||||
return gefunden
|
||||
|
||||
gefunden = [x for x in await asyncio.gather(*(eine(p) for p in punkte)) if x]
|
||||
gefunden = await _sammle([eine(p) for p in punkte], "Codestellen", melde)
|
||||
# Zwei Stellen können auf dieselbe fehlende Funktion zeigen
|
||||
raus = []
|
||||
for x in gefunden:
|
||||
@@ -313,7 +693,7 @@ async def code_im_feature(features: list, punkte: list, zaehl=None) -> list:
|
||||
if gleich:
|
||||
gleich["beleg"] += f', {x["beleg"]}'
|
||||
else:
|
||||
raus.append(x)
|
||||
raus.append(dict(x))
|
||||
return raus
|
||||
|
||||
|
||||
@@ -343,14 +723,26 @@ def probleme_code(features: list, fehlend: list) -> list:
|
||||
|
||||
# ---------------------------------------------------------------- Einstiege
|
||||
|
||||
async def pruefe_feature_im_code(features: list, quelle: dict, quelltext: str,
|
||||
zaehl=None) -> list:
|
||||
ohne = await feature_im_code(features, quelle, quelltext, zaehl)
|
||||
return probleme_feature(features, ohne)
|
||||
async def pruefe_feature_im_code(projekt: str, features: list, quelle: dict,
|
||||
quelltext: str, zaehl=None, melde=None) -> list:
|
||||
stand = ledger.lade(projekt)
|
||||
try:
|
||||
ohne = await feature_im_code(features, quelle, quelltext, stand, zaehl, melde)
|
||||
doppelt = await dubletten(features, stand, melde)
|
||||
finally:
|
||||
# Auch ein abgebrochener Lauf hat schon Urteile gefällt; die sind bezahlt
|
||||
ledger.sichere(projekt, stand)
|
||||
return probleme_feature(features, ohne + doppelt)
|
||||
|
||||
|
||||
async def pruefe_code_im_feature(features: list, quelle: dict, zaehl=None) -> list:
|
||||
async def pruefe_code_im_feature(projekt: str, features: list, quelle: dict,
|
||||
zaehl=None, melde=None) -> list:
|
||||
punkte = quellcode.interaktionen(quelle)
|
||||
if not punkte:
|
||||
raise RuntimeError("Keine Bedienstellen im Quelltext gefunden")
|
||||
return probleme_code(features, await code_im_feature(features, punkte, zaehl))
|
||||
stand = ledger.lade(projekt)
|
||||
try:
|
||||
fehlend = await code_im_feature(features, punkte, stand, zaehl, melde)
|
||||
finally:
|
||||
ledger.sichere(projekt, stand)
|
||||
return probleme_code(features, fehlend)
|
||||
|
||||
97
backend/ledger.py
Normal file
97
backend/ledger.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Gedächtnis der Inhaltsprüfungen: was einmal beurteilt wurde, bleibt beurteilt.
|
||||
|
||||
Ein Urteil über eine Codestelle kostet sechs Anfragen und fällt bei Grenzfällen mal
|
||||
so, mal so. Genau daran scheiterte die Wiederholbarkeit: derselbe Stand lieferte in
|
||||
Runde 1 einen Befund, in Runde 2 keinen, in Runde 3 wieder einen.
|
||||
|
||||
Gespeichert wird deshalb das Urteil, nicht nur das Ergebnis. Der nächste Lauf fragt
|
||||
nur noch, was er nicht schon weiß. Ein Eintrag gilt weiter, solange seine
|
||||
Voraussetzung nachweisbar dasteht:
|
||||
|
||||
- ein Beleg, solange die zitierte Zeile noch in der Datei steht
|
||||
- eine Abdeckung, solange die nennende Funktion noch auf einer Karte steht
|
||||
- eine Fehlmeldung, solange keine passende Funktion dazugekommen ist
|
||||
|
||||
Fällt die Voraussetzung weg, fällt der Eintrag weg und wird neu gefragt. Damit
|
||||
bleibt gleicher Stand gleiches Ergebnis, und geänderter Stand wird wirklich neu
|
||||
beurteilt.
|
||||
|
||||
Die Datei liegt im Projektordner. Sie wandert beim Umbenennen mit und verschwindet
|
||||
beim Löschen — ohne Zutun, weil beides den Ordner bewegt. Wer von vorn anfangen
|
||||
will, löscht sie; der nächste Lauf fragt dann wieder alles.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import projekte
|
||||
|
||||
DATEI = ".pruefung.json"
|
||||
|
||||
# Ein Fach je Frage. Getrennt, weil sie unterschiedlich ungültig werden.
|
||||
FAECHER = (
|
||||
"belege", # Funktion -> Codestelle, die sie umsetzt (Prüfung 2)
|
||||
"ohne_code", # Funktion -> kein Code gefunden (Prüfung 2)
|
||||
"paare", # Funktionspaar -> Dublette oder verschieden (Prüfung 2)
|
||||
"abdeckung", # Codestelle -> Funktion, die sie beschreibt (Prüfung 3)
|
||||
"ohne_karte", # Codestelle -> keine Funktion beschreibt sie (Prüfung 3)
|
||||
)
|
||||
|
||||
|
||||
def _pfad(projekt: str):
|
||||
return projekte.wurzel(projekt) / DATEI
|
||||
|
||||
|
||||
def lade(projekt: str) -> dict:
|
||||
"""Der gespeicherte Stand. Kaputte oder fehlende Datei heißt: nichts gemerkt."""
|
||||
try:
|
||||
daten = json.loads(_pfad(projekt).read_text())
|
||||
except (OSError, ValueError):
|
||||
daten = {}
|
||||
return {fach: dict(daten.get(fach) or {}) for fach in FAECHER}
|
||||
|
||||
|
||||
def sichere(projekt: str, stand: dict):
|
||||
"""Sortiert und eingerückt — die Datei soll sich lesen und vergleichen lassen.
|
||||
|
||||
Erst daneben schreiben, dann umbenennen. Ein Absturz mitten im Schreiben ließe
|
||||
sonst eine halbe Datei zurück, und das Gedächtnis eines ganzen Laufs wäre weg —
|
||||
teuer bezahlt und nicht wiederherstellbar.
|
||||
"""
|
||||
text = json.dumps({fach: stand.get(fach) or {} for fach in FAECHER},
|
||||
indent=1, ensure_ascii=False, sort_keys=True)
|
||||
ziel = _pfad(projekt)
|
||||
neben = ziel.with_suffix(".json.neu")
|
||||
neben.write_text(text + "\n")
|
||||
neben.replace(ziel)
|
||||
|
||||
|
||||
def abdruck(*teile: str) -> str:
|
||||
"""Kurzer Fingerabdruck über den Stand, der ein Urteil trägt."""
|
||||
roh = json.dumps([t or "" for t in teile], ensure_ascii=False).encode()
|
||||
return hashlib.sha1(roh).hexdigest()[:16]
|
||||
|
||||
|
||||
# Schlüssel sind JSON-Listen. Ein Trennzeichen wäre einfacher, aber Kartennamen und
|
||||
# Funktionstitel dürfen alles enthalten — und ein Schlüssel, der sich nicht wieder
|
||||
# zerlegen lässt, ist keiner.
|
||||
|
||||
def schluessel(*teile: str) -> str:
|
||||
return json.dumps(list(teile), ensure_ascii=False)
|
||||
|
||||
|
||||
def paarschluessel(a: str, b: str) -> str:
|
||||
return json.dumps(sorted((a, b)), ensure_ascii=False)
|
||||
|
||||
|
||||
def teile(wert: str) -> list:
|
||||
try:
|
||||
gelesen = json.loads(wert)
|
||||
except ValueError:
|
||||
return []
|
||||
return gelesen if isinstance(gelesen, list) else []
|
||||
|
||||
|
||||
def behalte(fach: dict, gueltig: set) -> dict:
|
||||
"""Einträge zu verschwundenen Stellen wegräumen — sonst wächst die Datei ewig."""
|
||||
return {k: v for k, v in fach.items() if k in gueltig}
|
||||
@@ -14,6 +14,16 @@ import re
|
||||
MARKER = re.compile(r"^<!--\s*([a-zä-ü]+)\s*-->\s*$")
|
||||
TRENNER = re.compile(r"^-{3,}\s*$")
|
||||
|
||||
# „vision" ist keine Einstufung, sondern eine eigene Kartenart: sie hält fest, wie
|
||||
# es gedacht ist, und wird von keiner Prüfung angefasst. Unbekanntes ist „kern".
|
||||
VISION = "vision"
|
||||
ARTEN = ("kern", "optional", VISION)
|
||||
|
||||
|
||||
def _art(bloecke: list) -> str:
|
||||
wert = bloecke[0].strip().lower() if bloecke else ""
|
||||
return wert if wert in ARTEN else "kern"
|
||||
|
||||
|
||||
def zerlege(roh: str) -> dict:
|
||||
# Erst in Blöcke (schluessel, text) in Dateireihenfolge schneiden
|
||||
@@ -70,7 +80,7 @@ def zerlege(roh: str) -> dict:
|
||||
|
||||
return {
|
||||
"titel": titel_zeilen[0] if titel_zeilen else "",
|
||||
"art": "optional" if art and art[0].strip().lower() == "optional" else "kern",
|
||||
"art": _art(art),
|
||||
"ziel": "\n\n".join(ziel),
|
||||
"regeln": "\n".join(regeln),
|
||||
"funktionen": funktionen,
|
||||
|
||||
@@ -472,12 +472,12 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
|
||||
async def _stil_aufgaben(projekt: str):
|
||||
"""Prüfstellen der Stilprüfung samt der Kataloge, die sie brauchen."""
|
||||
stil = {b: _stil_lesen(b) for b in BEREICHE}
|
||||
liste = [a for f in features.liste(projekt) for a in aufgaben(f)]
|
||||
liste = [a for f in features.pruefbar(projekt) for a in aufgaben(f)]
|
||||
return liste, lambda a, _zaehl: _pruefe(stil, a)
|
||||
|
||||
|
||||
def _quelle(projekt: str):
|
||||
alle = features.liste(projekt)
|
||||
alle = features.pruefbar(projekt)
|
||||
quelle = quellcode.dateien()
|
||||
if not quelle:
|
||||
raise ValueError("Kein Quelltext gefunden")
|
||||
@@ -485,7 +485,8 @@ def _quelle(projekt: str):
|
||||
|
||||
|
||||
async def _feature_aufgaben(projekt: str):
|
||||
"""Ein Lauf über alle Funktionen: steht hinter jeder auch Code?
|
||||
"""Ein Lauf über alle Funktionen: steht hinter jeder auch Code? Und beschreibt
|
||||
keine zwei Funktionen dieselbe Sache?
|
||||
|
||||
Ein Aufruf statt vieler, weil das Ergebnis je Karte gebündelt wird — zwei
|
||||
Vorschläge für dieselbe Funktionsliste wären nicht unabhängig anwendbar.
|
||||
@@ -496,7 +497,8 @@ async def _feature_aufgaben(projekt: str):
|
||||
anzahl = sum(len(f["sektionen"]["funktionen"]) for f in alle)
|
||||
|
||||
async def abgleich(_, zaehl):
|
||||
return await inhaltspruefung.pruefe_feature_im_code(alle, quelle, text, zaehl)
|
||||
return await inhaltspruefung.pruefe_feature_im_code(
|
||||
projekt, alle, quelle, text, zaehl, _melder(projekt))
|
||||
|
||||
return _platzhalter(anzahl), abgleich
|
||||
|
||||
@@ -508,11 +510,26 @@ async def _code_aufgaben(projekt: str):
|
||||
punkte = quellcode.interaktionen(quelle)
|
||||
|
||||
async def abgleich(_, zaehl):
|
||||
return await inhaltspruefung.pruefe_code_im_feature(alle, quelle, zaehl)
|
||||
return await inhaltspruefung.pruefe_code_im_feature(
|
||||
projekt, alle, quelle, zaehl, _melder(projekt))
|
||||
|
||||
return _platzhalter(len(punkte)), abgleich
|
||||
|
||||
|
||||
def _melder(projekt: str):
|
||||
"""Sagt dem Lauf, was schiefging, ohne ihn abzubrechen.
|
||||
|
||||
Ein Sammellauf ist eine einzige Aufgabe. Flöge er wegen einer Stelle, wären
|
||||
alle anderen Befunde weg — und der Balken stünde trotzdem auf 100 %. So bleibt
|
||||
das Ergebnis erhalten und der Fehlstand steht daneben.
|
||||
"""
|
||||
def melde(text: str, fehler: int = 0):
|
||||
if lauf := _laeufe.get(projekt):
|
||||
lauf["meldung"] = text
|
||||
lauf["fehler"] += fehler
|
||||
return melde
|
||||
|
||||
|
||||
def _platzhalter(anzahl: int) -> list:
|
||||
"""Eine echte Aufgabe, der Rest nur für den Fortschrittsbalken."""
|
||||
return [True] + [None] * max(0, anzahl - 1)
|
||||
|
||||
@@ -89,7 +89,16 @@ def _umfeld(zeilen: list, nr: int, rand: int = 5) -> str:
|
||||
return "\n".join(zeilen[von:bis]).strip()
|
||||
|
||||
|
||||
_ENDE = re.compile(r"^(function |const |async function |\S)")
|
||||
def _pyrumpf(zeilen: list, nr: int, hoechstens: int = 14) -> str:
|
||||
"""Der eingerückte Block unter einer Zeile — für Python statt geschweifter Klammern."""
|
||||
if nr > len(zeilen):
|
||||
return "" # Dekorator ohne Rumpf, etwa am Dateiende
|
||||
block = [zeilen[nr - 1]]
|
||||
for weiter in zeilen[nr:nr + hoechstens]:
|
||||
if weiter.strip() and not weiter[:1].isspace() and not weiter.startswith("@"):
|
||||
break
|
||||
block.append(weiter)
|
||||
return "\n".join(block).rstrip()
|
||||
|
||||
|
||||
def _rumpf(zeilen: list, name: str, hoechstens: int = 14) -> str:
|
||||
@@ -120,9 +129,17 @@ def interaktionen(quelle: dict = None) -> list[dict]:
|
||||
|
||||
Dazu Fenster-Lauscher: ein Tastaturgriff ist eine Fähigkeit, auch ohne Knopf.
|
||||
|
||||
Backend-Routen stehen bewusst nicht drin. Jede nutzbare Route hängt an einem
|
||||
Knopf, der schon in der Liste steht — als eigene Einträge wurden sie doppelt
|
||||
gemeldet und dabei der falschen Karte zugeordnet.
|
||||
Dazu die Backend-Routen. Sie standen zwischenzeitlich nicht drin, weil sie
|
||||
doppelt gemeldet und dabei der falschen Karte zugeordnet wurden. Beides ist
|
||||
jetzt aufgefangen: das Gedächtnis hält die Zuordnung fest, und der
|
||||
Dublettenabgleich fängt zwei Funktionen für dieselbe Sache. Was hinter einem
|
||||
Knopf liegt, ist der einzige Weg an Parser, Dateiverwaltung und Prüflogik —
|
||||
ohne die Routen bliebe der ganze Rücken des Programms ungeprüft.
|
||||
|
||||
Reine Hilfsfunktionen bleiben draußen. Ein Kunde bestellt keine Funktion namens
|
||||
`_slug`; sie wird über die Route erreicht, die ihn interessiert. Jedes
|
||||
öffentliche Symbol aufzunehmen ergäbe wieder ein Code-Inventar statt einer
|
||||
Auftragsliste.
|
||||
"""
|
||||
quelle = dateien() if quelle is None else quelle
|
||||
raus, gesehen = [], set()
|
||||
@@ -144,6 +161,11 @@ def interaktionen(quelle: dict = None) -> list[dict]:
|
||||
for m in _LAUSCHER.finditer(zeile):
|
||||
merke(f"lauscher:{m.group(1)}.{m.group(2)}", "Tastatur oder Maus",
|
||||
pfad, nr, zeilen, zeile)
|
||||
for m in _ROUTE.finditer(zeile):
|
||||
punkt = merke(f"route:{m.group(1)} {m.group(2)}", "Schnittstelle",
|
||||
pfad, nr, zeilen, zeile)
|
||||
if punkt is not None:
|
||||
punkt["rumpf"] = _pyrumpf(zeilen, nr + 1)
|
||||
if not pfad.endswith(".vue"):
|
||||
continue
|
||||
for m in _EREIGNIS.finditer(zeile):
|
||||
|
||||
@@ -44,7 +44,9 @@ def test_interaktionen_sind_deterministisch():
|
||||
# Zwölf Aufrufe von starte() sind eine Fähigkeit, nicht zwölf
|
||||
assert schluessel.count("bedienen:starte") == 1
|
||||
assert "bedienen:menue" in schluessel
|
||||
assert not any(s.startswith("schnittstelle:") for s in schluessel) # Verdrahtung
|
||||
# Routen: der einzige Weg an Parser, Dateiverwaltung und Prüflogik
|
||||
assert "route:get /projekte" in schluessel
|
||||
assert "route:post /projekte" in schluessel
|
||||
assert "menue:+ Projekt erstellen" in schluessel
|
||||
# Technische Aufhänger und Zuweisungen sind keine Fähigkeiten
|
||||
assert not any(s.startswith("bedienen:fertig") for s in schluessel)
|
||||
|
||||
532
backend/tests/test_ledger.py
Normal file
532
backend/tests/test_ledger.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""Das Gedächtnis der Inhaltsprüfungen: was gilt weiter, was wird neu gefragt.
|
||||
|
||||
Die Prüfungen 2 und 3 fragen ein Modell. Dieselbe Frage kippt bei Grenzfällen auch
|
||||
bei Temperatur 0 — vorher meldete Runde 1 einen Befund, Runde 2 keinen, Runde 3
|
||||
wieder einen. Hier steht, unter welchen Bedingungen ein Urteil stehen bleibt.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import agents
|
||||
import features
|
||||
import inhalt
|
||||
import ledger
|
||||
import projekte
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def projekt(datenordner):
|
||||
projekte.anlegen("Test")
|
||||
return "Test"
|
||||
|
||||
|
||||
def karte(projekt, titel, *funktionen):
|
||||
"""funktionen sind (name, regel) oder (name, regel, [testschritte])."""
|
||||
text = f"<!-- titel -->\n{titel}\n"
|
||||
for eintrag in funktionen:
|
||||
name, regel, schritte = (*eintrag, [])[:3]
|
||||
text += f"<!-- funktion -->\n{name}\n<!-- regeln -->\n- {regel}\n"
|
||||
if schritte:
|
||||
text += "<!-- testen -->\n" + "".join(f"- {s}\n" for s in schritte)
|
||||
return features.anlegen(projekt, text)
|
||||
|
||||
|
||||
QUELLE = {"frontend/src/App.vue": '<button @click="starte">los</button>\n'}
|
||||
|
||||
|
||||
class Zaehler:
|
||||
"""Ersetzt das Modell und zählt mit, wie oft überhaupt gefragt wurde."""
|
||||
|
||||
def __init__(self, antwort):
|
||||
self.antwort = antwort
|
||||
self.rufe = 0
|
||||
|
||||
async def __call__(self, system, nachricht, temperatur=0.0):
|
||||
self.rufe += 1
|
||||
wert = self.antwort
|
||||
return wert(nachricht, self.rufe) if callable(wert) else wert
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Datei
|
||||
|
||||
def test_leeres_gedaechtnis_hat_alle_faecher(projekt):
|
||||
stand = ledger.lade(projekt)
|
||||
assert set(stand) == set(ledger.FAECHER)
|
||||
assert all(v == {} for v in stand.values())
|
||||
|
||||
|
||||
def test_kaputte_datei_wird_wie_leer_behandelt(projekt):
|
||||
(projekte.wurzel(projekt) / ledger.DATEI).write_text("{kein json")
|
||||
assert ledger.lade(projekt)["belege"] == {}
|
||||
|
||||
|
||||
def test_gemerktes_ueberlebt_den_lauf(projekt):
|
||||
stand = ledger.lade(projekt)
|
||||
stand["belege"][ledger.schluessel("a", "Tun")] = {"abdruck": "x", "datei": "a.py",
|
||||
"beleg": "def tun():"}
|
||||
ledger.sichere(projekt, stand)
|
||||
assert ledger.lade(projekt)["belege"] == stand["belege"]
|
||||
|
||||
|
||||
def test_schluessel_bleibt_zerlegbar(projekt):
|
||||
"""Titel dürfen alles enthalten — auch das, was als Trennzeichen taugen würde."""
|
||||
fies = 'Klick auf "x", dann | und \\ und ["a","b"]'
|
||||
assert ledger.teile(ledger.schluessel("karte", fies)) == ["karte", fies]
|
||||
|
||||
|
||||
def test_gedaechtnis_ist_kein_feature(projekt):
|
||||
karte(projekt, "Eins")
|
||||
ledger.sichere(projekt, ledger.lade(projekt))
|
||||
assert [f["sektionen"]["titel"] for f in features.liste(projekt)] == ["Eins"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Prüfung 2
|
||||
|
||||
GEFUNDEN = json.dumps({"gefunden": True, "datei": "frontend/src/App.vue",
|
||||
"beleg": '<button @click="starte">los</button>'})
|
||||
|
||||
|
||||
async def test_beleg_wird_gemerkt_und_beim_zweiten_lauf_nicht_gefragt(
|
||||
projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(GEFUNDEN)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
alle = features.liste(projekt)
|
||||
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
|
||||
assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == []
|
||||
erste_runde = modell.rufe
|
||||
assert erste_runde >= len(inhalt.SUCHE_TEMPERATUREN)
|
||||
|
||||
assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == []
|
||||
assert modell.rufe == erste_runde # keine einzige neue Anfrage
|
||||
|
||||
|
||||
async def test_beleg_verfaellt_wenn_die_zeile_verschwindet(projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(GEFUNDEN)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
alle = features.liste(projekt)
|
||||
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
|
||||
await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
|
||||
vorher = modell.rufe
|
||||
|
||||
anders = {"frontend/src/App.vue": "<div>nichts mehr</div>\n"}
|
||||
await inhalt.pruefe_feature_im_code(projekt, alle, anders, "### x\n<div>")
|
||||
assert modell.rufe > vorher # der Beleg trägt nicht mehr, also neu fragen
|
||||
|
||||
|
||||
async def test_geaenderte_funktion_wird_neu_gefragt(projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(GEFUNDEN)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
|
||||
await inhalt.pruefe_feature_im_code(projekt, features.liste(projekt), QUELLE, text)
|
||||
vorher = modell.rufe
|
||||
|
||||
datei = projekte.wurzel(projekt) / f"{features.liste(projekt)[0]['id']}.md"
|
||||
datei.write_text(datei.read_text().replace("- Knopf", "- Ganz anderer Inhalt"))
|
||||
await inhalt.pruefe_feature_im_code(projekt, features.liste(projekt), QUELLE, text)
|
||||
assert modell.rufe > vorher
|
||||
|
||||
|
||||
async def test_fehlanzeige_gilt_nur_bei_unveraendertem_quelltext(projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(json.dumps({"gefunden": False, "grund": "gibt es nicht"}))
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
alle = features.liste(projekt)
|
||||
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
|
||||
erste = await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
|
||||
assert len(erste) == 1
|
||||
vorher = modell.rufe
|
||||
|
||||
zweite = await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
|
||||
assert [p["vorschlag"] for p in zweite] == [p["vorschlag"] for p in erste]
|
||||
assert modell.rufe == vorher
|
||||
|
||||
# Neuer Quelltext: eine Löschempfehlung muss frisch belegt werden
|
||||
mehr = {**QUELLE, "backend/neu.py": "def starte():\n pass\n"}
|
||||
await inhalt.pruefe_feature_im_code(projekt, alle, mehr, text)
|
||||
assert modell.rufe > vorher
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Dubletten
|
||||
|
||||
def punkt(schluessel="bedienen:starte", beleg='<button @click="starte">los</button>'):
|
||||
return {"schluessel": schluessel, "art": "Bedienung",
|
||||
"datei": "frontend/src/App.vue", "zeile": 1, "beleg": beleg,
|
||||
"umfeld": beleg, "rumpf": ""}
|
||||
|
||||
|
||||
async def test_gleicher_beleg_macht_zwei_funktionen_zu_kandidaten(projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
karte(projekt, "Zwei", ("Lauf beginnen", "Knopf"))
|
||||
monkeypatch.setattr(agents, "frage", Zaehler(json.dumps({"gegenstand_gleich": True, "ergebnis_gleich": True})))
|
||||
|
||||
alle = features.liste(projekt)
|
||||
stand = ledger.lade(projekt)
|
||||
for f in alle:
|
||||
stand["belege"][ledger.schluessel(f["id"], f["sektionen"]["funktionen"][0]["text"])] = {
|
||||
"abdruck": "egal", "datei": "frontend/src/App.vue",
|
||||
"beleg": '<button @click="starte">los</button>'}
|
||||
gefunden = await inhalt.dubletten(alle, stand)
|
||||
assert len(gefunden) == 1
|
||||
# Gleich stark: der Titel entscheidet, damit der Vorschlag nicht springt
|
||||
assert gefunden[0]["titel"] == "Etwas starten"
|
||||
assert "Lauf beginnen" in gefunden[0]["grund"]
|
||||
|
||||
|
||||
async def test_uneiniges_urteil_ist_keine_dublette_und_wird_gemerkt(
|
||||
projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
karte(projekt, "Zwei", ("Lauf beginnen", "Knopf"))
|
||||
|
||||
def wechselhaft(nachricht, ruf):
|
||||
return json.dumps({"gegenstand_gleich": True, "ergebnis_gleich": ruf == 1})
|
||||
|
||||
modell = Zaehler(wechselhaft)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
alle = features.liste(projekt)
|
||||
stand = ledger.lade(projekt)
|
||||
for f in alle:
|
||||
stand["belege"][ledger.schluessel(f["id"], f["sektionen"]["funktionen"][0]["text"])] = {
|
||||
"abdruck": "egal", "datei": "a.vue", "beleg": "x"}
|
||||
assert await inhalt.dubletten(alle, stand) == []
|
||||
vorher = modell.rufe
|
||||
assert await inhalt.dubletten(alle, stand) == []
|
||||
assert modell.rufe == vorher # Ablehnung wird gemerkt, nicht neu bezahlt
|
||||
|
||||
|
||||
def test_gegenstueck_faellt_nicht_ueber_den_titel():
|
||||
"""anlegen/entfernen und öffnen/schließen dürfen den Titelkanal nicht auslösen —
|
||||
sonst kostet jedes Gegensatzpaar drei Anfragen für nichts."""
|
||||
for a, b in (("Feature anlegen", "Feature entfernen"),
|
||||
("Panel öffnen", "Panel schließen")):
|
||||
assert inhalt._aehnlich(a, b) < inhalt.TITEL_KANAL
|
||||
|
||||
|
||||
ENTFERNEN = ["Vollmodus öffnen", "RMB auf die Funktion",
|
||||
'Klick auf "Funktion entfernen"', "Funktion entfernt"]
|
||||
|
||||
|
||||
def test_umschriebene_dublette_wird_kandidat(projekt):
|
||||
"""Anderer Titel, andere Karte, dieselbe Sache. Weder Titel noch Beleg fangen
|
||||
das — nur der Inhaltskanal."""
|
||||
karte(projekt, "Funktionen",
|
||||
("Funktion entfernen", "x-Icon", ENTFERNEN),
|
||||
("Funktion anlegen", "+-Icon", ["Vollmodus öffnen", "RMB auf die Karte",
|
||||
'Klick auf "Funktion erstellen"',
|
||||
"Funktion angelegt"]),
|
||||
("Testschritte anpassen", "Ein Schritt je Zeile",
|
||||
["Vollmodus öffnen", "LMB auf Testen", "Text ändern", "Schritte angepasst"]))
|
||||
karte(projekt, "Features", ("Funktion löschen", "x-Icon", ENTFERNEN))
|
||||
|
||||
alle = inhalt._funktionen(features.liste(projekt))
|
||||
paare = {frozenset((alle[a]["titel"], alle[b]["titel"])) for _, a, b, _ in
|
||||
inhalt._kandidaten(alle, {})}
|
||||
assert frozenset(("Funktion entfernen", "Funktion löschen")) in paare
|
||||
assert frozenset(("Funktion anlegen", "Funktion entfernen")) not in paare
|
||||
|
||||
|
||||
def test_floskeln_zaehlen_nicht_mit(projekt):
|
||||
"""Was fast überall steht, unterscheidet nichts und fliegt aus dem Vergleich."""
|
||||
schritte = ["Vollmodus öffnen", "Text ändern", "Angepasst"]
|
||||
karte(projekt, "Eins",
|
||||
*[(f"{feld} anpassen", "eine Regel", schritte)
|
||||
for feld in ("Titel", "Ziel", "Regeln", "Testen", "Badge")])
|
||||
kerne = inhalt._kerne(inhalt._funktionen(features.liste(projekt)))
|
||||
gemeinsam = set.intersection(*kerne.values())
|
||||
assert gemeinsam == set() # „vollmodus", „ändern", „angepasst" sind raus
|
||||
assert all(k for k in kerne.values()) # jede Funktion behält ihr eigenes Wort
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Prüfung 3
|
||||
|
||||
ABGEDECKT = json.dumps({"abgedeckt": True, "funktion": "Etwas starten"})
|
||||
FEHLT = json.dumps({"abgedeckt": False, "karte": "Eins", "titel": "Neu bedienen"})
|
||||
|
||||
|
||||
async def test_abdeckung_wird_gemerkt_solange_die_funktion_dasteht(
|
||||
projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(ABGEDECKT)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
stand = ledger.lade(projekt)
|
||||
alle = features.liste(projekt)
|
||||
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
|
||||
vorher = modell.rufe
|
||||
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
|
||||
assert modell.rufe == vorher
|
||||
|
||||
# Funktion weg: die Abdeckung trägt nicht mehr
|
||||
ohne = features.liste(projekt)
|
||||
ohne[0]["sektionen"]["funktionen"] = []
|
||||
await inhalt.code_im_feature(ohne, [punkt()], stand)
|
||||
assert modell.rufe > vorher
|
||||
|
||||
|
||||
async def test_geaenderte_codestelle_wird_neu_beurteilt(projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(ABGEDECKT)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
stand = ledger.lade(projekt)
|
||||
alle = features.liste(projekt)
|
||||
await inhalt.code_im_feature(alle, [punkt()], stand)
|
||||
vorher = modell.rufe
|
||||
await inhalt.code_im_feature(alle, [punkt(beleg="ganz anderer Code")], stand)
|
||||
assert modell.rufe > vorher
|
||||
|
||||
|
||||
async def test_fehlmeldung_bleibt_wortgleich_bis_die_funktion_da_ist(
|
||||
projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(FEHLT)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
stand = ledger.lade(projekt)
|
||||
alle = features.liste(projekt)
|
||||
erste = await inhalt.code_im_feature(alle, [punkt()], stand)
|
||||
assert [x["titel"] for x in erste] == ["Neu bedienen"]
|
||||
vorher = modell.rufe
|
||||
|
||||
zweite = await inhalt.code_im_feature(alle, [punkt()], stand)
|
||||
assert zweite == erste
|
||||
assert modell.rufe == vorher # kein Flackern, keine neue Anfrage
|
||||
|
||||
|
||||
async def test_angenommener_vorschlag_loest_die_fehlmeldung_ab(projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler(FEHLT)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
stand = ledger.lade(projekt)
|
||||
await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand)
|
||||
vorher = modell.rufe
|
||||
|
||||
# Der Nutzer nimmt den Vorschlag an — jetzt muss neu beurteilt werden
|
||||
modell.antwort = ABGEDECKT
|
||||
karte(projekt, "Zwei", ("Neu bedienen", "Knopf"))
|
||||
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand) == []
|
||||
assert modell.rufe > vorher
|
||||
|
||||
|
||||
async def test_unentschiedenes_urteil_wird_festgehalten(projekt, monkeypatch):
|
||||
"""Drei zu drei ist ein Münzwurf. Der darf einmal fallen, nicht jede Runde."""
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
|
||||
def haelfte(nachricht, ruf):
|
||||
return ABGEDECKT if ruf % 2 else FEHLT
|
||||
|
||||
modell = Zaehler(haelfte)
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
stand = ledger.lade(projekt)
|
||||
alle = features.liste(projekt)
|
||||
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
|
||||
vorher = modell.rufe
|
||||
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
|
||||
assert modell.rufe == vorher
|
||||
|
||||
|
||||
async def test_verschwundene_stelle_raeumt_ihren_eintrag_weg(projekt, monkeypatch):
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
monkeypatch.setattr(agents, "frage", Zaehler(ABGEDECKT))
|
||||
|
||||
stand = ledger.lade(projekt)
|
||||
alle = features.liste(projekt)
|
||||
await inhalt.code_im_feature(alle, [punkt()], stand)
|
||||
assert stand["abdeckung"]
|
||||
await inhalt.code_im_feature(alle, [punkt(schluessel="bedienen:anderes")], stand)
|
||||
assert "bedienen:starte" not in stand["abdeckung"]
|
||||
|
||||
|
||||
def test_fehlende_stelle_landet_auch_auf_erfundener_karte(projekt):
|
||||
"""Sechs Stimmen sagen „fehlt", nennen aber Karten, die es nicht gibt. Vorher
|
||||
fiel der Befund lautlos weg."""
|
||||
karte(projekt, "Projekte", ("Projekt anlegen", "+-Icon"))
|
||||
karten = {f["sektionen"]["titel"]: f for f in features.liste(projekt)}
|
||||
urteile = [{"abgedeckt": False, "karte": "Datenverwaltung", "titel": "x"}] * 4
|
||||
assert inhalt._zielkarte(urteile, karten) is karten["Projekte"]
|
||||
|
||||
|
||||
def test_erster_aufloesbarer_kartenname_gewinnt(projekt):
|
||||
karte(projekt, "Projekte", ("Projekt anlegen", "+-Icon"))
|
||||
karte(projekt, "Darkmode", ("Darkmode umschalten", "Icon"))
|
||||
karten = {f["sektionen"]["titel"]: f for f in features.liste(projekt)}
|
||||
urteile = ([{"abgedeckt": False, "karte": "Export", "titel": "x"}] * 3
|
||||
+ [{"abgedeckt": False, "karte": "Darkmode", "titel": "x"}] * 2)
|
||||
assert inhalt._zielkarte(urteile, karten) is karten["Darkmode"]
|
||||
|
||||
|
||||
def test_ohne_karten_kein_ziel(projekt):
|
||||
assert inhalt._zielkarte([{"abgedeckt": False, "karte": "X"}], {}) is None
|
||||
|
||||
|
||||
def test_grund_trennt_dublette_von_fehlendem_code():
|
||||
"""Beides heißt streichen — aber aus verschiedenen Gründen, und der Grund
|
||||
entscheidet, ob man zustimmt."""
|
||||
assert inhalt._wegfall_grund([{"art": "dublette"}]) == "1 Funktion doppelt"
|
||||
assert inhalt._wegfall_grund([{"art": "ohne_code"}] * 2) \
|
||||
== "2 Funktionen ohne eigenen Code"
|
||||
assert inhalt._wegfall_grund([{"art": "dublette"}, {"art": "ohne_code"}]) \
|
||||
== "1 Funktion doppelt, 1 Funktion ohne eigenen Code"
|
||||
|
||||
|
||||
def test_halbe_datei_ueberschreibt_nicht_die_ganze(projekt, monkeypatch):
|
||||
"""Absturz beim Schreiben: die alte Datei muss stehen bleiben."""
|
||||
stand = ledger.lade(projekt)
|
||||
stand["belege"]["a"] = {"abdruck": "x", "datei": "a.py", "beleg": "z"}
|
||||
ledger.sichere(projekt, stand)
|
||||
|
||||
echt = ledger.json.dumps
|
||||
|
||||
def platzt(*a, **k):
|
||||
raise MemoryError("mittendrin")
|
||||
|
||||
monkeypatch.setattr(ledger.json, "dumps", platzt)
|
||||
with pytest.raises(MemoryError):
|
||||
ledger.sichere(projekt, {"belege": {"b": {}}})
|
||||
monkeypatch.setattr(ledger.json, "dumps", echt)
|
||||
assert ledger.lade(projekt)["belege"] == stand["belege"]
|
||||
|
||||
|
||||
async def test_ausgefallene_stimmen_merken_nichts(projekt, monkeypatch):
|
||||
"""Fällt der Anbieter aus, darf keine Löschempfehlung im Gedächtnis landen."""
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
|
||||
async def tot(system, nachricht, temperatur=0.0):
|
||||
raise RuntimeError("429")
|
||||
|
||||
monkeypatch.setattr(agents, "frage", tot)
|
||||
stand = ledger.lade(projekt)
|
||||
gemeldet = []
|
||||
assert await inhalt.feature_im_code(features.liste(projekt), QUELLE, "x", stand,
|
||||
melde=lambda t, n=0: gemeldet.append(n)) == []
|
||||
assert gemeldet == [1] # laut gescheitert, nicht still
|
||||
assert stand["ohne_code"] == {}
|
||||
assert stand["belege"] == {}
|
||||
|
||||
|
||||
async def test_ein_ausfall_rettet_die_anderen_urteile(projekt, monkeypatch):
|
||||
"""Eine kaputte Stelle darf die bezahlten Urteile der anderen nicht wegwerfen."""
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
|
||||
async def launisch(system, nachricht, temperatur=0.0):
|
||||
if "bedienen:kaputt" in nachricht:
|
||||
raise RuntimeError("429")
|
||||
return ABGEDECKT
|
||||
|
||||
monkeypatch.setattr(agents, "frage", launisch)
|
||||
stand = ledger.lade(projekt)
|
||||
gemeldet = []
|
||||
await inhalt.code_im_feature(
|
||||
features.liste(projekt),
|
||||
[punkt(), punkt(schluessel="bedienen:kaputt", beleg="bedienen:kaputt")],
|
||||
stand, melde=lambda t, n=0: gemeldet.append(n))
|
||||
assert gemeldet == [1]
|
||||
assert "bedienen:starte" in stand["abdeckung"]
|
||||
assert "bedienen:kaputt" not in stand["abdeckung"]
|
||||
|
||||
|
||||
async def test_projekt_ohne_karten_stuerzt_nicht_ab(projekt, monkeypatch):
|
||||
monkeypatch.setattr(agents, "frage", Zaehler(FEHLT))
|
||||
stand = ledger.lade(projekt)
|
||||
assert await inhalt.code_im_feature([], [punkt()], stand) == []
|
||||
assert await inhalt.dubletten([], stand) == []
|
||||
assert await inhalt.feature_im_code([], QUELLE, "x", stand) == []
|
||||
|
||||
|
||||
async def test_feststehendes_urteil_braucht_keine_vollen_stimmen(projekt, monkeypatch):
|
||||
"""Vier von sechs sagen „abgedeckt": die zwei fehlenden könnten die Schwelle
|
||||
nicht mehr reißen. Daraus einen Fehlschlag zu machen wäre Verschwendung."""
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
ausfaelle = {"n": 0}
|
||||
|
||||
async def zwei_platzen(system, nachricht, temperatur=0.0):
|
||||
ausfaelle["n"] += 1
|
||||
if ausfaelle["n"] <= 2:
|
||||
raise RuntimeError("429")
|
||||
return ABGEDECKT
|
||||
|
||||
monkeypatch.setattr(agents, "frage", zwei_platzen)
|
||||
stand = ledger.lade(projekt)
|
||||
gemeldet = []
|
||||
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
|
||||
melde=lambda t, n=0: gemeldet.append(n)) == []
|
||||
assert gemeldet == [] # kein Fehler: das Ergebnis stand fest
|
||||
assert "bedienen:starte" in stand["abdeckung"]
|
||||
|
||||
|
||||
async def test_offenes_urteil_bei_ausfall_wird_gemeldet(projekt, monkeypatch):
|
||||
"""Vier Stimmen sagen „fehlt", zwei fehlen — mit ihnen wäre die Schwelle
|
||||
erreicht. Das ist offen und muss laut scheitern, nicht still durchgehen."""
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
ausfaelle = {"n": 0}
|
||||
|
||||
async def zwei_platzen(system, nachricht, temperatur=0.0):
|
||||
ausfaelle["n"] += 1
|
||||
if ausfaelle["n"] <= 2:
|
||||
raise RuntimeError("429")
|
||||
return FEHLT
|
||||
|
||||
monkeypatch.setattr(agents, "frage", zwei_platzen)
|
||||
stand = ledger.lade(projekt)
|
||||
gemeldet = []
|
||||
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
|
||||
melde=lambda t, n=0: gemeldet.append(n)) == []
|
||||
assert gemeldet == [1]
|
||||
assert stand["abdeckung"] == {} and stand["ohne_karte"] == {}
|
||||
|
||||
|
||||
async def test_zwei_ueberlebende_stimmen_urteilen_nicht(projekt, monkeypatch):
|
||||
"""Rechnerisch könnten zwei Stimmen die Schwelle unerreichbar machen. Als
|
||||
dauerhaft gemerktes Urteil ist das zu wenig."""
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
ausfaelle = {"n": 0}
|
||||
|
||||
async def vier_platzen(system, nachricht, temperatur=0.0):
|
||||
ausfaelle["n"] += 1
|
||||
if ausfaelle["n"] <= 4:
|
||||
raise RuntimeError("429")
|
||||
return ABGEDECKT
|
||||
|
||||
monkeypatch.setattr(agents, "frage", vier_platzen)
|
||||
stand = ledger.lade(projekt)
|
||||
gemeldet = []
|
||||
await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
|
||||
melde=lambda t, n=0: gemeldet.append(n))
|
||||
assert gemeldet == [1]
|
||||
assert stand["abdeckung"] == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Vision
|
||||
|
||||
VISION = "<!-- titel -->\nVision\n<!-- art -->\nvision\n<!-- ziel -->\nSo soll es werden\n"
|
||||
|
||||
|
||||
def test_vision_ist_eine_eigene_kartenart(projekt):
|
||||
f = features.anlegen(projekt, VISION)
|
||||
assert f["sektionen"]["art"] == "vision"
|
||||
assert f["sektionen"]["ziel"] == "So soll es werden"
|
||||
|
||||
|
||||
def test_unbekannte_art_bleibt_kern(projekt):
|
||||
f = features.anlegen(projekt, "<!-- titel -->\nX\n<!-- art -->\nwichtig\n")
|
||||
assert f["sektionen"]["art"] == "kern"
|
||||
|
||||
|
||||
def test_keine_pruefung_fasst_die_vision_an(projekt):
|
||||
"""Die Vision ist die Vorlage, nicht der Prüfling."""
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
features.anlegen(projekt, VISION)
|
||||
assert len(features.liste(projekt)) == 2
|
||||
titel = [f["sektionen"]["titel"] for f in features.pruefbar(projekt)]
|
||||
assert titel == ["Eins"]
|
||||
Reference in New Issue
Block a user