This commit is contained in:
team3
2026-07-28 12:31:42 +02:00
parent 58efba988d
commit 2e86d682eb
15 changed files with 1823 additions and 65 deletions

View File

@@ -32,6 +32,12 @@ def liste(projekt: str) -> list[dict]:
return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower()) return sorted(eintraege, key=lambda e: e["sektionen"]["titel"].lower())
def pruefbar(projekt: str) -> list[dict]:
"""Alles außer der Vision. Die hält fest, wie es gedacht ist — sie ist die
Vorlage, nicht der Prüfling, und keine Prüfung schlägt Änderungen daran vor."""
return [f for f in liste(projekt) if f["sektionen"]["art"] != parser.VISION]
def anlegen(projekt: str, roh: str) -> dict: def anlegen(projekt: str, roh: str) -> dict:
ordner = projekte.wurzel(projekt) ordner = projekte.wurzel(projekt)
titel = parser.zerlege(roh)["titel"] titel = parser.zerlege(roh)["titel"]

View File

@@ -1,6 +1,7 @@
"""Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen. """Zwei Abgleiche zwischen Karten und Quelltext, in beide Richtungen.
**Feature im Code** — steht hinter jeder Funktion einer Karte auch Code? **Feature im Code** — steht hinter jeder Funktion einer Karte auch Code? Dazu:
beschreiben zwei Funktionen dieselbe Fähigkeit?
**Code im Feature** — hat jede Stelle im Code auch eine Funktion auf einer Karte? **Code im Feature** — hat jede Stelle im Code auch eine Funktion auf einer Karte?
Beide arbeiten mit derselben Beweislast, nur andersherum. Beide arbeiten mit derselben Beweislast, nur andersherum.
@@ -17,14 +18,22 @@ dieselbe Frage kippt auch bei Temperatur 0 gelegentlich.
Was der Code selbst weiß, entscheidet der Code: die Liste der Bedienstellen kommt Was der Code selbst weiß, entscheidet der Code: die Liste der Bedienstellen kommt
aus `quellcode.interaktionen()`, jeder Beleg wird gegen die Datei geschlagen. 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 asyncio
import difflib import difflib
import re import re
from collections import Counter
import agents import agents
import config import config
import ledger
import quellcode import quellcode
# Ohne Drossel liefen alle Anfragen gleichzeitig los und der Provider antwortete # Ohne Drossel liefen alle Anfragen gleichzeitig los und der Provider antwortete
@@ -86,7 +95,7 @@ JSON-Objekt."""
ABDECKUNG_SYSTEM = """Du entscheidest, ob eine Stelle im Programm schon von einer ABDECKUNG_SYSTEM = """Du entscheidest, ob eine Stelle im Programm schon von einer
beschriebenen Funktion abgedeckt ist. Antworte ausschließlich mit JSON.""" beschriebenen Funktion abgedeckt ist. Antworte ausschließlich mit JSON."""
ABDECKUNG = """An dieser Stelle kann eine Person etwas auslösen: ABDECKUNG = """Diese Stelle im Programm lässt sich auslösen:
{art} in {datei}, Zeile {zeile} {art} in {datei}, Zeile {zeile}
@@ -100,7 +109,9 @@ Die Karten des Projekts beschreiben bisher das hier:
Beschreibt eine dieser Funktionen, was an der Stelle oben passiert? Beschreibt eine dieser Funktionen, was an der Stelle oben passiert?
Wortwahl darf abweichen; entscheidend ist die Sache. Ziel, Regeln und Testschritte Wortwahl darf abweichen; entscheidend ist die Sache. Ziel, Regeln und Testschritte
einer Funktion gehören mit zu dem, was sie abdeckt. einer Funktion gehören mit zu dem, was sie abdeckt, und die Regeln der Karte
ebenso: eine Zusicherung wie „alphabetisch sortiert" ist beschrieben, auch wenn
kein Knopf dazu gehört.
Antworte ausschließlich mit JSON: Antworte ausschließlich mit JSON:
{{"abgedeckt": true, "funktion": "..."}} {{"abgedeckt": true, "funktion": "..."}}
@@ -114,8 +125,40 @@ oder
Rein technische Aufhänger gelten als abgedeckt: Speichern beim Verlassen eines Rein technische Aufhänger gelten als abgedeckt: Speichern beim Verlassen eines
Feldes, Abbrechen mit Escape, Gesundheitsprüfungen, interne Weiterleitung. 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.""" 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 # 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 # findet, genügt zum Freispruch — die Anklage lautet auf Löschen, da wiegt ein
# Fehlurteil schwerer als ein übersehener Rest. # Fehlurteil schwerer als ein übersehener Rest.
@@ -126,8 +169,34 @@ ANKLAGE_AB = 4
# 0:6, Grenzfälle etwa 3:3 — die Schwelle bei fünf trennt beides. # 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) URTEIL_TEMPERATUREN = (0.0, 0.2, 0.4, 0.6, 0.8, 1.0)
FEHLT_AB = 5 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 # Ab dieser Ähnlichkeit gelten zwei Titel als dieselbe Sache
GLEICH = 0.75 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: def _normal(text: str) -> str:
@@ -144,11 +213,34 @@ def _json(antwort: str) -> dict:
return pruefung._json_lesen(antwort) 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 # ---------------------------------------------------------------- Feature im Code
def _funktionstext(karte: str, f: dict) -> str: def _funktionstext(karte: str, f: dict) -> str:
"""Alles, was die Funktion behauptet — danach wird im Code gesucht.""" """Alles, was die Funktion behauptet — danach wird im Code gesucht.
zeilen = [f"Karte: {karte}", f'Funktion: {f["text"]}']
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"]: if f["ziel"]:
zeilen.append(f'Ziel: {f["ziel"]}') zeilen.append(f'Ziel: {f["ziel"]}')
for feld in ("regeln", "testen"): for feld in ("regeln", "testen"):
@@ -158,16 +250,47 @@ def _funktionstext(karte: str, f: dict) -> str:
return "\n".join(zeilen) return "\n".join(zeilen)
async def feature_im_code(features: list, quelle: dict, quelltext: str, def _quellabdruck(quelle: dict) -> str:
zaehl=None) -> list: return ledger.abdruck(_SUCHE_STAND,
"""Funktionen, für die keine Stimme einen nachprüfbaren Beleg findet.""" *(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"]] 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): async def eine(paar):
feature, x = paar feature, x = paar
nachricht = SUCHE_NACHRICHT.format( schluessel = ledger.schluessel(feature["id"], x["text"])
quelle=quelltext, text = _funktionstext(feature["sektionen"]["titel"], x)
funktion=_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(*( proben = await asyncio.gather(*(
_urteil(SUCHE_SYSTEM, nachricht, t) for t in SUCHE_TEMPERATUREN _urteil(SUCHE_SYSTEM, nachricht, t) for t in SUCHE_TEMPERATUREN
), return_exceptions=True) ), return_exceptions=True)
@@ -175,7 +298,8 @@ async def feature_im_code(features: list, quelle: dict, quelltext: str,
if zaehl: if zaehl:
zaehl() zaehl()
gruende = [] gruende = []
brauchbar = belegt = behauptet = 0 brauchbar = behauptet = 0
beleg = None
for u in proben: for u in proben:
if not isinstance(u, dict): if not isinstance(u, dict):
continue continue
@@ -185,26 +309,196 @@ async def feature_im_code(features: list, quelle: dict, quelltext: str,
# verschreibt sich beim Abschreiben von Backticks — daraus eine # verschreibt sich beim Abschreiben von Backticks — daraus eine
# Löschempfehlung zu machen, wäre grob falsch. # Löschempfehlung zu machen, wäre grob falsch.
behauptet += 1 behauptet += 1
if quellcode.belegt(quelle, str(u.get("datei") or "").strip(), datei = str(u.get("datei") or "").strip()
str(u.get("beleg") or "").strip()): zitat = str(u.get("beleg") or "").strip()
belegt += 1 if beleg is None and quellcode.belegt(quelle, datei, zitat):
beleg = {"abdruck": wie_gehabt, "datei": datei, "beleg": zitat}
elif u.get("grund"): elif u.get("grund"):
gruende.append(str(u["grund"]).strip()) gruende.append(str(u["grund"]).strip())
if belegt: stand["belege"].pop(schluessel, None)
stand["ohne_code"].pop(schluessel, None)
if beleg:
stand["belege"][schluessel] = beleg
return None # geprüfter Beleg: die Funktion steht im Code return None # geprüfter Beleg: die Funktion steht im Code
if behauptet: if behauptet:
return None # widersprüchliche Stimmen: nichts behaupten return None # widersprüchliche Stimmen: nichts behaupten und nichts merken
if brauchbar < ANKLAGE_AB: if brauchbar < ANKLAGE_AB:
# Zu wenige Stimmen für eine Löschempfehlung. Lieber laut scheitern # Zu wenige Stimmen für eine Löschempfehlung. Lieber laut scheitern
# als eine gültige Funktion wegen eines Ausfalls anzuklagen. # als eine gültige Funktion wegen eines Ausfalls anzuklagen.
raise RuntimeError( raise RuntimeError(
f'{x["text"]}: nur {brauchbar} von ' f'{x["text"]}: nur {brauchbar} von '
f"{len(SUCHE_TEMPERATUREN)} Stimmen brauchbar") 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"], return {"feature": feature["id"], "titel": x["text"],
"grund": gruende[0] if gruende else "Kein Beleg im Code gefunden"} "art": "ohne_code", "grund": grund}
return [x for x in await asyncio.gather(*(eine(a) for a in auftraege)) if x] return await _sammle([eine(a) for a in auftraege], "Funktionen", melde)
# ---------------------------------------------------------------- Dubletten
def _funktionen(features: list) -> dict:
"""schluessel -> alles, was zur Funktion gehört."""
raus = {}
for f in features:
for x in f["sektionen"]["funktionen"]:
raus[ledger.schluessel(f["id"], x["text"])] = {
"feature": f["id"], "karte": f["sektionen"]["titel"],
"titel": x["text"], "eintrag": x,
"text": _funktionstext("", x),
# Ohne die Feldnamen: „Regeln:" steht über jedem Block, und eine
# Funktion namens „Regeln anpassen" verlöre sonst ihr eigenes Wort
"worte": " ".join((f["sektionen"]["titel"], x["text"], x["ziel"],
x["regeln"], x["testen"]))}
return raus
def _gewicht(f: dict) -> tuple:
"""Wer mehr sagt, bleibt stehen. Bei Gleichstand entscheidet der Name — nicht
schön, aber vorhersagbar; ein Zufallssieger machte den Vorschlag unstet."""
x = f["eintrag"]
zeilen = len([z for z in (x["regeln"] + "\n" + x["testen"]).splitlines() if z.strip()])
return (zeilen, len(x["ziel"]), f["titel"])
_WORT = re.compile(r"[a-zä-üß0-9]{4,}")
# Beugung abschneiden statt richtig stemmen: „entfernen" und „entfernt" sind
# dasselbe Wort, und ein Stemmer legte die Sprache im Code fest.
STAMM = 5
def _kerne(alle: dict) -> dict:
"""Je Funktion die Wörter, die sie von den anderen unterscheiden.
Was in jeder fünften Funktion steht, fliegt raus. Sonst misst der Vergleich nur
noch die gemeinsame Schrittfolge, und „Titel anpassen" und „Ziel anpassen"
stimmen zu hundert Prozent überein.
"""
mengen = {k: set(_WORT.findall(v["worte"].lower())) for k, v in alle.items()}
haeufig = Counter(wort for m in mengen.values() for wort in m)
grenze = max(3, int(len(mengen) * FLOSKEL_ANTEIL))
return {k: {w[:STAMM] for w in m if haeufig[w] < grenze} for k, m in mengen.items()}
def _ueberlappung(a: set, b: set) -> float:
return len(a & b) / min(len(a), len(b)) if a and b else 0.0
def _kandidaten(alle: dict, belege: dict) -> list[tuple]:
"""Paare, die überhaupt eine Dublette sein könnten, mit dem Anlass.
Drei Kanäle, weil eine Dublette auf drei Arten auffällt: dieselbe Codezeile als
Beleg, fast derselbe Titel, fast dieselben Wörter. Der letzte fängt, was die
ersten beiden durchlassen — „Funktion löschen" und „Funktion entfernen" haben
weder denselben Titel noch zwingend denselben Beleg.
Jedes Paar zu fragen wäre quadratisch und träfe fast nie. Erst billig
eingrenzen, dann einmal teuer urteilen.
"""
paare = {}
def merke(a, b, anlass):
if a != b:
paare.setdefault(ledger.paarschluessel(a, b), (a, b, anlass))
nach_stelle = {}
for name, eintrag in belege.items():
if name in alle:
stelle = (eintrag["datei"], re.sub(r"\s+", " ", eintrag["beleg"]).strip())
nach_stelle.setdefault(stelle, []).append(name)
for (datei, zeile), gruppe in nach_stelle.items():
gruppe = sorted(gruppe)
for i, a in enumerate(gruppe):
for b in gruppe[i + 1:]:
merke(a, b, f"Beide belegen dieselbe Zeile in {datei}: {zeile}")
kern = _kerne(alle)
namen = sorted(alle)
for i, a in enumerate(namen):
for b in namen[i + 1:]:
if _aehnlich(alle[a]["titel"], alle[b]["titel"]) >= TITEL_KANAL:
merke(a, b, "Die Titel sind fast gleich.")
elif _ueberlappung(kern[a], kern[b]) >= INHALT_KANAL:
merke(a, b, "Beide Beschreibungen bestehen fast aus denselben Wörtern.")
return [(k, *paare[k]) for k in sorted(paare)]
async def _ist_dublette(a: dict, b: dict, anlass: str) -> bool:
"""Einstimmig gleicher Gegenstand und gleiches Ergebnis — sonst keine Dublette.
Das Gesamturteil rechnet der Code aus den Teilantworten. Gefragt nach der
Dublette an sich, widersprach eine Stimme ihrer eigenen Analyse: derselbe
Gegenstand, dasselbe Tun, und trotzdem „nein". Zwei kleine Fragen beantwortet
sie verlässlich, die große nicht.
Gefragt wird nach dem Ergebnis, nicht nach dem Verb. „Löschen" und „entfernen"
hielten zwei von drei Stimmen für verschiedenes Tun — was danach anders ist,
beschrieben sie gleich.
"""
nachricht = DUBLETTE.format(text_a=a["text"], text_b=b["text"], anlass=anlass)
proben = await asyncio.gather(*(
_urteil(DUBLETTE_SYSTEM, nachricht, t) for t in DUBLETTE_TEMPERATUREN
), return_exceptions=True)
stimmen = [p for p in proben if isinstance(p, dict)]
if len(stimmen) < len(DUBLETTE_TEMPERATUREN):
raise RuntimeError(f'Dublette {a["titel"]}/{b["titel"]}: '
f"nur {len(stimmen)} von "
f"{len(DUBLETTE_TEMPERATUREN)} Stimmen brauchbar")
return all(u.get("gegenstand_gleich") is True and u.get("ergebnis_gleich") is True
for u in stimmen)
async def dubletten(features: list, stand: dict, melde=None) -> list:
"""Funktionen, die eine andere Funktion doppelt beschreiben.
Gemeldet wird immer die schwächere von beiden — die stärkere bleibt stehen.
Ein einmal gefälltes Urteil bleibt gemerkt, auch das ablehnende: sonst zahlt
jeder Lauf erneut für dieselbe Frage und antwortet womöglich anders.
"""
alle = _funktionen(features)
paare = _kandidaten(alle, stand["belege"])
stand["paare"] = ledger.behalte(stand["paare"], {p[0] for p in paare})
if len(paare) > DUBLETTEN_CAP:
if melde:
melde(f"{len(paare)} Kandidatenpaare, geprüft wurden {DUBLETTEN_CAP}")
paare = paare[:DUBLETTEN_CAP]
async def urteile(paar):
schluessel, a, b, anlass = paar
links, rechts = alle[a], alle[b]
wie_gehabt = ledger.abdruck(_DUBLETTE_STAND, links["text"], rechts["text"])
gemerkt = stand["paare"].get(schluessel)
if gemerkt and gemerkt["abdruck"] == wie_gehabt:
gleich = gemerkt["gleich"]
else:
gleich = await _ist_dublette(links, rechts, anlass)
stand["paare"][schluessel] = {"abdruck": wie_gehabt, "gleich": gleich}
if not gleich:
return None
schwach, stark = sorted((links, rechts), key=_gewicht)
return {"feature": schwach["feature"], "titel": schwach["titel"],
"art": "dublette",
"grund": f'Beschreibt „{stark["titel"]}“ '
f'(Karte {stark["karte"]}) ein zweites Mal'}
return await _sammle([urteile(p) for p in paare], "Funktionspaare", melde)
def _wegfall_grund(betroffen: list) -> str:
"""Was die Karte verliert, in einem Halbsatz. Ohne Trennung nach Anlass stand
über einer Dublette „ohne eigenen Code" — beides heißt streichen, aber aus
verschiedenen Gründen, und der Grund entscheidet, ob man zustimmt."""
teile = []
for art, eins, viele in (("dublette", "Funktion doppelt", "Funktionen doppelt"),
("ohne_code", "Funktion ohne eigenen Code",
"Funktionen ohne eigenen Code")):
n = sum(x["art"] == art for x in betroffen)
if n:
teile.append(f"{n} {eins if n == 1 else viele}")
return ", ".join(teile)
def probleme_feature(features: list, ohne_code: list) -> list: def probleme_feature(features: list, ohne_code: list) -> list:
@@ -214,13 +508,16 @@ def probleme_feature(features: list, ohne_code: list) -> list:
raus = [] raus = []
for f in features: for f in features:
ist = [x["text"] for x in f["sektionen"]["funktionen"]] ist = [x["text"] for x in f["sektionen"]["funktionen"]]
betroffen = [x for x in ohne_code if x["feature"] == f["id"]] betroffen, gesehen = [], set()
for x in ohne_code:
if x["feature"] == f["id"] and x["titel"] not in gesehen:
gesehen.add(x["titel"])
betroffen.append(x)
if not betroffen: if not betroffen:
continue continue
weg = [x["titel"] for x in betroffen] weg = [x["titel"] for x in betroffen]
neu = [t for t in ist if t not in weg] neu = [t for t in ist if t not in weg]
grund = (f"{len(weg)} Funktion ohne Code" if len(weg) == 1 grund = _wegfall_grund(betroffen)
else f"{len(weg)} Funktionen ohne Code")
beleg = " · ".join(f'{x["titel"]}: {x["grund"]}' for x in betroffen) beleg = " · ".join(f'{x["titel"]}: {x["grund"]}' for x in betroffen)
for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu), for p in pruefung._stellen("funktionen", "\n".join(ist), "\n".join(neu),
grund, beleg): grund, beleg):
@@ -237,6 +534,10 @@ def _funktionsliste(features: list) -> str:
Nur die Titel reichten nicht: der Richter musste raten, ob eine Funktion eine Nur die Titel reichten nicht: der Richter musste raten, ob eine Funktion eine
Stelle abdeckt, und urteilte von Runde zu Runde anders. 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 = [] teile = []
for f in features: for f in features:
@@ -244,6 +545,10 @@ def _funktionsliste(features: list) -> str:
block = [f'## Karte: {s["titel"]}'] block = [f'## Karte: {s["titel"]}']
if s["ziel"]: if s["ziel"]:
block.append(f'Ziel: {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"]: for x in s["funktionen"]:
block.append(f'\n### {x["text"]} (Karte: {s["titel"]})') block.append(f'\n### {x["text"]} (Karte: {s["titel"]})')
if x["ziel"]: if x["ziel"]:
@@ -268,43 +573,118 @@ async def _abdeckung(punkt: dict, liste: str) -> list:
return [p for p in proben if isinstance(p, dict)] return [p for p in proben if isinstance(p, dict)]
def _naechste_karte(name: str, karten: dict): def _naechste_karte(name: str, karten: dict, schwelle: float = 0.6):
if not name: if not name or not karten:
return None return None
treffer = max(karten, key=lambda k: _aehnlich(name, k)) treffer = max(karten, key=lambda k: _aehnlich(name, k))
return karten[treffer] if _aehnlich(name, treffer) > 0.6 else None return karten[treffer] if _aehnlich(name, treffer) >= schwelle else None
async def code_im_feature(features: list, punkte: list, zaehl=None) -> list: def _zielkarte(urteile: list, karten: dict):
"""Bedienstellen, die von keiner Funktion abgedeckt sind.""" """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) liste = _funktionsliste(features)
karten = {f["sektionen"]["titel"]: f for f in 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): 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) urteile = await _abdeckung(punkt, liste)
if zaehl: if zaehl:
zaehl() zaehl()
if len(urteile) < FEHLT_AB: nein = sum(u.get("abgedeckt") is False for u in urteile)
# Nicht verschweigen: ausgefallene Stimmen sähen sonst aus wie „ist ausgefallen = len(URTEIL_TEMPERATUREN) - len(urteile)
# abgedeckt" und der Lauf meldete stillschweigend zu wenig. Ein if len(urteile) < URTEIL_AB or nein < FEHLT_AB <= nein + ausgefallen:
# einzelner Ausfall darf den Lauf aber nicht abreißen — es zählt, ob # Zu dünn oder offen. Dünn: zwei überlebende Stimmen genügen rechnerisch
# noch genug Stimmen übrig sind, um die Schwelle zu erreichen. # 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( raise RuntimeError(
f'{punkt["schluessel"]}: nur {len(urteile)} von ' f'{punkt["schluessel"]}: nur {len(urteile)} von '
f"{len(URTEIL_TEMPERATUREN)} Stimmen brauchbar") f"{len(URTEIL_TEMPERATUREN)} Stimmen brauchbar")
if sum(u.get("abgedeckt") is False for u in urteile) < FEHLT_AB: stand["abdeckung"].pop(schluessel, None)
stand["ohne_karte"].pop(schluessel, None)
# Steht das Ergebnis schon fest, zählen die fehlenden Stimmen nicht mehr.
# Vier Stimmen „abgedeckt" bei sechs möglichen können die Schwelle nicht
# mehr reißen — daraus einen Fehlschlag zu machen, wäre Verschwendung.
if nein < FEHLT_AB:
# Abgedeckt — oder unentschieden, was dasselbe Ergebnis hat. Gemerkt
# wird beides; der Name nur, wenn die Stimmen sich auf einen einigen.
namen = Counter(str(u.get("funktion") or "").strip()
for u in urteile if u.get("abgedeckt"))
name, treffer = namen.most_common(1)[0] if namen else ("", 0)
# Nur ein Name, den es wirklich gibt, taugt als Verfallsdatum. Deckt
# eine Regel die Stelle ab, nennt die Stimme deren Wortlaut — der
# stünde auf ewig ungültig in der Datei und würde jeden Lauf neu gefragt.
gilt = treffer >= len(urteile) / 2 and _normal(name) in normal_da
stand["abdeckung"][schluessel] = {"abdruck": wie_gehabt,
"funktion": name if gilt else ""}
return None return None
ziele = [str(u.get("karte") or "").strip() for u in urteile]
name = max(set(ziele), key=ziele.count) treffer = _zielkarte(urteile, karten)
treffer = karten.get(name) or _naechste_karte(name, karten)
titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")] titel = [str(u.get("titel") or "").strip() for u in urteile if u.get("titel")]
if not treffer or not titel: if not treffer or not titel:
return None return None
return {"feature": treffer["id"], gefunden = {"feature": treffer["id"],
"titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)), "titel": max(titel, key=lambda t: sum(_aehnlich(t, x) for x in titel)),
"beleg": f'{punkt["datei"]}:{punkt["zeile"]}'} "beleg": f'{punkt["datei"]}:{punkt["zeile"]}'}
stand["ohne_karte"][schluessel] = {"abdruck": wie_gehabt, **gefunden}
return gefunden
gefunden = [x for x in await asyncio.gather(*(eine(p) for p in punkte)) if x] gefunden = await _sammle([eine(p) for p in punkte], "Codestellen", melde)
# Zwei Stellen können auf dieselbe fehlende Funktion zeigen # Zwei Stellen können auf dieselbe fehlende Funktion zeigen
raus = [] raus = []
for x in gefunden: for x in gefunden:
@@ -313,7 +693,7 @@ async def code_im_feature(features: list, punkte: list, zaehl=None) -> list:
if gleich: if gleich:
gleich["beleg"] += f', {x["beleg"]}' gleich["beleg"] += f', {x["beleg"]}'
else: else:
raus.append(x) raus.append(dict(x))
return raus return raus
@@ -343,14 +723,26 @@ def probleme_code(features: list, fehlend: list) -> list:
# ---------------------------------------------------------------- Einstiege # ---------------------------------------------------------------- Einstiege
async def pruefe_feature_im_code(features: list, quelle: dict, quelltext: str, async def pruefe_feature_im_code(projekt: str, features: list, quelle: dict,
zaehl=None) -> list: quelltext: str, zaehl=None, melde=None) -> list:
ohne = await feature_im_code(features, quelle, quelltext, zaehl) stand = ledger.lade(projekt)
return probleme_feature(features, ohne) try:
ohne = await feature_im_code(features, quelle, quelltext, stand, zaehl, melde)
doppelt = await dubletten(features, stand, melde)
finally:
# Auch ein abgebrochener Lauf hat schon Urteile gefällt; die sind bezahlt
ledger.sichere(projekt, stand)
return probleme_feature(features, ohne + doppelt)
async def pruefe_code_im_feature(features: list, quelle: dict, zaehl=None) -> list: async def pruefe_code_im_feature(projekt: str, features: list, quelle: dict,
zaehl=None, melde=None) -> list:
punkte = quellcode.interaktionen(quelle) punkte = quellcode.interaktionen(quelle)
if not punkte: if not punkte:
raise RuntimeError("Keine Bedienstellen im Quelltext gefunden") raise RuntimeError("Keine Bedienstellen im Quelltext gefunden")
return probleme_code(features, await code_im_feature(features, punkte, zaehl)) stand = ledger.lade(projekt)
try:
fehlend = await code_im_feature(features, punkte, stand, zaehl, melde)
finally:
ledger.sichere(projekt, stand)
return probleme_code(features, fehlend)

