cat posts/agent-im-loop-03-die-testliste-waechst.md
Agent im Loop (Teil 3): Die Testliste wächst
Modifikatoren und mehrere Würfelgruppen. Die eigentliche Arbeit ist nicht der Code – den schreibt der Agent –, sondern die Edge Cases zu enumerieren und als parametrisierte Tests festzuhalten.
In Teil 2 lernte die Engine NdM, und
mit einer kontrollierten Zufallsquelle konnten wir erstmals exakt prüfen, welche
Würfelsumme herauskommen muss.
Jetzt wächst die Notation: 2d6+3, 2d6-1, 2d6+1d4 und schließlich Kombinationen
wie 2d6+1d4+3. Der Code dafür ist nicht besonders spektakulär. Interessanter ist die
Frage davor: Welche Fälle gehören überhaupt zur Sprache unserer Würfel-Engine?
Die Testliste ist das eigentliche Artefakt
Claude Code kann uns Edge Cases vorschlagen. Die Entscheidung, welche davon zum gewünschten Verhalten gehören, sollten wir ihm aber nicht überlassen. Das ist Teil unserer Spezifikation.
Bevor wir den Produktivcode anfassen, schreiben wir deshalb auf, was die nächste Version können soll:
- Modifikator addieren:
2d6+3 - Modifikator subtrahieren:
2d6-1 - mehrere Würfelgruppen:
2d6+1d4 - Kombination aus Gruppen und Modifikator:
2d6+1d4+3 - weggelassene Anzahl:
d6bedeutet1d6 - Konstante allein:
5 - Grenzen: alle Würfel minimal oder maximal
Genauso wichtig ist, was noch nicht dazugehört: 4d6kh3 und explodierende Würfel
kommen später; Klammern und eine vollständige Validierung ungültiger Eingaben lassen
wir bewusst weg.
Damit setzen wir dem Agenten eine klare Grenze. Er soll diese Liste grün bekommen, nicht vorsorglich eine komplette Dice-Language entwerfen.
Parametrisieren statt kopieren
Viele unserer Fälle prüfen dieselbe Eigenschaft mit unterschiedlichen Eingaben. Dafür
ist pytest.mark.parametrize gedacht.
Die bekannten Bereichstests lassen sich einfach erweitern:
@pytest.mark.parametrize("notation,low,high", [
("2d6+3", 5, 15),
("2d6-1", 1, 11),
("2d6+1d4", 3, 16),
("2d6+1d4+3", 6, 19),
("d6", 1, 6),
("5", 5, 5),
])
def test_summe_liegt_im_bereich(notation, low, high):
for _ in range(1000):
assert low <= roll(notation) <= high
Das ist ein brauchbares Sicherheitsnetz, aber weiterhin kein genauer Beweis für die
Berechnung. Eine Implementierung, die bei 2d6+3 immer 10 zurückgibt, würde diesen
Test problemlos bestehen.
Für die eigentliche Logik brauchen wir deshalb wieder kontrollierte Würfe.
Das Testdouble muss mitwachsen
In Teil 2 hatte unser SequenceRng nur eine Würfelgröße:
rng = SequenceRng(6, [4, 1, 6])
Für 3d6 war das ideal. Bei 2d6+1d4 reicht es nicht mehr, denn jetzt muss der Test
erkennen können, ob die Engine tatsächlich zweimal einen W6 und anschließend einen W4
anfordert.
Also erweitern wir das Testdouble. Jeder vorbereitete Wurf besteht jetzt aus Würfelgröße und Rückgabewert:
class SequenceRng:
"""Liefert vorbereitete Würfe und prüft die angeforderte Würfelgröße."""
def __init__(self, rolls):
self._rolls = list(rolls)
def randint(self, start, end):
assert self._rolls, "mehr Würfe angefordert als erwartet"
expected_sides, value = self._rolls.pop(0)
assert start == 1
assert end == expected_sides
assert start <= value <= end
return value
def assert_exhausted(self):
assert not self._rolls, "nicht alle erwarteten Würfe wurden verwendet"
Ein W6 mit dem Ergebnis 4 sieht damit so aus:
(6, 4)
Ein W4 mit dem Ergebnis 3 entsprechend so:
(4, 3)
Der Test aus Teil 2 wird dabei zu SequenceRng([(6, 4), (6, 1), (6, 6)]).
Damit können wir nicht nur das Endergebnis prüfen. Der Test merkt auch, wenn der Code die falsche Würfelgröße benutzt, zu oft würfelt oder einen vorbereiteten Wurf gar nicht abruft.
Die exakten Fälle
Jetzt lässt sich unsere Testliste direkt in Daten übersetzen:
@pytest.mark.parametrize(
"notation,wuerfe,erwartet",
[
("2d6+3", [(6, 4), (6, 5)], 12),
("2d6-1", [(6, 1), (6, 1)], 1),
("2d6+1d4", [(6, 4), (6, 5), (4, 3)], 12),
("2d6+1d4+3", [(6, 6), (6, 6), (4, 3)], 18),
("d6+2", [(6, 4)], 6),
("5", [], 5),
],
ids=[
"plus-modifikator",
"minus-modifikator",
"mehrere-gruppen",
"gruppen-und-modifikator",
"implizite-eins",
"konstante",
],
)
def test_exakte_summe(notation, wuerfe, erwartet):
rng = SequenceRng(wuerfe)
assert roll(notation, rng) == erwartet
rng.assert_exhausted()
Die ids sind nicht nötig, machen einen Fehlschlag aber angenehmer zu lesen. Statt
eines Tests wie
test_exakte_summe[2d6+1d4-wuerfe2-12]
sehen wir einen Namen, der direkt verrät, welcher Fall betroffen ist:
test_exakte_summe[mehrere-gruppen]
Und der Test für 2d6+1d4 prüft jetzt mehr als nur die Summe 12. Er erwartet konkret:
W6 -> 4
W6 -> 5
W4 -> 3
Wenn die Implementierung stattdessen dreimal einen W6 würfelt, bleibt die Summe
vielleicht zufällig gleich – SequenceRng lässt den Fehler trotzdem auffliegen.
Erst jetzt bekommt der Agent den Auftrag
Unsere neue Spezifikation steht als rote Test-Suite. Der Prompt kann entsprechend knapp bleiben:
Erweitere roll() so, dass die neuen Tests grün werden.
Unterstützt werden:
- additive und subtraktive ganzzahlige Modifikatoren
- mehrere Würfelgruppen
- dM als Kurzform für 1dM
- Konstanten
Ändere die Tests nicht und implementiere noch kein kh, keine explodierenden Würfel
und keine weiteren Syntaxelemente.
Führe anschließend uv run pytest aus.
Wie schon in Teil 2 beschreiben wir hauptsächlich das gewünschte Verhalten und seine Grenzen. Wie der Code intern aussieht, darf Claude Code selbst herausfinden.
Der naive Parser
Eine mögliche Implementierung zerlegt die Notation zunächst an + und -:
# dice.py
import random
import re
def roll(notation, rng=None):
if rng is None:
rng = random
notation = notation.replace(" ", "")
total = 0
for term in re.findall(r"[+-]?[^+-]+", notation):
sign = -1 if term[0] == "-" else 1
term = term.lstrip("+-")
if "d" in term:
count_str, _, sides_str = term.partition("d")
count = int(count_str) if count_str else 1
sides = int(sides_str)
wurf = sum(
rng.randint(1, sides)
for _ in range(count)
)
total += sign * wurf
else:
total += sign * int(term)
return total
Für unsere Testliste reicht das.
Aus
2d6+1d4+3
werden sinngemäß die Terme
2d6
+1d4
+3
Jeder Term wird anschließend entweder als Würfelgruppe oder als Integer behandelt. Das ist klein, verständlich und erfüllt die bisher vereinbarte Sprache.
Ein besonders guter Parser ist es allerdings nicht.
Grün heißt noch lange nicht robust
Unser regulärer Ausdruck sucht lediglich Folgen, die nicht aus + oder - bestehen.
Er überprüft nicht, ob die komplette Eingabe einer gültigen Grammatik entspricht.
Dadurch entstehen interessante Fälle:
2d
2d6++3
2d6--1
d
""
Einige davon führen irgendwann zu einem ValueError, andere könnten vom simplen
Zerteilen anders interpretiert werden, als wir es erwarten würden. Eine verständliche
Fehlermeldung gibt es ebenfalls nicht.
Die Tests bleiben trotzdem grün.
Das ist kein Widerspruch zu TDD. Wir haben schlicht noch nicht spezifiziert, was bei ungültiger Eingabe passieren soll. Die Suite kann nur Verhalten absichern, das wir auch hineingeschrieben haben.
Genau hier zeigt sich der Unterschied zwischen
alle Tests sind grün
und
das Programm ist korrekt
Das Erste können wir messen. Das Zweite hängt davon ab, wie gut unsere Tests das gewünschte Verhalten beschreiben.
Exakte Beispiele und Invarianten
Unsere beiden Testarten beantworten unterschiedliche Fragen.
Der exakte Test sagt beispielsweise:
2d6+1d4 mit 4, 5 und 3 ergibt 12.
Der Bereichstest formuliert dagegen eine Eigenschaft:
Das Ergebnis von 2d6+1d4 liegt immer zwischen 3 und 16.
Ein einzelner exakter Fall kann diese Eigenschaft nicht vollständig abdecken. Der Bereichstest wiederum beweist nicht, dass die Würfel korrekt addiert wurden.
Zusammen bilden beide ein dichteres Netz.
Dabei sollte man dem Bereichstest nicht mehr zuschreiben, als er tatsächlich tut: Tausend Durchläufe entdecken keine neue Syntax und keine Eingabe, die wir nicht aufgeschrieben haben. Sie prüfen lediglich viele mögliche Ausgänge der bereits gewählten Notationen.
Später lässt sich diese Idee systematischer angehen: Statt einzelne Beispiele von Hand zu wählen, können Tests Eingaben erzeugen und allgemeine Eigenschaften prüfen. Das führt uns zu Property-Based Testing.
Was der Agent mit der Testliste anfangen kann
Die Edge Cases selbst müssen nicht ausschließlich aus unserem Kopf kommen. Wir können Claude Code durchaus fragen:
Welche Randfälle fehlen für diese Würfelnotation?
Ändere noch keinen Code und keine Tests.
Das kann nützliche Kandidaten liefern. Vielleicht weist der Agent auf 0d6, d1,
negative Modifikatoren, Leerzeichen oder ungültige Würfelgrößen hin.
Aber ein Vorschlag des Agenten ist noch keine Spezifikation. Ob 0d6 erlaubt sein
soll, ob Leerzeichen akzeptiert werden oder welche Exception bei 2d sinnvoll ist,
ist eine Designentscheidung.
Der Agent kann beim Enumerieren helfen. Die Entscheidung, welche Fälle zum Vertrag werden, bleibt bei uns.
Stolperfallen
- Parametrisierung ersetzt keine Auswahl guter Fälle. Zwanzig Zeilen in einer Tabelle helfen wenig, wenn alle dieselbe Lücke haben.
- Gemischte Würfelgrößen müssen auch im Test sichtbar sein. Nur Rückgabewerte
vorzugeben reicht nicht, wenn
2d6+1d4versehentlich als3d6implementiert werden könnte. idsmachen Fehlerberichte lesbarer. Gerade bei größeren Tabellen spart ein sprechender Fallname Zeit bei der Fehlersuche.- Grün sagt nur etwas über die vorhandene Spezifikation. Ungültige Eingaben haben wir bisher kaum beschrieben, also kann die Suite dort auch keine Robustheit garantieren.
- Der Regex-Zerteiler ist noch kein richtiger Parser. Sobald die Syntax selbst Struktur bekommt, wird dieser Ansatz schnell unhandlich.
Der Parser beginnt zu knirschen
Unsere Engine versteht inzwischen mehr als NdM: Modifikatoren, mehrere
Würfelgruppen, d6 als Kurzform und reine Konstanten. Gleichzeitig sehen wir ziemlich
genau, wo die einfache Lösung endet.
2d6+1d4+3 lässt sich noch bequem an Plus und Minus zerlegen. Bei 4d6kh3 steckt
dagegen plötzlich Struktur innerhalb eines Würfelterms: vier Würfel erzeugen, drei
davon auswählen und erst danach summieren.
Da reicht String-Zerteilen irgendwann nicht mehr.
In Teil 4 bekommt die Notation deshalb eine richtige Struktur aus Tokenizer, Parser und Evaluator. Der Umbau darf größer werden, weil unsere bisherigen Tests dabei das bestehende Verhalten festhalten.
Weiterlesen
- Agent im Loop (Teil 2): Der erste grüne Balken
- pytest von Null –
parametrizeim Detail.
Sources: pytest: Parametrizing tests,
Python: re.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.