Zum Inhalt springen

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: d6 bedeutet 1d6
  • 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+1d4 versehentlich als 3d6 implementiert werden könnte.
  • ids machen 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

Sources: pytest: Parametrizing tests, Python: re.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!