Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
7.1 KiB
Python
179 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Mutationstest.py
|
|
"""
|
|
Kapitel Testen: Wer testet die Tests?
|
|
|
|
`test_or_kern.py` besteht aus 31 Tests, und alle sind gruen. Das ist noch kein
|
|
Ergebnis. Eine Testsuite, die nie rot war, ist keine Pruefung, sondern eine
|
|
Vermutung - sie koennte aus lauter `assert True` bestehen und saehe genauso aus.
|
|
|
|
Dieses Programm prueft die Pruefung. Es baut nacheinander KLEINE, gezielte
|
|
Fehler in `or_kern.py` ein - jeder einzelne ist ein Fehler, den man beim
|
|
Programmieren tatsaechlich macht - und laesst die Testsuite darauf los. Fuer
|
|
jede Mutation gibt es genau zwei moegliche Ausgaenge:
|
|
|
|
GETOETET Mindestens ein Test wird rot. Die Suite haette diesen Fehler
|
|
in der Praxis gefunden.
|
|
UEBERLEBT Alle Tests bleiben gruen. Die Suite haette diesen Fehler
|
|
DURCHGEHEN LASSEN - hier fehlt ein Test.
|
|
|
|
Ueberlebende Mutationen sind das eigentliche Ergebnis: Sie zeigen die Luecken,
|
|
und zwar ohne dass man sie erraten muss.
|
|
|
|
Das Verfahren heisst Mutationstest und ist alt; neu ist nur, wie gut es zu
|
|
Optimierungsmodellen passt. Dort kann man den Zielwert meist nicht gegen einen
|
|
bekannten Sollwert pruefen - also weiss man ohne so ein Experiment nicht, ob
|
|
die Ersatzpruefungen (Eigenschaften, Invarianten) ueberhaupt greifen.
|
|
|
|
WICHTIG: Es wird nichts veraendert. Jede Mutation laeuft in einem eigenen
|
|
temporaeren Verzeichnis mit einer Kopie von or_kern.py.
|
|
|
|
Benoetigt: pytest; im selben Verzeichnis or_kern.py und test_or_kern.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
# (Name, gesuchter Text, Ersatz) - jede Zeile ein realistischer Fehler.
|
|
MUTATIONEN = [
|
|
("Nebenbedingung umgedreht (<= wird >=)",
|
|
"<= problem.kapazitaeten[ressource], name=ressource)",
|
|
">= problem.kapazitaeten[ressource], name=ressource)"),
|
|
|
|
("Maximierung wird Minimierung",
|
|
"solver.Maximize(sum(menge[p.name] * p.deckungsbeitrag",
|
|
"solver.Minimize(sum(menge[p.name] * p.deckungsbeitrag"),
|
|
|
|
("Vorzeichen der Dualwerte vergessen",
|
|
"schattenpreise={r: float(-m) for r, m in",
|
|
"schattenpreise={r: float(m) for r, m in"),
|
|
|
|
("Abnahmepruefung aufgeweicht (Toleranz 1e-6 -> 1.0)",
|
|
"toleranz: float = 1e-6) -> list[str]:",
|
|
"toleranz: float = 1.0) -> list[str]:"),
|
|
|
|
("Kapazitaetspruefung uebersprungen",
|
|
"if ist > grenze + toleranz:",
|
|
"if False:"),
|
|
|
|
("Pruefung auf doppelte Produktnamen entfernt",
|
|
"if len(set(namen)) != len(namen):",
|
|
"if False:"),
|
|
|
|
("Ganzzahligkeitspruefung entfernt",
|
|
"if abs(wert - round(wert)) > toleranz:",
|
|
"if False:"),
|
|
|
|
("Zielwertabgleich entfernt",
|
|
"if abs(nachgerechnet - loesung.zielwert) > toleranz * max(1.0, abs(nachgerechnet)):",
|
|
"if False:"),
|
|
|
|
("ZEITLIMIT gilt faelschlich als brauchbar",
|
|
"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG)",
|
|
"return self in (SolverStatus.OPTIMAL, SolverStatus.ZULAESSIG,\n"
|
|
" SolverStatus.ZEITLIMIT)"),
|
|
|
|
("Ressourcenreihenfolge nicht mehr stabil",
|
|
"return sorted(self.kapazitaeten)",
|
|
"return list(self.kapazitaeten)"),
|
|
]
|
|
|
|
|
|
def fuehre_suite_aus(verzeichnis: str) -> tuple[int, int, str]:
|
|
"""Laesst pytest im Verzeichnis laufen; liefert (bestanden, fehlgeschlagen)."""
|
|
ergebnis = subprocess.run(
|
|
[sys.executable, "-m", "pytest", "test_or_kern.py", "-q",
|
|
"--tb=no", "-p", "no:cacheprovider"],
|
|
cwd=verzeichnis, capture_output=True, text=True, timeout=600)
|
|
letzte = [z for z in ergebnis.stdout.strip().splitlines() if z.strip()]
|
|
zeile = letzte[-1] if letzte else ""
|
|
bestanden = fehlgeschlagen = 0
|
|
for teil in zeile.replace("=", " ").split(","):
|
|
for wort in teil.split():
|
|
if wort.isdigit():
|
|
zahl = int(wort)
|
|
elif wort.startswith("passed"):
|
|
bestanden = zahl
|
|
elif wort.startswith("failed") or wort.startswith("error"):
|
|
fehlgeschlagen += zahl
|
|
return bestanden, fehlgeschlagen, zeile
|
|
|
|
|
|
def mutiere_und_pruefe(quelle: str, ziel: str, suche: str,
|
|
ersatz: str) -> tuple[int, int, str] | None:
|
|
"""Legt eine mutierte Kopie an und laesst die Suite darauf laufen."""
|
|
with tempfile.TemporaryDirectory() as verzeichnis:
|
|
text = open(quelle, encoding="utf-8").read()
|
|
if text.count(suche) != 1:
|
|
return None # Muster passt nicht (mehr)
|
|
open(os.path.join(verzeichnis, "or_kern.py"), "w",
|
|
encoding="utf-8").write(text.replace(suche, ersatz))
|
|
shutil.copy(ziel, verzeichnis)
|
|
return fuehre_suite_aus(verzeichnis)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
hier = os.path.dirname(os.path.abspath(__file__))
|
|
quelle = os.path.join(hier, "or_kern.py")
|
|
suite = os.path.join(hier, "test_or_kern.py")
|
|
for pfad in (quelle, suite):
|
|
if not os.path.exists(pfad):
|
|
raise SystemExit(f"Nicht gefunden: {pfad}\n"
|
|
f"Beide Dateien muessen im selben Verzeichnis liegen.")
|
|
|
|
print("=" * 84)
|
|
print(" MUTATIONSTEST: WUERDE DIE SUITE DIESE FEHLER FINDEN?")
|
|
print("=" * 84)
|
|
|
|
with tempfile.TemporaryDirectory() as v:
|
|
shutil.copy(quelle, v)
|
|
shutil.copy(suite, v)
|
|
bestanden, fehlgeschlagen, _ = fuehre_suite_aus(v)
|
|
print(f"Ausgangslage: {bestanden} Tests, {fehlgeschlagen} rot.\n")
|
|
if fehlgeschlagen:
|
|
raise SystemExit("Die Suite ist schon ohne Mutation rot - erst das reparieren.")
|
|
|
|
print(f"{'eingebauter Fehler':<48} {'rote Tests':>11} {'Urteil':>12}")
|
|
print("-" * 84)
|
|
|
|
getoetet, ueberlebt = [], []
|
|
for name, suche, ersatz in MUTATIONEN:
|
|
ergebnis = mutiere_und_pruefe(quelle, suite, suche, ersatz)
|
|
if ergebnis is None:
|
|
print(f"{name:<48} {'-':>11} {'nicht anwendbar':>12}")
|
|
continue
|
|
_, rot, _ = ergebnis
|
|
urteil = "GETOETET" if rot else "UEBERLEBT"
|
|
(getoetet if rot else ueberlebt).append(name)
|
|
print(f"{name:<48} {rot:>11} {urteil:>12}")
|
|
|
|
print("-" * 84)
|
|
quote = len(getoetet) / max(1, len(getoetet) + len(ueberlebt)) * 100
|
|
print(f"Getoetet: {len(getoetet)} von {len(getoetet) + len(ueberlebt)} "
|
|
f"({quote:.0f} %)")
|
|
|
|
print("\n" + "=" * 84)
|
|
print(" WAS DAS ERGEBNIS BEDEUTET")
|
|
print("=" * 84)
|
|
if ueberlebt:
|
|
print("Diese Fehler haette die Suite DURCHGEHEN LASSEN:\n")
|
|
for name in ueberlebt:
|
|
print(f" * {name}")
|
|
print("\nJeder ueberlebende Eintrag ist eine Testluecke - und zwar eine")
|
|
print("gefundene, keine vermutete. Das ist der ganze Zweck des Verfahrens:")
|
|
print("Es sagt einem, welchen Test man als naechstes schreiben sollte,")
|
|
print("statt dass man raten muss.")
|
|
else:
|
|
print("Alle eingebauten Fehler wurden gefunden. Das heisst nicht, dass die")
|
|
print("Suite vollstaendig ist - nur, dass sie diese zehn Fehler faengt.")
|
|
print()
|
|
print("Die Quote selbst ist keine Kennzahl fuer ein Dashboard. Zehn von Hand")
|
|
print("gewaehlte Mutationen sind keine Stichprobe aus der Menge aller")
|
|
print("moeglichen Fehler. Was zaehlt, ist die LISTE der Ueberlebenden.")
|
|
print("=" * 84)
|