97
backend/ledger.py Normal file
View File

@@ -0,0 +1,97 @@
"""Gedächtnis der Inhaltsprüfungen: was einmal beurteilt wurde, bleibt beurteilt.
Ein Urteil über eine Codestelle kostet sechs Anfragen und fällt bei Grenzfällen mal
so, mal so. Genau daran scheiterte die Wiederholbarkeit: derselbe Stand lieferte in
Runde 1 einen Befund, in Runde 2 keinen, in Runde 3 wieder einen.
Gespeichert wird deshalb das Urteil, nicht nur das Ergebnis. Der nächste Lauf fragt
nur noch, was er nicht schon weiß. Ein Eintrag gilt weiter, solange seine
Voraussetzung nachweisbar dasteht:
- ein Beleg, solange die zitierte Zeile noch in der Datei steht
- eine Abdeckung, solange die nennende Funktion noch auf einer Karte steht
- eine Fehlmeldung, solange keine passende Funktion dazugekommen ist
Fällt die Voraussetzung weg, fällt der Eintrag weg und wird neu gefragt. Damit
bleibt gleicher Stand gleiches Ergebnis, und geänderter Stand wird wirklich neu
beurteilt.
Die Datei liegt im Projektordner. Sie wandert beim Umbenennen mit und verschwindet
beim Löschen — ohne Zutun, weil beides den Ordner bewegt. Wer von vorn anfangen
will, löscht sie; der nächste Lauf fragt dann wieder alles.
"""
import hashlib
import json
import projekte
DATEI = ".pruefung.json"
# Ein Fach je Frage. Getrennt, weil sie unterschiedlich ungültig werden.
FAECHER = (
"belege", # Funktion -> Codestelle, die sie umsetzt (Prüfung 2)
"ohne_code", # Funktion -> kein Code gefunden (Prüfung 2)
"paare", # Funktionspaar -> Dublette oder verschieden (Prüfung 2)
"abdeckung", # Codestelle -> Funktion, die sie beschreibt (Prüfung 3)
"ohne_karte", # Codestelle -> keine Funktion beschreibt sie (Prüfung 3)
)
def _pfad(projekt: str):
return projekte.wurzel(projekt) / DATEI
def lade(projekt: str) -> dict:
"""Der gespeicherte Stand. Kaputte oder fehlende Datei heißt: nichts gemerkt."""
try:
daten = json.loads(_pfad(projekt).read_text())
except (OSError, ValueError):
daten = {}
return {fach: dict(daten.get(fach) or {}) for fach in FAECHER}
def sichere(projekt: str, stand: dict):
"""Sortiert und eingerückt — die Datei soll sich lesen und vergleichen lassen.
Erst daneben schreiben, dann umbenennen. Ein Absturz mitten im Schreiben ließe
sonst eine halbe Datei zurück, und das Gedächtnis eines ganzen Laufs wäre weg —
teuer bezahlt und nicht wiederherstellbar.
"""
text = json.dumps({fach: stand.get(fach) or {} for fach in FAECHER},
indent=1, ensure_ascii=False, sort_keys=True)
ziel = _pfad(projekt)
neben = ziel.with_suffix(".json.neu")
neben.write_text(text + "\n")
neben.replace(ziel)
def abdruck(*teile: str) -> str:
"""Kurzer Fingerabdruck über den Stand, der ein Urteil trägt."""
roh = json.dumps([t or "" for t in teile], ensure_ascii=False).encode()
return hashlib.sha1(roh).hexdigest()[:16]
# Schlüssel sind JSON-Listen. Ein Trennzeichen wäre einfacher, aber Kartennamen und
# Funktionstitel dürfen alles enthalten — und ein Schlüssel, der sich nicht wieder
# zerlegen lässt, ist keiner.
def schluessel(*teile: str) -> str:
return json.dumps(list(teile), ensure_ascii=False)
def paarschluessel(a: str, b: str) -> str:
return json.dumps(sorted((a, b)), ensure_ascii=False)
def teile(wert: str) -> list:
try:
gelesen = json.loads(wert)
except ValueError:
return []
return gelesen if isinstance(gelesen, list) else []
def behalte(fach: dict, gueltig: set) -> dict:
"""Einträge zu verschwundenen Stellen wegräumen — sonst wächst die Datei ewig."""
return {k: v for k, v in fach.items() if k in gueltig}

