#!/usr/bin/env python3

# test_or_kern.py
"""
Kapitel Testen: Eine Testsuite fuer ein Optimierungsmodell.

Das Grundproblem beim Testen von Optimierungsmodellen: Man kennt die richtige
Antwort nicht. Bei einer Funktion `steuer(brutto)` schreibt man den erwarteten
Wert hin. Bei "der beste Produktionsplan fuer 400 Auftraege" gibt es niemanden,
der ihn unabhaengig ausrechnen koennte - sonst braeuchte man den Solver nicht.

Deshalb testet man nicht den WERT, sondern vier andere Dinge:

  1. EIGENSCHAFTEN  Die Loesung haelt jede Nebenbedingung ein, und der
                    ausgewiesene Zielwert passt zu den Mengen.
  2. INVARIANTEN    Was das Ergebnis NICHT aendern darf: die Reihenfolge der
                    Produkte, die Waehrungseinheit, ein zusaetzliches Produkt
                    mit Deckungsbeitrag 0.
  3. REGRESSION     Eine kleine Instanz mit von Hand nachgerechnetem Optimum.
  4. FEHLERFAELLE   Unsinnige Eingaben werden abgewiesen, unloesbare Modelle
                    als unloesbar erkannt - und die Abnahmepruefung schlaegt
                    tatsaechlich an, wenn man ihr eine kaputte Loesung gibt.

Der vierte Punkt ist der wichtigste und wird am haeufigsten vergessen: Eine
Pruefung, die noch nie etwas gefunden hat, ist keine Pruefung, sondern eine
Vermutung. Die Tests unten fuettern `pruefe_loesung()` deshalb absichtlich mit
falschen Loesungen und verlangen, dass sie anschlaegt.

Aufruf:
    pytest test_or_kern.py -v
    python3 test_or_kern.py          # ruft pytest selbst auf

Benoetigt: pytest, numpy, pydantic, ortools, scipy (ueber or_kern)
"""

from __future__ import annotations

import pytest

from or_kern import (Loesung, Produkt, Produktionsproblem, SolverStatus,
                     loese_mit_glop, loese_mit_scipy, pruefe_loesung,
                     status_von_scipy)


# --- Die Instanz, gegen die getestet wird ----------------------------------

def schreinerei() -> Produktionsproblem:
    """Der Fall aus Kapitel Einfuehrung - klein genug fuer die Handrechnung.

    Optimum: 30 Tische, 60 Stuehle, Deckungsbeitrag 10.800 EUR.
    Beide Ressourcen sind voll ausgelastet (3*30 + 1*60 = 150,
    6*30 + 1*60 = 240).
    """
    return Produktionsproblem(
        produkte=[
            Produkt(name="Tisch", deckungsbeitrag=240.0,
                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
            Produkt(name="Stuhl", deckungsbeitrag=60.0,
                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
        ],
        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0},
    )


# Beide Modellbauer durchlaufen JEDEN Test. Ein Test, der nur mit einem Solver
# laeuft, prueft die Bibliothek mit - nicht das Modell.
MODELLBAUER = [loese_mit_glop, loese_mit_scipy]
NAMEN = ["glop", "scipy"]


@pytest.fixture(params=MODELLBAUER, ids=NAMEN)
def bauer(request):
    return request.param


# --- 1. Eigenschaften ------------------------------------------------------

def test_loesung_haelt_alle_nebenbedingungen_ein(bauer):
    """Die Abnahmepruefung darf nichts zu beanstanden haben."""
    problem = schreinerei()
    loesung = bauer(problem)
    assert loesung.status is SolverStatus.OPTIMAL
    assert pruefe_loesung(problem, loesung) == []


def test_zielwert_passt_zu_den_mengen(bauer):
    """Der ausgewiesene Zielwert wird unabhaengig nachgerechnet.

    Klingt trivial, ist es nicht: Wer die Zielfunktion im Modell anders
    zusammensetzt als im Bericht (etwa Gebuehren einmal abgezogen, einmal
    nicht), merkt es sonst nie.
    """
    problem = schreinerei()
    loesung = bauer(problem)
    nachgerechnet = sum(p.deckungsbeitrag * loesung.werte[p.name]
                        for p in problem.produkte)
    assert loesung.zielwert == pytest.approx(nachgerechnet, abs=1e-6)


def test_beide_solver_liefern_dasselbe():
    """Der Kern der Architektur aus Kapitel Praxisfallen, als Test."""
    problem = schreinerei()
    glop, scipy_ = loese_mit_glop(problem), loese_mit_scipy(problem)
    assert glop.zielwert == pytest.approx(scipy_.zielwert, abs=1e-6)
    for ressource in problem.ressourcen:
        assert glop.schattenpreise[ressource] == pytest.approx(
            scipy_.schattenpreise[ressource], abs=1e-6)


# --- 2. Invarianten --------------------------------------------------------

def test_produktreihenfolge_aendert_nichts(bauer):
    """Dieselben Daten in anderer Zeilenreihenfolge - gleiches Ergebnis.

    Das faengt die Spaltenvertauschungsfalle aus Kapitel Finanzdaten ab: ein
    Modell, das Positionen statt Namen benutzt, faellt hier durch.
    """
    problem = schreinerei()
    gedreht = Produktionsproblem(produkte=list(reversed(problem.produkte)),
                                 kapazitaeten=problem.kapazitaeten)
    assert bauer(problem).zielwert == pytest.approx(bauer(gedreht).zielwert)


def test_waehrungseinheit_skaliert_linear(bauer):
    """Deckungsbeitraege in Cent statt Euro: Zielwert mal 100, Mengen gleich.

    Der Test prueft nicht die Mathematik - die ist offensichtlich -, sondern
    die NUMERIK. Wer schlecht skalierte Modelle baut (Kapitel Fundament),
    bekommt hier Abweichungen weit ueber der Toleranz.
    """
    problem = schreinerei()
    in_cent = Produktionsproblem(
        produkte=[Produkt(name=p.name, deckungsbeitrag=p.deckungsbeitrag * 100,
                          verbrauch=p.verbrauch) for p in problem.produkte],
        kapazitaeten=problem.kapazitaeten)
    basis, skaliert = bauer(problem), bauer(in_cent)
    assert skaliert.zielwert == pytest.approx(basis.zielwert * 100, rel=1e-9)
    for p in problem.produkte:
        assert skaliert.werte[p.name] == pytest.approx(basis.werte[p.name],
                                                       abs=1e-6)


def test_produkt_ohne_deckungsbeitrag_aendert_das_optimum_nicht(bauer):
    """Ein Produkt, das nichts einbringt, darf den Zielwert nicht heben."""
    problem = schreinerei()
    mit_nullprodukt = Produktionsproblem(
        produkte=problem.produkte + [
            Produkt(name="Muster", deckungsbeitrag=0.0,
                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0})],
        kapazitaeten=problem.kapazitaeten)
    assert bauer(mit_nullprodukt).zielwert <= bauer(problem).zielwert + 1e-6


