operations_research/OR_HTML_04/programme/Mutationstest.py

179 lines
7.1 KiB
Python
Raw Normal View History

Version 04 als eigenes Repository 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>
2026-09-08 01:20:09 +02:00
#!/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)