update
@@ -14,8 +14,10 @@ API_VERSION = "2023-06-01"
|
||||
|
||||
# Der Provider drosselt bei vielen gleichzeitigen Anfragen
|
||||
WIEDERHOLBAR = (429, 500, 502, 503, 504)
|
||||
WIEDERHOLUNGEN = 4
|
||||
WIEDERHOLUNGEN = 6
|
||||
PAUSE = 2.0
|
||||
# Länger als das zu warten hilft nicht mehr; dann ist die Drossel zu weit offen
|
||||
HOECHSTPAUSE = 60.0
|
||||
|
||||
|
||||
class NichtKonfiguriert(ValueError):
|
||||
@@ -37,6 +39,15 @@ def pruefe_konfiguration():
|
||||
raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt")
|
||||
|
||||
|
||||
def _pause(res, versuch: int) -> float:
|
||||
"""Wie lange bis zum nächsten Versuch — der Provider darf es selbst sagen."""
|
||||
gesagt = res.headers.get("retry-after")
|
||||
try:
|
||||
return min(float(gesagt), HOECHSTPAUSE)
|
||||
except (TypeError, ValueError):
|
||||
return min(PAUSE * (2 ** versuch), HOECHSTPAUSE)
|
||||
|
||||
|
||||
async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
|
||||
"""Eine Anfrage, eine Antwort — der Text der Modellantwort.
|
||||
Kalt geroutet: Temperatur 0, kein Thinking; die Prüfung soll nicht raten.
|
||||
@@ -63,7 +74,7 @@ async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str:
|
||||
# Zu viele Anfragen oder Serverfehler: warten und noch einmal.
|
||||
# Ohne das fielen bei großen Läufen ganze Stimmen aus — und ein
|
||||
# fehlendes Urteil sah aus wie „nichts gefunden".
|
||||
await asyncio.sleep(PAUSE * (2 ** versuch))
|
||||
await asyncio.sleep(_pause(res, versuch))
|
||||
if res.status_code != 200:
|
||||
raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}")
|
||||
antwort = res.json()
|
||||
|
||||
292
backend/chat.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Karten im Gespräch entwickeln.
|
||||
|
||||
Man schreibt, was man vorhat. Die KI antwortet und legt daneben, wie die Karten
|
||||
danach aussähen. Übernommen wird einzeln — dasselbe ✓ und ✕ wie bei der Prüfung.
|
||||
|
||||
Warum kein eigener Anzeigeweg: Ein Vorschlag ist ein Vorschlag, egal ob ihn die
|
||||
Prüfung oder das Gespräch gemacht hat. Der Chat liefert deshalb genau die Form, die
|
||||
`pruefung._stellen` erzeugt — Karten zeigen ihn an derselben Stelle an, der Vergleich
|
||||
funktioniert, Annehmen schreibt die Datei.
|
||||
|
||||
Das Modell schreibt nie selbst in eine Datei. Es beschreibt den Sollzustand einer
|
||||
Karte; was sich dadurch ändert, rechnet der Code aus. Das ist dieselbe Beweislast wie
|
||||
bei der Prüfung und der Grund, warum ein Missverständnis hier nichts kaputt macht.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import agents
|
||||
import features
|
||||
import parser
|
||||
import projekte
|
||||
import pruefung
|
||||
|
||||
DATEI = ".chat.json"
|
||||
# So viele Wortmeldungen bleiben stehen. Mehr wäre teuer je Anfrage und hilft wenig:
|
||||
# was vor zwanzig Schritten besprochen wurde, steht längst auf den Karten.
|
||||
TIEFE = 30
|
||||
# Antworten in einem Rutsch. Ein Gespräch verträgt keine sechs Stimmen — es soll
|
||||
# schnell sein, und was falsch ist, sieht man am Vorschlag.
|
||||
TEMPERATUR = 0.3
|
||||
|
||||
SYSTEM = """Du entwickelst mit einer Person zusammen die Feature-Karten eines Projekts.
|
||||
|
||||
Eine Karte ist ein Auftrag: die Person will ein Problem gelöst haben. Die Lösung
|
||||
besteht aus Regeln und Funktionen. Eine Funktion ist etwas, das man für sich testen
|
||||
kann. Was nur die Umsetzung betrifft, gehört auf keine Karte.
|
||||
|
||||
{stil}
|
||||
|
||||
Der aktuelle Stand:
|
||||
|
||||
{karten}
|
||||
|
||||
Antworte ausschließlich mit JSON:
|
||||
{{"antwort": "...", "karten": [
|
||||
{{"titel": "...", "art": "kern", "ziel": "...", "regeln": ["..."],
|
||||
"funktionen": [{{"titel": "...", "ziel": "...", "regeln": ["..."],
|
||||
"testen": ["..."]}}]}}
|
||||
]}}
|
||||
|
||||
- antwort: was du der Person sagst, auf Deutsch, höchstens vier Sätze
|
||||
- karten: nur die Karten, die sich ändern oder dazukommen sollen — jeweils
|
||||
VOLLSTÄNDIG, so wie sie danach aussehen sollen. Der Stand oben hat dieselbe Form:
|
||||
kopiere die Karte von dort und ändere nur, was sich ändern soll. Jede Zeile, die
|
||||
du nicht wörtlich mit abschreibst, gilt als gestrichen.
|
||||
- art: „kern", wenn die Anwendung ohne die Karte keinen Sinn hätte, sonst „optional"
|
||||
- ziel, regeln und testen dürfen leer sein oder fehlen
|
||||
|
||||
Ist nichts zu ändern, ist karten leer. Ändere nur, wonach gefragt wurde; Karten,
|
||||
die niemand erwähnt hat, lässt du weg.
|
||||
|
||||
Frag nach, was du nicht weißt — aber leg trotzdem einen Entwurf daneben. Eine Frage
|
||||
ohne Vorschlag hilft niemandem: an einem Entwurf sieht die Person sofort, was du
|
||||
falsch verstanden hast. Erfinde dabei nichts, was nicht gesagt wurde; lass eine
|
||||
Funktion lieber knapp als ausgedacht."""
|
||||
|
||||
# Die Formatregel steht auch hier, nicht nur im System-Prompt. Bei langen Antworten
|
||||
# vergaß das Modell sie sonst und schrieb reinen Fließtext — die ganze Wortmeldung
|
||||
# war verloren.
|
||||
NACHRICHT = """{verlauf}
|
||||
|
||||
Neue Nachricht der Person:
|
||||
{text}
|
||||
|
||||
Antworte ausschließlich mit dem JSON-Objekt, ohne Text davor oder danach."""
|
||||
|
||||
NACHBESSERN = """Deine Antwort war kein JSON.
|
||||
|
||||
Schreibe sie noch einmal, ausschließlich als das JSON-Objekt aus der Vorgabe —
|
||||
keine Einleitung, kein Nachsatz, keine Code-Zäune."""
|
||||
|
||||
|
||||
def _pfad(projekt: str):
|
||||
return projekte.wurzel(projekt) / DATEI
|
||||
|
||||
|
||||
def verlauf(projekt: str) -> list:
|
||||
try:
|
||||
eintraege = json.loads(_pfad(projekt).read_text())
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return eintraege if isinstance(eintraege, list) else []
|
||||
|
||||
|
||||
def _sichere(projekt: str, eintraege: list):
|
||||
ziel = _pfad(projekt)
|
||||
neben = ziel.with_suffix(".json.neu")
|
||||
neben.write_text(json.dumps(eintraege[-TIEFE:], indent=1, ensure_ascii=False) + "\n")
|
||||
neben.replace(ziel)
|
||||
|
||||
|
||||
def leeren(projekt: str):
|
||||
_pfad(projekt).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _punkte(text: str) -> list:
|
||||
return [z.strip("- ").strip() for z in (text or "").splitlines() if z.strip()]
|
||||
|
||||
|
||||
def _als_spec(f: dict) -> dict:
|
||||
"""Eine Karte in genau der Form, in der das Modell antworten soll."""
|
||||
s = f["sektionen"]
|
||||
return {"titel": s["titel"], "art": s["art"], "ziel": s["ziel"],
|
||||
"regeln": _punkte(s["regeln"]),
|
||||
"funktionen": [{"titel": x["text"], "ziel": x["ziel"],
|
||||
"regeln": _punkte(x["regeln"]),
|
||||
"testen": _punkte(x["testen"])}
|
||||
for x in s["funktionen"]]}
|
||||
|
||||
|
||||
def _kartenstand(projekt: str) -> str:
|
||||
"""Der Stand als JSON — dieselbe Form wie die Antwort.
|
||||
|
||||
Vorher stand er als Fließtext da. Das Modell musste ihn beim Zurückschreiben
|
||||
übersetzen und schob dabei Regeln zwischen Karten- und Funktionsebene hin und
|
||||
her: aus „eine Funktion ergänzen" wurden vier Änderungen. Gleiche Form heißt,
|
||||
dass Abschreiben nichts kostet.
|
||||
"""
|
||||
vision, karten = "", []
|
||||
for f in features.liste(projekt):
|
||||
if f["sektionen"]["art"] == parser.VISION:
|
||||
vision = f["sektionen"]["ziel"]
|
||||
else:
|
||||
karten.append(_als_spec(f))
|
||||
text = json.dumps(karten, indent=1, ensure_ascii=False) if karten \
|
||||
else "Noch keine Karten."
|
||||
if vision:
|
||||
text = f"So ist das Projekt gedacht:\n\n{vision}\n\nDie Karten:\n\n{text}"
|
||||
return text
|
||||
|
||||
|
||||
def _zeilen(wert) -> str:
|
||||
"""regeln und testen kommen als Liste oder als Block — hier wird eins daraus."""
|
||||
if isinstance(wert, list):
|
||||
eintraege = [str(z).strip("- ").strip() for z in wert]
|
||||
else:
|
||||
eintraege = [z.strip("- ").strip() for z in str(wert or "").splitlines()]
|
||||
return "\n".join(f"- {z}" for z in eintraege if z)
|
||||
|
||||
|
||||
TRENNER = "-" * 40
|
||||
|
||||
|
||||
def roh(karte: dict) -> str:
|
||||
"""Aus dem Sollzustand die Markdown-Datei — dieselbe Form wie im Frontend."""
|
||||
art = str(karte.get("art") or "kern").strip().lower()
|
||||
teile = [f'<!-- titel -->\n{str(karte.get("titel") or "").strip()}',
|
||||
f'<!-- art -->\n{art if art in parser.ARTEN else "kern"}']
|
||||
if str(karte.get("ziel") or "").strip():
|
||||
teile.append(f'<!-- ziel -->\n{str(karte["ziel"]).strip()}')
|
||||
if _zeilen(karte.get("regeln")):
|
||||
teile.append(f'<!-- regeln -->\n{_zeilen(karte.get("regeln"))}')
|
||||
for f in karte.get("funktionen") or []:
|
||||
block = [f'<!-- funktion -->\n{str(f.get("titel") or "").strip()}']
|
||||
if f.get("stand") == parser.OFFEN:
|
||||
block.append(f'<!-- stand -->\n{parser.OFFEN}')
|
||||
if str(f.get("ziel") or "").strip():
|
||||
block.append(f'<!-- ziel -->\n{str(f["ziel"]).strip()}')
|
||||
for feld in ("regeln", "testen"):
|
||||
if _zeilen(f.get(feld)):
|
||||
block.append(f'<!-- {feld} -->\n{_zeilen(f.get(feld))}')
|
||||
teile.append("\n".join(block))
|
||||
return f"\n{TRENNER}\n".join(teile) + "\n"
|
||||
|
||||
|
||||
def _felder(sektionen: dict) -> dict:
|
||||
return {"titel": sektionen["titel"], "badge": sektionen["art"],
|
||||
"ziel": sektionen["ziel"], "regeln": sektionen["regeln"],
|
||||
"funktionen": "\n".join(f["text"] for f in sektionen["funktionen"])}
|
||||
|
||||
|
||||
def _vergleiche(alt: dict, neu: dict, feature_id: str, grund: str) -> list:
|
||||
"""Was sich an einer bestehenden Karte ändert, Feld für Feld.
|
||||
|
||||
Auf Funktionsebene wird nur die Liste der Titel verglichen. Wer den Inhalt einer
|
||||
Funktion ändern will, bekommt dafür eigene Stellen — sonst wäre ein Vorschlag ein
|
||||
Klotz, den man nur ganz oder gar nicht nehmen kann.
|
||||
"""
|
||||
raus = []
|
||||
for feld in ("titel", "badge", "ziel", "regeln", "funktionen"):
|
||||
for p in pruefung._stellen(feld, alt.get(feld, ""), neu.get(feld, ""),
|
||||
grund, "Aus dem Gespräch"):
|
||||
p["feature"] = feature_id
|
||||
p["funktion"] = ""
|
||||
# Was das Gespräch an Funktionen erfindet, ist bestellt, nicht gebaut.
|
||||
# Dieselbe Stelle aus Prüfung 3 hätte belegten Code — dort wäre „offen" falsch.
|
||||
if feld == "funktionen":
|
||||
p["neu_offen"] = True
|
||||
raus.append(p)
|
||||
return raus
|
||||
|
||||
|
||||
def _funktionsstellen(alt: dict, neu: dict, feature_id: str, grund: str) -> list:
|
||||
"""Änderungen innerhalb einer Funktion, je Feld eine eigene Stelle."""
|
||||
vorher = {f["text"]: f for f in alt["funktionen"]}
|
||||
raus = []
|
||||
for f in neu["funktionen"]:
|
||||
bestand = vorher.get(f["text"])
|
||||
if not bestand:
|
||||
continue # neue Funktion: steckt schon im Listenvergleich
|
||||
for feld in ("ziel", "regeln", "testen"):
|
||||
for p in pruefung._stellen(feld, bestand[feld], f[feld], grund,
|
||||
"Aus dem Gespräch"):
|
||||
p["feature"] = feature_id
|
||||
p["funktion"] = f["text"]
|
||||
raus.append(p)
|
||||
return raus
|
||||
|
||||
|
||||
def vorschlaege(projekt: str, karten: list) -> list:
|
||||
"""Aus den Sollzuständen die einzelnen Stellen — bestehende Karten wie neue."""
|
||||
nach_titel = {f["sektionen"]["titel"].lower(): f for f in features.liste(projekt)}
|
||||
raus = []
|
||||
for karte in karten:
|
||||
if not isinstance(karte, dict) or not str(karte.get("titel") or "").strip():
|
||||
continue
|
||||
titel = str(karte["titel"]).strip()
|
||||
# Den Stand entscheidet der Code, nicht das Modell: was schon gebaut war,
|
||||
# bleibt gebaut; was das Gespräch neu erfindet, ist bestellt und noch offen.
|
||||
# Das Modell sieht das Feld gar nicht — ein Feld weniger, das es vergessen kann.
|
||||
bestand = nach_titel.get(titel.lower())
|
||||
gebaut = {x["text"] for x in bestand["sektionen"]["funktionen"]
|
||||
if x["stand"] == parser.GEBAUT} if bestand else set()
|
||||
for f in karte.get("funktionen") or []:
|
||||
f["stand"] = parser.GEBAUT if str(f.get("titel") or "").strip() in gebaut \
|
||||
else parser.OFFEN
|
||||
text = roh(karte)
|
||||
neu = parser.zerlege(text)
|
||||
if bestand is None:
|
||||
# Neue Karte: es gibt keine Stelle, an der man ansetzen könnte —
|
||||
# deshalb ein eigener Vorschlag, der die ganze Datei mitbringt.
|
||||
raus.append({"feld": "neue_karte", "original": "", "vorschlag": titel,
|
||||
"roh": text, "ganzes_feld": True, "feature": "",
|
||||
"funktion": "", "regel": "Aus dem Gespräch",
|
||||
"problem": "Neue Karte"})
|
||||
continue
|
||||
grund = f"Aus dem Gespräch: {titel}"
|
||||
raus += _vergleiche(_felder(bestand["sektionen"]), _felder(neu),
|
||||
bestand["id"], grund)
|
||||
raus += _funktionsstellen(bestand["sektionen"], neu, bestand["id"], grund)
|
||||
return raus
|
||||
|
||||
|
||||
async def frage(projekt: str, text: str) -> dict:
|
||||
"""Eine Runde: Frage rein, Antwort und Vorschläge raus."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
raise ValueError("Nachricht ist leer")
|
||||
agents.pruefe_konfiguration()
|
||||
|
||||
bisher = verlauf(projekt)
|
||||
gespraech = "\n\n".join(f'{"Person" if e["rolle"] == "person" else "Du"}: {e["text"]}'
|
||||
for e in bisher) or "Noch nichts besprochen."
|
||||
# Beide Kataloge: der Chat schreibt Karten UND Funktionen. Vorher kannte er nur
|
||||
# die Kartenregeln und erfand Funktionstitel wie „Galerie im Modal" — ein
|
||||
# Bereich statt einer Handlung, und die Prüfung merkte es erst hinterher.
|
||||
system = SYSTEM.format(
|
||||
stil="\n\n".join(pruefung._stil_lesen(b) for b in pruefung.BEREICHE),
|
||||
karten=_kartenstand(projekt))
|
||||
nachricht = NACHRICHT.format(verlauf=gespraech, text=text)
|
||||
|
||||
# Ein Ausrutscher beim JSON kostet sonst die ganze Wortmeldung. Zweiter Versuch
|
||||
# mit etwas mehr Streuung; danach fliegt der Fehler nach oben und ist sichtbar.
|
||||
daten = None
|
||||
for versuch in range(3):
|
||||
antwort = await agents.frage(system, nachricht, TEMPERATUR + 0.1 * versuch)
|
||||
try:
|
||||
daten = pruefung._json_lesen(antwort)
|
||||
break
|
||||
except ValueError:
|
||||
if versuch == 2:
|
||||
raise ValueError("Antwort war nicht lesbar — bitte noch einmal senden")
|
||||
# Nachbessern statt blind wiederholen: das Modell sieht seinen eigenen
|
||||
# Fehlversuch und weiß, was daran fehlte.
|
||||
nachricht = (f"{nachricht}\n\nDein letzter Versuch war:\n"
|
||||
f"{antwort[:2000]}\n\n{NACHBESSERN}")
|
||||
satz = str(daten.get("antwort") or "").strip() or "Verstanden."
|
||||
probleme = vorschlaege(projekt, daten.get("karten") or [])
|
||||
|
||||
bisher += [{"rolle": "person", "text": text}, {"rolle": "ki", "text": satz}]
|
||||
_sichere(projekt, bisher)
|
||||
return {"antwort": satz, "probleme": probleme, "verlauf": verlauf(projekt)}
|
||||
@@ -27,6 +27,13 @@ MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "")
|
||||
MINIMAX_MODELL = os.environ.get("MINIMAX_MODELL", "")
|
||||
MINIMAX_URL = os.environ.get("MINIMAX_URL", "")
|
||||
|
||||
# Das Gespräch darf woanders laufen als die Prüfungen. Es ist ein einzelner Aufruf
|
||||
# und lebt von Sprachgefühl; die Prüfungen sind hundert Stimmen und leben von der
|
||||
# Temperaturstreuung. Nicht gesetzt heißt: alles über MiniMax.
|
||||
CHAT_API_KEY = os.environ.get("CHAT_API_KEY", "")
|
||||
CHAT_MODELL = os.environ.get("CHAT_MODELL", "")
|
||||
CHAT_URL = os.environ.get("CHAT_URL", "https://api.anthropic.com/v1/messages")
|
||||
|
||||
|
||||
def _zahl(name: str, standard: int) -> int:
|
||||
try:
|
||||
|
||||
@@ -6,12 +6,15 @@ import parser
|
||||
import projekte
|
||||
|
||||
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||
# Dateisysteme lassen 255 Zeichen je Name zu. Der Titel darf länger sein — er steht
|
||||
# in der Datei, nicht im Namen. Ohne Deckel warf ein langer Titel einen Serverfehler.
|
||||
NAMENSLAENGE = 80
|
||||
|
||||
|
||||
def _slug(titel: str) -> str:
|
||||
s = titel.lower().translate(_UMLAUTE)
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
|
||||
return s or "feature"
|
||||
return s[:NAMENSLAENGE].strip("-") or "feature"
|
||||
|
||||
|
||||
def _eintrag(datei) -> dict:
|
||||
@@ -26,8 +29,14 @@ def _datei(projekt: str, id: str):
|
||||
return datei
|
||||
|
||||
|
||||
def liste(projekt: str) -> list[dict]:
|
||||
dateien = projekte.wurzel(projekt).glob("*.md")
|
||||
def liste(projekt: str, nur_stand: bool = False) -> list[dict]:
|
||||
"""Die Karten. Standardmäßig die, an denen gerade gearbeitet wird.
|
||||
|
||||
`nur_stand` liest stattdessen den Projektordner — die Übersicht soll sagen,
|
||||
was das Projekt heute kann, nicht was jemand gerade plant.
|
||||
"""
|
||||
ordner = projekte.stand(projekt) if nur_stand else projekte.wurzel(projekt)
|
||||
dateien = ordner.glob("*.md")
|
||||
eintraege = [_eintrag(d) for d in sorted(dateien)]
|
||||
return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower())
|
||||
|
||||
@@ -38,17 +47,21 @@ def pruefbar(projekt: str) -> list[dict]:
|
||||
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"]
|
||||
if not titel:
|
||||
raise ValueError("Titel fehlt")
|
||||
slug = _slug(titel)
|
||||
def _frei(ordner, slug: str):
|
||||
"""Der erste freie Dateiname zum Slug; gleiche Namen bekommen eine Nummer."""
|
||||
datei = ordner / f"{slug}.md"
|
||||
n = 2
|
||||
while datei.exists():
|
||||
datei = ordner / f"{slug}-{n}.md"
|
||||
n += 1
|
||||
return datei
|
||||
|
||||
|
||||
def anlegen(projekt: str, roh: str) -> dict:
|
||||
titel = parser.zerlege(roh)["titel"]
|
||||
if not titel:
|
||||
raise ValueError("Titel fehlt")
|
||||
datei = _frei(projekte.wurzel(projekt), _slug(titel))
|
||||
datei.write_text(roh)
|
||||
return _eintrag(datei)
|
||||
|
||||
@@ -58,11 +71,7 @@ def _folge_titel(datei, titel: str):
|
||||
slug = _slug(titel)
|
||||
if re.fullmatch(re.escape(slug) + r"(-\d+)?", datei.stem):
|
||||
return datei # passt schon (inkl. Kollisions-Suffix)
|
||||
ziel = datei.parent / f"{slug}.md"
|
||||
n = 2
|
||||
while ziel.exists():
|
||||
ziel = datei.parent / f"{slug}-{n}.md"
|
||||
n += 1
|
||||
ziel = _frei(datei.parent, slug)
|
||||
datei.rename(ziel)
|
||||
return ziel
|
||||
|
||||
@@ -72,9 +81,35 @@ def speichern(projekt: str, id: str, roh: str) -> dict:
|
||||
titel = parser.zerlege(roh)["titel"]
|
||||
if not titel:
|
||||
raise ValueError("Titel fehlt")
|
||||
vorher = datei.read_text()
|
||||
datei.write_text(roh)
|
||||
return _eintrag(_folge_titel(datei, titel))
|
||||
neu = _folge_titel(datei, titel)
|
||||
# Ein neuer Titel benennt die Datei um. Der Rückschritt muss beides kennen:
|
||||
# die neue Datei fällt weg, die alte kommt mit ihrem alten Inhalt zurück.
|
||||
schritt = {datei.stem: vorher}
|
||||
if neu.stem != datei.stem:
|
||||
schritt[neu.stem] = None
|
||||
return _eintrag(neu)
|
||||
|
||||
|
||||
def loeschen(projekt: str, id: str):
|
||||
_datei(projekt, id).unlink()
|
||||
datei = _datei(projekt, id)
|
||||
titel = parser.zerlege(datei.read_text())["titel"] or id
|
||||
datei.unlink()
|
||||
|
||||
|
||||
def verschieben(projekt: str, id: str, ziel: str) -> dict:
|
||||
"""Ein Feature in ein anderes Projekt umhängen.
|
||||
|
||||
Kein Kopieren: die Datei wandert, damit es sie nur einmal gibt. Der Verlauf
|
||||
hält beide Seiten fest — sonst ließe sich der Schritt nicht zurücknehmen.
|
||||
"""
|
||||
if ziel == projekt:
|
||||
raise ValueError("Feature liegt schon in diesem Projekt")
|
||||
datei = _datei(projekt, id)
|
||||
roh = datei.read_text()
|
||||
titel = parser.zerlege(roh)["titel"] or id
|
||||
# Erst das Ziel auflösen: gibt es das Projekt nicht, ist noch nichts passiert
|
||||
neu = _frei(projekte.wurzel(ziel), _slug(titel))
|
||||
datei.rename(neu)
|
||||
return _eintrag(neu)
|
||||
|
||||
@@ -1,748 +0,0 @@
|
||||
"""Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen.
|
||||
|
||||
**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.
|
||||
|
||||
Ein Vorhandensein lässt sich zitieren, ein Nichtvorhandensein nicht. Darum fragt
|
||||
„Feature im Code" sechsmal unabhängig nach dem Beleg und meldet erst, wenn keine
|
||||
einzige Stimme eine Codezeile findet, die auch wirklich dasteht. Ein geprüfter Fund
|
||||
genügt zum Freispruch — sechs Fehlversuche sind die Anklage. Ein früherer Versuch
|
||||
ließ das Modell direkt urteilen und schlug dabei vor, gültige Inhalte zu löschen.
|
||||
|
||||
„Code im Feature" fragt umgekehrt je Stelle, ob eine bestehende Funktion sie
|
||||
abdeckt, und meldet ab fünf von sechs Nein-Stimmen. Einstimmigkeit war zu scharf:
|
||||
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
|
||||
# mit 429. Die ausgefallenen Stimmen sahen dann aus wie „nichts gefunden".
|
||||
_schranke = None
|
||||
|
||||
|
||||
def _drossel():
|
||||
global _schranke
|
||||
if _schranke is None:
|
||||
_schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
|
||||
return _schranke
|
||||
|
||||
|
||||
async def _frage(system: str, nachricht: str, temperatur: float) -> str:
|
||||
async with _drossel():
|
||||
return await agents.frage(system, nachricht, temperatur)
|
||||
|
||||
|
||||
async def _urteil(system: str, nachricht: str, temperatur: float):
|
||||
"""Eine Stimme als JSON. Unlesbares wird einmal neu gefragt, dann aufgegeben —
|
||||
bei hunderten Anfragen je Lauf ist ein Ausrutscher normal, ein Abbruch nicht."""
|
||||
for versuch in range(3):
|
||||
try:
|
||||
return _json(await _frage(system, nachricht, temperatur + 0.1 * versuch))
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# Achtung: diese Texte gehen unformatiert als System-Prompt raus. Doppelte Klammern
|
||||
# wären hier keine Escapes, sondern landeten wörtlich in der Antwort.
|
||||
SUCHE_SYSTEM = """Du suchst im Quelltext den Code zu einer beschriebenen Funktion.
|
||||
|
||||
Antworte ausschließlich mit JSON:
|
||||
{"gefunden": true, "datei": "...", "beleg": "..."}
|
||||
oder
|
||||
{"gefunden": false, "grund": "..."}
|
||||
|
||||
- datei: der Pfad genau so, wie er über der Datei steht
|
||||
- beleg: eine Zeile aus dieser Datei, Zeichen für Zeichen abgeschrieben
|
||||
- grund: höchstens 8 Wörter, wenn es den Code nicht gibt
|
||||
|
||||
Suche gründlich und im ganzen Quelltext. Es zählt nicht nur, was einen Knopf hat:
|
||||
Hintergrundlogik, Parser, Dateiverwaltung, Prüfregeln und Anzeigeverhalten sind
|
||||
genauso Code. Ein erfundener Beleg ist wertlos — er wird nachgeschlagen."""
|
||||
|
||||
SUCHE_NACHRICHT = """{quelle}
|
||||
|
||||
Ende des Quelltextes.
|
||||
|
||||
Eine Karte beschreibt diese Funktion:
|
||||
|
||||
{funktion}
|
||||
|
||||
Finde die Stelle im Quelltext, die das umsetzt. Antworte ausschließlich mit dem
|
||||
JSON-Objekt."""
|
||||
|
||||
ABDECKUNG_SYSTEM = """Du entscheidest, ob eine Stelle im Programm schon von einer
|
||||
beschriebenen Funktion abgedeckt ist. Antworte ausschließlich mit JSON."""
|
||||
|
||||
ABDECKUNG = """Diese Stelle im Programm lässt sich auslösen:
|
||||
|
||||
{art} in {datei}, Zeile {zeile}
|
||||
|
||||
```
|
||||
{umfeld}
|
||||
```
|
||||
{rumpf}
|
||||
Die Karten des Projekts beschreiben bisher das hier:
|
||||
|
||||
{funktionen}
|
||||
|
||||
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, 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": "..."}}
|
||||
oder
|
||||
{{"abgedeckt": false, "karte": "...", "titel": "..."}}
|
||||
|
||||
- funktion: der bestehende Funktionstitel, wörtlich, wenn abgedeckt
|
||||
- karte: der Titel der Karte, in die die Fähigkeit thematisch gehört
|
||||
- titel: wie die neue Funktion heißen soll — Objekt und Verb im Infinitiv,
|
||||
höchstens 5 Wörter
|
||||
|
||||
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.
|
||||
SUCHE_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
|
||||
# So viele Stimmen müssen überhaupt geantwortet haben, bevor angeklagt wird
|
||||
ANKLAGE_AB = 4
|
||||
# Sechs Stimmen, fünf müssen „fehlt" sagen. Eindeutige Stellen antworten 6:0 oder
|
||||
# 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:
|
||||
return re.sub(r"[^a-zä-ü0-9 ]", "", (text or "").lower()).strip()
|
||||
|
||||
|
||||
def _aehnlich(a: str, b: str) -> float:
|
||||
return difflib.SequenceMatcher(None, _normal(a), _normal(b)).ratio()
|
||||
|
||||
|
||||
def _json(antwort: str) -> dict:
|
||||
import pruefung # zur Laufzeit, sonst Ringschluss
|
||||
|
||||
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.
|
||||
|
||||
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"):
|
||||
if f[feld]:
|
||||
eintraege = [z.strip("- ").strip() for z in f[feld].splitlines() if z.strip()]
|
||||
zeilen.append(f'{feld.capitalize()}: {"; ".join(eintraege)}')
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
if zaehl:
|
||||
zaehl()
|
||||
gruende = []
|
||||
brauchbar = behauptet = 0
|
||||
beleg = None
|
||||
for u in proben:
|
||||
if not isinstance(u, dict):
|
||||
continue
|
||||
brauchbar += 1
|
||||
if u.get("gefunden"):
|
||||
# Auch ein misslungenes Zitat ist ein Widerspruch. Ein Modell
|
||||
# verschreibt sich beim Abschreiben von Backticks — daraus eine
|
||||
# Löschempfehlung zu machen, wäre grob falsch.
|
||||
behauptet += 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())
|
||||
|
||||
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 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"],
|
||||
"art": "ohne_code", "grund": grund}
|
||||
|
||||
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:
|
||||
"""Ein Vorschlag je Karte: die betroffenen Funktionen aus der Liste nehmen."""
|
||||
import pruefung
|
||||
|
||||
raus = []
|
||||
for f in features:
|
||||
ist = [x["text"] for x in f["sektionen"]["funktionen"]]
|
||||
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 = _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):
|
||||
p["feature"] = f["id"]
|
||||
p["funktion"] = ""
|
||||
raus.append(p)
|
||||
return raus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Code im Feature
|
||||
|
||||
def _funktionsliste(features: list) -> str:
|
||||
"""Die Karten mit allem, was sie sagen.
|
||||
|
||||
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:
|
||||
s = f["sektionen"]
|
||||
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"]:
|
||||
block.append(f'Ziel: {x["ziel"]}')
|
||||
for feld in ("regeln", "testen"):
|
||||
if x[feld]:
|
||||
zeilen = ", ".join(z.strip("- ").strip()
|
||||
for z in x[feld].splitlines() if z.strip())
|
||||
block.append(f'{feld.capitalize()}: {zeilen}')
|
||||
teile.append("\n".join(block))
|
||||
return "\n\n".join(teile) or "—"
|
||||
|
||||
|
||||
async def _abdeckung(punkt: dict, liste: str) -> list:
|
||||
rumpf = f'\nDahinter steht:\n\n```\n{punkt["rumpf"]}\n```\n' if punkt["rumpf"] else ""
|
||||
nachricht = ABDECKUNG.format(funktionen=liste, art=punkt["art"],
|
||||
datei=punkt["datei"], zeile=punkt["zeile"],
|
||||
umfeld=punkt["umfeld"], rumpf=rumpf)
|
||||
proben = await asyncio.gather(*(
|
||||
_urteil(ABDECKUNG_SYSTEM, nachricht, t) for t in URTEIL_TEMPERATUREN
|
||||
), return_exceptions=True)
|
||||
return [p for p in proben if isinstance(p, dict)]
|
||||
|
||||
|
||||
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) >= schwelle else None
|
||||
|
||||
|
||||
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()
|
||||
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")
|
||||
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
|
||||
|
||||
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
|
||||
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 = await _sammle([eine(p) for p in punkte], "Codestellen", melde)
|
||||
# Zwei Stellen können auf dieselbe fehlende Funktion zeigen
|
||||
raus = []
|
||||
for x in gefunden:
|
||||
gleich = next((y for y in raus if y["feature"] == x["feature"]
|
||||
and _aehnlich(y["titel"], x["titel"]) > GLEICH), None)
|
||||
if gleich:
|
||||
gleich["beleg"] += f', {x["beleg"]}'
|
||||
else:
|
||||
raus.append(dict(x))
|
||||
return raus
|
||||
|
||||
|
||||
def probleme_code(features: list, fehlend: list) -> list:
|
||||
"""Ein Vorschlag je Karte: die fehlenden Funktionen anhängen."""
|
||||
import pruefung
|
||||
|
||||
raus = []
|
||||
for f in features:
|
||||
ist = [x["text"] for x in f["sektionen"]["funktionen"]]
|
||||
dazu = [x for x in fehlend if x["feature"] == f["id"]]
|
||||
if not dazu:
|
||||
continue
|
||||
# Bestehende Reihenfolge behalten, Neues hinten anhängen — sortieren würde
|
||||
# aus einer Ergänzung einen Umbau machen. Angezeigt wird ohnehin alphabetisch.
|
||||
neu = list(dict.fromkeys(ist + [x["titel"] for x in dazu]))
|
||||
grund = (f"{len(dazu)} Stelle ohne Funktion" if len(dazu) == 1
|
||||
else f"{len(dazu)} Stellen ohne Funktion")
|
||||
beleg = " · ".join(x["beleg"] for x in dazu)
|
||||
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
|
||||
grund, beleg):
|
||||
p["feature"] = f["id"]
|
||||
p["funktion"] = ""
|
||||
raus.append(p)
|
||||
return raus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Einstiege
|
||||
|
||||
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(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")
|
||||
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)
|
||||
BIN
backend/k1-1024.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
backend/k1-1280.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
backend/k1-1600.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
backend/k1-390.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
backend/k1-640.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
backend/k1-820.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
backend/komfort.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -9,8 +9,7 @@ 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
|
||||
- eine Fehlanzeige, solange sich am Quelltext nichts geändert hat
|
||||
|
||||
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
|
||||
@@ -30,11 +29,10 @@ DATEI = ".pruefung.json"
|
||||
|
||||
# Ein Fach je Frage. Getrennt, weil sie unterschiedlich ungültig werden.
|
||||
FAECHER = (
|
||||
"stil", # Prüfstelle -> ihr Stilurteil (Prüfung 1)
|
||||
"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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
BIN
backend/offen.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
@@ -19,6 +19,14 @@ TRENNER = re.compile(r"^-{3,}\s*$")
|
||||
VISION = "vision"
|
||||
ARTEN = ("kern", "optional", VISION)
|
||||
|
||||
# Eine Funktion ist entweder geliefert oder noch offen. „offen" heißt: die Karte
|
||||
# beschreibt sie schon, den Code gibt es noch nicht — bestellt, nicht gebaut.
|
||||
# Ohne diese Unterscheidung meldet die Vorwärtsprüfung jede geplante Funktion als
|
||||
# Fehler und will sie streichen.
|
||||
OFFEN = "offen"
|
||||
GEBAUT = "gebaut"
|
||||
STAENDE = (GEBAUT, OFFEN)
|
||||
|
||||
|
||||
def _art(bloecke: list) -> str:
|
||||
wert = bloecke[0].strip().lower() if bloecke else ""
|
||||
@@ -61,7 +69,11 @@ def zerlege(roh: str) -> dict:
|
||||
# vor der ersten Funktion: Karten-Ziel, danach: Ziel der Funktion
|
||||
(funktionen[-1]["ziel"] if funktionen else ziel).append(text)
|
||||
elif schluessel == "funktion":
|
||||
funktionen.append({"text": text, "ziel": [], "regeln": [], "testen": []})
|
||||
funktionen.append({"text": text, "ziel": [], "regeln": [], "testen": [],
|
||||
"stand": GEBAUT})
|
||||
elif schluessel == "stand" and funktionen:
|
||||
wert = text.strip().lower()
|
||||
funktionen[-1]["stand"] = wert if wert in STAENDE else GEBAUT
|
||||
elif schluessel in ("regel", "regeln"):
|
||||
# vor der ersten Funktion: Karten-Regeln, danach: Regeln der Funktion
|
||||
(funktionen[-1]["regeln"] if funktionen else regeln).append(text)
|
||||
|
||||
163
backend/planung.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Eine Planung: der Kartensatz, wie er nach der nächsten Umsetzung aussehen soll.
|
||||
|
||||
Die Übersicht beantwortet „was kann mein Projekt". Eine geplante Funktion gehört
|
||||
dort nicht hin — sie wäre gelogen. Deshalb liegt die Planung in einer eigenen
|
||||
Kopie und der Projektordner bleibt unberührt, bis sie umgesetzt wird.
|
||||
|
||||
Kopiert wird der ganze Ordner, nicht nur die Karten. Gespräch und
|
||||
Urteilsgedächtnis gehören zur Planung: was während des Planens besprochen und
|
||||
beurteilt wurde, gilt für den geplanten Stand, nicht für den heutigen.
|
||||
|
||||
Solange eine Planung läuft, arbeitet `projekte.wurzel()` in der Kopie. Keine
|
||||
Schreibfunktion muss davon wissen.
|
||||
|
||||
Es gibt höchstens eine Planung je Projekt. Sie endet auf zwei Arten: umgesetzt —
|
||||
dann ersetzt die Kopie den Projektstand — oder abgebrochen, dann verschwindet sie
|
||||
spurlos.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
|
||||
import parser
|
||||
import projekte
|
||||
|
||||
# Was beim Vergleich zählt: der sichtbare Inhalt einer Funktion, nicht ihre Datei.
|
||||
# Wird eine Karte umbenannt, wandert ihre Datei — der Vergleich hinge sonst an
|
||||
# einem Dateinamen statt an dem, was dasteht.
|
||||
FELDER = ("ziel", "regeln", "testen", "stand")
|
||||
|
||||
|
||||
def laeuft(projekt: str) -> bool:
|
||||
return (projekte.stand(projekt) / projekte.PLANUNG).is_dir()
|
||||
|
||||
|
||||
def _kopie(projekt: str):
|
||||
return projekte.stand(projekt) / projekte.PLANUNG
|
||||
|
||||
|
||||
def starte(projekt: str):
|
||||
"""Arbeitskopie anlegen. Ab hier zeigt `wurzel()` dorthin."""
|
||||
ziel = _kopie(projekt)
|
||||
if ziel.is_dir():
|
||||
raise ValueError("Für dieses Projekt läuft schon eine Planung")
|
||||
shutil.copytree(projekte.stand(projekt), ziel,
|
||||
ignore=shutil.ignore_patterns(projekte.PLANUNG))
|
||||
|
||||
|
||||
def brich_ab(projekt: str):
|
||||
"""Planung verwerfen. Der Projektstand hat sie nie gesehen."""
|
||||
shutil.rmtree(_kopie(projekt), ignore_errors=True)
|
||||
|
||||
|
||||
def setze_um(projekt: str):
|
||||
"""Die Kopie wird der neue Projektstand.
|
||||
|
||||
Erst die Kopie herausheben, dann den alten Stand räumen, dann die Kopie an
|
||||
seine Stelle. Ein Absturz mitten im Räumen ließe sonst ein Projekt ohne Karten
|
||||
zurück — und die Planung gleich mit.
|
||||
"""
|
||||
kopie = _kopie(projekt)
|
||||
if not kopie.is_dir():
|
||||
raise ValueError("Für dieses Projekt läuft keine Planung")
|
||||
ordner = projekte.stand(projekt)
|
||||
daneben = ordner.parent / f"{ordner.name}.neu"
|
||||
shutil.rmtree(daneben, ignore_errors=True)
|
||||
kopie.rename(daneben)
|
||||
for eintrag in ordner.iterdir():
|
||||
shutil.rmtree(eintrag) if eintrag.is_dir() else eintrag.unlink()
|
||||
for eintrag in daneben.iterdir():
|
||||
eintrag.rename(ordner / eintrag.name)
|
||||
daneben.rmdir()
|
||||
|
||||
|
||||
def _karten(ordner) -> dict:
|
||||
"""titel -> Sektionen. Verglichen wird über den Titel, nicht über die Datei."""
|
||||
raus = {}
|
||||
for datei in sorted(ordner.glob("*.md")):
|
||||
s = parser.zerlege(datei.read_text())
|
||||
raus[s["titel"]] = s
|
||||
return raus
|
||||
|
||||
|
||||
def _funktionstext(f: dict) -> str:
|
||||
return "\n".join(str(f.get(feld) or "") for feld in FELDER)
|
||||
|
||||
|
||||
def _funktionen(karte: dict) -> dict:
|
||||
return {f["text"]: _funktionstext(f) for f in karte["funktionen"]}
|
||||
|
||||
|
||||
def _kartentext(karte: dict) -> str:
|
||||
return "\n".join((karte.get("art") or "", karte.get("ziel") or "",
|
||||
karte.get("regeln") or ""))
|
||||
|
||||
|
||||
def aenderungen(projekt: str) -> dict:
|
||||
"""Was die Planung am Projektstand ändert — je Karte und je Funktion.
|
||||
|
||||
Ohne laufende Planung ist das leer. Die Übersicht zeigt dann nichts an, und
|
||||
die Karten sehen aus wie immer.
|
||||
"""
|
||||
if not laeuft(projekt):
|
||||
return {}
|
||||
alt = _karten(projekte.stand(projekt))
|
||||
neu = _karten(_kopie(projekt))
|
||||
|
||||
raus = {}
|
||||
for titel in set(alt) | set(neu):
|
||||
a, b = alt.get(titel), neu.get(titel)
|
||||
if a is None:
|
||||
raus[titel] = {"art": "neu", "funktionen": {
|
||||
f["text"]: "neu" for f in b["funktionen"]}}
|
||||
continue
|
||||
if b is None:
|
||||
raus[titel] = {"art": "weg", "funktionen": {
|
||||
f["text"]: "weg" for f in a["funktionen"]}}
|
||||
continue
|
||||
|
||||
fa, fb = _funktionen(a), _funktionen(b)
|
||||
funktionen = {}
|
||||
for name in set(fa) | set(fb):
|
||||
if name not in fa:
|
||||
funktionen[name] = "neu"
|
||||
elif name not in fb:
|
||||
funktionen[name] = "weg"
|
||||
elif fa[name] != fb[name]:
|
||||
funktionen[name] = "geaendert"
|
||||
kopf = _kartentext(a) != _kartentext(b)
|
||||
if funktionen or kopf:
|
||||
raus[titel] = {"art": "geaendert", "funktionen": funktionen,
|
||||
"kopf": kopf}
|
||||
return raus
|
||||
|
||||
|
||||
def _datei_zu(ordner, titel: str):
|
||||
for datei in ordner.glob("*.md"):
|
||||
if parser.zerlege(datei.read_text())["titel"] == titel:
|
||||
return datei
|
||||
return None
|
||||
|
||||
|
||||
def zuruecksetzen(projekt: str, titel: str):
|
||||
"""Eine Karte auf den Projektstand zurückholen.
|
||||
|
||||
Drei Fälle: neu dazugekommen (löschen), weggefallen (zurückholen), geändert
|
||||
(überschreiben). Der Rest der Planung bleibt stehen.
|
||||
"""
|
||||
if not laeuft(projekt):
|
||||
raise ValueError("Für dieses Projekt läuft keine Planung")
|
||||
ordner, kopie = projekte.stand(projekt), _kopie(projekt)
|
||||
urspruenglich = _datei_zu(ordner, titel)
|
||||
geplant = _datei_zu(kopie, titel)
|
||||
|
||||
if urspruenglich is None and geplant is None:
|
||||
raise FileNotFoundError(f"Karte „{titel}“ nicht gefunden")
|
||||
if urspruenglich is None:
|
||||
geplant.unlink()
|
||||
return
|
||||
ziel = geplant or (kopie / urspruenglich.name)
|
||||
ziel.write_text(urspruenglich.read_text())
|
||||
|
||||
|
||||
def stand(projekt: str) -> dict:
|
||||
return {"laeuft": laeuft(projekt), "aenderungen": aenderungen(projekt)}
|
||||
@@ -4,6 +4,10 @@ import shutil
|
||||
|
||||
import config
|
||||
|
||||
# Die Arbeitskopie einer laufenden Planung. Liegt im Projektordner und beginnt mit
|
||||
# einem Punkt — damit taucht sie im Listing nicht als eigenes Projekt auf.
|
||||
PLANUNG = ".planung"
|
||||
|
||||
|
||||
def _pruefe_name(name: str):
|
||||
if not name or not name.strip():
|
||||
@@ -51,9 +55,24 @@ def loeschen(name: str):
|
||||
shutil.rmtree(ordner)
|
||||
|
||||
|
||||
def wurzel(name: str):
|
||||
"""Projektordner für Feature-Zugriffe; FileNotFoundError bei unbekanntem Projekt."""
|
||||
def stand(name: str):
|
||||
"""Der Projektordner selbst — was das Projekt heute kann.
|
||||
|
||||
Nur wer den umgesetzten Stand meint, ruft das. Alles andere nimmt `wurzel()`.
|
||||
"""
|
||||
ordner = _ordner(name)
|
||||
if not ordner.is_dir():
|
||||
raise FileNotFoundError(f"Projekt „{name}“ nicht gefunden")
|
||||
return ordner
|
||||
|
||||
|
||||
def wurzel(name: str):
|
||||
"""Der Ordner, in dem gerade gearbeitet wird.
|
||||
|
||||
Ohne offene Planung ist das der Projektordner. Läuft eine Planung, ist es ihre
|
||||
Arbeitskopie — und zwar für alles: Karten, Gespräch, Gedächtnis. Ein
|
||||
Schalter statt einer Fallunterscheidung in jeder Schreibfunktion.
|
||||
"""
|
||||
ordner = stand(name)
|
||||
kopie = ordner / PLANUNG
|
||||
return kopie if kopie.is_dir() else ordner
|
||||
|
||||
@@ -18,10 +18,18 @@ from collections import Counter
|
||||
import agents
|
||||
import config
|
||||
import features
|
||||
import inhalt as inhaltspruefung
|
||||
import quellcode
|
||||
import ledger
|
||||
import struktur
|
||||
|
||||
class LaeuftBereits(ValueError):
|
||||
"""Eigener Typ, weil 409 genau eine Bedeutung haben muss.
|
||||
|
||||
Vorher war jeder Fehlstart ein 409 — auch ein fehlendes Quellverzeichnis. Das
|
||||
Frontend liest 409 als „läuft schon" und hängt sich an einen Lauf, den es nicht
|
||||
gibt. Ergebnis: „Keine Probleme gefunden" statt der Fehlermeldung.
|
||||
"""
|
||||
|
||||
|
||||
# projektname -> {"laeuft": bool, "fertig": int, "gesamt": int, "fehler": int,
|
||||
# "probleme": [...], "meldung": str}
|
||||
_laeufe = {}
|
||||
@@ -445,7 +453,17 @@ async def _neue_felder(system: str, aufgabe: dict, offen: list, erlaubt: tuple,
|
||||
return vorschlaege
|
||||
|
||||
|
||||
async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
|
||||
def _stilschluessel(aufgabe: dict) -> str:
|
||||
return ledger.schluessel(aufgabe["feature"], aufgabe["funktion"])
|
||||
|
||||
|
||||
def _stilabdruck(aufgabe: dict, system: str) -> str:
|
||||
"""Woran das Urteil hängt: der geprüfte Ausschnitt, der Katalog, die Frage.
|
||||
Ändert sich eines davon, ist das gemerkte Urteil von gestern."""
|
||||
return ledger.abdruck(system, inhalt(aufgabe))
|
||||
|
||||
|
||||
async def _pruefe(stil: dict, aufgabe: dict, stand: dict = None) -> list[dict]:
|
||||
"""Erst messen, dann umschreiben lassen, dann den Unterschied ausrechnen."""
|
||||
bereich = BEREICHE[aufgabe["bereich"]]
|
||||
system = SYSTEM.format(
|
||||
@@ -455,6 +473,15 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
|
||||
hinweis=bereich["hinweis"],
|
||||
)
|
||||
offen = struktur.befunde(aufgabe["bereich"], aufgabe["felder"])
|
||||
|
||||
# Gemessene Verstöße stehen fest — die dürfen nie aus dem Gedächtnis kommen.
|
||||
# Sonst bliebe eine neu hinzugekommene Messregel an alten Karten wirkungslos.
|
||||
schluessel, wie_gehabt = _stilschluessel(aufgabe), _stilabdruck(aufgabe, system)
|
||||
if stand is not None and not offen:
|
||||
gemerkt = stand["stil"].get(schluessel)
|
||||
if gemerkt and gemerkt["abdruck"] == wie_gehabt:
|
||||
return [dict(p) for p in gemerkt["probleme"]]
|
||||
|
||||
vorschlaege = await _neue_felder(system, aufgabe, offen, bereich["felder"],
|
||||
katalogzeilen(stil[aufgabe["bereich"]]))
|
||||
|
||||
@@ -466,83 +493,33 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
|
||||
for p in probleme:
|
||||
p["feature"] = aufgabe["feature"]
|
||||
p["funktion"] = aufgabe["funktion"]
|
||||
if stand is not None:
|
||||
stand["stil"][schluessel] = {"abdruck": wie_gehabt, "probleme": probleme}
|
||||
return probleme
|
||||
|
||||
|
||||
async def _stil_aufgaben(projekt: str):
|
||||
"""Prüfstellen der Stilprüfung samt der Kataloge, die sie brauchen."""
|
||||
"""Prüfstellen der Stilprüfung samt der Kataloge, die sie brauchen.
|
||||
|
||||
Auch hier ein Gedächtnis: 176 Anfragen je Lauf brachten den Anbieter zum
|
||||
Drosseln, und die ausgefallenen Stellen meldeten dann nichts. Unveränderter
|
||||
Ausschnitt bei unverändertem Katalog heißt: das Urteil von vorhin gilt noch.
|
||||
"""
|
||||
stil = {b: _stil_lesen(b) for b in BEREICHE}
|
||||
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.pruefbar(projekt)
|
||||
quelle = quellcode.dateien()
|
||||
if not quelle:
|
||||
raise ValueError("Kein Quelltext gefunden")
|
||||
return alle, quelle
|
||||
|
||||
|
||||
async def _feature_aufgaben(projekt: str):
|
||||
"""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.
|
||||
Der Zähler läuft trotzdem je Funktion mit.
|
||||
"""
|
||||
alle, quelle = _quelle(projekt)
|
||||
text = quellcode.als_text(quelle)
|
||||
anzahl = sum(len(f["sektionen"]["funktionen"]) for f in alle)
|
||||
|
||||
async def abgleich(_, zaehl):
|
||||
return await inhaltspruefung.pruefe_feature_im_code(
|
||||
projekt, alle, quelle, text, zaehl, _melder(projekt))
|
||||
|
||||
return _platzhalter(anzahl), abgleich
|
||||
|
||||
|
||||
async def _code_aufgaben(projekt: str):
|
||||
"""Ein Lauf über alle Bedienstellen — Vollständigkeit ist eine Eigenschaft der
|
||||
Menge, nicht der einzelnen Karte."""
|
||||
alle, quelle = _quelle(projekt)
|
||||
punkte = quellcode.interaktionen(quelle)
|
||||
|
||||
async def abgleich(_, 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)
|
||||
stand = ledger.lade(projekt)
|
||||
stand["stil"] = ledger.behalte(stand["stil"], {_stilschluessel(a) for a in liste})
|
||||
return (liste,
|
||||
lambda a, _zaehl: _pruefe(stil, a, stand),
|
||||
lambda: ledger.sichere(projekt, stand))
|
||||
|
||||
|
||||
ARTEN = {
|
||||
"stil": _stil_aufgaben,
|
||||
"feature": _feature_aufgaben, # Feature im Code: was steht ohne Code da?
|
||||
"code": _code_aufgaben, # Code im Feature: was fehlt auf den Karten?
|
||||
}
|
||||
|
||||
|
||||
async def _lauf(projekt: str, liste: list[dict], arbeite):
|
||||
async def _lauf(projekt: str, liste: list[dict], arbeite, abschluss=None):
|
||||
lauf = _laeufe[projekt]
|
||||
schranke = asyncio.Semaphore(config.PRUEFUNG_PARALLEL)
|
||||
|
||||
@@ -570,6 +547,9 @@ async def _lauf(projekt: str, liste: list[dict], arbeite):
|
||||
try:
|
||||
await asyncio.gather(*(eine(a) for a in liste))
|
||||
finally:
|
||||
# Auch ein abgebrochener Lauf hat schon Urteile gefällt; die sind bezahlt
|
||||
if abschluss:
|
||||
abschluss()
|
||||
lauf["laeuft"] = False
|
||||
|
||||
|
||||
@@ -578,10 +558,10 @@ async def starte(projekt: str, art: str = "stil") -> dict:
|
||||
if art not in ARTEN:
|
||||
raise ValueError(f"Unbekannte Prüfart „{art}“")
|
||||
if status(projekt)["laeuft"]:
|
||||
raise ValueError("Prüfung läuft bereits")
|
||||
raise LaeuftBereits("Prüfung läuft bereits")
|
||||
agents.pruefe_konfiguration() # früher Stopp statt halbem Lauf
|
||||
# Vorbereiten vor dem Task: ein Fehler darin wäre sonst unsichtbar
|
||||
liste, arbeite = await ARTEN[art](projekt)
|
||||
liste, arbeite, *rest = await ARTEN[art](projekt)
|
||||
_laeufe[projekt] = {
|
||||
"laeuft": True,
|
||||
"art": art,
|
||||
@@ -591,7 +571,7 @@ async def starte(projekt: str, art: str = "stil") -> dict:
|
||||
"probleme": [],
|
||||
"meldung": "",
|
||||
}
|
||||
aufgabe = asyncio.create_task(_lauf(projekt, liste, arbeite))
|
||||
aufgabe = asyncio.create_task(_lauf(projekt, liste, arbeite, *rest))
|
||||
_aufgaben.add(aufgabe)
|
||||
aufgabe.add_done_callback(_aufgaben.discard)
|
||||
return _laeufe[projekt]
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Der Quelltext als Prüfgrundlage.
|
||||
|
||||
Die Inhaltsprüfung vergleicht Karten mit dem, was das Programm wirklich kann.
|
||||
Damit das nicht auf Behauptungen hinausläuft, muss jede Aussage über den Code
|
||||
eine wörtlich zitierte Zeile mitbringen — `belegt()` prüft sie nach.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import config
|
||||
|
||||
# Was zum lauffähigen Programm gehört. Tests beschreiben keine Nutzerfunktionen.
|
||||
MUSTER = (
|
||||
"backend/*.py",
|
||||
"frontend/index.html",
|
||||
"frontend/src/*.js",
|
||||
"frontend/src/**/*.js",
|
||||
"frontend/src/**/*.vue",
|
||||
)
|
||||
AUSGENOMMEN = ("tests/", "/tests/", "node_modules/", "dist/")
|
||||
|
||||
|
||||
def _passt(pfad: str) -> bool:
|
||||
return not any(a in pfad for a in AUSGENOMMEN)
|
||||
|
||||
|
||||
def dateien() -> dict:
|
||||
"""Pfad relativ zur Projektwurzel -> Inhalt, in fester Reihenfolge."""
|
||||
raus = {}
|
||||
for muster in MUSTER:
|
||||
for datei in sorted(config.PROJECT_ROOT.glob(muster)):
|
||||
pfad = str(datei.relative_to(config.PROJECT_ROOT))
|
||||
if datei.is_file() and _passt(pfad):
|
||||
raus[pfad] = datei.read_text()
|
||||
return dict(sorted(raus.items()))
|
||||
|
||||
|
||||
def als_text(quelle: dict = None) -> str:
|
||||
"""Alle Dateien am Stück, jede mit Überschrift — so kann das Modell zitieren."""
|
||||
quelle = dateien() if quelle is None else quelle
|
||||
return "\n\n".join(f"### {pfad}\n{text.rstrip()}" for pfad, text in quelle.items())
|
||||
|
||||
|
||||
def _normal(zeile: str) -> str:
|
||||
return re.sub(r"\s+", " ", zeile).strip()
|
||||
|
||||
|
||||
def belegt(quelle: dict, pfad: str, schnipsel: str) -> bool:
|
||||
"""Steht der Schnipsel wörtlich in der Datei?
|
||||
|
||||
Verglichen wird ohne Rücksicht auf Einrückung — Modelle geben sie ungenau
|
||||
wieder. Alles andere muss stimmen, sonst ist der Beleg wertlos.
|
||||
"""
|
||||
text = quelle.get(pfad)
|
||||
if text is None or not schnipsel.strip():
|
||||
return False
|
||||
gesucht = [_normal(z) for z in schnipsel.splitlines() if z.strip()]
|
||||
if not gesucht:
|
||||
return False
|
||||
vorhanden = [_normal(z) for z in text.splitlines()]
|
||||
# Zusammenhängend: eine mehrzeilige Angabe muss auch mehrzeilig dastehen
|
||||
for i in range(len(vorhanden) - len(gesucht) + 1):
|
||||
if vorhanden[i:i + len(gesucht)] == gesucht:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Die Stellen, an denen ein Mensch etwas auslöst. Vorher hat ein Modell die
|
||||
# Fähigkeiten frei aufgezählt — jede Runde andere, jede Runde anders benannt.
|
||||
# Hier steht die Liste fest, weil sie aus dem Code fällt.
|
||||
_EREIGNIS = re.compile(r'@(click|auxclick|contextmenu|keyup|keydown|blur|submit|change)'
|
||||
r'[\w.]*\s*=\s*"([^"]*)"')
|
||||
_MENUEPUNKT = re.compile(r"""\{\s*text:\s*['"](.+?)['"]\s*,\s*aktion:""")
|
||||
_ROUTE = re.compile(r'@router\.(get|post|put|delete)\("([^"]+)"\)')
|
||||
_LAUSCHER = re.compile(r'(window|document)\.addEventListener\(\s*[\'"](\w+)[\'"]')
|
||||
_NAME = re.compile(r"[A-Za-zÄÖÜäöü_$][\w$]*")
|
||||
|
||||
# Rein technische Aufhänger: Speichern beim Verlassen, Abbrechen mit Escape.
|
||||
# Sie tragen keine eigene Fähigkeit, sondern gehören zur Bedienung eines Feldes.
|
||||
_UNINTERESSANT = {"fertig", "abbrechen", "blur", "waehle"}
|
||||
# emit('auswaehlen') ist eine Fähigkeit, emit('abbrechen') nicht — es zählt das
|
||||
# Ereignis, nicht der Aufruf. Vorher fiel mit „emit" auch das Auswählen weg.
|
||||
_EMIT = re.compile(r"""emit\(\s*['"](\w+)['"]""")
|
||||
_ZUWEISUNG = re.compile(r"^\s*[\w.]+\s*=[^=]")
|
||||
|
||||
|
||||
def _umfeld(zeilen: list, nr: int, rand: int = 5) -> str:
|
||||
von, bis = max(0, nr - rand - 1), min(len(zeilen), nr + rand)
|
||||
return "\n".join(zeilen[von:bis]).strip()
|
||||
|
||||
|
||||
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:
|
||||
"""Die Umsetzung hinter einem Namen.
|
||||
|
||||
Das Markup allein sagt nur, dass ein Knopf etwas aufruft. Was er tut, steht im
|
||||
Rumpf — ohne den musste der Prüfer raten und urteilte jede Runde anders.
|
||||
"""
|
||||
anfang = re.compile(rf"^\s*(async\s+)?function\s+{re.escape(name)}\s*\(|"
|
||||
rf"^\s*const\s+{re.escape(name)}\s*=")
|
||||
for i, zeile in enumerate(zeilen):
|
||||
if not anfang.match(zeile):
|
||||
continue
|
||||
block = [zeile]
|
||||
for weiter in zeilen[i + 1:i + hoechstens]:
|
||||
block.append(weiter)
|
||||
if weiter.startswith("}"):
|
||||
break
|
||||
return "\n".join(block)
|
||||
return ""
|
||||
|
||||
|
||||
def interaktionen(quelle: dict = None) -> list[dict]:
|
||||
"""Auslösbare Stellen im Code, eine je Bedienhandlung.
|
||||
|
||||
Zusammengefasst nach dem aufgerufenen Namen: `@click="starte('ziel')"` steht
|
||||
zwölfmal im Markup, ist aber eine Fähigkeit.
|
||||
|
||||
Dazu Fenster-Lauscher: ein Tastaturgriff ist eine Fähigkeit, auch ohne Knopf.
|
||||
|
||||
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()
|
||||
|
||||
def merke(schluessel, art, pfad, nr, zeilen, beleg):
|
||||
if schluessel in gesehen:
|
||||
return None
|
||||
gesehen.add(schluessel)
|
||||
punkt = {"schluessel": schluessel, "art": art, "datei": pfad, "zeile": nr,
|
||||
"beleg": beleg.strip(), "umfeld": _umfeld(zeilen, nr), "rumpf": ""}
|
||||
raus.append(punkt)
|
||||
return punkt
|
||||
|
||||
for pfad, text in quelle.items():
|
||||
zeilen = text.splitlines()
|
||||
for nr, zeile in enumerate(zeilen, 1):
|
||||
for m in _MENUEPUNKT.finditer(zeile):
|
||||
merke(f"menue:{m.group(1)}", "Menüpunkt", pfad, nr, zeilen, zeile)
|
||||
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):
|
||||
ausdruck = m.group(2)
|
||||
if _ZUWEISUNG.match(ausdruck) or ausdruck.startswith("$"):
|
||||
continue # Zuweisung oder Ereignis-Objekt, keine Fähigkeit
|
||||
treffer = _EMIT.search(ausdruck) or _NAME.search(ausdruck)
|
||||
if not treffer or treffer.group(1 if _EMIT.search(ausdruck) else 0) \
|
||||
in _UNINTERESSANT:
|
||||
continue
|
||||
name = treffer.group(1) if _EMIT.search(ausdruck) else treffer.group(0)
|
||||
punkt = merke(f"bedienen:{name}", "Bedienung", pfad, nr, zeilen, zeile)
|
||||
if punkt is not None:
|
||||
punkt["rumpf"] = _rumpf(zeilen, name)
|
||||
return raus
|
||||
|
||||
|
||||
def uebersicht(quelle: dict = None) -> str:
|
||||
"""Kurze Dateiliste mit Zeilenzahl — für Prompts, die den Code nicht brauchen."""
|
||||
quelle = dateien() if quelle is None else quelle
|
||||
return "\n".join(f"- {p} ({len(t.splitlines())} Zeilen)" for p, t in quelle.items())
|
||||
@@ -4,8 +4,10 @@ hier wird auf 400/404/409 gemappt."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
import agents
|
||||
import chat
|
||||
import features
|
||||
import projekte
|
||||
import planung
|
||||
import pruefung
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -55,9 +57,13 @@ async def projekt_loeschen(name: str):
|
||||
|
||||
|
||||
@router.get("/projekte/{name}/features")
|
||||
async def features_liste(name: str):
|
||||
async def features_liste(name: str, sicht: str = "planung"):
|
||||
"""`sicht=uebersicht` liest den Projektstand, sonst die Arbeitskopie.
|
||||
|
||||
Ohne laufende Planung sind beide dasselbe Verzeichnis.
|
||||
"""
|
||||
try:
|
||||
return features.liste(name)
|
||||
return features.liste(name, nur_stand=sicht == "uebersicht")
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
@@ -89,10 +95,10 @@ async def pruefung_starten(name: str, body: dict | None = None):
|
||||
return await pruefung.starte(name, art)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except agents.NichtKonfiguriert as e:
|
||||
raise HTTPException(400, str(e)) # 409 heißt „läuft bereits" — hier fehlt die .env
|
||||
except pruefung.LaeuftBereits as e:
|
||||
raise HTTPException(409, str(e)) # 409 heißt genau das und sonst nichts
|
||||
except ValueError as e:
|
||||
raise HTTPException(409, str(e))
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.get("/projekte/{name}/pruefung")
|
||||
@@ -100,6 +106,56 @@ async def pruefung_status(name: str):
|
||||
return pruefung.status(name)
|
||||
|
||||
|
||||
@router.get("/projekte/{name}/planung")
|
||||
async def planung_stand(name: str):
|
||||
try:
|
||||
return planung.stand(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.post("/projekte/{name}/planung")
|
||||
async def planung_starten(name: str):
|
||||
try:
|
||||
planung.starte(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(409, str(e))
|
||||
return planung.stand(name)
|
||||
|
||||
|
||||
@router.post("/projekte/{name}/planung/umsetzen")
|
||||
async def planung_umsetzen(name: str):
|
||||
try:
|
||||
planung.setze_um(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(409, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/projekte/{name}/planung")
|
||||
async def planung_abbrechen(name: str):
|
||||
try:
|
||||
planung.brich_ab(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/projekte/{name}/planung/zuruecksetzen")
|
||||
async def planung_zuruecksetzen(name: str, body: dict):
|
||||
try:
|
||||
planung.zuruecksetzen(name, (body or {}).get("titel") or "")
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(409, str(e))
|
||||
return planung.stand(name)
|
||||
|
||||
|
||||
@router.delete("/projekte/{name}/pruefung")
|
||||
async def pruefung_beenden(name: str):
|
||||
pruefung.beende(name)
|
||||
@@ -113,3 +169,42 @@ async def feature_loeschen(name: str, id: str):
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
raise HTTPException(404, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/projekte/{name}/features/{id}/projekt")
|
||||
async def feature_verschieben(name: str, id: str, body: dict):
|
||||
try:
|
||||
return features.verschieben(name, id, (body.get("ziel") or "").strip())
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.get("/projekte/{name}/chat")
|
||||
async def chat_verlauf(name: str):
|
||||
try:
|
||||
return {"verlauf": chat.verlauf(name)}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.post("/projekte/{name}/chat")
|
||||
async def chat_frage(name: str, body: dict):
|
||||
try:
|
||||
return await chat.frage(name, body.get("text") or "")
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except agents.NichtKonfiguriert as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@router.delete("/projekte/{name}/chat")
|
||||
async def chat_leeren(name: str):
|
||||
try:
|
||||
chat.leeren(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
@@ -18,6 +18,9 @@ Gilt für jedes Feld.
|
||||
Diese Vorgaben prüft der Code selbst, exakt und für jede Zeile. Halte dich beim
|
||||
Schreiben daran — aber berufe dich nie darauf. Als `regel` sind sie nicht zulässig.
|
||||
|
||||
- Jede Funktion hat Ziel, Regeln oder Testschritte — nie nur einen Titel
|
||||
- Titel einer Funktion endet auf ein Verb im Infinitiv
|
||||
- Testschritte beschreiben genau einen Ablauf
|
||||
- Kein Satzzeichen am Zeilenende, kein Punkt und kein Semikolon
|
||||
- Keine Füllwörter. Die Liste ist abschließend: auch, noch, einfach, jeweils, ggf.
|
||||
- Keine Klammern und keine Schrägstriche
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Feld `testen`. Gibt es nur bei Funktionen, nie auf Kartenebene.
|
||||
|
||||
- Eine Zeile je Schritt
|
||||
- Genau ein Ablauf: handeln, handeln, Ergebnis
|
||||
- Nach dem Ergebnis folgt keine Handlung mehr
|
||||
- Schritte in Reihenfolge der Bedienung
|
||||
- Kein Schritt der Bedienung fehlt
|
||||
- Letzte Zeile beschreibt den Endzustand, keine Handlung
|
||||
@@ -16,3 +18,12 @@ Richtig:
|
||||
- Funktion entfernt
|
||||
```
|
||||
Falsch: letzte Zeile `Klick zum entfernen` (Handlung statt Endzustand)
|
||||
|
||||
Falsch, weil zwei Abläufe in einer Funktion stehen:
|
||||
```
|
||||
- Klick auf Miniaturbild
|
||||
- Großansicht öffnet und schließt
|
||||
- Option aus Großansicht auswählbar
|
||||
```
|
||||
Jede Zeile ist hier ein eigenes Ergebnis, kein Schritt eines Durchlaufs. Solche
|
||||
Funktionen sind mehrere und gehören getrennt.
|
||||
|
||||
@@ -4,6 +4,8 @@ Feld `titel`, wenn `funktion` gesetzt ist.
|
||||
|
||||
- Kurzsatz aus Objekt und Verb im Infinitiv
|
||||
- Genau eine Tätigkeit
|
||||
- Endet auf ein Verb im Infinitiv
|
||||
- Nennt eine Handlung, keinen Bereich, keinen Zustand
|
||||
|
||||
Richtig: `Feature erstellen` · `Anzeigemodus umschalten` · `Feature als Datei speichern`
|
||||
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten)
|
||||
Falsch: `Erstellung von Features` (Substantivierung) · `Feature erstellen und öffnen` (zwei Tätigkeiten) · `Galerie im Modal` (ein Bereich) · `Preis aktualisiert` (ein Zustand) · `In den Warenkorb` (kein Verb)
|
||||
|
||||
@@ -29,8 +29,21 @@ BADGE = ("kern", "optional")
|
||||
# „funktionen" gehört der Inhaltsprüfung, hat aber dieselbe Gestalt.
|
||||
ZEILENFELDER = ("regeln", "testen", "funktionen")
|
||||
|
||||
# Ein Funktionstitel nennt eine Handlung. Ohne Verb am Ende steht dort ein Bereich
|
||||
# („Galerie im Modal") oder ein Zustand („Preis aktualisiert") — beides beschreibt
|
||||
# nichts, was man für sich testen könnte. Deutsche Infinitive enden auf -en, -ln,
|
||||
# -rn; wie Füllwörter und Wortgrenzen ist auch das an die Sprache gebunden.
|
||||
_INFINITIV = re.compile(r"(?:e[lr]?n|ln|rn)$", re.I)
|
||||
# Eine Handlung steht als Infinitiv am Anfang oder am Ende der Zeile („Bestätigen im
|
||||
# Modal", „Text ändern") oder beginnt mit einer Taste. Alles andere ist ein Ergebnis.
|
||||
# Nur Anfang und Ende, nicht irgendwo: deutsche Mehrzahl endet auch auf -en.
|
||||
_TASTE = re.compile(r"^(?:LMB|RMB|MMB|Klick|Doppelklick|Enter|Escape|Strg|Tab)\b", re.I)
|
||||
|
||||
SATZENDE = ".;"
|
||||
_KLAMMER = re.compile(r"[()/]")
|
||||
# Eine Vorlage ist kein Inhalt. Das Modell schlug `<testschritt>` als Testschritt
|
||||
# vor — formal richtig, gemeint war „hier fehlt noch etwas".
|
||||
_LUECKE = re.compile(r"<[^<>]*>|\.\.\.|\bTODO\b|\bXXX\b", re.I)
|
||||
_STRICH = re.compile(r"^[-*]\s*")
|
||||
|
||||
|
||||
@@ -68,9 +81,15 @@ def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
|
||||
gruende = []
|
||||
if _KLAMMER.search(zeile):
|
||||
gruende.append("Klammer oder Schrägstrich")
|
||||
if _LUECKE.search(zeile):
|
||||
gruende.append("Platzhalter statt Inhalt")
|
||||
grenze = WORTGRENZEN.get((bereich, feld))
|
||||
if grenze and len(zeile.split()) > grenze:
|
||||
gruende.append(f"Mehr als {grenze} Wörter")
|
||||
if bereich == "funktion" and feld == "titel":
|
||||
woerter = zeile.split()
|
||||
if woerter and not _INFINITIV.search(woerter[-1]):
|
||||
gruende.append("Kein Verb im Infinitiv am Ende")
|
||||
if feld == "ziel":
|
||||
saetze = [s.strip() for s in re.split(r"[.!?]+", zeile) if s.strip()]
|
||||
if len(saetze) > ZIEL_SAETZE:
|
||||
@@ -80,6 +99,30 @@ def _umschreiben(bereich: str, feld: str, zeile: str) -> list[str]:
|
||||
return gruende
|
||||
|
||||
|
||||
def _handlung(zeile: str) -> bool:
|
||||
woerter = zeile.split()
|
||||
if not woerter:
|
||||
return True
|
||||
return bool(_TASTE.match(zeile) or _INFINITIV.search(woerter[-1])
|
||||
or _INFINITIV.search(woerter[0]))
|
||||
|
||||
|
||||
def _zweiter_ablauf(zeilen: list[str]) -> str:
|
||||
"""Die Zeile, ab der ein zweiter Durchlauf beginnt — sonst leer.
|
||||
|
||||
Ein Ablauf ist: handeln, handeln, Ergebnis. Steht nach dem Ergebnis wieder eine
|
||||
Handlung, sind es zwei Abläufe — und damit zwei Funktionen. Genau so entstand
|
||||
„Bildergalerie bedienen" mit blättern, Miniatur wählen und Großansicht in einem.
|
||||
"""
|
||||
ergebnis = False
|
||||
for zeile in zeilen:
|
||||
if ergebnis and _handlung(zeile):
|
||||
return zeile
|
||||
if not _handlung(zeile):
|
||||
ergebnis = True
|
||||
return ""
|
||||
|
||||
|
||||
def _zeile(bereich: str, feld: str, zeile: str) -> list[dict]:
|
||||
"""Höchstens ein Befund je Zeile. Zwei Vorschläge für dieselbe Zeile wären nicht
|
||||
unabhängig — der zweite fände seine Stelle nach dem ersten nicht mehr."""
|
||||
@@ -94,13 +137,28 @@ def _zeile(bereich: str, feld: str, zeile: str) -> list[dict]:
|
||||
def befunde(bereich: str, felder: dict) -> list[dict]:
|
||||
"""Alle messbaren Verstöße einer Prüfstelle, in Reihenfolge der Felder."""
|
||||
raus = []
|
||||
# Ein bloßer Titel beschreibt nichts — und lässt sich über alles stülpen. Der
|
||||
# Prüfer hielt damit einen Druckknopf für „Modal mit Tastatur steuern".
|
||||
# Nur wenn die ganze Funktion vorliegt: bei einem einzelnen Feld sagt das Fehlen
|
||||
# der anderen nichts.
|
||||
inhaltsfelder = ("ziel", "regeln", "testen")
|
||||
if bereich == "funktion" and all(f in felder for f in inhaltsfelder) \
|
||||
and not any((felder.get(f) or "").strip() for f in inhaltsfelder):
|
||||
raus.append(_problem("testen", "", "Funktion ohne Ziel, Regeln und Testen"))
|
||||
return raus
|
||||
for feld, text in felder.items():
|
||||
if feld == "badge":
|
||||
if text and text not in BADGE:
|
||||
raus.append(_problem(feld, text, "Weder kern noch optional", "kern"))
|
||||
continue
|
||||
for zeile in _zeilen(feld, text or ""):
|
||||
zeilen = _zeilen(feld, text or "")
|
||||
for zeile in zeilen:
|
||||
raus += _zeile(bereich, feld, zeile)
|
||||
if feld == "testen" and not raus:
|
||||
# Erst prüfen, wenn die Zeilen für sich in Ordnung sind: sonst hängen
|
||||
# zwei Vorschläge am selben Feld und der zweite findet seine Stelle nicht.
|
||||
if bruch := _zweiter_ablauf(zeilen):
|
||||
raus.append(_problem(feld, bruch, "Zweiter Ablauf in einer Funktion"))
|
||||
return raus
|
||||
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import inhalt
|
||||
import quellcode
|
||||
|
||||
|
||||
def test_unformatierte_prompts_ohne_doppelklammern():
|
||||
"""Was nicht durch .format() geht, darf keine Escape-Klammern tragen —
|
||||
das Modell schrieb sie sonst wörtlich in die Antwort und das JSON war kaputt."""
|
||||
for name in ("ABDECKUNG_SYSTEM",):
|
||||
text = getattr(inhalt, name)
|
||||
assert "{{" not in text and "}}" not in text, name
|
||||
|
||||
|
||||
def test_formatierte_prompts_ueberstehen_format():
|
||||
punkt = {"art": "Bedienung", "datei": "a.vue", "zeile": 1,
|
||||
"umfeld": "x", "rumpf": ""}
|
||||
fertig = inhalt.ABDECKUNG.format(funktionen="—", **punkt)
|
||||
assert '{"abgedeckt": true' in fertig
|
||||
assert "{{" not in fertig
|
||||
|
||||
|
||||
def test_beleg_muss_woertlich_dastehen():
|
||||
quelle = {"a.py": "def eins():\n return 1\n"}
|
||||
assert quellcode.belegt(quelle, "a.py", "def eins():")
|
||||
assert quellcode.belegt(quelle, "a.py", " def eins(): ") # Einrückung egal
|
||||
assert quellcode.belegt(quelle, "a.py", "def eins():\nreturn 1")
|
||||
assert not quellcode.belegt(quelle, "a.py", "def zwei():")
|
||||
assert not quellcode.belegt(quelle, "b.py", "def eins():")
|
||||
assert not quellcode.belegt(quelle, "a.py", "")
|
||||
|
||||
|
||||
def test_interaktionen_sind_deterministisch():
|
||||
quelle = {
|
||||
"frontend/src/X.vue": (
|
||||
'<button @click="starte(\'ziel\')">a</button>\n'
|
||||
'<button @click="starte(\'regeln\')">b</button>\n'
|
||||
'<div @contextmenu="menue">c</div>\n'
|
||||
'<input @blur="fertig" @keydown.esc="abbrechen">\n'
|
||||
'<span @click="offen = false">zu</span>\n'
|
||||
),
|
||||
"backend/routes.py": '@router.get("/projekte")\n@router.post("/projekte")\n',
|
||||
"frontend/src/m.js": " { text: '+ Projekt erstellen', aktion: () => x() },\n",
|
||||
}
|
||||
schluessel = [p["schluessel"] for p in quellcode.interaktionen(quelle)]
|
||||
# Zwölf Aufrufe von starte() sind eine Fähigkeit, nicht zwölf
|
||||
assert schluessel.count("bedienen:starte") == 1
|
||||
assert "bedienen:menue" in schluessel
|
||||
# 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)
|
||||
assert not any(s.startswith("bedienen:abbrechen") for s in schluessel)
|
||||
assert not any(s.startswith("bedienen:offen") for s in schluessel)
|
||||
# Zweiter Aufruf liefert exakt dasselbe
|
||||
assert schluessel == [p["schluessel"] for p in quellcode.interaktionen(quelle)]
|
||||
|
||||
|
||||
def test_tests_sind_kein_quelltext():
|
||||
assert not any("tests/" in p for p in quellcode.dateien())
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Das Gedächtnis der Inhaltsprüfungen: was gilt weiter, was wird neu gefragt.
|
||||
"""Das Gedächtnis der Stilprüfung: 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.
|
||||
Die Prüfung fragt 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
|
||||
@@ -11,7 +11,6 @@ import pytest
|
||||
|
||||
import agents
|
||||
import features
|
||||
import inhalt
|
||||
import ledger
|
||||
import projekte
|
||||
|
||||
@@ -33,9 +32,6 @@ def karte(projekt, titel, *funktionen):
|
||||
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."""
|
||||
|
||||
@@ -82,431 +78,6 @@ def test_gedaechtnis_ist_kein_feature(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"
|
||||
@@ -530,3 +101,65 @@ def test_keine_pruefung_fasst_die_vision_an(projekt):
|
||||
assert len(features.liste(projekt)) == 2
|
||||
titel = [f["sektionen"]["titel"] for f in features.pruefbar(projekt)]
|
||||
assert titel == ["Eins"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Prüfung 1
|
||||
|
||||
async def test_stilurteil_wird_gemerkt_und_nicht_neu_gefragt(projekt, monkeypatch):
|
||||
"""176 Anfragen je Lauf brachten den Anbieter zum Drosseln. Unveränderter
|
||||
Ausschnitt bei unverändertem Katalog heißt: das Urteil von vorhin gilt."""
|
||||
import pruefung
|
||||
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler('{"felder": {}}')
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
|
||||
for a in liste:
|
||||
assert await arbeite(a, None) == []
|
||||
abschluss()
|
||||
erste = modell.rufe
|
||||
assert erste >= len(pruefung.TEMPERATUREN)
|
||||
|
||||
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
|
||||
for a in liste:
|
||||
assert await arbeite(a, None) == []
|
||||
assert modell.rufe == erste # keine einzige neue Anfrage
|
||||
|
||||
|
||||
async def test_geaenderter_text_wird_neu_gefragt(projekt, monkeypatch):
|
||||
import pruefung
|
||||
|
||||
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
|
||||
modell = Zaehler('{"felder": {}}')
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
|
||||
for a in liste:
|
||||
await arbeite(a, None)
|
||||
abschluss()
|
||||
vorher = modell.rufe
|
||||
|
||||
datei = projekte.wurzel(projekt) / f"{features.liste(projekt)[0]['id']}.md"
|
||||
datei.write_text(datei.read_text().replace("- Knopf", "- Ganz anderer Text"))
|
||||
liste, arbeite, _ = await pruefung._stil_aufgaben(projekt)
|
||||
for a in liste:
|
||||
await arbeite(a, None)
|
||||
assert modell.rufe > vorher
|
||||
|
||||
|
||||
async def test_gemessener_verstoss_kommt_nie_aus_dem_gedaechtnis(projekt, monkeypatch):
|
||||
"""Sonst bliebe eine neu hinzugekommene Messregel an alten Karten wirkungslos."""
|
||||
import pruefung
|
||||
|
||||
karte(projekt, "Eins", ("Etwas starten", "Auch Passwort prüfen."))
|
||||
modell = Zaehler('{"felder": {"regeln": {"neu": "- Passwort prüfen",'
|
||||
' "grund": "Füllwort", "regel": "x"}}}')
|
||||
monkeypatch.setattr(agents, "frage", modell)
|
||||
|
||||
for lauf in range(2):
|
||||
liste, arbeite, abschluss = await pruefung._stil_aufgaben(projekt)
|
||||
gefunden = [p for a in liste for p in await arbeite(a, None)]
|
||||
abschluss()
|
||||
assert [p["vorschlag"] for p in gefunden] == ["Passwort prüfen"], lauf
|
||||
assert modell.rufe > len(pruefung.TEMPERATUREN) # zweimal wirklich gefragt
|
||||
|
||||
@@ -33,8 +33,10 @@ def test_alle_sektionen():
|
||||
"ziel": "",
|
||||
"regeln": "- Passwort nie loggen\n- Sperre nach 5 Fehlversuchen",
|
||||
"testen": "- Formular öffnen\n- Daten eingeben",
|
||||
"stand": "gebaut",
|
||||
},
|
||||
{"text": "Abmeldung", "ziel": "", "regeln": "", "testen": ""},
|
||||
{"text": "Abmeldung", "ziel": "", "regeln": "", "testen": "",
|
||||
"stand": "gebaut"},
|
||||
]
|
||||
|
||||
|
||||
@@ -114,3 +116,22 @@ def test_marker_mit_leerraum():
|
||||
def test_titel_erste_nicht_leere_zeile():
|
||||
s = zerlege("<!-- titel -->\n\nErste\nZweite\n")
|
||||
assert s["titel"] == "Erste"
|
||||
|
||||
|
||||
def test_stand_offen_wird_gelesen():
|
||||
"""Bestellt, aber noch nicht gebaut — sonst klagt die Vorwärtsprüfung sie an."""
|
||||
s = zerlege("<!-- titel -->\nX\n<!-- funktion -->\nA\n<!-- stand -->\noffen\n"
|
||||
"<!-- funktion -->\nB\n")
|
||||
assert [(f["text"], f["stand"]) for f in s["funktionen"]] == [("A", "offen"),
|
||||
("B", "gebaut")]
|
||||
assert s["sonstiges"] == "" # der Marker landet nicht im Rest
|
||||
|
||||
|
||||
def test_unbekannter_stand_gilt_als_gebaut():
|
||||
s = zerlege("<!-- titel -->\nX\n<!-- funktion -->\nA\n<!-- stand -->\nirgendwas\n")
|
||||
assert s["funktionen"][0]["stand"] == "gebaut"
|
||||
|
||||
|
||||
def test_stand_ohne_funktion_faellt_in_sonstiges():
|
||||
s = zerlege("<!-- titel -->\nX\n<!-- stand -->\noffen\n")
|
||||
assert s["sonstiges"] == "offen"
|
||||
|
||||
201
backend/tests/test_planung.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Die Planung: eine Arbeitskopie, die den Projektstand nicht anfasst.
|
||||
|
||||
Geprüft wird die eine Zusage — solange die Planung läuft, bleibt die Übersicht,
|
||||
was sie war. Und dass sie einen Neustart übersteht, weil sie auf der Platte liegt.
|
||||
"""
|
||||
|
||||
import features
|
||||
import parser
|
||||
import planung
|
||||
import projekte
|
||||
import pytest
|
||||
|
||||
|
||||
def karte(projekt: str, titel: str, *funktionen: str) -> str:
|
||||
roh = f"<!-- titel -->\n{titel}\n" + "".join(
|
||||
f"{'-' * 40}\n<!-- funktion -->\n{f}\n<!-- regeln -->\n- Etwas gilt\n"
|
||||
for f in funktionen)
|
||||
return features.anlegen(projekt, roh)["id"]
|
||||
|
||||
|
||||
def titel(projekt: str) -> list:
|
||||
return sorted(f["sektionen"]["titel"] for f in features.liste(projekt))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def projekt(datenordner):
|
||||
projekte.anlegen("P")
|
||||
karte("P", "Galerie", "Bild wählen")
|
||||
karte("P", "Shop", "Karte kaufen")
|
||||
return "P"
|
||||
|
||||
|
||||
def test_ohne_planung_arbeitet_alles_im_projektordner(projekt):
|
||||
assert not planung.laeuft(projekt)
|
||||
assert projekte.wurzel(projekt) == projekte.stand(projekt)
|
||||
assert planung.aenderungen(projekt) == {}
|
||||
|
||||
|
||||
def test_planung_leitet_das_schreiben_um(projekt):
|
||||
planung.starte(projekt)
|
||||
assert projekte.wurzel(projekt) != projekte.stand(projekt)
|
||||
|
||||
karte(projekt, "Neu", "Etwas tun")
|
||||
# Die Karte steht in der Kopie …
|
||||
assert "Neu" in titel(projekt)
|
||||
# … und nicht im Projektstand
|
||||
assert not (projekte.stand(projekt) / "neu.md").exists()
|
||||
|
||||
|
||||
def test_zweite_planung_wird_abgelehnt(projekt):
|
||||
planung.starte(projekt)
|
||||
with pytest.raises(ValueError):
|
||||
planung.starte(projekt)
|
||||
|
||||
|
||||
def test_planung_ueberlebt_einen_neustart(projekt):
|
||||
planung.starte(projekt)
|
||||
karte(projekt, "Neu", "Etwas tun")
|
||||
# Nichts im Speicher — der nächste Prozess sieht nur die Platte
|
||||
assert planung.laeuft(projekt)
|
||||
assert "Neu" in planung.aenderungen(projekt)
|
||||
|
||||
|
||||
def test_abbruch_laesst_den_projektstand_unberuehrt(projekt):
|
||||
vorher = titel(projekt)
|
||||
planung.starte(projekt)
|
||||
karte(projekt, "Neu", "Etwas tun")
|
||||
planung.brich_ab(projekt)
|
||||
assert not planung.laeuft(projekt)
|
||||
assert titel(projekt) == vorher
|
||||
|
||||
|
||||
def test_umsetzen_macht_die_kopie_zum_stand(projekt):
|
||||
planung.starte(projekt)
|
||||
karte(projekt, "Neu", "Etwas tun")
|
||||
planung.setze_um(projekt)
|
||||
assert not planung.laeuft(projekt)
|
||||
assert "Neu" in titel(projekt)
|
||||
assert not (projekte.stand(projekt) / projekte.PLANUNG).exists()
|
||||
|
||||
|
||||
def test_umsetzen_ohne_planung_ist_ein_fehler(projekt):
|
||||
with pytest.raises(ValueError):
|
||||
planung.setze_um(projekt)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Änderungen
|
||||
|
||||
def test_neue_karte_ist_neu(projekt):
|
||||
planung.starte(projekt)
|
||||
karte(projekt, "Neu", "Etwas tun")
|
||||
a = planung.aenderungen(projekt)
|
||||
assert a["Neu"]["art"] == "neu"
|
||||
assert a["Neu"]["funktionen"] == {"Etwas tun": "neu"}
|
||||
assert "Galerie" not in a
|
||||
|
||||
|
||||
def test_entfernte_karte_ist_weg(projekt):
|
||||
planung.starte(projekt)
|
||||
features.loeschen(projekt, "shop")
|
||||
a = planung.aenderungen(projekt)
|
||||
assert a["Shop"]["art"] == "weg"
|
||||
assert a["Shop"]["funktionen"] == {"Karte kaufen": "weg"}
|
||||
|
||||
|
||||
def test_neue_funktion_wird_markiert(projekt):
|
||||
planung.starte(projekt)
|
||||
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
|
||||
features.speichern(projekt, f["id"],
|
||||
f["roh"] + f"{'-' * 40}\n<!-- funktion -->\nBild löschen\n")
|
||||
a = planung.aenderungen(projekt)
|
||||
assert a["Galerie"]["art"] == "geaendert"
|
||||
assert a["Galerie"]["funktionen"] == {"Bild löschen": "neu"}
|
||||
|
||||
|
||||
def test_geaenderte_regel_wird_markiert(projekt):
|
||||
planung.starte(projekt)
|
||||
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
|
||||
features.speichern(projekt, f["id"], f["roh"].replace("Etwas gilt", "Anderes gilt"))
|
||||
assert planung.aenderungen(projekt)["Galerie"]["funktionen"] == {
|
||||
"Bild wählen": "geaendert"}
|
||||
|
||||
|
||||
def test_umbenannte_karte_ist_neu_und_weg(projekt):
|
||||
"""Verglichen wird über den Titel. Eine Umbenennung ist zwei Änderungen."""
|
||||
planung.starte(projekt)
|
||||
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Shop")
|
||||
features.speichern(projekt, f["id"], f["roh"].replace("Shop", "Laden", 1))
|
||||
a = planung.aenderungen(projekt)
|
||||
assert a["Shop"]["art"] == "weg" and a["Laden"]["art"] == "neu"
|
||||
|
||||
|
||||
def test_gleicher_inhalt_ist_keine_aenderung(projekt):
|
||||
planung.starte(projekt)
|
||||
f = features.liste(projekt)[0]
|
||||
features.speichern(projekt, f["id"], f["roh"])
|
||||
assert planung.aenderungen(projekt) == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Zurücksetzen
|
||||
|
||||
def test_zuruecksetzen_loescht_eine_neue_karte(projekt):
|
||||
planung.starte(projekt)
|
||||
karte(projekt, "Neu", "Etwas tun")
|
||||
planung.zuruecksetzen(projekt, "Neu")
|
||||
assert planung.aenderungen(projekt) == {}
|
||||
assert "Neu" not in titel(projekt)
|
||||
|
||||
|
||||
def test_zuruecksetzen_holt_eine_geloeschte_karte_zurueck(projekt):
|
||||
planung.starte(projekt)
|
||||
features.loeschen(projekt, "shop")
|
||||
planung.zuruecksetzen(projekt, "Shop")
|
||||
assert planung.aenderungen(projekt) == {}
|
||||
assert "Shop" in titel(projekt)
|
||||
|
||||
|
||||
def test_zuruecksetzen_stellt_den_alten_inhalt_her(projekt):
|
||||
planung.starte(projekt)
|
||||
f = next(x for x in features.liste(projekt) if x["sektionen"]["titel"] == "Galerie")
|
||||
features.speichern(projekt, f["id"], f["roh"].replace("Etwas gilt", "Anderes gilt"))
|
||||
planung.zuruecksetzen(projekt, "Galerie")
|
||||
assert planung.aenderungen(projekt) == {}
|
||||
|
||||
|
||||
def test_zuruecksetzen_laesst_die_uebrigen_stehen(projekt):
|
||||
planung.starte(projekt)
|
||||
karte(projekt, "Eins", "A tun")
|
||||
karte(projekt, "Zwei", "B tun")
|
||||
planung.zuruecksetzen(projekt, "Eins")
|
||||
assert set(planung.aenderungen(projekt)) == {"Zwei"}
|
||||
|
||||
|
||||
def test_zuruecksetzen_ohne_planung_ist_ein_fehler(projekt):
|
||||
with pytest.raises(ValueError):
|
||||
planung.zuruecksetzen(projekt, "Galerie")
|
||||
|
||||
|
||||
def test_unbekannte_karte_zuruecksetzen_meldet_es(projekt):
|
||||
planung.starte(projekt)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
planung.zuruecksetzen(projekt, "Gibt es nicht")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Nachbarschaft
|
||||
|
||||
def test_die_kopie_ist_kein_eigenes_projekt(projekt):
|
||||
planung.starte(projekt)
|
||||
assert [p["name"] for p in projekte.liste()] == ["P"]
|
||||
|
||||
|
||||
def test_gespraech_und_gedaechtnis_wandern_mit(projekt):
|
||||
"""Was während des Planens beurteilt wurde, gilt für den geplanten Stand."""
|
||||
(projekte.stand(projekt) / ".pruefung.json").write_text('{"stil": {}}')
|
||||
planung.starte(projekt)
|
||||
assert (projekte.wurzel(projekt) / ".pruefung.json").is_file()
|
||||
|
||||
|
||||
def test_die_kopie_kopiert_sich_nicht_selbst(projekt):
|
||||
planung.starte(projekt)
|
||||
assert not (projekte.wurzel(projekt) / projekte.PLANUNG).exists()
|
||||
@@ -197,8 +197,9 @@ async def test_herkunft_kommt_vom_code_nicht_vom_modell(datenordner, monkeypatch
|
||||
|
||||
async def luegt(system, inhalt, temperatur=0.0):
|
||||
# Das Modell behauptet eine fremde Funktion — das muss ignoriert werden
|
||||
# „Anmelden" ist selbst ein gültiger Titel — sonst weist die Messung ihn ab
|
||||
return ('{"felder": {"titel": {"regel": "Sprache: Deutsch, Präsens, keine Anrede",'
|
||||
' "neu": "Anders", "grund": "x"},'
|
||||
' "neu": "Einloggen", "grund": "x"},'
|
||||
' "funktion": {"neu": "Erfunden", "grund": "y"}}}')
|
||||
|
||||
monkeypatch.setattr(agents, "frage", luegt)
|
||||
|
||||
@@ -166,3 +166,96 @@ def test_gemessene_befunde_haengen_am_ausschnitt(datenordner):
|
||||
text = pruefung.inhalt(karte, befunde)
|
||||
assert "Gemessen, bitte neu schreiben:" in text
|
||||
assert "Mehr als 3 Wörter" in text
|
||||
|
||||
|
||||
def test_funktionstitel_braucht_ein_verb():
|
||||
"""Ohne Verb steht dort ein Bereich oder ein Zustand — nichts Testbares."""
|
||||
for titel in ("Galerie im Modal", "Preis aktualisiert", "In den Warenkorb",
|
||||
"Modal tastaturgesteuert"):
|
||||
assert _probleme(_pruefe("funktion", "titel", titel)) \
|
||||
== ["Kein Verb im Infinitiv am Ende"], titel
|
||||
|
||||
|
||||
def test_gute_funktionstitel_bleiben_unberuehrt():
|
||||
for titel in ("Feature anlegen", "Anzeigemodus umschalten", "Option wählen",
|
||||
"Nächsten Rückschritt anzeigen", "Karten prüfen", "Stand umschalten",
|
||||
"Projekt aufrufen", "Bildergalerie bedienen", "PDF hochladen"):
|
||||
assert _pruefe("funktion", "titel", titel) == [], titel
|
||||
|
||||
|
||||
def test_kartentitel_darf_ohne_verb_stehen():
|
||||
"""Eine Karte ist ein Gegenstand, keine Handlung."""
|
||||
assert _pruefe("feature", "titel", "Karten prüfen") == []
|
||||
assert _pruefe("feature", "titel", "Darkmode") == []
|
||||
|
||||
|
||||
ABLAUF = "- Vollmodus öffnen\n- LMB auf Regeln\n- Text ändern\n- Regeln angepasst"
|
||||
|
||||
|
||||
def test_ein_ablauf_geht_durch():
|
||||
assert _pruefe("funktion", "testen", ABLAUF) == []
|
||||
|
||||
|
||||
def test_zweiter_ablauf_wird_gemeldet():
|
||||
"""So entstand „Bildergalerie bedienen": blättern, Miniatur, Großansicht in einem."""
|
||||
zwei = ("- Zurück-Button klicken\n- Vorheriges Bild erscheint\n"
|
||||
"- Miniaturbild klicken\n- Zugehöriges Bild erscheint")
|
||||
b = _pruefe("funktion", "testen", zwei)
|
||||
assert _probleme(b) == ["Zweiter Ablauf in einer Funktion"]
|
||||
assert b[0]["original"] == "Miniaturbild klicken"
|
||||
|
||||
|
||||
def test_nominalstil_ist_eine_handlung():
|
||||
"""„Bestätigen im Modal" hat das Verb vorn — sonst gälte es als Ergebnis."""
|
||||
text = ("- RMB auf Featurecard\n- Klick auf \"Feature entfernen\"\n"
|
||||
"- Bestätigen im Modal\n- Feature entfernt")
|
||||
assert _pruefe("funktion", "testen", text) == []
|
||||
|
||||
|
||||
def test_mehrzahl_auf_en_ist_kein_verb():
|
||||
"""Deutsche Mehrzahl endet auch auf -en — nur Anfang und Ende zählen."""
|
||||
text = "- LMB auf Icon\n- Fehlerhafte Karten rot markiert"
|
||||
assert _pruefe("funktion", "testen", text) == []
|
||||
|
||||
|
||||
def test_zeilenfehler_haben_vorrang():
|
||||
"""Erst die Zeilen in Ordnung bringen; zwei Vorschläge je Feld wären nicht
|
||||
unabhängig anwendbar."""
|
||||
text = "- Auch klicken.\n- Ergebnis da\n- Weiter klicken"
|
||||
assert _probleme(_pruefe("funktion", "testen", text)) \
|
||||
== ["Satzzeichen am Zeilenende, Füllwort „auch“"]
|
||||
|
||||
|
||||
def test_funktion_ohne_jedes_feld_ist_leer():
|
||||
"""Ein bloßer Titel beschreibt nichts — der Prüfer stülpt ihn über alles."""
|
||||
b = struktur.befunde("funktion", {"titel": "Modal steuern", "ziel": "",
|
||||
"regeln": "", "testen": ""})
|
||||
assert _probleme(b) == ["Funktion ohne Ziel, Regeln und Testen"]
|
||||
|
||||
|
||||
def test_ein_einziges_feld_genuegt():
|
||||
for feld in ("ziel", "regeln", "testen"):
|
||||
felder = {"titel": "Modal steuern", "ziel": "", "regeln": "", "testen": ""}
|
||||
felder[feld] = "Etwas tun" if feld == "ziel" else "- Etwas tun"
|
||||
assert struktur.befunde("funktion", felder) == [], feld
|
||||
|
||||
|
||||
def test_einzelnes_feld_loest_nichts_aus():
|
||||
"""Beim Prüfen eines Feldes sagt das Fehlen der anderen nichts."""
|
||||
assert struktur.befunde("funktion", {"titel": "Modal steuern"}) == []
|
||||
|
||||
|
||||
def test_kartenebene_darf_leer_sein():
|
||||
assert struktur.befunde("feature", {"titel": "Darkmode", "badge": "kern",
|
||||
"ziel": "", "regeln": ""}) == []
|
||||
|
||||
|
||||
def test_platzhalter_ist_kein_inhalt():
|
||||
"""`<testschritt>` erfüllt jede Formregel und sagt trotzdem nichts."""
|
||||
assert struktur._umschreiben("funktion", "testen", "<testschritt>")
|
||||
assert struktur._umschreiben("funktion", "regeln", "Noch offen ...")
|
||||
assert struktur._umschreiben("funktion", "regeln", "TODO klären")
|
||||
|
||||
|
||||
def test_kleiner_als_ist_kein_platzhalter():
|
||||
assert not struktur._umschreiben("funktion", "regeln", "Wert 3 < 5")
|
||||
|
||||