# --- 3. Regression ---------------------------------------------------------

def test_bekanntes_optimum(bauer):
    """Von Hand nachgerechnet (Kapitel Einfuehrung, Handrechnung).

    Das ist der einzige Test, der einen ZAHLENWERT festschreibt - und er darf
    es, weil dieser Wert von Hand belegt ist. Fuer grosse Instanzen gibt es
    diesen Test nicht; dort tragen die Eigenschaften und Invarianten.
    """
    loesung = bauer(schreinerei())
    assert loesung.zielwert == pytest.approx(10_800.0, abs=1e-6)
    assert loesung.werte["Tisch"] == pytest.approx(30.0, abs=1e-6)
    assert loesung.werte["Stuhl"] == pytest.approx(60.0, abs=1e-6)


def test_schattenpreise_bekannt(bauer):
    """40 EUR je Montagestunde, 20 EUR je Einheit Plattenmaterial."""
    loesung = bauer(schreinerei())
    assert loesung.schattenpreise["Montagestunden"] == pytest.approx(40.0, abs=1e-6)
    assert loesung.schattenpreise["Plattenmaterial"] == pytest.approx(20.0, abs=1e-6)


# --- 4. Fehlerfaelle: schlaegt die Pruefung ueberhaupt an? -----------------

def test_pruefung_findet_kapazitaetsverletzung():
    """Eine Loesung, die zu viel verbraucht, MUSS beanstandet werden."""
    problem = schreinerei()
    kaputt = Loesung(status=SolverStatus.OPTIMAL,
                     werte={"Tisch": 50.0, "Stuhl": 60.0},   # 3*50+60 = 210 > 150
                     zielwert=50 * 240.0 + 60 * 60.0)
    beanstandungen = pruefe_loesung(problem, kaputt)
    assert any("Montagestunden" in b for b in beanstandungen)


def test_pruefung_findet_falschen_zielwert():
    """Mengen und Zielwert passen nicht zusammen - der haeufigste stille Fehler."""
    problem = schreinerei()
    kaputt = Loesung(status=SolverStatus.OPTIMAL,
                     werte={"Tisch": 30.0, "Stuhl": 60.0},
                     zielwert=99_999.0)
    assert any("Zielwert" in b for b in pruefe_loesung(problem, kaputt))


def test_pruefung_findet_negative_mengen():
    problem = schreinerei()
    kaputt = Loesung(status=SolverStatus.OPTIMAL,
                     werte={"Tisch": -5.0, "Stuhl": 60.0},
                     zielwert=-5 * 240.0 + 60 * 60.0)
    assert any("negativ" in b for b in pruefe_loesung(problem, kaputt))


def test_pruefung_findet_gebrochene_ganzzahligkeit():
    """Die Falle aus Kapitel MILP: 0,99999998 ist nicht 1."""
    problem = schreinerei()
    fast_ganz = Loesung(status=SolverStatus.OPTIMAL,
                        werte={"Tisch": 29.4, "Stuhl": 61.8},
                        zielwert=29.4 * 240.0 + 61.8 * 60.0)
    beanstandungen = pruefe_loesung(problem, fast_ganz, ganzzahlig=["Tisch"])
    assert any("ganzzahlig" in b for b in beanstandungen)