View File

@@ -14,6 +14,16 @@ import re
MARKER = re.compile(r"^<!--\s*([a-zä-ü]+)\s*-->\s*$") MARKER = re.compile(r"^<!--\s*([a-zä-ü]+)\s*-->\s*$")
TRENNER = re.compile(r"^-{3,}\s*$") TRENNER = re.compile(r"^-{3,}\s*$")
# „vision" ist keine Einstufung, sondern eine eigene Kartenart: sie hält fest, wie
# es gedacht ist, und wird von keiner Prüfung angefasst. Unbekanntes ist „kern".
VISION = "vision"
ARTEN = ("kern", "optional", VISION)
def _art(bloecke: list) -> str:
wert = bloecke[0].strip().lower() if bloecke else ""
return wert if wert in ARTEN else "kern"
def zerlege(roh: str) -> dict: def zerlege(roh: str) -> dict:
# Erst in Blöcke (schluessel, text) in Dateireihenfolge schneiden # Erst in Blöcke (schluessel, text) in Dateireihenfolge schneiden
@@ -70,7 +80,7 @@ def zerlege(roh: str) -> dict:
return { return {
"titel": titel_zeilen[0] if titel_zeilen else "", "titel": titel_zeilen[0] if titel_zeilen else "",
"art": "optional" if art and art[0].strip().lower() == "optional" else "kern", "art": _art(art),
"ziel": "\n\n".join(ziel), "ziel": "\n\n".join(ziel),
"regeln": "\n".join(regeln), "regeln": "\n".join(regeln),
"funktionen": funktionen, "funktionen": funktionen,

View File

@@ -472,12 +472,12 @@ async def _pruefe(stil: dict, aufgabe: dict) -> list[dict]:
async def _stil_aufgaben(projekt: str): 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."""
stil = {b: _stil_lesen(b) for b in BEREICHE} stil = {b: _stil_lesen(b) for b in BEREICHE}
liste = [a for f in features.liste(projekt) for a in aufgaben(f)] liste = [a for f in features.pruefbar(projekt) for a in aufgaben(f)]
return liste, lambda a, _zaehl: _pruefe(stil, a) return liste, lambda a, _zaehl: _pruefe(stil, a)
def _quelle(projekt: str): def _quelle(projekt: str):
alle = features.liste(projekt) alle = features.pruefbar(projekt)
quelle = quellcode.dateien() quelle = quellcode.dateien()
if not quelle: if not quelle:
raise ValueError("Kein Quelltext gefunden") raise ValueError("Kein Quelltext gefunden")
@@ -485,7 +485,8 @@ def _quelle(projekt: str):
async def _feature_aufgaben(projekt: str): async def _feature_aufgaben(projekt: str):
"""Ein Lauf über alle Funktionen: steht hinter jeder auch Code? """Ein Lauf über alle Funktionen: steht hinter jeder auch Code? Und beschreibt
keine zwei Funktionen dieselbe Sache?
Ein Aufruf statt vieler, weil das Ergebnis je Karte gebündelt wird — zwei 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. Vorschläge für dieselbe Funktionsliste wären nicht unabhängig anwendbar.
@@ -496,7 +497,8 @@ async def _feature_aufgaben(projekt: str):
anzahl = sum(len(f["sektionen"]["funktionen"]) for f in alle) anzahl = sum(len(f["sektionen"]["funktionen"]) for f in alle)
async def abgleich(_, zaehl): async def abgleich(_, zaehl):
return await inhaltspruefung.pruefe_feature_im_code(alle, quelle, text, zaehl) return await inhaltspruefung.pruefe_feature_im_code(
projekt, alle, quelle, text, zaehl, _melder(projekt))
return _platzhalter(anzahl), abgleich return _platzhalter(anzahl), abgleich
@@ -508,11 +510,26 @@ async def _code_aufgaben(projekt: str):
punkte = quellcode.interaktionen(quelle) punkte = quellcode.interaktionen(quelle)
async def abgleich(_, zaehl): async def abgleich(_, zaehl):
return await inhaltspruefung.pruefe_code_im_feature(alle, quelle, zaehl) return await inhaltspruefung.pruefe_code_im_feature(
projekt, alle, quelle, zaehl, _melder(projekt))
return _platzhalter(len(punkte)), abgleich 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: def _platzhalter(anzahl: int) -> list:
"""Eine echte Aufgabe, der Rest nur für den Fortschrittsbalken.""" """Eine echte Aufgabe, der Rest nur für den Fortschrittsbalken."""
return [True] + [None] * max(0, anzahl - 1) return [True] + [None] * max(0, anzahl - 1)

View File

@@ -89,7 +89,16 @@ def _umfeld(zeilen: list, nr: int, rand: int = 5) -> str:
return "\n".join(zeilen[von:bis]).strip() return "\n".join(zeilen[von:bis]).strip()
_ENDE = re.compile(r"^(function |const |async function |\S)") def _pyrumpf(zeilen: list, nr: int, hoechstens: int = 14) -> str:
"""Der eingerückte Block unter einer Zeile — für Python statt geschweifter Klammern."""
if nr > len(zeilen):
return "" # Dekorator ohne Rumpf, etwa am Dateiende
block = [zeilen[nr - 1]]
for weiter in zeilen[nr:nr + hoechstens]:
if weiter.strip() and not weiter[:1].isspace() and not weiter.startswith("@"):
break
block.append(weiter)
return "\n".join(block).rstrip()
def _rumpf(zeilen: list, name: str, hoechstens: int = 14) -> str: def _rumpf(zeilen: list, name: str, hoechstens: int = 14) -> str:
@@ -120,9 +129,17 @@ def interaktionen(quelle: dict = None) -> list[dict]:
Dazu Fenster-Lauscher: ein Tastaturgriff ist eine Fähigkeit, auch ohne Knopf. Dazu Fenster-Lauscher: ein Tastaturgriff ist eine Fähigkeit, auch ohne Knopf.
Backend-Routen stehen bewusst nicht drin. Jede nutzbare Route hängt an einem Dazu die Backend-Routen. Sie standen zwischenzeitlich nicht drin, weil sie
Knopf, der schon in der Liste steht — als eigene Einträge wurden sie doppelt doppelt gemeldet und dabei der falschen Karte zugeordnet wurden. Beides ist
gemeldet und dabei der falschen Karte zugeordnet. 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 quelle = dateien() if quelle is None else quelle
raus, gesehen = [], set() raus, gesehen = [], set()
@@ -144,6 +161,11 @@ def interaktionen(quelle: dict = None) -> list[dict]:
for m in _LAUSCHER.finditer(zeile): for m in _LAUSCHER.finditer(zeile):
merke(f"lauscher:{m.group(1)}.{m.group(2)}", "Tastatur oder Maus", merke(f"lauscher:{m.group(1)}.{m.group(2)}", "Tastatur oder Maus",
pfad, nr, zeilen, zeile) 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"): if not pfad.endswith(".vue"):
continue continue
for m in _EREIGNIS.finditer(zeile): for m in _EREIGNIS.finditer(zeile):

View File

@@ -44,7 +44,9 @@ def test_interaktionen_sind_deterministisch():
# Zwölf Aufrufe von starte() sind eine Fähigkeit, nicht zwölf # Zwölf Aufrufe von starte() sind eine Fähigkeit, nicht zwölf
assert schluessel.count("bedienen:starte") == 1 assert schluessel.count("bedienen:starte") == 1
assert "bedienen:menue" in schluessel assert "bedienen:menue" in schluessel
assert not any(s.startswith("schnittstelle:") for s in schluessel) # Verdrahtung # Routen: der einzige Weg an Parser, Dateiverwaltung und Prüflogik
assert "route:get /projekte" in schluessel
assert "route:post /projekte" in schluessel
assert "menue:+ Projekt erstellen" in schluessel assert "menue:+ Projekt erstellen" in schluessel
# Technische Aufhänger und Zuweisungen sind keine Fähigkeiten # 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:fertig") for s in schluessel)

View File

@@ -0,0 +1,532 @@
"""Das Gedächtnis der Inhaltsprüfungen: was gilt weiter, was wird neu gefragt.
Die Prüfungen 2 und 3 fragen ein Modell. Dieselbe Frage kippt bei Grenzfällen auch
bei Temperatur 0 — vorher meldete Runde 1 einen Befund, Runde 2 keinen, Runde 3
wieder einen. Hier steht, unter welchen Bedingungen ein Urteil stehen bleibt.
"""
import json
import pytest
import agents
import features
import inhalt
import ledger
import projekte
@pytest.fixture
def projekt(datenordner):
projekte.anlegen("Test")
return "Test"
def karte(projekt, titel, *funktionen):
"""funktionen sind (name, regel) oder (name, regel, [testschritte])."""
text = f"<!-- titel -->\n{titel}\n"
for eintrag in funktionen:
name, regel, schritte = (*eintrag, [])[:3]
text += f"<!-- funktion -->\n{name}\n<!-- regeln -->\n- {regel}\n"
if schritte:
text += "<!-- testen -->\n" + "".join(f"- {s}\n" for s in schritte)
return features.anlegen(projekt, text)
QUELLE = {"frontend/src/App.vue": '<button @click="starte">los</button>\n'}
class Zaehler:
"""Ersetzt das Modell und zählt mit, wie oft überhaupt gefragt wurde."""
def __init__(self, antwort):
self.antwort = antwort
self.rufe = 0
async def __call__(self, system, nachricht, temperatur=0.0):
self.rufe += 1
wert = self.antwort
return wert(nachricht, self.rufe) if callable(wert) else wert
# ---------------------------------------------------------------- Datei
def test_leeres_gedaechtnis_hat_alle_faecher(projekt):
stand = ledger.lade(projekt)
assert set(stand) == set(ledger.FAECHER)
assert all(v == {} for v in stand.values())
def test_kaputte_datei_wird_wie_leer_behandelt(projekt):
(projekte.wurzel(projekt) / ledger.DATEI).write_text("{kein json")
assert ledger.lade(projekt)["belege"] == {}
def test_gemerktes_ueberlebt_den_lauf(projekt):
stand = ledger.lade(projekt)
stand["belege"][ledger.schluessel("a", "Tun")] = {"abdruck": "x", "datei": "a.py",
"beleg": "def tun():"}
ledger.sichere(projekt, stand)
assert ledger.lade(projekt)["belege"] == stand["belege"]
def test_schluessel_bleibt_zerlegbar(projekt):
"""Titel dürfen alles enthalten — auch das, was als Trennzeichen taugen würde."""
fies = 'Klick auf "x", dann | und \\ und ["a","b"]'
assert ledger.teile(ledger.schluessel("karte", fies)) == ["karte", fies]
def test_gedaechtnis_ist_kein_feature(projekt):
karte(projekt, "Eins")
ledger.sichere(projekt, ledger.lade(projekt))
assert [f["sektionen"]["titel"] for f in features.liste(projekt)] == ["Eins"]
# ---------------------------------------------------------------- Prüfung 2
GEFUNDEN = json.dumps({"gefunden": True, "datei": "frontend/src/App.vue",
"beleg": '<button @click="starte">los</button>'})
async def test_beleg_wird_gemerkt_und_beim_zweiten_lauf_nicht_gefragt(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(GEFUNDEN)
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == []
erste_runde = modell.rufe
assert erste_runde >= len(inhalt.SUCHE_TEMPERATUREN)
assert await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text) == []
assert modell.rufe == erste_runde # keine einzige neue Anfrage
async def test_beleg_verfaellt_wenn_die_zeile_verschwindet(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(GEFUNDEN)
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
vorher = modell.rufe
anders = {"frontend/src/App.vue": "<div>nichts mehr</div>\n"}
await inhalt.pruefe_feature_im_code(projekt, alle, anders, "### x\n<div>")
assert modell.rufe > vorher # der Beleg trägt nicht mehr, also neu fragen
async def test_geaenderte_funktion_wird_neu_gefragt(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(GEFUNDEN)
monkeypatch.setattr(agents, "frage", modell)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
await inhalt.pruefe_feature_im_code(projekt, features.liste(projekt), QUELLE, text)
vorher = modell.rufe
datei = projekte.wurzel(projekt) / f"{features.liste(projekt)[0]['id']}.md"
datei.write_text(datei.read_text().replace("- Knopf", "- Ganz anderer Inhalt"))
await inhalt.pruefe_feature_im_code(projekt, features.liste(projekt), QUELLE, text)
assert modell.rufe > vorher
async def test_fehlanzeige_gilt_nur_bei_unveraendertem_quelltext(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(json.dumps({"gefunden": False, "grund": "gibt es nicht"}))
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
text = "### frontend/src/App.vue\n" + QUELLE["frontend/src/App.vue"]
erste = await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
assert len(erste) == 1
vorher = modell.rufe
zweite = await inhalt.pruefe_feature_im_code(projekt, alle, QUELLE, text)
assert [p["vorschlag"] for p in zweite] == [p["vorschlag"] for p in erste]
assert modell.rufe == vorher
# Neuer Quelltext: eine Löschempfehlung muss frisch belegt werden
mehr = {**QUELLE, "backend/neu.py": "def starte():\n pass\n"}
await inhalt.pruefe_feature_im_code(projekt, alle, mehr, text)
assert modell.rufe > vorher
# ---------------------------------------------------------------- Dubletten
def punkt(schluessel="bedienen:starte", beleg='<button @click="starte">los</button>'):
return {"schluessel": schluessel, "art": "Bedienung",
"datei": "frontend/src/App.vue", "zeile": 1, "beleg": beleg,
"umfeld": beleg, "rumpf": ""}
async def test_gleicher_beleg_macht_zwei_funktionen_zu_kandidaten(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
karte(projekt, "Zwei", ("Lauf beginnen", "Knopf"))
monkeypatch.setattr(agents, "frage", Zaehler(json.dumps({"gegenstand_gleich": True, "ergebnis_gleich": True})))
alle = features.liste(projekt)
stand = ledger.lade(projekt)
for f in alle:
stand["belege"][ledger.schluessel(f["id"], f["sektionen"]["funktionen"][0]["text"])] = {
"abdruck": "egal", "datei": "frontend/src/App.vue",
"beleg": '<button @click="starte">los</button>'}
gefunden = await inhalt.dubletten(alle, stand)
assert len(gefunden) == 1
# Gleich stark: der Titel entscheidet, damit der Vorschlag nicht springt
assert gefunden[0]["titel"] == "Etwas starten"
assert "Lauf beginnen" in gefunden[0]["grund"]
async def test_uneiniges_urteil_ist_keine_dublette_und_wird_gemerkt(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
karte(projekt, "Zwei", ("Lauf beginnen", "Knopf"))
def wechselhaft(nachricht, ruf):
return json.dumps({"gegenstand_gleich": True, "ergebnis_gleich": ruf == 1})
modell = Zaehler(wechselhaft)
monkeypatch.setattr(agents, "frage", modell)
alle = features.liste(projekt)
stand = ledger.lade(projekt)
for f in alle:
stand["belege"][ledger.schluessel(f["id"], f["sektionen"]["funktionen"][0]["text"])] = {
"abdruck": "egal", "datei": "a.vue", "beleg": "x"}
assert await inhalt.dubletten(alle, stand) == []
vorher = modell.rufe
assert await inhalt.dubletten(alle, stand) == []
assert modell.rufe == vorher # Ablehnung wird gemerkt, nicht neu bezahlt
def test_gegenstueck_faellt_nicht_ueber_den_titel():
"""anlegen/entfernen und öffnen/schließen dürfen den Titelkanal nicht auslösen —
sonst kostet jedes Gegensatzpaar drei Anfragen für nichts."""
for a, b in (("Feature anlegen", "Feature entfernen"),
("Panel öffnen", "Panel schließen")):
assert inhalt._aehnlich(a, b) < inhalt.TITEL_KANAL
ENTFERNEN = ["Vollmodus öffnen", "RMB auf die Funktion",
'Klick auf "Funktion entfernen"', "Funktion entfernt"]
def test_umschriebene_dublette_wird_kandidat(projekt):
"""Anderer Titel, andere Karte, dieselbe Sache. Weder Titel noch Beleg fangen
das — nur der Inhaltskanal."""
karte(projekt, "Funktionen",
("Funktion entfernen", "x-Icon", ENTFERNEN),
("Funktion anlegen", "+-Icon", ["Vollmodus öffnen", "RMB auf die Karte",
'Klick auf "Funktion erstellen"',
"Funktion angelegt"]),
("Testschritte anpassen", "Ein Schritt je Zeile",
["Vollmodus öffnen", "LMB auf Testen", "Text ändern", "Schritte angepasst"]))
karte(projekt, "Features", ("Funktion löschen", "x-Icon", ENTFERNEN))
alle = inhalt._funktionen(features.liste(projekt))
paare = {frozenset((alle[a]["titel"], alle[b]["titel"])) for _, a, b, _ in
inhalt._kandidaten(alle, {})}
assert frozenset(("Funktion entfernen", "Funktion löschen")) in paare
assert frozenset(("Funktion anlegen", "Funktion entfernen")) not in paare
def test_floskeln_zaehlen_nicht_mit(projekt):
"""Was fast überall steht, unterscheidet nichts und fliegt aus dem Vergleich."""
schritte = ["Vollmodus öffnen", "Text ändern", "Angepasst"]
karte(projekt, "Eins",
*[(f"{feld} anpassen", "eine Regel", schritte)
for feld in ("Titel", "Ziel", "Regeln", "Testen", "Badge")])
kerne = inhalt._kerne(inhalt._funktionen(features.liste(projekt)))
gemeinsam = set.intersection(*kerne.values())
assert gemeinsam == set() # „vollmodus", „ändern", „angepasst" sind raus
assert all(k for k in kerne.values()) # jede Funktion behält ihr eigenes Wort
# ---------------------------------------------------------------- Prüfung 3
ABGEDECKT = json.dumps({"abgedeckt": True, "funktion": "Etwas starten"})
FEHLT = json.dumps({"abgedeckt": False, "karte": "Eins", "titel": "Neu bedienen"})
async def test_abdeckung_wird_gemerkt_solange_die_funktion_dasteht(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(ABGEDECKT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
vorher = modell.rufe
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
assert modell.rufe == vorher
# Funktion weg: die Abdeckung trägt nicht mehr
ohne = features.liste(projekt)
ohne[0]["sektionen"]["funktionen"] = []
await inhalt.code_im_feature(ohne, [punkt()], stand)
assert modell.rufe > vorher
async def test_geaenderte_codestelle_wird_neu_beurteilt(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(ABGEDECKT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
await inhalt.code_im_feature(alle, [punkt()], stand)
vorher = modell.rufe
await inhalt.code_im_feature(alle, [punkt(beleg="ganz anderer Code")], stand)
assert modell.rufe > vorher
async def test_fehlmeldung_bleibt_wortgleich_bis_die_funktion_da_ist(
projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(FEHLT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
erste = await inhalt.code_im_feature(alle, [punkt()], stand)
assert [x["titel"] for x in erste] == ["Neu bedienen"]
vorher = modell.rufe
zweite = await inhalt.code_im_feature(alle, [punkt()], stand)
assert zweite == erste
assert modell.rufe == vorher # kein Flackern, keine neue Anfrage
async def test_angenommener_vorschlag_loest_die_fehlmeldung_ab(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
modell = Zaehler(FEHLT)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand)
vorher = modell.rufe
# Der Nutzer nimmt den Vorschlag an — jetzt muss neu beurteilt werden
modell.antwort = ABGEDECKT
karte(projekt, "Zwei", ("Neu bedienen", "Knopf"))
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand) == []
assert modell.rufe > vorher
async def test_unentschiedenes_urteil_wird_festgehalten(projekt, monkeypatch):
"""Drei zu drei ist ein Münzwurf. Der darf einmal fallen, nicht jede Runde."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
def haelfte(nachricht, ruf):
return ABGEDECKT if ruf % 2 else FEHLT
modell = Zaehler(haelfte)
monkeypatch.setattr(agents, "frage", modell)
stand = ledger.lade(projekt)
alle = features.liste(projekt)
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
vorher = modell.rufe
assert await inhalt.code_im_feature(alle, [punkt()], stand) == []
assert modell.rufe == vorher
async def test_verschwundene_stelle_raeumt_ihren_eintrag_weg(projekt, monkeypatch):
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
monkeypatch.setattr(agents, "frage", Zaehler(ABGEDECKT))
stand = ledger.lade(projekt)
alle = features.liste(projekt)
await inhalt.code_im_feature(alle, [punkt()], stand)
assert stand["abdeckung"]
await inhalt.code_im_feature(alle, [punkt(schluessel="bedienen:anderes")], stand)
assert "bedienen:starte" not in stand["abdeckung"]
def test_fehlende_stelle_landet_auch_auf_erfundener_karte(projekt):
"""Sechs Stimmen sagen „fehlt", nennen aber Karten, die es nicht gibt. Vorher
fiel der Befund lautlos weg."""
karte(projekt, "Projekte", ("Projekt anlegen", "+-Icon"))
karten = {f["sektionen"]["titel"]: f for f in features.liste(projekt)}
urteile = [{"abgedeckt": False, "karte": "Datenverwaltung", "titel": "x"}] * 4
assert inhalt._zielkarte(urteile, karten) is karten["Projekte"]
def test_erster_aufloesbarer_kartenname_gewinnt(projekt):
karte(projekt, "Projekte", ("Projekt anlegen", "+-Icon"))
karte(projekt, "Darkmode", ("Darkmode umschalten", "Icon"))
karten = {f["sektionen"]["titel"]: f for f in features.liste(projekt)}
urteile = ([{"abgedeckt": False, "karte": "Export", "titel": "x"}] * 3
+ [{"abgedeckt": False, "karte": "Darkmode", "titel": "x"}] * 2)
assert inhalt._zielkarte(urteile, karten) is karten["Darkmode"]
def test_ohne_karten_kein_ziel(projekt):
assert inhalt._zielkarte([{"abgedeckt": False, "karte": "X"}], {}) is None
def test_grund_trennt_dublette_von_fehlendem_code():
"""Beides heißt streichen — aber aus verschiedenen Gründen, und der Grund
entscheidet, ob man zustimmt."""
assert inhalt._wegfall_grund([{"art": "dublette"}]) == "1 Funktion doppelt"
assert inhalt._wegfall_grund([{"art": "ohne_code"}] * 2) \
== "2 Funktionen ohne eigenen Code"
assert inhalt._wegfall_grund([{"art": "dublette"}, {"art": "ohne_code"}]) \
== "1 Funktion doppelt, 1 Funktion ohne eigenen Code"
def test_halbe_datei_ueberschreibt_nicht_die_ganze(projekt, monkeypatch):
"""Absturz beim Schreiben: die alte Datei muss stehen bleiben."""
stand = ledger.lade(projekt)
stand["belege"]["a"] = {"abdruck": "x", "datei": "a.py", "beleg": "z"}
ledger.sichere(projekt, stand)
echt = ledger.json.dumps
def platzt(*a, **k):
raise MemoryError("mittendrin")
monkeypatch.setattr(ledger.json, "dumps", platzt)
with pytest.raises(MemoryError):
ledger.sichere(projekt, {"belege": {"b": {}}})
monkeypatch.setattr(ledger.json, "dumps", echt)
assert ledger.lade(projekt)["belege"] == stand["belege"]
async def test_ausgefallene_stimmen_merken_nichts(projekt, monkeypatch):
"""Fällt der Anbieter aus, darf keine Löschempfehlung im Gedächtnis landen."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
async def tot(system, nachricht, temperatur=0.0):
raise RuntimeError("429")
monkeypatch.setattr(agents, "frage", tot)
stand = ledger.lade(projekt)
gemeldet = []
assert await inhalt.feature_im_code(features.liste(projekt), QUELLE, "x", stand,
melde=lambda t, n=0: gemeldet.append(n)) == []
assert gemeldet == [1] # laut gescheitert, nicht still
assert stand["ohne_code"] == {}
assert stand["belege"] == {}
async def test_ein_ausfall_rettet_die_anderen_urteile(projekt, monkeypatch):
"""Eine kaputte Stelle darf die bezahlten Urteile der anderen nicht wegwerfen."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
async def launisch(system, nachricht, temperatur=0.0):
if "bedienen:kaputt" in nachricht:
raise RuntimeError("429")
return ABGEDECKT
monkeypatch.setattr(agents, "frage", launisch)
stand = ledger.lade(projekt)
gemeldet = []
await inhalt.code_im_feature(
features.liste(projekt),
[punkt(), punkt(schluessel="bedienen:kaputt", beleg="bedienen:kaputt")],
stand, melde=lambda t, n=0: gemeldet.append(n))
assert gemeldet == [1]
assert "bedienen:starte" in stand["abdeckung"]
assert "bedienen:kaputt" not in stand["abdeckung"]
async def test_projekt_ohne_karten_stuerzt_nicht_ab(projekt, monkeypatch):
monkeypatch.setattr(agents, "frage", Zaehler(FEHLT))
stand = ledger.lade(projekt)
assert await inhalt.code_im_feature([], [punkt()], stand) == []
assert await inhalt.dubletten([], stand) == []
assert await inhalt.feature_im_code([], QUELLE, "x", stand) == []
async def test_feststehendes_urteil_braucht_keine_vollen_stimmen(projekt, monkeypatch):
"""Vier von sechs sagen „abgedeckt": die zwei fehlenden könnten die Schwelle
nicht mehr reißen. Daraus einen Fehlschlag zu machen wäre Verschwendung."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
ausfaelle = {"n": 0}
async def zwei_platzen(system, nachricht, temperatur=0.0):
ausfaelle["n"] += 1
if ausfaelle["n"] <= 2:
raise RuntimeError("429")
return ABGEDECKT
monkeypatch.setattr(agents, "frage", zwei_platzen)
stand = ledger.lade(projekt)
gemeldet = []
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
melde=lambda t, n=0: gemeldet.append(n)) == []
assert gemeldet == [] # kein Fehler: das Ergebnis stand fest
assert "bedienen:starte" in stand["abdeckung"]
async def test_offenes_urteil_bei_ausfall_wird_gemeldet(projekt, monkeypatch):
"""Vier Stimmen sagen „fehlt", zwei fehlen — mit ihnen wäre die Schwelle
erreicht. Das ist offen und muss laut scheitern, nicht still durchgehen."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
ausfaelle = {"n": 0}
async def zwei_platzen(system, nachricht, temperatur=0.0):
ausfaelle["n"] += 1
if ausfaelle["n"] <= 2:
raise RuntimeError("429")
return FEHLT
monkeypatch.setattr(agents, "frage", zwei_platzen)
stand = ledger.lade(projekt)
gemeldet = []
assert await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
melde=lambda t, n=0: gemeldet.append(n)) == []
assert gemeldet == [1]
assert stand["abdeckung"] == {} and stand["ohne_karte"] == {}
async def test_zwei_ueberlebende_stimmen_urteilen_nicht(projekt, monkeypatch):
"""Rechnerisch könnten zwei Stimmen die Schwelle unerreichbar machen. Als
dauerhaft gemerktes Urteil ist das zu wenig."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
ausfaelle = {"n": 0}
async def vier_platzen(system, nachricht, temperatur=0.0):
ausfaelle["n"] += 1
if ausfaelle["n"] <= 4:
raise RuntimeError("429")
return ABGEDECKT
monkeypatch.setattr(agents, "frage", vier_platzen)
stand = ledger.lade(projekt)
gemeldet = []
await inhalt.code_im_feature(features.liste(projekt), [punkt()], stand,
melde=lambda t, n=0: gemeldet.append(n))
assert gemeldet == [1]
assert stand["abdeckung"] == {}
# ---------------------------------------------------------------- Vision
VISION = "<!-- titel -->\nVision\n<!-- art -->\nvision\n<!-- ziel -->\nSo soll es werden\n"
def test_vision_ist_eine_eigene_kartenart(projekt):
f = features.anlegen(projekt, VISION)
assert f["sektionen"]["art"] == "vision"
assert f["sektionen"]["ziel"] == "So soll es werden"
def test_unbekannte_art_bleibt_kern(projekt):
f = features.anlegen(projekt, "<!-- titel -->\nX\n<!-- art -->\nwichtig\n")
assert f["sektionen"]["art"] == "kern"
def test_keine_pruefung_fasst_die_vision_an(projekt):
"""Die Vision ist die Vorlage, nicht der Prüfling."""
karte(projekt, "Eins", ("Etwas starten", "Knopf"))
features.anlegen(projekt, VISION)
assert len(features.liste(projekt)) == 2
titel = [f["sektionen"]["titel"] for f in features.pruefbar(projekt)]
assert titel == ["Eins"]

390
daten/Planer/.pruefung.json Normal file
View File

@@ -0,0 +1,390 @@
{
"abdeckung": {
"bedienen:ablehnen": {
"abdruck": "61a0e2e5f8a4c439",
"funktion": "Vorschlag annehmen"
},
"bedienen:alleAblehnen": {
"abdruck": "9d613f6b111b9b67",
"funktion": "Alle Vorschläge verwerfen"
},
"bedienen:alleAnnehmen": {
"abdruck": "9456d726b83144c9",
"funktion": "Alle Vorschläge annehmen"
},
"bedienen:annehmen": {
"abdruck": "97dc5220725e77ee",
"funktion": "Vorschlag annehmen"
},
"bedienen:auswaehlen": {
"abdruck": "31f34c41cbd1bd93",
"funktion": "Projekt aufrufen"
},
"bedienen:bereichsMenue": {
"abdruck": "421627feeb92867c",
"funktion": "Projekt aufrufen"
},
"bedienen:bestaetigen": {
"abdruck": "b1ce0152c68722c9",
"funktion": "Feature anlegen"
},
"bedienen:funktionsMenue": {
"abdruck": "67bf62e64b2af11f",
"funktion": "Funktion entfernen"
},
"bedienen:kartenMenue": {
"abdruck": "bc47fd3a97b2027c",
"funktion": "Feature entfernen"
},
"bedienen:leistenMenue": {
"abdruck": "90a66ccbe71ddc74",
"funktion": "Projekt anlegen"
},
"bedienen:projektMenue": {
"abdruck": "58ca0dac4286c61d",
"funktion": "Projekt umbenennen"
},
"bedienen:schliesse": {
"abdruck": "f78739ce74d876f5",
"funktion": ""
},
"bedienen:springe": {
"abdruck": "67b827c9bba0c05a",
"funktion": "Problemstelle anspringen"
},
"bedienen:starte": {
"abdruck": "06e7ff1e70efc603",
"funktion": "Titel anpassen"
},
"bedienen:startePruefung": {
"abdruck": "1f9ac6d1bb70cda8",
"funktion": "Stil prüfen"
},
"bedienen:umbenennen": {
"abdruck": "af404032e5814872",
"funktion": "Projekt umbenennen"
},
"bedienen:wechsleAnsicht": {
"abdruck": "ee50f2dbfa66f779",
"funktion": "Anzeigemodus umschalten"
},
"bedienen:wechsleArt": {
"abdruck": "3fcd27f68677ed3a",
"funktion": "Feature einstufen"
},
"bedienen:wechsleDarkMode": {
"abdruck": "51e5efb1e9196f3b",
"funktion": "Darkmode umschalten"
},
"lauscher:window.click": {
"abdruck": "5dd03db00120c133",
"funktion": "Kontextmenü schließen"
},
"lauscher:window.keydown": {
"abdruck": "70b3f36549535bfc",
"funktion": ""
},
"menue:+ Feature erstellen": {
"abdruck": "a9a3fefd653555c7",
"funktion": "Feature anlegen"
},
"menue:+ Funktion erstellen": {
"abdruck": "0f968068851c2f7a",
"funktion": "Feature entfernen"
},
"menue:+ Projekt erstellen": {
"abdruck": "dbbe1573ebe97251",
"funktion": "Projekt entfernen"
},
"menue:+ Vision erstellen": {
"abdruck": "1d814205e510b035",
"funktion": "Feature anlegen"
},
"menue:✎ Projekt anpassen": {
"abdruck": "cd7390c2f6852257",
"funktion": "Projekt umbenennen"
},
"menue:✕ Feature entfernen": {
"abdruck": "e773a996f84a8d7b",
"funktion": "Feature entfernen"
},
"menue:✕ Funktion entfernen": {
"abdruck": "99495f37aa040550",
"funktion": "Funktion entfernen"
},
"menue:✕ Projekt entfernen": {
"abdruck": "bc7b8c360ac3c403",
"funktion": "Projekt anlegen"
},
"menue:✕ Vision entfernen": {
"abdruck": "ec65c57f36ff14d0",
"funktion": "Anzeigemodus umschalten"
},
"route:delete /projekte/{name}": {
"abdruck": "9014a4b884cca27a",
"funktion": "Projekt entfernen"
},
"route:delete /projekte/{name}/features/{id}": {
"abdruck": "ff8d0773ef483606",
"funktion": "Feature entfernen"
},
"route:delete /projekte/{name}/pruefung": {
"abdruck": "79f2bdc0413ea88a",
"funktion": "Prüfung beenden"
},
"route:get /health": {
"abdruck": "3214361386c64911",
"funktion": ""
},
"route:get /projekte": {
"abdruck": "2f6756caafbe76b8",
"funktion": ""
},
"route:get /projekte/{name}/features": {
"abdruck": "509119bcecfd63e6",
"funktion": "Projekt aufrufen"
},
"route:get /projekte/{name}/pruefung": {
"abdruck": "f7d46d8a5be26212",
"funktion": "Stil prüfen"
},
"route:post /projekte": {
"abdruck": "c3e70befc5eb772b",
"funktion": "Projekt anlegen"
},
"route:post /projekte/{name}/features": {
"abdruck": "df08920d8f5e5d4d",
"funktion": "Feature anlegen"
},
"route:post /projekte/{name}/pruefung": {
"abdruck": "9fb8d6c9260477c7",
"funktion": "Stil prüfen"
},
"route:put /projekte/{name}": {
"abdruck": "6030731add729040",
"funktion": "Projekt umbenennen"
},
"route:put /projekte/{name}/features/{id}": {
"abdruck": "e95aea276bf795cd",
"funktion": ""
}
},
"belege": {
"[\"darkmode\", \"Darkmode umschalten\"]": {
"abdruck": "653c08d0647a7f7b",
"beleg": "function wechsleDarkMode() {\n modus.value = MODI[(MODI.indexOf(modus.value) + 1) % MODI.length]\n localStorage.setItem('darkModus', modus.value)\n wendeAn()\n}",
"datei": "frontend/src/App.vue"
},
"[\"design\", \"Kontextmenü schließen\"]": {
"abdruck": "519c5a56bf4b9810",
"beleg": "schliesseMenue()",
"datei": "frontend/src/composables/kontextmenue.js"
},
"[\"features\", \"Anzeigemodus umschalten\"]": {
"abdruck": "b22ba2dd122887fa",
"beleg": "function wechsleAnsicht() {\n gross.value = !gross.value\n}",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"features\", \"Feature anlegen\"]": {
"abdruck": "d2968bea16a06b65",
"beleg": "async function erstellen(titel) {",
"datei": "frontend/src/components/FeatureListe.vue"
},
"[\"features\", \"Feature einstufen\"]": {
"abdruck": "de473e39c7232637",
"beleg": "function wechsleArt() {\n s.value.art = s.value.art === 'kern' ? 'optional' : 'kern'\n speichern()\n}",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"features\", \"Feature entfernen\"]": {
"abdruck": "c6f27381a41a63d7",
"beleg": "def loeschen(projekt: str, id: str):\n _datei(projekt, id).unlink()",
"datei": "backend/features.py"
},
"[\"features\", \"Regeln anpassen\"]": {
"abdruck": "0af0e4eadcaae560",
"beleg": "<div v-else-if=\"s.regeln\" class=\"klickbar\" @click=\"starte('regeln')\">",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"features\", \"Titel anpassen\"]": {
"abdruck": "7f727f71dc555492",
"beleg": "<h3 v-else :title=\"gross ? 'Klicken zum Bearbeiten' : null\" @click=\"starte('titel')\">{{ s.titel }}</h3>",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"features\", \"Ziel anpassen\"]": {
"abdruck": "965b52779fa6aa35",
"beleg": "<div v-else-if=\"s.ziel\" class=\"inhalt klickbar\" v-html=\"md(s.ziel)\" @click=\"starte('ziel')\" />",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"funktionen\", \"Funktion anlegen\"]": {
"abdruck": "6250215b920cf0f0",
"beleg": "async function funktionErstellen(titel) {",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"funktionen\", \"Funktion entfernen\"]": {
"abdruck": "20e8d8e507ee6310",
"beleg": "{ text: '✕ Funktion entfernen', aktion: () => (modal.value = { art: 'entfernen', i }) },",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"funktionen\", \"Regeln anpassen\"]": {
"abdruck": "303d6988603e3d61",
"beleg": "async function starte(feld, i, j) {\n if (!gross.value) return // Bearbeiten nur in der ausführlichen Ansicht\n const original = wert(feld, i, j)\n aktiv.value = { feld, i, j, original }\n entwurf.value = original\n await nextTick()\n const el = Array.isArray(feldRef.value) ? feldRef.value[0] : feldRef.value\n el?.focus()\n if (feld === 'titel') el?.select()\n}",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"funktionen\", \"Testschritte anpassen\"]": {
"abdruck": "e303ebeaece423de",
"beleg": "<div v-else class=\"klickbar\" @click=\"starte('testen', i)\">",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"funktionen\", \"Titel anpassen\"]": {
"abdruck": "3ceaa19290c793b4",
"beleg": "s.value.funktionen[a.i].text = neu",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"funktionen\", \"Ziel anpassen\"]": {
"abdruck": "4cb617031cbf6ef4",
"beleg": "<div v-else class=\"inhalt klickbar\" v-html=\"md(f.ziel)\" @click=\"starte('ziel', i)\" />",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"karten-pruefen\", \"Alle Vorschläge annehmen\"]": {
"abdruck": "52fb2acd3d6945fa",
"beleg": "async function alleAnnehmen() {\n props.probleme.forEach(p => { if (anwenden(p)) erledige(p) })\n await speichern()\n}",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"karten-pruefen\", \"Alle Vorschläge verwerfen\"]": {
"abdruck": "f98fd3fc005b9e56",
"beleg": "function alleAblehnen() {\n props.probleme.forEach(erledige)\n}",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"karten-pruefen\", \"Code in Features prüfen\"]": {
"abdruck": "daf5820ada5ee059",
"beleg": "async def code_im_feature(features: list, punkte: list, stand: dict,",
"datei": "backend/inhalt.py"
},
"[\"karten-pruefen\", \"Features im Code prüfen\"]": {
"abdruck": "acc652c172671790",
"beleg": "ANKLAGE_AB = 4",
"datei": "backend/inhalt.py"
},
"[\"karten-pruefen\", \"Problemstelle anspringen\"]": {
"abdruck": "340a32321fd23834",
"beleg": "karte.classList.add('gesucht')",
"datei": "frontend/src/components/PruefPanel.vue"
},
"[\"karten-pruefen\", \"Prüfung beenden\"]": {
"abdruck": "7400070de29ce762",
"beleg": "<button class=\"icon-knopf\" title=\"Schließen\" @click=\"schliesse\">✕</button>",
"datei": "frontend/src/components/PruefPanel.vue"
},
"[\"karten-pruefen\", \"Stil prüfen\"]": {
"abdruck": "86c4530f5dd0bd52",
"beleg": "async def starte(projekt: str, art: str = \"stil\") -> dict:",
"datei": "backend/pruefung.py"
},
"[\"karten-pruefen\", \"Vorschlag annehmen\"]": {
"abdruck": "e4b81d800fd23fec",
"beleg": "async function annehmen(p) {\n if (anwenden(p)) erledige(p)\n await speichern()\n}",
"datei": "frontend/src/components/FeatureKarte.vue"
},
"[\"karten-pruefen\", \"Vorschlag verwerfen\"]": {
"abdruck": "b37b74ea369ed66c",
"beleg": "export function erledige(problem) {\n probleme.value = probleme.value.filter(p => p !== problem)\n}",
"datei": "frontend/src/composables/pruefung.js"
},
"[\"projekte\", \"Projekt anlegen\"]": {
"abdruck": "9b3598c9081155b2",
"beleg": "def anlegen(name: str):",
"datei": "backend/projekte.py"
},
"[\"projekte\", \"Projekt aufrufen\"]": {
"abdruck": "1071109545496535",
"beleg": "@click=\"emit('auswaehlen', p.name)\"",
"datei": "frontend/src/components/SeitenLeiste.vue"
},
"[\"projekte\", \"Projekt entfernen\"]": {
"abdruck": "e413063557fa0f13",
"beleg": "shutil.rmtree(ordner)",
"datei": "backend/projekte.py"
},
"[\"projekte\", \"Projekt umbenennen\"]": {
"abdruck": "d4e71f8ef907393c",
"beleg": "function projektMenue(e, name) {\n oeffneMenue(e, [\n { text: '✎ Projekt anpassen', aktion: () => starteUmbenennen(name) },\n { text: '✕ Projekt entfernen', aktion: () => oeffneModal({ art: 'entfernen', name }) },\n ])\n}",
"datei": "frontend/src/components/SeitenLeiste.vue"
},
"[\"vision-festhalten\", \"Anzeigemodus umschalten\"]": {
"abdruck": "b983b82c02343560",
"beleg": "const gross = ref(false)",
"datei": "frontend/src/components/VisionKarte.vue"
},
"[\"vision-festhalten\", \"Vision anlegen\"]": {
"abdruck": "58ceb9502d1754bf",
"beleg": "<div class=\"bereich\" @contextmenu.prevent=\"bereichsMenue\">",
"datei": "frontend/src/components/FeatureListe.vue"
},
"[\"vision-festhalten\", \"Vision anpassen\"]": {
"abdruck": "dd527b57b4c9685e",
"beleg": "async function fertig() {\n if (!aktiv.value) return\n aktiv.value = false\n const neu = entwurf.value.trim()\n if (neu === text.value) return\n text.value = neu\n await speichern()\n}",
"datei": "frontend/src/components/VisionKarte.vue"
},
"[\"vision-festhalten\", \"Vision entfernen\"]": {
"abdruck": "0aeb156d13b32b7e",
"beleg": "function kartenMenue(e) {\n oeffneMenue(e, [{ text: '✕ Vision entfernen', aktion: () => emit('entfernen') }])\n}",
"datei": "frontend/src/components/VisionKarte.vue"
}
},
"ohne_code": {},
"ohne_karte": {},
"paare": {
"[\"[\\\"features\\\", \\\"Anzeigemodus umschalten\\\"]\", \"[\\\"vision-festhalten\\\", \\\"Anzeigemodus umschalten\\\"]\"]": {
"abdruck": "d23706be97d6369a",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Feature anlegen\\\"]\", \"[\\\"funktionen\\\", \\\"Funktion anlegen\\\"]\"]": {
"abdruck": "3eb9956887005cc4",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Feature entfernen\\\"]\", \"[\\\"funktionen\\\", \\\"Funktion entfernen\\\"]\"]": {
"abdruck": "9717a5c6eab69f22",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Regeln anpassen\\\"]\", \"[\\\"funktionen\\\", \\\"Regeln anpassen\\\"]\"]": {
"abdruck": "827215ea68dcbada",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Titel anpassen\\\"]\", \"[\\\"features\\\", \\\"Ziel anpassen\\\"]\"]": {
"abdruck": "69360dc1502aa01f",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Titel anpassen\\\"]\", \"[\\\"funktionen\\\", \\\"Titel anpassen\\\"]\"]": {
"abdruck": "01fbbfc1ca91d32a",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Titel anpassen\\\"]\", \"[\\\"funktionen\\\", \\\"Ziel anpassen\\\"]\"]": {
"abdruck": "414ddf4e7bff9896",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Ziel anpassen\\\"]\", \"[\\\"funktionen\\\", \\\"Titel anpassen\\\"]\"]": {
"abdruck": "83a0a87e978c859a",
"gleich": false
},
"[\"[\\\"features\\\", \\\"Ziel anpassen\\\"]\", \"[\\\"funktionen\\\", \\\"Ziel anpassen\\\"]\"]": {
"abdruck": "5e97e3c6fe9df287",
"gleich": false
},
"[\"[\\\"funktionen\\\", \\\"Funktion anlegen\\\"]\", \"[\\\"projekte\\\", \\\"Projekt anlegen\\\"]\"]": {
"abdruck": "202f51c53a73b452",
"gleich": false
},
"[\"[\\\"funktionen\\\", \\\"Funktion entfernen\\\"]\", \"[\\\"projekte\\\", \\\"Projekt entfernen\\\"]\"]": {
"abdruck": "a9d2c4dd5e70fa2c",
"gleich": false
},
"[\"[\\\"funktionen\\\", \\\"Funktion entfernen\\\"]\", \"[\\\"vision-festhalten\\\", \\\"Vision entfernen\\\"]\"]": {
"abdruck": "4090ee6826466fa3",
"gleich": false
},
"[\"[\\\"funktionen\\\", \\\"Titel anpassen\\\"]\", \"[\\\"funktionen\\\", \\\"Ziel anpassen\\\"]\"]": {
"abdruck": "17b00db94f54b62d",
"gleich": false
}
}
}

View File

@@ -65,7 +65,7 @@ Feature entfernen
Regeln anpassen Regeln anpassen
<!-- testen --> <!-- testen -->
- Vollmodus öffnen - Vollmodus öffnen
- LMB auf Regeln - LMB auf Regeln der Karte
- Text ändern - Text ändern
- Regeln angepasst - Regeln angepasst
<!-- funktion --> <!-- funktion -->
@@ -74,13 +74,13 @@ Titel anpassen
- Aktualisiert den Dateinamen - Aktualisiert den Dateinamen
<!-- testen --> <!-- testen -->
- Vollmodus öffnen - Vollmodus öffnen
- LMB auf Titel - LMB auf Titel der Karte
- Text ändern - Text ändern
- Titel angepasst - Titel angepasst
<!-- funktion --> <!-- funktion -->
Ziel anpassen Ziel anpassen
<!-- testen --> <!-- testen -->
- Vollmodus öffnen - Vollmodus öffnen
- LMB auf Ziel - LMB auf Ziel der Karte
- Text ändern - Text ändern
- Ziel angepasst - Ziel angepasst

View File

@@ -0,0 +1,53 @@
<!-- titel -->
Vision festhalten
----------------------------------------
<!-- art -->
optional
----------------------------------------
<!-- ziel -->
Hält fest, wie es gedacht ist
----------------------------------------
<!-- regeln -->
- Höchstens eine je Projekt
- Goldener Rand
- Steht als erste Karte
- Nur Titel und ein Textfeld
- Text wird als Markdown gezeigt
- Keine Prüfung ändert sie
----------------------------------------
<!-- funktion -->
Anzeigemodus umschalten
<!-- ziel -->
Kompaktmodus zum Lesen, Vollmodus zum Anpassen
<!-- regeln -->
- Standard ist Kompaktmodus
- Kompakt zeigt nur die obersten Punkte
- Augen-Icon rechts oben
- Vollmodus geht über alle Spalten
<!-- testen -->
- MMB auf die Vision
- Modus umgeschaltet
<!-- funktion -->
Vision anlegen
<!-- testen -->
- RMB auf Hauptbereich
- Klick auf "Vision erstellen"
- Vision angelegt
<!-- funktion -->
Vision anpassen
<!-- regeln -->
- Anpassen nur im Vollmodus
- Verlassen speichert
- Escape verwirft
<!-- testen -->
- Vollmodus öffnen
- LMB auf den Text
- Text ändern
- Vision angepasst
<!-- funktion -->
Vision entfernen
<!-- testen -->
- RMB auf die Vision
- Klick auf "Vision entfernen"
- Bestätigen im Modal
- Vision entfernt

19
daten/Planer/vision.md Normal file
View File

@@ -0,0 +1,19 @@
<!-- titel -->
Vision
----------------------------------------
<!-- art -->
vision
----------------------------------------
<!-- ziel -->
- (Basis) Projekte und ihre Features manuell verwalten
- Mit Hilfe von KI-Chat Live Projekte entwickeln und Features erweitern
- Ich schreibe mit der KI und definiere meine Idee
- Die KI zeigt mir Live wie sich die Features ändern
- Ich verbessere und die KI passt Live an
- Wenn ich zufrieden bin, dann lasse ich die Features von der KI implementieren
- Zu einem ausgewählten Projekt analysiert die KI alle kompakten Informationen dazu
- Ich wähle ein Verzeichnis das als Quelle dient
- Die Quelle wird durch ein System geschickt, dass kompakte Informationen extrahiert
- Die kompakten Informationen enthalten das Wissen zu dem WIE vom Projekt
- Also ich brauche die Funktionen und Regeln zum Projekt, nicht wie es Implementiert wird
- Es werden Features zum Projekt generiert, die es komplett abbilden

View File

@@ -13,6 +13,8 @@
--rot: #b91c1c; --rot: #b91c1c;
--rot-bg: #fee2e2; --rot-bg: #fee2e2;
--code-bg: #f1f5f9; --code-bg: #f1f5f9;
--gold: #a16207; /* amber-700: 4.9 auf weiß, trägt auch als Text */
--gold-bg: #fdf6e3;
} }
html.dark { html.dark {
@@ -30,6 +32,8 @@ html.dark {
--rot: #fca5a5; --rot: #fca5a5;
--rot-bg: #450a0a; --rot-bg: #450a0a;
--code-bg: #111827; --code-bg: #111827;
--gold: #fbbf24;
--gold-bg: #2a2410;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }

View File

@@ -1,9 +1,10 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js' import { fetchFeatures, featureAnlegen, featureLoeschen } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js' import { oeffneMenue } from '../composables/kontextmenue.js'
import { problemeFuer } from '../composables/pruefung.js' import { problemeFuer } from '../composables/pruefung.js'
import FeatureKarte from './FeatureKarte.vue' import FeatureKarte from './FeatureKarte.vue'
import VisionKarte from './VisionKarte.vue'
import ModalBox from './ModalBox.vue' import ModalBox from './ModalBox.vue'
const props = defineProps({ const props = defineProps({
@@ -43,8 +44,29 @@ function aktualisiert(altId, f) {
sortiere() sortiere()
} }
// Die Vision steht über den Features, nicht zwischen ihnen — eigene Zeile, goldener
// Rand, und höchstens eine je Projekt.
const vision = computed(() => features.value.find(f => f.sektionen.art === 'vision'))
const karten = computed(() => features.value.filter(f => f.sektionen.art !== 'vision'))
function bereichsMenue(e) { function bereichsMenue(e) {
oeffneMenue(e, [{ text: '+ Feature erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) }]) const eintraege = [
{ text: '+ Feature erstellen', aktion: () => oeffneModal({ art: 'erstellen' }) },
]
if (!vision.value) {
eintraege.push({ text: '+ Vision erstellen', aktion: visionErstellen })
}
oeffneMenue(e, eintraege)
}
async function visionErstellen() {
meldung.value = ''
try {
features.value.push(mitKey(await featureAnlegen(
props.projekt, '<!-- titel -->\nVision\n<!-- art -->\nvision\n')))
} catch (e) {
meldung.value = e.message
}
} }
function oeffneModal(m) { function oeffneModal(m) {
@@ -83,8 +105,16 @@ watch(() => props.projekt, laden, { immediate: true })
<p v-if="meldung && !modal" class="meldung">{{ meldung }}</p> <p v-if="meldung && !modal" class="meldung">{{ meldung }}</p>
<div class="gitter"> <div class="gitter">
<VisionKarte
v-if="vision"
:key="vision.key"
:projekt="projekt"
:feature="vision"
@aktualisiert="aktualisiert"
@entfernen="oeffneModal({ art: 'entfernen', feature: vision })"
/>
<FeatureKarte <FeatureKarte
v-for="f in features" v-for="f in karten"
:key="f.key" :key="f.key"
:projekt="projekt" :projekt="projekt"
:feature="f" :feature="f"
@@ -105,7 +135,7 @@ watch(() => props.projekt, laden, { immediate: true })
/> />
<ModalBox <ModalBox
v-else-if="modal" v-else-if="modal"
titel="Feature entfernen" :titel="`${modal.feature.sektionen.titel === 'Vision' ? 'Vision' : 'Feature'} entfernen`"
:frage="`„${modal.feature.sektionen.titel}“ entfernen?`" :frage="`„${modal.feature.sektionen.titel}“ entfernen?`"
knopf="Entfernen" knopf="Entfernen"
:meldung="meldung" :meldung="meldung"

View File

@@ -0,0 +1,184 @@
<script setup>
import { nextTick, ref, watch } from 'vue'
import { marked } from 'marked'
import { featureSpeichern } from '../api.js'
import { oeffneMenue } from '../composables/kontextmenue.js'
// Die Vision hält fest, wie es gedacht ist. Sie ist die Vorlage, nicht der
// Prüfling — deshalb hat sie weder Badge noch Funktionen noch Problemhinweise.
// Eine eigene Karte statt eines Sonderwegs in FeatureKarte: dort steckt die ganze
// Prüfmechanik, von der hier nichts gilt.
//
// Die beiden Modi hat sie wie jede Karte: Kompaktmodus zum Lesen, Vollmodus zum
// Anpassen. Kompakt bleiben nur die obersten Punkte stehen — eine Vision ist
// verschachtelt, und ausgeklappt drängt sie alles andere aus dem Bild.
const props = defineProps({
projekt: { type: String, required: true },
feature: { type: Object, required: true },
})
const emit = defineEmits(['aktualisiert', 'entfernen'])
const TITEL = 'Vision'
const TRENNER = '-'.repeat(40)
const text = ref(props.feature.sektionen.ziel)
watch(() => props.feature, f => { text.value = f.sektionen.ziel })
const gross = ref(false)
const aktiv = ref(false)
const entwurf = ref('')
const meldung = ref('')
const feldRef = ref(null)
// Lokale, selbst geschriebene Daten — kein Sanitizing nötig
const md = (t) => marked.parse(t)
async function starte() {
if (!gross.value) return // Bearbeiten nur in der ausführlichen Ansicht
entwurf.value = text.value
aktiv.value = true
await nextTick()
feldRef.value?.focus()
}
function abbrechen() {
aktiv.value = false
}
async function fertig() {
if (!aktiv.value) return
aktiv.value = false
const neu = entwurf.value.trim()
if (neu === text.value) return
text.value = neu
await speichern()
}
// Kompaktmodus zum Lesen, Vollmodus zum Anpassen — MMB auf die Karte
// oder das Augen-Icon schaltet um
function wechsleAnsicht() {
gross.value = !gross.value
if (!gross.value) aktiv.value = false
}
function kartenMenue(e) {
oeffneMenue(e, [{ text: '✕ Vision entfernen', aktion: () => emit('entfernen') }])
}
function baueRoh() {
return [
`<!-- titel -->\n${TITEL}`,
'<!-- art -->\nvision',
`<!-- ziel -->\n${text.value}`,
].join(`\n${TRENNER}\n`) + '\n'
}
async function speichern() {
meldung.value = ''
try {
emit('aktualisiert', props.feature.id,
await featureSpeichern(props.projekt, props.feature.id, baueRoh()))
} catch (e) {
meldung.value = e.message
}
}
function zeilen(t) {
return Math.max(6, t.split('\n').length + 1)
}
</script>
<template>
<article
class="karte vision"
:class="{ gross }"
@mousedown.middle.prevent
@auxclick.middle.prevent="wechsleAnsicht"
@contextmenu.prevent.stop="kartenMenue"
>
<header>
<h3>{{ TITEL }}</h3>
<button
class="icon-knopf"
:title="gross ? 'Kompaktmodus' : 'Vollmodus'"
@click="wechsleAnsicht"
>👁</button>
</header>
<textarea
v-if="aktiv"
ref="feldRef"
v-model="entwurf"
:rows="zeilen(entwurf)"
@blur="fertig"
@keydown.esc="abbrechen"
/>
<div
v-else-if="text"
class="inhalt"
:class="{ klickbar: gross }"
:title="gross ? 'Klicken zum Bearbeiten' : null"
v-html="md(text)"
@click="starte"
/>
<p v-else-if="gross" class="hinweis" @click="starte">Wie soll es werden?</p>
<p v-else class="hinweis">Noch nichts festgehalten.</p>
<p v-if="meldung" class="meldung">{{ meldung }}</p>
</article>
</template>
<style scoped>
/* Goldener Rand. Kompakt eine Karte wie jede andere, im Vollmodus über alle
Spalten — sonst würde der ausgeklappte Text die schmale Spalte sprengen. */
.vision { border: 2px solid var(--gold); cursor: pointer; }
.vision.gross { cursor: default; column-span: all; display: block; }
header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
h3 {
margin: 0;
flex: 1;
font-size: 0.95rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gold);
user-select: none;
}
.klickbar { cursor: text; border-radius: 4px; }
.klickbar:hover { background: var(--gold-bg); }
.hinweis {
margin: 0;
color: var(--dezent);
user-select: none;
}
.gross .hinweis { cursor: text; }
textarea {
width: 100%;
resize: vertical;
border-color: var(--gold);
}
.inhalt :deep(p) { margin: 0.2rem 0; }
.inhalt :deep(ul), .inhalt :deep(ol) { margin: 0.2rem 0; padding-left: 1.2rem; }
.inhalt :deep(li) { margin: 0.1rem 0; }
.inhalt :deep(h1), .inhalt :deep(h2), .inhalt :deep(h3) {
font-size: 0.95rem;
margin: 0.6rem 0 0.3rem;
}
/* Kompakt: nur die oberste Ebene. Die Verschachtelung ist die Ausarbeitung —
sie gehört in den Vollmodus, sonst füllt die Vision den halben Schirm. */
.vision:not(.gross) .inhalt {
user-select: none;
max-height: 11rem;
overflow: hidden;
}
.vision:not(.gross) .inhalt :deep(ul ul),
.vision:not(.gross) .inhalt :deep(ul ol),
.vision:not(.gross) .inhalt :deep(ol ul),
.vision:not(.gross) .inhalt :deep(ol ol) { display: none; }
.meldung { margin: 0.4rem 0 0; }
</style>