def test_pruefung_verweigert_unbrauchbaren_status():
    """Ohne verwertbares Ergebnis wird gar nicht erst gerechnet."""
    problem = schreinerei()
    ohne = Loesung(status=SolverStatus.UNZULAESSIG)
    beanstandungen = pruefe_loesung(problem, ohne)
    assert len(beanstandungen) == 1 and "unzulaessig" in beanstandungen[0]


@pytest.mark.parametrize("daten, stichwort", [
    (dict(produkte=[Produkt(name="Tisch", deckungsbeitrag=240.0,
                            verbrauch={"Montagestunden": 3.0})],
          kapazitaeten={"Montagestunden": 0.0}), "greater than"),
    (dict(produkte=[Produkt(name="Regal", deckungsbeitrag=130.0,
                            verbrauch={"Lackieren": 2.0})],
          kapazitaeten={"Montagestunden": 150.0}), "Kapazitaetsangabe"),
    (dict(produkte=[], kapazitaeten={"Montagestunden": 150.0}), "at least 1"),
])
def test_unsinnige_eingaben_werden_beim_einlesen_abgewiesen(daten, stichwort):
    """Drei Fehler, die NICHT erst beim Loesen auffallen duerfen."""
    with pytest.raises(Exception) as fehler:
        Produktionsproblem(**daten)
    assert stichwort in str(fehler.value)


def test_doppelter_produktname_wird_abgewiesen():
    produkt = Produkt(name="Tisch", deckungsbeitrag=240.0,
                      verbrauch={"Montagestunden": 3.0})
    with pytest.raises(Exception) as fehler:
        Produktionsproblem(produkte=[produkt, produkt],
                           kapazitaeten={"Montagestunden": 150.0})
    assert "mehrfach" in str(fehler.value)


@pytest.mark.parametrize("rohstatus, erwartet", [
    (0, SolverStatus.OPTIMAL),
    (1, SolverStatus.ZEITLIMIT),
    (2, SolverStatus.UNZULAESSIG),
    (3, SolverStatus.UNBESCHRAENKT),
    (4, SolverStatus.FEHLERHAFT),
    (99, SolverStatus.UNBEKANNT),
])
def test_statusuebersetzung_scipy(rohstatus, erwartet):
    """Die Uebersetzertabelle selbst - inklusive des unbekannten Falls."""
    class Ergebnis:
        status = rohstatus
    assert status_von_scipy(Ergebnis()) is erwartet


def test_status_eigenschaften_sind_konsistent():
    """brauchbar und modellfehler duerfen sich nie ueberschneiden."""
    for status in SolverStatus:
        assert not (status.brauchbar and status.modellfehler)


# Die beiden folgenden Tests gab es zuerst nicht. Sie sind entstanden, weil
# Mutationstest.py zwei eingebaute Fehler UEBERLEBEN liess - siehe den
# Abschnitt "Wer testet die Tests?".

def test_nur_optimal_und_zulaessig_gelten_als_brauchbar():
    """ZEITLIMIT ist NICHT brauchbar - der Solver hat nichts gefunden.

    Der Unterschied entscheidet, ob ein Nachtjob einen Plan ausliefert oder
    Alarm schlaegt. Ein aufgeweichtes 'brauchbar' faellt sonst nirgends auf.
    """
    assert SolverStatus.OPTIMAL.brauchbar
    assert SolverStatus.ZULAESSIG.brauchbar
    for status in (SolverStatus.ZEITLIMIT, SolverStatus.UNZULAESSIG,
                   SolverStatus.UNBESCHRAENKT, SolverStatus.FEHLERHAFT,
                   SolverStatus.UNBEKANNT):
        assert not status.brauchbar, f"{status.value} darf nicht brauchbar sein"


def test_ressourcenreihenfolge_haengt_nicht_an_der_eingabe():
    """Zwei Mappings mit gleichem Inhalt, andere Einfuegereihenfolge.

    Die Verbrauchsmatrix muss zeilenweise identisch sein. Ohne diese
    Zusicherung passt die Matrix irgendwann nicht mehr zum
    Kapazitaetsvektor - die Vertauschungsfalle aus Kapitel Finanzdaten,
    nur eine Ebene tiefer.
    """
    produkte = schreinerei().produkte
    vorwaerts = Produktionsproblem(
        produkte=produkte,
        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})
    rueckwaerts = Produktionsproblem(
        produkte=produkte,
        kapazitaeten={"Plattenmaterial": 240.0, "Montagestunden": 150.0})
    assert vorwaerts.ressourcen == rueckwaerts.ressourcen
    assert (vorwaerts.verbrauchsmatrix() == rueckwaerts.verbrauchsmatrix()).all()
    assert (vorwaerts.kapazitaetsvektor() == rueckwaerts.kapazitaetsvektor()).all()


if __name__ == "__main__":
    import sys
    sys.exit(pytest.main([__file__, "-v", "--tb=short", "-p", "no:cacheprovider"]))
