From 0a32dadcc4d0d3194c8bf855bfce4a5f0741558d Mon Sep 17 00:00:00 2001 From: Alex Mueller Date: Sat, 29 Aug 2026 14:26:27 +0200 Subject: [PATCH] Implementiere Kapitel 08 bis 14 (Operator Overloading, Unit Testing, Vererbung, Polymorphie, Exceptions, Persistenz, Tkinter GUI) --- 07_referenzen_und_speicher/index.html | 2 +- 08_operator_overloading_dunder/README.md | 43 ++ 08_operator_overloading_dunder/aufgabe.py | 166 ++++++++ 08_operator_overloading_dunder/index.html | 280 +++++++++++++ .../musterloesung.py | 146 +++++++ .../test_aufgabe.py | 142 +++++++ 09_eigene_unit_tests_schreiben/README.md | 48 +++ 09_eigene_unit_tests_schreiben/aufgabe.py | 183 ++++++++ 09_eigene_unit_tests_schreiben/index.html | 299 +++++++++++++ .../musterloesung.py | 149 +++++++ .../test_aufgabe.py | 175 ++++++++ 10_vererbung_und_super/README.md | 91 ++++ 10_vererbung_und_super/aufgabe.py | 284 +++++++++++++ 10_vererbung_und_super/index.html | 359 ++++++++++++++++ 10_vererbung_und_super/musterloesung.py | 168 ++++++++ 10_vererbung_und_super/test_aufgabe.py | 193 +++++++++ 11_polymorphie_und_interfaces/README.md | 87 ++++ 11_polymorphie_und_interfaces/aufgabe.py | 309 ++++++++++++++ 11_polymorphie_und_interfaces/index.html | 392 ++++++++++++++++++ .../musterloesung.py | 151 +++++++ 11_polymorphie_und_interfaces/test_aufgabe.py | 178 ++++++++ 12_exceptions_und_fehlerbehandlung/README.md | 44 ++ 12_exceptions_und_fehlerbehandlung/aufgabe.py | 191 +++++++++ 12_exceptions_und_fehlerbehandlung/index.html | 259 ++++++++++++ .../musterloesung.py | 141 +++++++ .../test_aufgabe.py | 166 ++++++++ 13_persistenz_json_und_csv/README.md | 44 ++ 13_persistenz_json_und_csv/aufgabe.py | 194 +++++++++ 13_persistenz_json_und_csv/index.html | 247 +++++++++++ 13_persistenz_json_und_csv/musterloesung.py | 143 +++++++ 13_persistenz_json_und_csv/test_aufgabe.py | 144 +++++++ 14_gui_mit_tkinter/README.md | 126 ++++++ 14_gui_mit_tkinter/aufgabe.py | 303 ++++++++++++++ 14_gui_mit_tkinter/gui_app.py | 314 ++++++++++++++ 14_gui_mit_tkinter/index.html | 351 ++++++++++++++++ 14_gui_mit_tkinter/musterloesung.py | 296 +++++++++++++ 14_gui_mit_tkinter/test_aufgabe.py | 266 ++++++++++++ README.md | 13 +- index.html | 80 +++- test_all.py | 7 + 40 files changed, 7152 insertions(+), 22 deletions(-) create mode 100644 08_operator_overloading_dunder/README.md create mode 100644 08_operator_overloading_dunder/aufgabe.py create mode 100644 08_operator_overloading_dunder/index.html create mode 100644 08_operator_overloading_dunder/musterloesung.py create mode 100644 08_operator_overloading_dunder/test_aufgabe.py create mode 100644 09_eigene_unit_tests_schreiben/README.md create mode 100644 09_eigene_unit_tests_schreiben/aufgabe.py create mode 100644 09_eigene_unit_tests_schreiben/index.html create mode 100644 09_eigene_unit_tests_schreiben/musterloesung.py create mode 100644 09_eigene_unit_tests_schreiben/test_aufgabe.py create mode 100644 10_vererbung_und_super/README.md create mode 100644 10_vererbung_und_super/aufgabe.py create mode 100644 10_vererbung_und_super/index.html create mode 100644 10_vererbung_und_super/musterloesung.py create mode 100644 10_vererbung_und_super/test_aufgabe.py create mode 100644 11_polymorphie_und_interfaces/README.md create mode 100644 11_polymorphie_und_interfaces/aufgabe.py create mode 100644 11_polymorphie_und_interfaces/index.html create mode 100644 11_polymorphie_und_interfaces/musterloesung.py create mode 100644 11_polymorphie_und_interfaces/test_aufgabe.py create mode 100644 12_exceptions_und_fehlerbehandlung/README.md create mode 100644 12_exceptions_und_fehlerbehandlung/aufgabe.py create mode 100644 12_exceptions_und_fehlerbehandlung/index.html create mode 100644 12_exceptions_und_fehlerbehandlung/musterloesung.py create mode 100644 12_exceptions_und_fehlerbehandlung/test_aufgabe.py create mode 100644 13_persistenz_json_und_csv/README.md create mode 100644 13_persistenz_json_und_csv/aufgabe.py create mode 100644 13_persistenz_json_und_csv/index.html create mode 100644 13_persistenz_json_und_csv/musterloesung.py create mode 100644 13_persistenz_json_und_csv/test_aufgabe.py create mode 100644 14_gui_mit_tkinter/README.md create mode 100644 14_gui_mit_tkinter/aufgabe.py create mode 100644 14_gui_mit_tkinter/gui_app.py create mode 100644 14_gui_mit_tkinter/index.html create mode 100644 14_gui_mit_tkinter/musterloesung.py create mode 100644 14_gui_mit_tkinter/test_aufgabe.py diff --git a/07_referenzen_und_speicher/index.html b/07_referenzen_und_speicher/index.html index b5200c4..8ea1d97 100644 --- a/07_referenzen_und_speicher/index.html +++ b/07_referenzen_und_speicher/index.html @@ -205,7 +205,7 @@ diff --git a/08_operator_overloading_dunder/README.md b/08_operator_overloading_dunder/README.md new file mode 100644 index 0000000..373df19 --- /dev/null +++ b/08_operator_overloading_dunder/README.md @@ -0,0 +1,43 @@ +# Kapitel 08: Operator Overloading & Dunder-Methoden ➕ + +In diesem Kapitel lernst du, wie du deine eigenen Klassen mit Pythons eingebauten Operatoren (`+`, `-`, `*`, `==`, `<`, `len()`, `abs()`) ausstattest. + +--- + +## 🎯 Was du lernst + +1. **Was sind Dunder-Methoden?** Methoden mit führenden und nachgestellten doppelten Unterstrichen (**D**ouble **Under**score, z.B. `__add__`). +2. **Magische Übersetzung:** Wenn du `a + b` schreibst, ruft Python im Hintergrund `a.__add__(b)` auf. +3. **Mathematische Operatoren überladen:** + - `__add__(self, other)` für `+` + - `__sub__(self, other)` für `-` + - `__mul__(self, other)` für `*` (z.B. Skalarmultiplikation mit Zahlen oder Skalarprodukt) + - `__rmul__(self, other)` für `*` von links (z.B. `3 * v`) +4. **Vergleiche überladen:** + - `__eq__(self, other)` für `==` + - `__lt__(self, other)` für `<` + - `__le__(self, other)` für `<=` +5. **Objekt- und Container-Dunder:** + - `__str__` (für Menschen) vs. `__repr__` (für Entwickler / Debugger) + - `__abs__` für `abs(objekt)` (z.B. Vektorlänge) + - `__len__` für `len(objekt)` (z.B. Anzahl von Wegpunkten) + - `__getitem__` für Indexzugriff `objekt[i]` + +--- + +## 📁 Die Dateien in diesem Ordner + +- **`index.html`**: Interaktive Lernseite mit visuellen Tabellen, Code-Beispielen & Ausklapp-Hilfen. +- **`aufgabe.py`**: Dein Arbeitsblatt zum Implementieren von `Vektor2D` und `Wegstrecke`. +- **`test_aufgabe.py`**: Automatische Tests (`python3 test_aufgabe.py`). +- **`musterloesung.py`**: Vollständig ausprogrammierte Beispiellösung. + +--- + +## 🚀 Schnellstart + +```bash +# 1. Bearbeite aufgabe.py +# 2. Teste deine Lösung: +python3 test_aufgabe.py +``` diff --git a/08_operator_overloading_dunder/aufgabe.py b/08_operator_overloading_dunder/aufgabe.py new file mode 100644 index 0000000..4142e71 --- /dev/null +++ b/08_operator_overloading_dunder/aufgabe.py @@ -0,0 +1,166 @@ +""" +Kapitel 08: Operator Overloading & Dunder-Methoden ➕ +=================================================== +Aufgabe: Mache 2D-Vektoren rechen- und vergleichbar mit magischen Dunder-Methoden! + +Implementiere die Klasse 'Vektor2D' und die Container-Klasse 'Wegstrecke'. +""" + +import math +from typing import Union, List, Optional + + +class Vektor2D: + """ + Repräsentiert einen 2D-Vektor (x, y). + """ + + # ========================================================================== + # TODO 1: Konstruktor __init__(self, x: float, y: float) + # Speichere self.x = float(x) und self.y = float(y). + # ========================================================================== + def __init__(self, x: float, y: float): + pass + + # ========================================================================== + # TODO 2: Darstellungsmethoden __repr__ und __str__ + # - __repr__(self) -> str: Gibt Entwickler-String "Vektor2D(x, y)" zurück (z.B. "Vektor2D(3.0, 4.0)") + # - __str__(self) -> str: Gibt benutzerfreundlichen String "(x, y)" zurück (z.B. "(3.0, 4.0)") + # ========================================================================== + def __repr__(self) -> str: + pass + + def __str__(self) -> str: + pass + + # ========================================================================== + # TODO 3: Länge und Absolutbetrag + # - laenge(self) -> float: Berechnet sqrt(x² + y²) + # - __abs__(self) -> float: Gibt self.laenge() zurück, damit abs(v) funktioniert! + # ========================================================================== + def laenge(self) -> float: + pass + + def __abs__(self) -> float: + pass + + # ========================================================================== + # TODO 4: Addition (__add__) und Subtraktion (__sub__) + # - __add__(self, other: 'Vektor2D') -> 'Vektor2D': + # Addiert zwei Vektoren komponentenweise: (self.x + other.x, self.y + other.y) + # - __sub__(self, other: 'Vektor2D') -> 'Vektor2D': + # Subtrahiert zwei Vektoren komponentenweise: (self.x - other.x, self.y - other.y) + # Hinweis: Falls other kein Vektor2D ist, gebe 'NotImplemented' zurück. + # ========================================================================== + def __add__(self, other: "Vektor2D") -> "Vektor2D": + pass + + def __sub__(self, other: "Vektor2D") -> "Vektor2D": + pass + + # ========================================================================== + # TODO 5: Multiplikation (__mul__) und Rechts-Multiplikation (__rmul__) + # - __mul__(self, other): + # * Wenn other eine Zahl ist (int oder float): + # Skalarmultiplikation -> neuer Vektor2D(self.x * other, self.y * other) + # * Wenn other ein Vektor2D ist: + # Skalarprodukt (Dot Product) -> self.x * other.x + self.y * other.y (eine Zahl!) + # * Andernfalls -> NotImplemented + # - __rmul__(self, other): + # * Ermöglicht Zahl * Vektor (z.B. 3 * v), indem self.__mul__(other) aufgerufen wird. + # ========================================================================== + def __mul__(self, other: Union[int, float, "Vektor2D"]) -> Union["Vektor2D", float]: + pass + + def __rmul__(self, other: Union[int, float]) -> "Vektor2D": + pass + + # ========================================================================== + # TODO 6: Vergleiche (__eq__, __lt__, __le__) + # - __eq__(self, other: object) -> bool: + # True, wenn other ein Vektor2D ist und self.x == other.x und self.y == other.y (Tipp: math.isclose) + # - __lt__(self, other: 'Vektor2D') -> bool: + # Vergleicht anhand der Vektorlänge: self.laenge() < other.laenge() + # - __le__(self, other: 'Vektor2D') -> bool: + # Vergleicht anhand der Vektorlänge: self.laenge() <= other.laenge() + # ========================================================================== + def __eq__(self, other: object) -> bool: + pass + + def __lt__(self, other: "Vektor2D") -> bool: + pass + + def __le__(self, other: "Vektor2D") -> bool: + pass + + +class Wegstrecke: + """ + Eine Sequenz von 2D-Wegpunkten (Vektor2D-Objekte). + """ + + # ========================================================================== + # TODO 7: Wegstrecke __init__ und punkt_hinzufuegen + # - __init__(self, punkte: list[Vektor2D] | None = None): + # Speichert self.punkte = list(punkte) if punkte else [] + # - punkt_hinzufuegen(self, punkt: Vektor2D) -> None: + # Fügt den Punkt zur internen Liste hinzu. + # ========================================================================== + def __init__(self, punkte: Optional[List[Vektor2D]] = None): + pass + + def punkt_hinzufuegen(self, punkt: Vektor2D) -> None: + pass + + # ========================================================================== + # TODO 8: Container-Dunder (__len__ und __getitem__) + # - __len__(self) -> int: + # Gibt die Anzahl der Wegpunkte zurück (len(self.punkte)). + # - __getitem__(self, index: int) -> Vektor2D: + # Gibt den Punkt an Index 'index' zurück (self.punkte[index]). + # ========================================================================== + def __len__(self) -> int: + pass + + def __getitem__(self, index: int) -> Vektor2D: + pass + + # ========================================================================== + # TODO 9: gesamtlaenge(self) -> float + # Berechnet die Summe aller Teilstrecken zwischen aufeinanderfolgenden Punkten: + # Distanz zwischen Punkt i und Punkt i+1 ist: abs(punkte[i+1] - punkte[i]) + # Wenn weniger als 2 Punkte vorhanden sind: gebe 0.0 zurück. + # ========================================================================== + def gesamtlaenge(self) -> float: + pass + + +# ============================================================================== +# Kleiner Test zum Ausprobieren im Terminal: +# (python3 aufgabe.py) +# ============================================================================== +if __name__ == "__main__": + v1 = Vektor2D(3, 4) + v2 = Vektor2D(1, 2) + + print("--- Vektor Test ---") + print(f"v1: {v1}") + print(f"repr(v1): {repr(v1)}") + if v1.laenge() is not None: + print(f"Länge |v1|: {abs(v1)}") + if (v1 + v2) is not None: + print(f"v1 + v2 = {v1 + v2}") + print(f"v1 - v2 = {v1 - v2}") + print(f"v1 * 3 = {v1 * 3}") + print(f"3 * v1 = {3 * v1}") + print(f"Skalarprodukt v1 * v2 = {v1 * v2}") + print(f"v1 == Vektor2D(3, 4): {v1 == Vektor2D(3, 4)}") + print(f"v2 < v1: {v2 < v1}") + + print("\n--- Wegstrecke Test ---") + route = Wegstrecke([Vektor2D(0, 0), Vektor2D(3, 0), Vektor2D(3, 4)]) + print(f"Anzahl Wegpunkte: {len(route) if len(route) is not None else 'noch nicht implementiert'}") + try: + print(f"Gesamtlänge Route: {route.gesamtlaenge()}") + except Exception as e: + print(f"Gesamtlänge Fehler: {e}") diff --git a/08_operator_overloading_dunder/index.html b/08_operator_overloading_dunder/index.html new file mode 100644 index 0000000..e7ed610 --- /dev/null +++ b/08_operator_overloading_dunder/index.html @@ -0,0 +1,280 @@ + + + + + + Kapitel 08: Operator Overloading & Dunder-Methoden + + + + +
+
+ +
Kapitel 08 • Schulabgleich 28.0 Spezielle Methoden
+

Operator Overloading & Dunder-Methoden ➕📐

+

Bringe deinen eigenen Objekten das Rechnen und Vergleichen bei – mit Pythons magischen Methoden!

+
+
+ +
+ + +
+

1. Was bedeutet "Dunder" & Operator Overloading?

+ +

+ In Python steht "Dunder" kurz für Double Underscore (doppelter Unterstrich), wie bei __init__ oder __str__. + Man nennt sie auch magische Methoden (Magic Methods) oder spezielle Methoden. +

+ +
+
🧙‍♂️ Der Universal-Übersetzer von Python:
+ Wenn du in Python schreibst: a + b, dann weiß Python von Natur aus nicht, was deine eigenen Klassen bedeuten. + Python ruft stattdessen heimlich im Hintergrund die Methode a.__add__(b) auf!
+ Operator Overloading bedeutet einfach: Du schreibst die Methode __add__ in deine Klasse, und schon funktioniert das Plus-Zeichen + für deine Objekte! +
+ +
+
+ Was Python im Hintergrund wirklich tut +
+
# Was du im Code schreibst:
+ergebnis = v1 + v2
+ist_gleich = (v1 == v2)
+laenge = len(meine_liste)
+
+# Was Python intern daraus macht:
+ergebnis = v1.__add__(v2)
+ist_gleich = v1.__eq__(v2)
+laenge = meine_liste.__len__()
+
+
+ + +
+

2. Die wichtigste Dunder-Methoden-Tabelle

+ +

Hier sind die am häufigsten genutzten magischen Methoden in Python:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KategorieOperator / FunktionDunder-MethodeBedeutung
Mathematika + b__add__(self, other)Addition
a - b__sub__(self, other)Subtraktion
a * b__mul__(self, other)Multiplikation (von links)
b * a__rmul__(self, other)Rechts-Multiplikation (z.B. 3 * v)
Vergleichea == b__eq__(self, other)Gleichheit (Equal)
a < b__lt__(self, other)Kleiner als (Less Than)
a <= b__le__(self, other)Kleiner gleich (Less or Equal)
Darstellung & Containerprint(a) / str(a)__str__(self)Schöne Ausgabe für Menschen
repr(a)__repr__(self)Eindeutige Debug-Ausgabe
abs(a)__abs__(self)Betrag / Länge
len(a)__len__(self)Anzahl von Elementen
+ +
+
💡 Unterschied zwischen __str__ und __repr__:
+
    +
  • __str__ ist für Endbenutzer gedacht. Beispiel: (3.0, 4.0).
  • +
  • __repr__ ist für Entwickler & Debugger gedacht. Sie soll so präzise sein, dass man das Objekt idealerweise wieder nachbauen könnte: Vektor2D(3.0, 4.0).
  • +
+
+
+ + +
+

3. Praxisbeispiel: Der 2D-Vektor 🚀

+ +

+ In Videospielen, Physiksimulationen und in der Geometrie rechnet man ständig mit 2D-Vektoren. + Mit Dunder-Methoden liest sich dein Code wie echte Mathematik! +

+ +
+
+ Beispiel: Vektor-Addition & Multiplikation +
+
class Vektor2D:
+    def __init__(self, x: float, y: float):
+        self.x = float(x)
+        self.y = float(y)
+
+    def __add__(self, other: 'Vektor2D') -> 'Vektor2D':
+        if isinstance(other, Vektor2D):
+            return Vektor2D(self.x + other.x, self.y + other.y)
+        return NotImplemented
+
+    def __mul__(self, other):
+        # Skalarmultiplikation mit Zahl: v * 3
+        if isinstance(other, (int, float)):
+            return Vektor2D(self.x * other, self.y * other)
+        # Skalarprodukt mit anderem Vektor: v1 * v2
+        elif isinstance(other, Vektor2D):
+            return self.x * other.x + self.y * other.y
+        return NotImplemented
+
+    def __rmul__(self, other):
+        # Ermöglicht: 3 * v (Zahl steht links)
+        return self.__mul__(other)
+
+
+ + +
+

4. 🎯 Deine Aufgaben in aufgabe.py

+ +

Öffne die Datei 08_operator_overloading_dunder/aufgabe.py und vervollständige die folgenden Klassen:

+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ +

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zu TODO 3 (Länge berechnen mit math.sqrt) +
+
+
import math
+
+def laenge(self) -> float:
+    return math.sqrt(self.x**2 + self.y**2)
+
+def __abs__(self) -> float:
+    return self.laenge()
+
+
+
+ +
+ 🔍 Tipp zu TODO 9 (Gesamtlänge der Wegstrecke berechnen) +
+

Iteriere über alle Punkte von 0 bis len(self.punkte) - 2. Die Distanz zwischen zwei Punkten ist einfach abs(self.punkte[i+1] - self.punkte[i])!

+
+
+
+ + +
+

5. 🧪 Lösung überprüfen

+

Führe im Terminal im Ordner 08_operator_overloading_dunder folgenden Befehl aus:

+
+
+ Terminal +
+
python3 test_aufgabe.py
+
+ + +
+ +
+ + + diff --git a/08_operator_overloading_dunder/musterloesung.py b/08_operator_overloading_dunder/musterloesung.py new file mode 100644 index 0000000..85a0df2 --- /dev/null +++ b/08_operator_overloading_dunder/musterloesung.py @@ -0,0 +1,146 @@ +""" +Kapitel 08: Operator Overloading & Dunder-Methoden ➕ +=================================================== +Musterlösung: Vollständige Implementierung von Vektor2D und Wegstrecke. +""" + +import math +from typing import Union, List, Optional + + +class Vektor2D: + """ + Repräsentiert einen zweidimensionalen Vektor (x, y) in der Ebene. + Unterstützt mathematische Operatoren (+, -, *), Vergleiche (==, <, <=), + sowie __str__, __repr__ und __abs__. + """ + + def __init__(self, x: float, y: float): + self.x: float = float(x) + self.y: float = float(y) + + def __repr__(self) -> str: + """Entwickler-Darstellung: Vektor2D(3.0, 4.0)""" + return f"Vektor2D({self.x}, {self.y})" + + def __str__(self) -> str: + """Benutzerfreundliche Darstellung: (3.0, 4.0)""" + return f"({self.x}, {self.y})" + + def laenge(self) -> float: + """Berechnet den Betrag (die euklidische Länge) des Vektors: sqrt(x² + y²)""" + return math.sqrt(self.x**2 + self.y**2) + + def __abs__(self) -> float: + """Ermöglicht abs(v) zur Berechnung der Vektorlänge.""" + return self.laenge() + + def __add__(self, other: "Vektor2D") -> "Vektor2D": + """Vektoraddition: (x1 + x2, y1 + y2)""" + if isinstance(other, Vektor2D): + return Vektor2D(self.x + other.x, self.y + other.y) + return NotImplemented + + def __sub__(self, other: "Vektor2D") -> "Vektor2D": + """Vektorsubtraktion: (x1 - x2, y1 - y2)""" + if isinstance(other, Vektor2D): + return Vektor2D(self.x - other.x, self.y - other.y) + return NotImplemented + + def __mul__(self, other: Union[int, float, "Vektor2D"]) -> Union["Vektor2D", float]: + """ + Multiplikation: + - Mit einer Zahl (int/float): Skalarmultiplikation -> neuer Vektor2D(x*k, y*k) + - Mit einem Vektor2D: Skalarprodukt (Dot Product) -> x1*x2 + y1*y2 (Zahl) + """ + if isinstance(other, (int, float)): + return Vektor2D(self.x * other, self.y * other) + elif isinstance(other, Vektor2D): + return self.x * other.x + self.y * other.y + return NotImplemented + + def __rmul__(self, other: Union[int, float]) -> "Vektor2D": + """Ermöglicht Zahl * Vektor (z.B. 3 * v), indem __mul__ aufgerufen wird.""" + if isinstance(other, (int, float)): + return self.__mul__(other) + return NotImplemented + + def __eq__(self, other: object) -> bool: + """Prüft zwei Vektoren auf Gleichheit ihrer Komponenten.""" + if not isinstance(other, Vektor2D): + return False + return math.isclose(self.x, other.x, abs_tol=1e-9) and math.isclose(self.y, other.y, abs_tol=1e-9) + + def __lt__(self, other: "Vektor2D") -> bool: + """Vergleicht zwei Vektoren anhand ihrer Länge (<).""" + if isinstance(other, Vektor2D): + return self.laenge() < other.laenge() + return NotImplemented + + def __le__(self, other: "Vektor2D") -> bool: + """Vergleicht zwei Vektoren anhand ihrer Länge (<=).""" + if isinstance(other, Vektor2D): + return self.laenge() <= other.laenge() + return NotImplemented + + +class Wegstrecke: + """ + Eine Sequenz von 2D-Wegpunkten, die Container-Dunder (__len__, __getitem__) + nutzt und die Gesamtlänge der Strecke berechnet. + """ + + def __init__(self, punkte: Optional[List[Vektor2D]] = None): + self.punkte: List[Vektor2D] = list(punkte) if punkte else [] + + def punkt_hinzufuegen(self, punkt: Vektor2D) -> None: + """Fügt einen neuen Wegpunkt hinzu.""" + self.punkte.append(punkt) + + def __len__(self) -> int: + """Gibt die Anzahl der Wegpunkte zurück: len(strecke)""" + return len(self.punkte) + + def __getitem__(self, index: int) -> Vektor2D: + """Ermöglicht Indexzugriff per strecke[i]""" + return self.punkte[index] + + def gesamtlaenge(self) -> float: + """ + Berechnet die Gesamtdistanz entlang aller Wegpunkte. + Nutzt Vektorsubtraktion und abs(), um Distanzen zwischen + aufeinanderfolgenden Punkten zu summieren. + """ + if len(self.punkte) < 2: + return 0.0 + + distanz = 0.0 + for i in range(len(self.punkte) - 1): + segment = self.punkte[i + 1] - self.punkte[i] + distanz += abs(segment) + return distanz + + +# ============================================================================== +# Terminal-Demo zum direkten Ausführen +# ============================================================================== +if __name__ == "__main__": + v1 = Vektor2D(3, 4) + v2 = Vektor2D(1, 2) + + print(f"v1: {v1}") # (3.0, 4.0) + print(f"repr(v1): {repr(v1)}") # Vektor2D(3.0, 4.0) + print(f"Länge |v1|: {abs(v1)}") # 5.0 + print(f"v1 + v2 = {v1 + v2}") # (4.0, 6.0) + print(f"v1 - v2 = {v1 - v2}") # (2.0, 2.0) + print(f"v1 * 3 = {v1 * 3}") # (9.0, 12.0) + print(f"3 * v1 = {3 * v1}") # (9.0, 12.0) + print(f"Skalarprodukt v1 * v2: {v1 * v2}") # 3*1 + 4*2 = 11.0 + print(f"v1 == Vektor2D(3, 4): {v1 == Vektor2D(3, 4)}") # True + print(f"v2 < v1: {v2 < v1}") # True (Länge sqrt(5) < 5.0) + + # Wegstrecken-Test + route = Wegstrecke([Vektor2D(0, 0), Vektor2D(3, 0), Vektor2D(3, 4)]) + print(f"Anzahl Punkte: {len(route)}") # 3 + print(f"Erster Punkt: {route[0]}") # (0.0, 0.0) + print(f"Gesamtlänge Route: {route.gesamtlaenge()}") # 3 + 4 = 7.0 diff --git a/08_operator_overloading_dunder/test_aufgabe.py b/08_operator_overloading_dunder/test_aufgabe.py new file mode 100644 index 0000000..beed836 --- /dev/null +++ b/08_operator_overloading_dunder/test_aufgabe.py @@ -0,0 +1,142 @@ +import unittest +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import aufgabe +except ImportError as e: + raise ImportError(f"Konnte 'aufgabe.py' nicht importieren: {e}") + + +class TestKapitel08(unittest.TestCase): + + def setUp(self): + Vektor2D = getattr(aufgabe, "Vektor2D", None) + Wegstrecke = getattr(aufgabe, "Wegstrecke", None) + self.assertIsNotNone(Vektor2D, "Klasse 'Vektor2D' nicht in aufgabe.py gefunden!") + self.assertIsNotNone(Wegstrecke, "Klasse 'Wegstrecke' nicht in aufgabe.py gefunden!") + self.Vektor2D = Vektor2D + self.Wegstrecke = Wegstrecke + + def test_01_init_und_darstellung(self): + """Prüft __init__, __repr__ und __str__ von Vektor2D.""" + v = self.Vektor2D(3, 4) + self.assertEqual(float(v.x), 3.0) + self.assertEqual(float(v.y), 4.0) + + # __repr__ soll Entwickler-Syntax liefern + self.assertEqual(repr(v), "Vektor2D(3.0, 4.0)") + + # __str__ soll lesbare Klammer-Syntax liefern + self.assertEqual(str(v), "(3.0, 4.0)") + + def test_02_laenge_und_abs(self): + """Prüft laenge() und die Dunder-Methode __abs__.""" + v = self.Vektor2D(3, 4) + self.assertAlmostEqual(v.laenge(), 5.0, places=4) + self.assertAlmostEqual(abs(v), 5.0, places=4) + + v_null = self.Vektor2D(0, 0) + self.assertAlmostEqual(abs(v_null), 0.0, places=4) + + v_neg = self.Vektor2D(-6, -8) + self.assertAlmostEqual(abs(v_neg), 10.0, places=4) + + def test_03_addition_und_subtraktion(self): + """Prüft __add__ und __sub__ zweier Vektoren.""" + v1 = self.Vektor2D(3, 4) + v2 = self.Vektor2D(1, -2) + + v_sum = v1 + v2 + self.assertIsInstance(v_sum, self.Vektor2D, "Das Ergebnis von v1 + v2 muss ein Vektor2D sein!") + self.assertAlmostEqual(v_sum.x, 4.0) + self.assertAlmostEqual(v_sum.y, 2.0) + + v_diff = v1 - v2 + self.assertIsInstance(v_diff, self.Vektor2D, "Das Ergebnis von v1 - v2 muss ein Vektor2D sein!") + self.assertAlmostEqual(v_diff.x, 2.0) + self.assertAlmostEqual(v_diff.y, 6.0) + + def test_04_skalarmultiplikation_und_skalarprodukt(self): + """Prüft Multiplikation mit Zahlen (__mul__, __rmul__) und Skalarprodukt (__mul__).""" + v = self.Vektor2D(2.5, -4.0) + + # Skalarmultiplikation Zahl von rechts + v_mal_2 = v * 2 + self.assertIsInstance(v_mal_2, self.Vektor2D) + self.assertAlmostEqual(v_mal_2.x, 5.0) + self.assertAlmostEqual(v_mal_2.y, -8.0) + + # Skalarmultiplikation Zahl von links (__rmul__) + v_links = 3 * v + self.assertIsInstance(v_links, self.Vektor2D) + self.assertAlmostEqual(v_links.x, 7.5) + self.assertAlmostEqual(v_links.y, -12.0) + + # Skalarprodukt zweier Vektoren: v1 * v2 -> x1*x2 + y1*y2 + v1 = self.Vektor2D(2, 3) + v2 = self.Vektor2D(4, -1) + skalarprodukt = v1 * v2 + self.assertTrue(isinstance(skalarprodukt, (int, float)), "Skalarprodukt muss eine Zahl sein!") + self.assertAlmostEqual(skalarprodukt, 2 * 4 + 3 * (-1)) # 8 - 3 = 5 + + def test_05_vergleiche(self): + """Prüft __eq__, __lt__ und __le__.""" + v1 = self.Vektor2D(3, 4) # Länge 5.0 + v2 = self.Vektor2D(3.0, 4.0) # Länge 5.0 (gleiche Werte) + v3 = self.Vektor2D(0, 5) # Länge 5.0 (andere Werte) + v4 = self.Vektor2D(1, 1) # Länge sqrt(2) ≈ 1.414 + v5 = self.Vektor2D(6, 8) # Länge 10.0 + + # Gleichheit + self.assertEqual(v1, v2) + self.assertNotEqual(v1, v3) + self.assertNotEqual(v1, "kein vektor") + + # Kleiner (<) + self.assertTrue(v4 < v1) + self.assertFalse(v1 < v4) + self.assertFalse(v1 < v3) # gleiche Länge + + # Kleiner-Gleich (<=) + self.assertTrue(v4 <= v1) + self.assertTrue(v1 <= v3) # gleiche Länge -> True + self.assertTrue(v1 <= v5) + self.assertFalse(v5 <= v1) + + def test_06_wegstrecke_container(self): + """Prüft Wegstrecke Initialisierung, __len__ und __getitem__.""" + p1 = self.Vektor2D(0, 0) + p2 = self.Vektor2D(2, 3) + p3 = self.Vektor2D(5, 7) + + strecke = self.Wegstrecke([p1, p2]) + self.assertEqual(len(strecke), 2, "len(strecke) muss 2 zurückgeben!") + self.assertEqual(strecke[0], p1, "strecke[0] muss p1 sein!") + self.assertEqual(strecke[1], p2, "strecke[1] muss p2 sein!") + + strecke.punkt_hinzufuegen(p3) + self.assertEqual(len(strecke), 3) + self.assertEqual(strecke[2], p3) + + def test_07_wegstrecke_gesamtlaenge(self): + """Prüft Wegstrecke.gesamtlaenge() mit 0, 1 und 3 Wegpunkten.""" + strecke_leer = self.Wegstrecke() + self.assertAlmostEqual(strecke_leer.gesamtlaenge(), 0.0) + + strecke_einzeln = self.Wegstrecke([self.Vektor2D(10, 20)]) + self.assertAlmostEqual(strecke_einzeln.gesamtlaenge(), 0.0) + + # Route (0,0) -> (3,0) Distanz = 3; (3,0) -> (3,4) Distanz = 4; Summe = 7 + route = self.Wegstrecke([ + self.Vektor2D(0, 0), + self.Vektor2D(3, 0), + self.Vektor2D(3, 4) + ]) + self.assertAlmostEqual(route.gesamtlaenge(), 7.0, places=4) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/09_eigene_unit_tests_schreiben/README.md b/09_eigene_unit_tests_schreiben/README.md new file mode 100644 index 0000000..751541a --- /dev/null +++ b/09_eigene_unit_tests_schreiben/README.md @@ -0,0 +1,48 @@ +# Kapitel 09: Eigene Unit Tests schreiben & TDD 🧪 + +In diesem Kapitel lernst du, wie professionelle Software-Entwickler ihren Code mit automatischen Tests absichern und wie **Test-Driven Development (TDD)** funktioniert. + +--- + +## 🎯 Was du lernst + +1. **Warum automatische Unit Tests?** + - Nie wieder manuelle `print()`-Ausgaben kontrollieren. + - Sofortiges Feedback bei Fehlern (Regression Prevention). + - Saubere Schnittstellendokumentation. +2. **Das `unittest`-Modul in Python:** + - Eigene Testklassen erstellen: `class MeinTest(unittest.TestCase)` + - Jede Testmethode muss mit `test_` beginnen (z.B. `def test_add(self):`). + - Die `setUp(self)`-Methode: Wird automatisch vor **jedem einzelnen Test** ausgeführt, um frische Testdaten bereitzustellen. +3. **Die wichtigsten Assert-Methoden:** + - `self.assertEqual(a, b)`: Prüft $a == b$. + - `self.assertAlmostEqual(a, b, places=2)`: Prüft Float-Rundung bei Kommazahlen. + - `self.assertTrue(x)` / `self.assertFalse(x)`: Prüft Wahrheitswerte. + - `self.assertIn(item, list)` / `self.assertNotIn(item, list)`: Prüft Enthaltensein. + - `with self.assertRaises(FehlerTyp):`: Prüft, ob erwartete Fehler (Exceptions wie `ValueError`, `ZeroDivisionError`) geworfen werden. +4. **Test-Driven Development (TDD):** + - 🔴 **Red:** Schreibe zuerst den Test (schlägt fehl). + - 🟢 **Green:** Schreibe den minimalen Code, der den Test besteht. + - 🔵 **Refactor:** Verbessere und säubere den Code ohne Angst vor Regressionen. + +--- + +## 📁 Die Dateien in diesem Ordner + +- **`index.html`**: Ausführliche interaktive HTML-Erklärung mit Assert-Tabelle, visuellen Boxen & Tipps. +- **`aufgabe.py`**: Vorgegebene Klassen `Taschenrechner` und `Bankkonto` sowie die Test-Klassen mit TODOs zum Selbstausfüllen. +- **`test_aufgabe.py`**: Der Metatest-Runner, der deine geschriebenen Unit Tests validiert (`python3 test_aufgabe.py`). +- **`musterloesung.py`**: Vollständig ausprogrammierte Testsuite. + +--- + +## 🚀 Schnellstart + +```bash +# 1. Bearbeite die Test-Klassen in aufgabe.py +# 2. Führe deine eigenen Tests aus: +python3 aufgabe.py + +# 3. Validiere deine Tests mit dem Metatest-Skript: +python3 test_aufgabe.py +``` diff --git a/09_eigene_unit_tests_schreiben/aufgabe.py b/09_eigene_unit_tests_schreiben/aufgabe.py new file mode 100644 index 0000000..7327215 --- /dev/null +++ b/09_eigene_unit_tests_schreiben/aufgabe.py @@ -0,0 +1,183 @@ +""" +Kapitel 09: Eigene Unit Tests schreiben & TDD 🧪 +================================================ +Aufgabe: Schreibe deine eigenen Unit Tests mit dem 'unittest'-Modul! + +Die Klassen 'Taschenrechner' und 'Bankkonto' sind bereits fertig programmiert. +Deine Aufgabe ist es, die Testklassen 'TestTaschenrechner' und 'TestBankkonto' +zu vervollständigen, sodass alle Funktionalitäten und Randfälle gründlich geprüft werden. +""" + +import math +import unittest +from typing import List + + +# ============================================================================== +# Vorgegebene Klassen (nicht verändern, sondern testen!) +# ============================================================================== + +class Taschenrechner: + """Einfacher Taschenrechner für mathematische Grundoperationen.""" + + def __init__(self): + self.speicher: float = 0.0 + + def add(self, a: float, b: float) -> float: + return a + b + + def sub(self, a: float, b: float) -> float: + return a - b + + def mul(self, a: float, b: float) -> float: + return a * b + + def div(self, a: float, b: float) -> float: + if b == 0: + raise ZeroDivisionError("Division durch Null ist nicht erlaubt!") + return a / b + + def wurzel(self, a: float) -> float: + if a < 0: + raise ValueError("Wurzel aus negativen Zahlen ist im Reellen nicht definiert!") + return math.sqrt(a) + + def speicher_plus(self, wert: float) -> None: + self.speicher += wert + + def speicher_loeschen(self) -> None: + self.speicher = 0.0 + + +class Bankkonto: + """Modelliert ein Bankkonto mit Ein-, Auszahlungen und Überweisungen.""" + + def __init__(self, inhaber: str, kontostand: float = 0.0): + self.inhaber: str = inhaber + self.kontostand: float = float(kontostand) + self.transaktionen: List[float] = [float(kontostand)] + + def einzahlen(self, betrag: float) -> None: + if betrag <= 0: + raise ValueError("Einzahlungsbetrag muss größer als 0 sein!") + self.kontostand += betrag + self.transaktionen.append(betrag) + + def abheben(self, betrag: float) -> None: + if betrag <= 0: + raise ValueError("Auszahlungsbetrag muss größer als 0 sein!") + if betrag > self.kontostand: + raise ValueError("Nicht genügend Guthaben auf dem Konto!") + self.kontostand -= betrag + self.transaktionen.append(-betrag) + + def ueberweisen(self, ziel_konto: "Bankkonto", betrag: float) -> None: + self.abheben(betrag) + ziel_konto.einzahlen(betrag) + + +# ============================================================================== +# HIER BEGINNT DEINE AUFGABE: Eigene Unit Tests schreiben! +# ============================================================================== + +class TestTaschenrechner(unittest.TestCase): + + # ========================================================================== + # TODO 1: setUp(self) + # Erstelle vor jedem Test eine frische Rechner-Instanz: self.calc = Taschenrechner() + # ========================================================================== + def setUp(self): + pass + + # ========================================================================== + # TODO 2: test_01_grundrechenarten(self) + # Teste mit self.assertEqual: + # - self.calc.add(2, 3) soll 5 sein + # - self.calc.sub(10, 4) soll 6 sein + # - self.calc.mul(3, 7) soll 21 sein + # ========================================================================== + def test_01_grundrechenarten(self): + pass + + # ========================================================================== + # TODO 3: test_02_division_und_kommazahlen(self) + # - Teste self.calc.div(10, 2) mit self.assertEqual (soll 5.0 sein) + # - Teste self.calc.div(10, 3) mit self.assertAlmostEqual(..., places=2) (ca. 3.33) + # ========================================================================== + def test_02_division_und_kommazahlen(self): + pass + + # ========================================================================== + # TODO 4: test_03_division_durch_null_exception(self) + # Teste mit 'with self.assertRaises(ZeroDivisionError):' + # dass self.calc.div(10, 0) den Fehler auslöst! + # ========================================================================== + def test_03_division_durch_null_exception(self): + pass + + # ========================================================================== + # TODO 5: test_04_wurzel_negativ_exception(self) + # - Teste self.calc.wurzel(16) soll 4.0 sein (assertEqual oder assertAlmostEqual) + # - Teste mit 'with self.assertRaises(ValueError):' dass self.calc.wurzel(-9) + # einen ValueError auslöst! + # ========================================================================== + def test_04_wurzel_negativ_exception(self): + pass + + # ========================================================================== + # TODO 6: test_05_speicher_funktionen(self) + # - Prüfe, dass self.calc.speicher zu Beginn 0.0 ist + # - Rufe self.calc.speicher_plus(15.5) und self.calc.speicher_plus(4.5) auf + # - Prüfe, dass self.calc.speicher jetzt 20.0 ist + # - Rufe self.calc.speicher_loeschen() auf und prüfe, dass speicher wieder 0.0 ist + # ========================================================================== + def test_05_speicher_funktionen(self): + pass + + +class TestBankkonto(unittest.TestCase): + + # ========================================================================== + # TODO 7: setUp(self) + # Erstelle zwei Testkonten: + # - self.k1 = Bankkonto("Alice", 100.0) + # - self.k2 = Bankkonto("Bob", 50.0) + # ========================================================================== + def setUp(self): + pass + + # ========================================================================== + # TODO 8: test_06_einzahlen_und_abheben(self) + # - Zahle 50.0 auf self.k1 ein und prüfe kontostand == 150.0 + # - Prüfe mit self.assertIn(50.0, self.k1.transaktionen), dass die Einzahlung gelistet ist + # - Hebe 30.0 von self.k1 ab und prüfe kontostand == 120.0 + # - Prüfe mit self.assertIn(-30.0, self.k1.transaktionen), dass die Auszahlung gelistet ist + # ========================================================================== + def test_06_einzahlen_und_abheben(self): + pass + + # ========================================================================== + # TODO 9: test_07_abheben_ueber_limit_exception(self) + # Teste mit 'with self.assertRaises(ValueError):' + # dass das Abheben von 200.0 (bei 100.0 Guthaben) einen ValueError auslöst! + # ========================================================================== + def test_07_abheben_ueber_limit_exception(self): + pass + + # ========================================================================== + # TODO 10: test_08_ueberweisung(self) + # - Überweise 40.0 von self.k1 an self.k2 + # - Prüfe self.k1.kontostand == 60.0 + # - Prüfe self.k2.kontostand == 90.0 + # - Prüfe Transaktionen: -40.0 in self.k1.transaktionen und 40.0 in self.k2.transaktionen + # ========================================================================== + def test_08_ueberweisung(self): + pass + + +# ============================================================================== +# Führe deine eigenen Tests direkt im Terminal aus: +# python3 aufgabe.py +# ============================================================================== +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/09_eigene_unit_tests_schreiben/index.html b/09_eigene_unit_tests_schreiben/index.html new file mode 100644 index 0000000..e440567 --- /dev/null +++ b/09_eigene_unit_tests_schreiben/index.html @@ -0,0 +1,299 @@ + + + + + + Kapitel 09: Eigene Unit Tests schreiben & TDD + + + + +
+
+ +
Kapitel 09 • Softwarequalität & TDD
+

Eigene Unit Tests schreiben & TDD 🧪🛡️

+

Lerne, wie du deinen Code mit Pythons unittest absicherst, Test-Driven Development anwendest und Fehler sofort entlarvst.

+
+
+ +
+ + +
+

1. Warum schreiben Profis eigene Unit Tests?

+ +

+ Bisher hast du deine Programme vermutlich getestet, indem du print()-Befehle geschrieben hast und per Auge überprüft hast, ob das Ergebnis stimmt. + Das Problem: Bei 100 Funktionen vergisst man leicht Randfälle, und wenn man eine Zeile ändert, merkt man oft nicht, dass man an anderer Stelle etwas kaputtgemacht hat (eine sogenannte Regression). +

+ +
+
🪂 Das automatische Sicherheitsnetz:
+ Ein Unit Test ist ein kleines Testprogramm, das eine einzelne Funktion oder Methode isoliert prüft. + Es vergleicht das tatsächliche Ergebnis automatisch mit dem Soll-Wert. + Läuft dein Code in 0.01 Sekunden durch 50 Tests, kannst du jederzeit mit 100%iger Sicherheit sagen: "Mein Code funktioniert fehlerfrei!" +
+ + +

Der TDD-Entwicklungszyklus (Red • Green • Refactor)

+

In der agilen Entwicklung nutzt man oft Test-Driven Development (TDD):

+ +
+ +
+

🔴 1. Red (Test schreiben)

+

+ Du schreibst zuerst den Test für ein Feature, das es noch gar nicht gibt. Der Test schlägt rot fehl. +

+
+ +
+

🟢 2. Green (Code schreiben)

+

+ Du implementierst genau so viel Code, dass der Test grün wird. +

+
+ +
+

🔵 3. Refactor (Aufräumen)

+

+ Du verbesserst den Code, machst ihn lesbarer und schneller – abgesichert durch deine Tests. +

+
+ +
+
+ + +
+

2. Der Aufbau einer unittest-Klasse

+ +

Python bringt ein komplettes Testframework bereits mit: import unittest.

+ +
+
+ Struktur einer Testklasse in Python +
+
import unittest
+
+class TestTaschenrechner(unittest.TestCase):
+
+    def setUp(self):
+        # Wird AUTOMATISCH vor JEDEM einzelnen Test ausgeführt!
+        self.calc = Taschenrechner()
+
+    def test_addition(self):
+        # WICHTIG: Jede Testmethode MUSS mit "test_" beginnen!
+        ergebnis = self.calc.add(2, 3)
+        self.assertEqual(ergebnis, 5)
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)
+
+ +
+
💡 Warum setUp() so genial ist:
+ In setUp() erstellst du frische Objekte für jeden Testlauf. Dadurch beeinflussen sich Tests niemals gegenseitig (Test-Isolation). +
+
+ + +
+

3. Die wichtigsten Assert-Methoden im Überblick

+ +

In deinen Testmethoden verwendest du self.assert...(), um Bedingungen zu prüfen:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Assert-MethodeBedeutungCode-Beispiel
self.assertEqual(a, b)Prüft $a == b$self.assertEqual(calc.add(2, 3), 5)
self.assertNotEqual(a, b)Prüft $a \ne b$self.assertNotEqual(konto.stand, 0)
self.assertAlmostEqual(a, b, places=2)Prüft Kommazahlen mit Rundungself.assertAlmostEqual(10/3, 3.333, places=2)
self.assertTrue(x) / assertFalse(x)Prüft Booleschen Wertself.assertTrue(konto.ist_aktiv)
self.assertIn(item, liste)Prüft, ob Element in Liste/String istself.assertIn(50.0, konto.transaktionen)
self.assertIsNone(x) / assertIsNotNone(x)Prüft is Noneself.assertIsNone(person.mutter)
with self.assertRaises(FehlerTyp):Prüft, ob eine Exception geworfen wirdwith self.assertRaises(ValueError): konto.abheben(9999)
+ +

Fehler & Exceptions testen mit assertRaises

+ +
+
+ Beispiel: Testen auf erwartete Abstürze / Fehler +
+
def test_division_durch_null(self):
+    # Der Test besteht NUR dann, wenn innerhalb des with-Blocks
+    # tatsächlich ein ZeroDivisionError ausgelöst wird!
+    with self.assertRaises(ZeroDivisionError):
+        self.calc.div(10, 0)
+
+
+ + +
+

4. 🎯 Deine Aufgaben in aufgabe.py

+ +

+ In 09_eigene_unit_tests_schreiben/aufgabe.py sind die Klassen Taschenrechner und Bankkonto bereits fertig vorgegeben. + Deine Aufgabe ist es, die Unit-Test-Klassen zu schreiben! +

+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ +

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zu TODO 3 (Periodische Kommazahlen testen) +
+

Weil 10 / 3 = 3.3333333333333335 im Computer nicht exakt dargestellt werden kann, vergleicht man mit Rundungstoleranz:

+
+
self.assertAlmostEqual(self.calc.div(10, 3), 3.3333, places=2)
+
+
+
+ +
+ 🔍 Tipp zu TODO 8 & 10 (Transaktionen mit assertIn prüfen) +
+
+
self.k1.einzahlen(50.0)
+self.assertEqual(self.k1.kontostand, 150.0)
+self.assertIn(50.0, self.k1.transaktionen)
+
+
+
+
+ + +
+

5. 🧪 Deine Tests ausführen & validieren

+ +

Schritt 1: Führe deine eigenen Tests im Terminal aus:

+
+
+ Terminal (Eigene Tests starten) +
+
python3 aufgabe.py
+
+ +

Schritt 2: Validiere deine Tests mit dem Metatest-Skript (prüft, ob deine Tests auch fehlerhaften Code entlarven!):

+
+
+ Terminal (Metatests prüfen) +
+
python3 test_aufgabe.py
+
+ + +
+ +
+ + + diff --git a/09_eigene_unit_tests_schreiben/musterloesung.py b/09_eigene_unit_tests_schreiben/musterloesung.py new file mode 100644 index 0000000..1286a3f --- /dev/null +++ b/09_eigene_unit_tests_schreiben/musterloesung.py @@ -0,0 +1,149 @@ +""" +Kapitel 09: Eigene Unit Tests schreiben & TDD 🧪 +================================================ +Musterlösung: Vollständige Test-Suite für Taschenrechner und Bankkonto. +""" + +import math +import unittest +from typing import List + + +# ============================================================================== +# Vorgegebene Klassen: Taschenrechner & Bankkonto +# ============================================================================== + +class Taschenrechner: + """Einfacher Taschenrechner für mathematische Grundoperationen.""" + + def __init__(self): + self.speicher: float = 0.0 + + def add(self, a: float, b: float) -> float: + return a + b + + def sub(self, a: float, b: float) -> float: + return a - b + + def mul(self, a: float, b: float) -> float: + return a * b + + def div(self, a: float, b: float) -> float: + if b == 0: + raise ZeroDivisionError("Division durch Null ist nicht erlaubt!") + return a / b + + def wurzel(self, a: float) -> float: + if a < 0: + raise ValueError("Wurzel aus negativen Zahlen ist im Reellen nicht definiert!") + return math.sqrt(a) + + def speicher_plus(self, wert: float) -> None: + self.speicher += wert + + def speicher_loeschen(self) -> None: + self.speicher = 0.0 + + +class Bankkonto: + """Modelliert ein Bankkonto mit Ein-, Auszahlungen und Überweisungen.""" + + def __init__(self, inhaber: str, kontostand: float = 0.0): + self.inhaber: str = inhaber + self.kontostand: float = float(kontostand) + self.transaktionen: List[float] = [float(kontostand)] + + def einzahlen(self, betrag: float) -> None: + if betrag <= 0: + raise ValueError("Einzahlungsbetrag muss größer als 0 sein!") + self.kontostand += betrag + self.transaktionen.append(betrag) + + def abheben(self, betrag: float) -> None: + if betrag <= 0: + raise ValueError("Auszahlungsbetrag muss größer als 0 sein!") + if betrag > self.kontostand: + raise ValueError("Nicht genügend Guthaben auf dem Konto!") + self.kontostand -= betrag + self.transaktionen.append(-betrag) + + def ueberweisen(self, ziel_konto: "Bankkonto", betrag: float) -> None: + self.abheben(betrag) + ziel_konto.einzahlen(betrag) + + +# ============================================================================== +# Musterlösung: Die Unit-Test-Klassen +# ============================================================================== + +class TestTaschenrechner(unittest.TestCase): + + def setUp(self): + """Wird vor JEDEM einzelnen Test ausgeführt.""" + self.calc = Taschenrechner() + + def test_01_grundrechenarten(self): + """Testet Addition, Subtraktion und Multiplikation.""" + self.assertEqual(self.calc.add(2, 3), 5) + self.assertEqual(self.calc.sub(10, 4), 6) + self.assertEqual(self.calc.mul(3, 7), 21) + + def test_02_division_und_kommazahlen(self): + """Testet normale Division und periodische Kommazahlen mit assertAlmostEqual.""" + self.assertEqual(self.calc.div(10, 2), 5.0) + self.assertAlmostEqual(self.calc.div(10, 3), 3.3333, places=2) + + def test_03_division_durch_null_exception(self): + """Testet, dass bei Division durch 0 ein ZeroDivisionError geworfen wird.""" + with self.assertRaises(ZeroDivisionError): + self.calc.div(10, 0) + + def test_04_wurzel_negativ_exception(self): + """Testet normale Wurzel und Exception bei negativen Zahlen.""" + self.assertAlmostEqual(self.calc.wurzel(16), 4.0) + with self.assertRaises(ValueError): + self.calc.wurzel(-9) + + def test_05_speicher_funktionen(self): + """Testet speicher_plus und speicher_loeschen.""" + self.assertEqual(self.calc.speicher, 0.0) + self.calc.speicher_plus(15.5) + self.calc.speicher_plus(4.5) + self.assertEqual(self.calc.speicher, 20.0) + self.calc.speicher_loeschen() + self.assertEqual(self.calc.speicher, 0.0) + + +class TestBankkonto(unittest.TestCase): + + def setUp(self): + """Erstellt zwei frische Konten vor jedem Test.""" + self.k1 = Bankkonto("Alice", 100.0) + self.k2 = Bankkonto("Bob", 50.0) + + def test_06_einzahlen_und_abheben(self): + """Testet Ein- und Auszahlung sowie Transaktionshistorie.""" + self.k1.einzahlen(50.0) + self.assertEqual(self.k1.kontostand, 150.0) + self.assertIn(50.0, self.k1.transaktionen) + + self.k1.abheben(30.0) + self.assertEqual(self.k1.kontostand, 120.0) + self.assertIn(-30.0, self.k1.transaktionen) + + def test_07_abheben_ueber_limit_exception(self): + """Testet, dass Abheben von mehr als Kontostand einen ValueError auslöst.""" + with self.assertRaises(ValueError): + self.k1.abheben(200.0) + + def test_08_ueberweisung(self): + """Testet erfolgreiche Überweisung zwischen zwei Konten.""" + self.k1.ueberweisen(self.k2, 40.0) + self.assertEqual(self.k1.kontostand, 60.0) + self.assertEqual(self.k2.kontostand, 90.0) + self.assertIn(-40.0, self.k1.transaktionen) + self.assertIn(40.0, self.k2.transaktionen) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/09_eigene_unit_tests_schreiben/test_aufgabe.py b/09_eigene_unit_tests_schreiben/test_aufgabe.py new file mode 100644 index 0000000..35fa8c7 --- /dev/null +++ b/09_eigene_unit_tests_schreiben/test_aufgabe.py @@ -0,0 +1,175 @@ +import io +import unittest +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import aufgabe +except ImportError as e: + raise ImportError(f"Konnte 'aufgabe.py' nicht importieren: {e}") + + +class TestKapitel09Meta(unittest.TestCase): + """ + Metatest-Suite: Prüft, ob die Schülerin saubere, wirksame Unit Tests geschrieben hat. + """ + + def setUp(self): + self.TestTaschenrechner = getattr(aufgabe, "TestTaschenrechner", None) + self.TestBankkonto = getattr(aufgabe, "TestBankkonto", None) + self.assertIsNotNone(self.TestTaschenrechner, "Klasse 'TestTaschenrechner' nicht in aufgabe.py gefunden!") + self.assertIsNotNone(self.TestBankkonto, "Klasse 'TestBankkonto' nicht in aufgabe.py gefunden!") + self.assertTrue(issubclass(self.TestTaschenrechner, unittest.TestCase)) + self.assertTrue(issubclass(self.TestBankkonto, unittest.TestCase)) + + def _run_test_case(self, test_class, test_method_name): + """Hilfsfunktion: Führt eine einzelne Testmethode aus und gibt das TestResult zurück.""" + suite = unittest.TestSuite() + suite.addTest(test_class(test_method_name)) + stream = io.StringIO() + runner = unittest.TextTestRunner(stream=stream, verbosity=0) + return runner.run(suite) + + def test_01_alle_testmethoden_vorhanden(self): + """Prüft, ob alle 8 geforderten Testmethoden existieren.""" + erwartete_calc_tests = [ + "test_01_grundrechenarten", + "test_02_division_und_kommazahlen", + "test_03_division_durch_null_exception", + "test_04_wurzel_negativ_exception", + "test_05_speicher_funktionen", + ] + for name in erwartete_calc_tests: + self.assertTrue( + hasattr(self.TestTaschenrechner, name), + f"Methode '{name}' fehlt in TestTaschenrechner!" + ) + + erwartete_konto_tests = [ + "test_06_einzahlen_und_abheben", + "test_07_abheben_ueber_limit_exception", + "test_08_ueberweisung", + ] + for name in erwartete_konto_tests: + self.assertTrue( + hasattr(self.TestBankkonto, name), + f"Methode '{name}' fehlt in TestBankkonto!" + ) + + def test_02_tests_bestehen_auf_korrektem_code(self): + """Prüft, ob alle Tests auf dem korrekten Code erfolgreich durchlaufen.""" + loader = unittest.TestLoader() + suite = unittest.TestSuite() + suite.addTests(loader.loadTestsFromTestCase(self.TestTaschenrechner)) + suite.addTests(loader.loadTestsFromTestCase(self.TestBankkonto)) + + stream = io.StringIO() + runner = unittest.TextTestRunner(stream=stream, verbosity=0) + result = runner.run(suite) + + self.assertGreaterEqual(result.testsRun, 8, "Es müssen mindestens 8 Tests ausgeführt werden!") + self.assertEqual( + len(result.failures), 0, + f"Deine Tests sind auf korrektem Code fehlgeschlagen:\n{stream.getvalue()}" + ) + self.assertEqual( + len(result.errors), 0, + f"Deine Tests haben Fehler/Abstürze verursacht:\n{stream.getvalue()}" + ) + + def test_03_entlarvt_rechenfehler(self): + """Mutationstest: Prüft, ob test_01 fehlerhafte Addition entlarvt.""" + OriginalCalc = aufgabe.Taschenrechner + class BuggyCalc(OriginalCalc): + def add(self, a, b): + return a + b + 99 # Absichtlicher Bug + + try: + aufgabe.Taschenrechner = BuggyCalc + result = self._run_test_case(self.TestTaschenrechner, "test_01_grundrechenarten") + self.assertFalse( + result.wasSuccessful(), + "test_01_grundrechenarten muss fehlschlagen, wenn add() ein falsches Ergebnis liefert! Hast du self.assertEqual verwendet?" + ) + finally: + aufgabe.Taschenrechner = OriginalCalc + + def test_04_entlarvt_fehlende_nulldivision_exception(self): + """Mutationstest: Prüft, ob test_03 fehlende ZeroDivisionError Exception entlarvt.""" + OriginalCalc = aufgabe.Taschenrechner + class BuggyCalc(OriginalCalc): + def div(self, a, b): + if b == 0: + return 0.0 # Fehler: Wirft KEINE Exception + return a / b + + try: + aufgabe.Taschenrechner = BuggyCalc + result = self._run_test_case(self.TestTaschenrechner, "test_03_division_durch_null_exception") + self.assertFalse( + result.wasSuccessful(), + "test_03_division_durch_null_exception muss fehlschlagen, wenn div(10, 0) keine Exception wirft! Nutze 'with self.assertRaises(ZeroDivisionError):'." + ) + finally: + aufgabe.Taschenrechner = OriginalCalc + + def test_05_entlarvt_fehlende_wurzel_exception(self): + """Mutationstest: Prüft, ob test_04 fehlende ValueError Exception entlarvt.""" + OriginalCalc = aufgabe.Taschenrechner + class BuggyCalc(OriginalCalc): + def wurzel(self, a): + if a < 0: + return 0.0 # Fehler: Wirft KEINE Exception + return super().wurzel(a) + + try: + aufgabe.Taschenrechner = BuggyCalc + result = self._run_test_case(self.TestTaschenrechner, "test_04_wurzel_negativ_exception") + self.assertFalse( + result.wasSuccessful(), + "test_04_wurzel_negativ_exception muss fehlschlagen, wenn wurzel(-9) keinen ValueError wirft!" + ) + finally: + aufgabe.Taschenrechner = OriginalCalc + + def test_06_entlarvt_fehlende_ueberziehung_exception(self): + """Mutationstest: Prüft, ob test_07 unbefugte Überziehung entlarvt.""" + OriginalKonto = aufgabe.Bankkonto + class BuggyKonto(OriginalKonto): + def abheben(self, betrag): + # Fehler: Erlaubt Überziehung ohne ValueError + self.kontostand -= betrag + + try: + aufgabe.Bankkonto = BuggyKonto + result = self._run_test_case(self.TestBankkonto, "test_07_abheben_ueber_limit_exception") + self.assertFalse( + result.wasSuccessful(), + "test_07_abheben_ueber_limit_exception muss fehlschlagen, wenn abheben() ohne Prüfung das Konto überzieht!" + ) + finally: + aufgabe.Bankkonto = OriginalKonto + + def test_07_entlarvt_fehlerhafte_ueberweisung(self): + """Mutationstest: Prüft, ob test_08 fehlerhafte Überweisung entlarvt.""" + OriginalKonto = aufgabe.Bankkonto + class BuggyKonto(OriginalKonto): + def ueberweisen(self, ziel_konto, betrag): + self.abheben(betrag) + # Fehler: Zielkonto erhält kein Geld! + + try: + aufgabe.Bankkonto = BuggyKonto + result = self._run_test_case(self.TestBankkonto, "test_08_ueberweisung") + self.assertFalse( + result.wasSuccessful(), + "test_08_ueberweisung muss fehlschlagen, wenn bei der Überweisung das Zielkonto nicht gutgeschrieben wird!" + ) + finally: + aufgabe.Bankkonto = OriginalKonto + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/10_vererbung_und_super/README.md b/10_vererbung_und_super/README.md new file mode 100644 index 0000000..1d5cbf7 --- /dev/null +++ b/10_vererbung_und_super/README.md @@ -0,0 +1,91 @@ +# Kapitel 10: Vererbung (Inheritance) & super() 🧬🚗 + +Willkommen zu Kapitel 10! In diesem Kapitel lernst du eines der mächtigsten Konzepte der Objektorientierten Programmierung kennen: **Vererbung**. + +--- + +## 🎯 Was du lernst + +1. **Was ist Vererbung?** + - Basisklasse (Elternklasse / Superklasse) vs. abgeleitete Klasse (Kindklasse / Subklasse). + - "Ist-ein"-Beziehung: Ein `Auto` *ist ein* `Fahrzeug`. Ein `ElektroAuto` *ist ein* `Auto`. +2. **Das DRY-Prinzip (Don't Repeat Yourself):** + - Gemeinsame Attribute (`marke`, `modell`, `baujahr`, `grundpreis`, `kilometerstand`) und Methoden (`fahren()`, `berechne_restwert()`) müssen nur **einmal** in der Basisklasse `Fahrzeug` geschrieben werden. +3. **Syntax in Python:** + ```python + class KindKlasse(ElternKlasse): + pass + ``` +4. **Der Konstruktor der Elternklasse: `super().__init__()`:** + - Mit `super().__init__(...)` delegierst du die Initialisierung der geerbten Attribute an die Elternklasse. +5. **Methoden erweitern & überschreiben (Method Overriding):** + - Eine Kindklasse kann Methoden der Elternklasse neu definieren oder mit `super().methode()` erweitern. +6. **Typ-Prüfung:** + - `isinstance(mein_auto, Fahrzeug)` → `True` + - `issubclass(ElektroAuto, Fahrzeug)` → `True` + +--- + +## 📊 UML-Klassendiagramm + +``` + ┌───────────────────────────────────────────────┐ + │ Fahrzeug │ + ├───────────────────────────────────────────────┤ + │ - marke: str │ + │ - modell: str │ + │ - baujahr: int │ + │ - grundpreis: float │ + │ - kilometerstand: float │ + ├───────────────────────────────────────────────┤ + │ + __init__(marke, modell, baujahr, grundpreis)│ + │ + fahren(km: float) -> None │ + │ + berechne_restwert(aktuelles_jahr) -> float │ + │ + info() -> str │ + └───────────────────────▲───────────────────────┘ + │ + ┌───────────────┴───────────────┐ + │ │ + ┌───────────┴───────────────┐ ┌───────────┴───────────────┐ + │ Auto │ │ Lkw │ + ├───────────────────────────┤ ├───────────────────────────┤ + │ - sitzplaetze: int │ │ - max_zuladung_kg: float │ + │ - anzahl_tueren: int │ │ - aktuelle_ladung_kg: floa│ + ├───────────────────────────┤ ├───────────────────────────┤ + │ + __init__(..., sitze, ...)│ │ + __init__(..., max_ladung)│ + │ + hupen() -> str │ │ + beladen(kg) -> bool │ + │ + info() -> str │ │ + entladen(kg) -> float │ + └───────────▲───────────────┘ │ + info() -> str │ + │ └───────────────────────────┘ + ┌───────────┴───────────────┐ + │ ElektroAuto │ + ├───────────────────────────┤ + │ - batterie_kapazitaet_kwh │ + │ - batterie_ladestand_kwh │ + │ - verbrauch_pro_100km │ + ├───────────────────────────┤ + │ + reichweite() -> float │ + │ + fahren(km) -> bool │ + │ + aufladen(kwh) -> float │ + │ + info() -> str │ + └───────────────────────────┘ +``` + +--- + +## 📁 Die Dateien in diesem Ordner + +- **`index.html`**: Die interaktive Lernseite mit visuellen Erklärungen, Analogien, Diagrammen und Lösungstipps. +- **`aufgabe.py`**: Dein Arbeitsblatt mit den Klassen `Fahrzeug`, `Auto`, `ElektroAuto` und `Lkw`. +- **`test_aufgabe.py`**: Automatische Tests (`python3 test_aufgabe.py`). +- **`musterloesung.py`**: Vollständig ausprogrammierte Beispiellösung. + +--- + +## 🚀 Schnellstart + +```bash +# 1. Bearbeite aufgabe.py +# 2. Teste deine Lösung: +python3 test_aufgabe.py +``` diff --git a/10_vererbung_und_super/aufgabe.py b/10_vererbung_und_super/aufgabe.py new file mode 100644 index 0000000..c197594 --- /dev/null +++ b/10_vererbung_und_super/aufgabe.py @@ -0,0 +1,284 @@ +""" +Kapitel 10: Vererbung (Inheritance) & super() 🧬🚗 +=================================================== +Schulabgleich: 25.0 Vererbung + +Aufgabe: +Erstelle eine Hierarchie von Fahrzeugen für eine moderne Fahrzeugflotte. +Nutze Vererbung (DRY-Prinzip) und super().__init__(), um gemeinsame Attribute +und Methoden von der Basisklasse 'Fahrzeug' an 'Auto', 'ElektroAuto' und 'Lkw' zu vererben. +""" + +from typing import List, Optional + + +class Fahrzeug: + """ + Basisklasse für alle Fahrzeuge. + + Attribute: + marke (str): Hersteller (z.B. "BMW", "Tesla", "MAN") + modell (str): Modellbezeichnung (z.B. "i4", "Model 3", "TGX") + baujahr (int): Baujahr des Fahrzeugs (z.B. 2022) + grundpreis (float): Ursprünglicher Neupreis in Euro + kilometerstand (float): Bereits gefahrene Kilometer (Startwert: 0.0) + """ + + # ========================================================================== + # TODO 1: Konstruktor __init__ der Basisklasse + # Parameter: marke (str), modell (str), baujahr (int), grundpreis (float) + # Attribute: + # - self.marke: str + # - self.modell: str + # - self.baujahr: int + # - self.grundpreis: float (als float konvertieren) + # - self.kilometerstand: float (Startwert: 0.0) + # ========================================================================== + def __init__(self, marke: str, modell: str, baujahr: int, grundpreis: float): + pass + + # ========================================================================== + # TODO 2: Methode fahren(self, km: float) -> None + # Erhöht den Kilometerstand um 'km', wenn km > 0 ist. + # Bei km <= 0 passiert nichts. + # ========================================================================== + def fahren(self, km: float) -> None: + pass + + # ========================================================================== + # TODO 3: Methode berechne_restwert(self, aktuelles_jahr: int) -> float + # Berechnet den aktuellen Restwert des Fahrzeugs: + # 1. Alter = max(0, aktuelles_jahr - self.baujahr) + # 2. Wertverlust = self.grundpreis * 0.05 * Alter (5% Wertverlust pro Jahr) + # 3. Restwert = self.grundpreis - Wertverlust + # 4. Mindestrestwert ist 10% des Grundpreises: max(self.grundpreis * 0.10, Restwert) + # Rückgabe: Restwert als float + # ========================================================================== + def berechne_restwert(self, aktuelles_jahr: int) -> float: + pass + + # ========================================================================== + # TODO 4: Methode info(self) -> str + # Gibt einen formatierten Infostring zurück: + # Format: "{marke} {modell} ({baujahr}) - {kilometerstand:.1f} km" + # Beispiel: "BMW 320d (2020) - 45000.0 km" + # ========================================================================== + def info(self) -> str: + pass + + def __str__(self) -> str: + return self.info() + + +class Auto(Fahrzeug): + """ + Abgeleitete Klasse für PKWs (erbt von Fahrzeug). + + Zusätzliche Attribute: + sitzplaetze (int): Anzahl der Sitze (Standardwert: 5) + anzahl_tueren (int): Anzahl der Türen (Standardwert: 5) + """ + + # ========================================================================== + # TODO 5: Konstruktor __init__ von Auto + # Parameter: marke (str), modell (str), baujahr (int), grundpreis (float), + # sitzplaetze (int = 5), anzahl_tueren (int = 5) + # 1. Rufe den Konstruktor der Elternklasse mit super().__init__(...) auf! + # 2. Setze self.sitzplaetze und self.anzahl_tueren + # ========================================================================== + def __init__( + self, + marke: str, + modell: str, + baujahr: int, + grundpreis: float, + sitzplaetze: int = 5, + anzahl_tueren: int = 5, + ): + pass + + # ========================================================================== + # TODO 6: Methode hupen(self) -> str + # Gibt einen Hup-Laut mit Marke und Modell zurück: + # Format: "Hup hup! Platz da für den {marke} {modell}!" + # Beispiel: "Hup hup! Platz da für den VW Golf!" + # ========================================================================== + def hupen(self) -> str: + pass + + # ========================================================================== + # TODO 7: Methode info(self) -> str (Method Overriding / Erweitern) + # Rufe super().info() auf und hänge " | {sitzplaetze} Sitze, {anzahl_tueren} Türen" an. + # Format: "{super().info()} | {self.sitzplaetze} Sitze, {self.anzahl_tueren} Türen" + # Beispiel: "VW Golf (2021) - 12000.0 km | 5 Sitze, 5 Türen" + # ========================================================================== + def info(self) -> str: + pass + + +class ElektroAuto(Auto): + """ + Abgeleitete Klasse für Elektroautos (Mehrstufige Vererbung: erbt von Auto). + + Zusätzliche Attribute: + batterie_kapazitaet_kwh (float): Maximale Akkukapazität in Kilowattstunden + batterie_ladestand_kwh (float): Aktueller Ladestand (Startwert: voll = batterie_kapazitaet_kwh) + verbrauch_pro_100km (float): Verbrauch in kWh auf 100 km (Standardwert: 18.0) + """ + + # ========================================================================== + # TODO 8: Konstruktor __init__ von ElektroAuto + # Parameter: marke (str), modell (str), baujahr (int), grundpreis (float), + # batterie_kapazitaet_kwh (float), sitzplaetze (int = 5), + # anzahl_tueren (int = 5), verbrauch_pro_100km (float = 18.0) + # 1. Rufe super().__init__(marke, modell, baujahr, grundpreis, sitzplaetze, anzahl_tueren) auf. + # 2. Setze self.batterie_kapazitaet_kwh = float(batterie_kapazitaet_kwh) + # 3. Setze self.batterie_ladestand_kwh = float(batterie_kapazitaet_kwh) (Akku ist anfangs voll) + # 4. Setze self.verbrauch_pro_100km = float(verbrauch_pro_100km) + # ========================================================================== + def __init__( + self, + marke: str, + modell: str, + baujahr: int, + grundpreis: float, + batterie_kapazitaet_kwh: float, + sitzplaetze: int = 5, + anzahl_tueren: int = 5, + verbrauch_pro_100km: float = 18.0, + ): + pass + + # ========================================================================== + # TODO 9: Methode reichweite(self) -> float + # Berechnet die verbleibende Reichweite in Kilometern: + # Formel: (aktueller_ladestand / verbrauch_pro_100km) * 100 + # Wenn verbrauch_pro_100km <= 0: 0.0 zurückgeben. + # ========================================================================== + def reichweite(self) -> float: + pass + + # ========================================================================== + # TODO 10: Methode fahren(self, km: float) -> bool (Überschreiben mit Logik) + # 1. Wenn km <= 0: False zurückgeben. + # 2. Berechne den benötigten Strom: benoetigt = (km / 100.0) * self.verbrauch_pro_100km + # 3. Wenn self.batterie_ladestand_kwh >= benoetigt: + # - Ziehe den Verbrauch vom Ladestand ab: self.batterie_ladestand_kwh -= benoetigt + # - Rufe super().fahren(km) auf (damit der Kilometerstand erhöht wird!) + # - return True + # 4. Wenn der Akku NICHT reicht: + # - Fahre nicht (keine Änderung an Kilometerstand oder Akku) + # - return False + # ========================================================================== + def fahren(self, km: float) -> bool: + pass + + # ========================================================================== + # TODO 11: Methode aufladen(self, kwh: float) -> float + # Lädt den Akku auf, aber maximal bis batterie_kapazitaet_kwh! + # 1. Wenn kwh <= 0: return 0.0 + # 2. Berechne den freien Platz im Akku: batterie_kapazitaet_kwh - batterie_ladestand_kwh + # 3. Tatsächlich geladene Menge = min(freier_platz, kwh) + # 4. Erhöhe batterie_ladestand_kwh um die geladene Menge + # 5. Gib die tatsächlich geladene Menge (float) zurück. + # ========================================================================== + def aufladen(self, kwh: float) -> float: + pass + + # ========================================================================== + # TODO 12: Methode info(self) -> str + # Format: "{super().info()} | Akku: {batterie_ladestand_kwh:.1f}/{batterie_kapazitaet_kwh:.1f} kWh ({reichweite():.1f} km Reichweite)" + # Beispiel: "Tesla Model 3 (2023) - 5000.0 km | 5 Sitze, 5 Türen | Akku: 60.0/60.0 kWh (333.3 km Reichweite)" + # ========================================================================== + def info(self) -> str: + pass + + +class Lkw(Fahrzeug): + """ + Abgeleitete Klasse für Lastkraftwagen (erbt direkt von Fahrzeug). + + Zusätzliche Attribute: + max_zuladung_kg (float): Maximale Frachtkapazität in kg + aktuelle_ladung_kg (float): Aktuell geladene Fracht (Startwert: 0.0) + """ + + # ========================================================================== + # TODO 13: Konstruktor __init__ von Lkw + # Parameter: marke (str), modell (str), baujahr (int), grundpreis (float), + # max_zuladung_kg (float) + # 1. Rufe super().__init__(marke, modell, baujahr, grundpreis) auf. + # 2. Setze self.max_zuladung_kg = float(max_zuladung_kg) + # 3. Setze self.aktuelle_ladung_kg = 0.0 + # ========================================================================== + def __init__( + self, + marke: str, + modell: str, + baujahr: int, + grundpreis: float, + max_zuladung_kg: float, + ): + pass + + # ========================================================================== + # TODO 14: Methode beladen(self, kg: float) -> bool + # 1. Wenn kg > 0 und self.aktuelle_ladung_kg + kg <= self.max_zuladung_kg: + # - Erhöhe self.aktuelle_ladung_kg um kg + # - return True + # 2. Sonst (Überladung oder ungültiges Gewicht): return False + # ========================================================================== + def beladen(self, kg: float) -> bool: + pass + + # ========================================================================== + # TODO 15: Methode entladen(self, kg: float) -> float + # Entlädt maximal die vorhandene Ladung. + # 1. Wenn kg <= 0: return 0.0 + # 2. tatsaechlich = min(self.aktuelle_ladung_kg, kg) + # 3. self.aktuelle_ladung_kg -= tatsaechlich + # 4. return tatsaechlich + # ========================================================================== + def entladen(self, kg: float) -> float: + pass + + # ========================================================================== + # TODO 16: Methode info(self) -> str + # Format: "{super().info()} | Ladung: {aktuelle_ladung_kg:.1f}/{max_zuladung_kg:.1f} kg" + # Beispiel: "MAN TGX (2019) - 150000.0 km | Ladung: 8500.0/18000.0 kg" + # ========================================================================== + def info(self) -> str: + pass + + +# ============================================================================== +# Terminal-Test zum Ausprobieren: (python3 aufgabe.py) +# ============================================================================== +if __name__ == "__main__": + print("--- 🚗 Auto testen ---") + golf = Auto("VW", "Golf 8", 2021, 28000.0, sitzplaetze=5, anzahl_tueren=5) + golf.fahren(150.0) + print(golf.info()) + print(golf.hupen()) + print(f"Restwert im Jahr 2026: {golf.berechne_restwert(2026):.2f} €") + + print("\n--- ⚡ ElektroAuto testen ---") + tesla = ElektroAuto("Tesla", "Model Y", 2023, 45000.0, batterie_kapazitaet_kwh=75.0, verbrauch_pro_100km=16.5) + print(tesla.info()) + print(f"Start-Reichweite: {tesla.reichweite():.1f} km") + + erfolg = tesla.fahren(200.0) + print(f"Fahrt 200 km erfolgreich? {erfolg}") + print(tesla.info()) + + geladen = tesla.aufladen(50.0) + print(f"Aufgeladen: {geladen:.1f} kWh") + print(tesla.info()) + + print("\n--- 🚛 LKW testen ---") + lkw = Lkw("Mercedes-Benz", "Actros", 2020, 110000.0, max_zuladung_kg=24000.0) + lkw.beladen(15000.0) + lkw.fahren(400.0) + print(lkw.info()) + entladen = lkw.entladen(5000.0) + print(f"Entladen: {entladen:.1f} kg -> Neue Ladung: {lkw.aktuelle_ladung_kg:.1f} kg") diff --git a/10_vererbung_und_super/index.html b/10_vererbung_und_super/index.html new file mode 100644 index 0000000..3de36f0 --- /dev/null +++ b/10_vererbung_und_super/index.html @@ -0,0 +1,359 @@ + + + + + + Kapitel 10: Vererbung & super() + + + + + +
+
+ +
Kapitel 10 • Schulabgleich 25.0
+

Vererbung (Inheritance) & super() 🧬🚗

+

Lerne, wie Klassen Eigenschaften und Methoden an Unterklassen vererben, Code-Duplikate verschwinden und wie super().__init__() funktioniert.

+
+
+ +
+ + +
+

1. Was ist Vererbung? (Das DRY-Prinzip)

+ +

+ In vielen Software-Projekten gibt es Klassen, die sich sehr ähnlich sind. + Stelle dir eine Fahrzeugflotte vor: Ein Auto, ein ElektroAuto und ein Lkw + haben alle eine Marke, ein Modell, ein Baujahr, einen Grundpreis und können fahren. +

+ +

+ Ohne Vererbung müsstest du all diese Variablen und Methoden in jeder einzelnen Klasse kopieren. + Das verletzt eine der wichtigsten Regeln der Programmierung: DRY (Don't Repeat Yourself)! +

+ +
+
🧬 Die DNA- & Stammbaum-Analogie:
+ Wie in der Biologie: Kinder erben Merkmale (Augenfarbe, Blutgruppe) von ihren Eltern, entwickeln aber auch ihre ganz eigenen Fähigkeiten (z.B. Klavierspielen oder Fußballspielen). +

+ In Python ist Fahrzeug die Elternklasse (Basisklasse) und Auto die Kindklasse (abgeleitete Klasse). + Das Auto erbt automatisch alles, was ein Fahrzeug kann, und fügt eigene Besonderheiten wie sitzplaetze und hupen() hinzu! +
+ +
+
🔍 "Ist-ein" (Vererbung) vs. "Hat-ein" (Komposition):
+
    +
  • Vererbung (Kapitel 10): Ein Auto ist ein Fahrzeug. Ein Elektroauto ist ein Auto.
  • +
  • Komposition (Kapitel 05): Ein Auto hat einen Motor. Eine Playlist hat viele Songs.
  • +
+
+
+ + +
+

2. 📊 UML-Klassendiagramm: Die Vererbungshierarchie

+ +

+ Im UML-Diagramm wird Vererbung durch einen Pfeil mit geschlossener Dreiecksspitze (▲) dargestellt. + Der Pfeil zeigt immer von der Kindklasse nach oben zur Elternklasse: +

+ +
+ +
+
Fahrzeug (Basisklasse)
+
+ - marke: str
+ - modell: str
+ - baujahr: int
+ - grundpreis: float
+ - kilometerstand: float = 0.0 +
+
+ + __init__(marke, modell, baujahr, grundpreis)
+ + fahren(km: float) -> None
+ + berechne_restwert(jahr: int) -> float
+ + info() -> str +
+
+ +
+
+
+ +
+ +
+
Auto
+
+ - sitzplaetze: int = 5
+ - anzahl_tueren: int = 5 +
+
+ + __init__(..., sitzplaetze, tueren)
+ + hupen() -> str
+ + info() -> str (überschrieben) +
+
+ + +
+
Lkw
+
+ - max_zuladung_kg: float
+ - aktuelle_ladung_kg: float = 0.0 +
+
+ + __init__(..., max_zuladung_kg)
+ + beladen(kg: float) -> bool
+ + entladen(kg: float) -> float
+ + info() -> str (überschrieben) +
+
+
+ +
+
+
+ + +
+
ElektroAuto (erbt von Auto)
+
+ - batterie_kapazitaet_kwh: float
+ - batterie_ladestand_kwh: float
+ - verbrauch_pro_100km: float = 18.0 +
+
+ + __init__(..., batterie_kapazitaet_kwh, ...)
+ + reichweite() -> float
+ + fahren(km: float) -> bool (mit Stromverbrauch)
+ + aufladen(kwh: float) -> float
+ + info() -> str (überschrieben) +
+
+
+
+ + +
+

3. Syntax: Wie funktioniert super() in Python?

+ +

Schritt 1: Vererbung deklarieren

+

Um von einer Klasse zu erben, schreibst du den Namen der Elternklasse in Klammern hinter den Klassennamen:

+ +
+
+ Python (Vererbungssyntax) +
+
class Auto(Fahrzeug):   # Auto erbt alle Attribute und Methoden von Fahrzeug!
+    pass
+
+ +

Schritt 2: Der Aufruf von super().__init__(...)

+

+ Wenn die Kindklasse einen eigenen Konstruktor __init__ besitzt, überschreibt sie zunächst den Eltern-Konstruktor. + Damit die Eltern-Attribute (marke, modell, etc.) trotzdem sauber initialisiert werden, rufen wir mit super().__init__() den Konstruktor der Elternklasse auf: +

+ +
+
+
+ + + +
+ Python (super().__init__ in Aktion) +
+
class Auto(Fahrzeug):
+    def __init__(self, marke: str, modell: str, baujahr: int, grundpreis: float, sitzplaetze: int = 5):
+        # 1. Eltern-Konstruktor aufrufen (setzt marke, modell, baujahr, grundpreis, kilometerstand):
+        super().__init__(marke, modell, baujahr, grundpreis)
+        
+        # 2. Zusätzliche, eigene Attribute der Kindklasse setzen:
+        self.sitzplaetze: int = sitzplaetze
+
+ +
+
⚠️ Häufiger Anfängerfehler: super().__init__() vergessen
+ Wenn du in der Kindklasse super().__init__() vergisst, existieren Attribute wie self.marke oder self.kilometerstand am Kind-Objekt überhaupt nicht! Der Code stürzt beim ersten Zugriff mit einem AttributeError ab. +
+
+ + +
+

4. Typ-Prüfung: isinstance() und issubclass()

+ +

Weil ein ElektroAuto von Auto und dieses von Fahrzeug erbt, ist ein Elektroauto gleichzeitig eine Instanz aller drei Klassen:

+ +
+
+ Python (Typ-Prüfungen) +
+
tesla = ElektroAuto("Tesla", "Model 3", 2023, 42000.0, batterie_kapazitaet_kwh=60.0)
+
+print(isinstance(tesla, ElektroAuto))  # True
+print(isinstance(tesla, Auto))         # True
+print(isinstance(tesla, Fahrzeug))     # True
+print(isinstance(tesla, Lkw))          # False (Lkw ist ein Geschwisterzweig!)
+
+print(issubclass(ElektroAuto, Fahrzeug)) # True
+
+
+ + +
+

5. 🎯 Deine Aufgaben in aufgabe.py

+ +

Öffne die Datei 10_vererbung_und_super/aufgabe.py und vervollständige die 4 Klassen:

+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ +

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zu TODO 3 (Restwert berechnen) +
+
+
alter = max(0, aktuelles_jahr - self.baujahr)
+wertverlust = self.grundpreis * 0.05 * alter
+restwert = self.grundpreis - wertverlust
+return max(self.grundpreis * 0.10, restwert)
+
+
+
+ +
+ 🔍 Tipp zu TODO 10 (ElektroAuto fahren) +
+
+
if km <= 0:
+    return False
+benoetigt = (km / 100.0) * self.verbrauch_pro_100km
+if self.batterie_ladestand_kwh >= benoetigt:
+    self.batterie_ladestand_kwh -= benoetigt
+    super().fahren(km)  # Erhöht den Kilometerstand in der Basisklasse!
+    return True
+return False
+
+
+
+
+ + +
+

6. 🧪 Lösung überprüfen

+

Führe im Terminal im Ordner 10_vererbung_und_super folgenden Befehl aus:

+ +
+
+ Terminal +
+
python3 test_aufgabe.py
+
+ + +
+ +
+ + + diff --git a/10_vererbung_und_super/musterloesung.py b/10_vererbung_und_super/musterloesung.py new file mode 100644 index 0000000..afe884e --- /dev/null +++ b/10_vererbung_und_super/musterloesung.py @@ -0,0 +1,168 @@ +""" +Kapitel 10: Vererbung & super() – Musterlösung 🧬🚗 +=================================================== +Schulabgleich: 25.0 Vererbung +""" + +from typing import List, Optional + + +class Fahrzeug: + """Basisklasse für alle Fahrzeuge.""" + + def __init__(self, marke: str, modell: str, baujahr: int, grundpreis: float): + self.marke: str = marke + self.modell: str = modell + self.baujahr: int = int(baujahr) + self.grundpreis: float = float(grundpreis) + self.kilometerstand: float = 0.0 + + def fahren(self, km: float) -> None: + if km > 0: + self.kilometerstand += float(km) + + def berechne_restwert(self, aktuelles_jahr: int) -> float: + alter = max(0, aktuelles_jahr - self.baujahr) + wertverlust = self.grundpreis * 0.05 * alter + restwert = self.grundpreis - wertverlust + return max(self.grundpreis * 0.10, restwert) + + def info(self) -> str: + return f"{self.marke} {self.modell} ({self.baujahr}) - {self.kilometerstand:.1f} km" + + def __str__(self) -> str: + return self.info() + + +class Auto(Fahrzeug): + """Abgeleitete Klasse für PKWs (erbt von Fahrzeug).""" + + def __init__( + self, + marke: str, + modell: str, + baujahr: int, + grundpreis: float, + sitzplaetze: int = 5, + anzahl_tueren: int = 5, + ): + super().__init__(marke, modell, baujahr, grundpreis) + self.sitzplaetze: int = int(sitzplaetze) + self.anzahl_tueren: int = int(anzahl_tueren) + + def hupen(self) -> str: + return f"Hup hup! Platz da für den {self.marke} {self.modell}!" + + def info(self) -> str: + return f"{super().info()} | {self.sitzplaetze} Sitze, {self.anzahl_tueren} Türen" + + +class ElektroAuto(Auto): + """Abgeleitete Klasse für Elektroautos (erbt von Auto).""" + + def __init__( + self, + marke: str, + modell: str, + baujahr: int, + grundpreis: float, + batterie_kapazitaet_kwh: float, + sitzplaetze: int = 5, + anzahl_tueren: int = 5, + verbrauch_pro_100km: float = 18.0, + ): + super().__init__(marke, modell, baujahr, grundpreis, sitzplaetze, anzahl_tueren) + self.batterie_kapazitaet_kwh: float = float(batterie_kapazitaet_kwh) + self.batterie_ladestand_kwh: float = float(batterie_kapazitaet_kwh) + self.verbrauch_pro_100km: float = float(verbrauch_pro_100km) + + def reichweite(self) -> float: + if self.verbrauch_pro_100km <= 0: + return 0.0 + return (self.batterie_ladestand_kwh / self.verbrauch_pro_100km) * 100.0 + + def fahren(self, km: float) -> bool: + if km <= 0: + return False + benoetigt = (km / 100.0) * self.verbrauch_pro_100km + if self.batterie_ladestand_kwh >= benoetigt: + self.batterie_ladestand_kwh -= benoetigt + super().fahren(km) + return True + return False + + def aufladen(self, kwh: float) -> float: + if kwh <= 0: + return 0.0 + freier_platz = self.batterie_kapazitaet_kwh - self.batterie_ladestand_kwh + tatsaechlich = min(freier_platz, float(kwh)) + self.batterie_ladestand_kwh += tatsaechlich + return tatsaechlich + + def info(self) -> str: + return ( + f"{super().info()} | Akku: {self.batterie_ladestand_kwh:.1f}/" + f"{self.batterie_kapazitaet_kwh:.1f} kWh ({self.reichweite():.1f} km Reichweite)" + ) + + +class Lkw(Fahrzeug): + """Abgeleitete Klasse für Lastkraftwagen (erbt von Fahrzeug).""" + + def __init__( + self, + marke: str, + modell: str, + baujahr: int, + grundpreis: float, + max_zuladung_kg: float, + ): + super().__init__(marke, modell, baujahr, grundpreis) + self.max_zuladung_kg: float = float(max_zuladung_kg) + self.aktuelle_ladung_kg: float = 0.0 + + def beladen(self, kg: float) -> bool: + if kg > 0 and (self.aktuelle_ladung_kg + kg <= self.max_zuladung_kg): + self.aktuelle_ladung_kg += float(kg) + return True + return False + + def entladen(self, kg: float) -> float: + if kg <= 0: + return 0.0 + tatsaechlich = min(self.aktuelle_ladung_kg, float(kg)) + self.aktuelle_ladung_kg -= tatsaechlich + return tatsaechlich + + def info(self) -> str: + return f"{super().info()} | Ladung: {self.aktuelle_ladung_kg:.1f}/{self.max_zuladung_kg:.1f} kg" + + +if __name__ == "__main__": + print("--- 🚗 Auto testen ---") + golf = Auto("VW", "Golf 8", 2021, 28000.0, sitzplaetze=5, anzahl_tueren=5) + golf.fahren(150.0) + print(golf.info()) + print(golf.hupen()) + print(f"Restwert im Jahr 2026: {golf.berechne_restwert(2026):.2f} €") + + print("\n--- ⚡ ElektroAuto testen ---") + tesla = ElektroAuto("Tesla", "Model Y", 2023, 45000.0, batterie_kapazitaet_kwh=75.0, verbrauch_pro_100km=16.5) + print(tesla.info()) + print(f"Start-Reichweite: {tesla.reichweite():.1f} km") + + erfolg = tesla.fahren(200.0) + print(f"Fahrt 200 km erfolgreich? {erfolg}") + print(tesla.info()) + + geladen = tesla.aufladen(50.0) + print(f"Aufgeladen: {geladen:.1f} kWh") + print(tesla.info()) + + print("\n--- 🚛 LKW testen ---") + lkw = Lkw("Mercedes-Benz", "Actros", 2020, 110000.0, max_zuladung_kg=24000.0) + lkw.beladen(15000.0) + lkw.fahren(400.0) + print(lkw.info()) + entladen = lkw.entladen(5000.0) + print(f"Entladen: {entladen:.1f} kg -> Neue Ladung: {lkw.aktuelle_ladung_kg:.1f} kg") diff --git a/10_vererbung_und_super/test_aufgabe.py b/10_vererbung_und_super/test_aufgabe.py new file mode 100644 index 0000000..1498879 --- /dev/null +++ b/10_vererbung_und_super/test_aufgabe.py @@ -0,0 +1,193 @@ +import unittest +import sys +from pathlib import Path + +# Sicherstellen, dass das Kapitel-Verzeichnis im Suchpfad liegt +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import aufgabe +except ImportError as e: + raise ImportError(f"Konnte 'aufgabe.py' nicht importieren: {e}") + + +class TestKapitel10(unittest.TestCase): + + def setUp(self): + self.Fahrzeug = getattr(aufgabe, "Fahrzeug", None) + self.Auto = getattr(aufgabe, "Auto", None) + self.ElektroAuto = getattr(aufgabe, "ElektroAuto", None) + self.Lkw = getattr(aufgabe, "Lkw", None) + + self.assertIsNotNone(self.Fahrzeug, "Klasse 'Fahrzeug' nicht gefunden!") + self.assertIsNotNone(self.Auto, "Klasse 'Auto' nicht gefunden!") + self.assertIsNotNone(self.ElektroAuto, "Klasse 'ElektroAuto' nicht gefunden!") + self.assertIsNotNone(self.Lkw, "Klasse 'Lkw' nicht gefunden!") + + def test_01_fahrzeug_init_und_fahren(self): + """Prüft Basisklasse Fahrzeug: Initialisierung und Fahren.""" + f = self.Fahrzeug("VW", "Golf", 2020, 25000.0) + self.assertEqual(f.marke, "VW") + self.assertEqual(f.modell, "Golf") + self.assertEqual(f.baujahr, 2020) + self.assertEqual(f.grundpreis, 25000.0) + self.assertEqual(f.kilometerstand, 0.0) + + f.fahren(120.5) + self.assertAlmostEqual(f.kilometerstand, 120.5) + + # Negative km dürfen den Kilometerstand nicht verändern + f.fahren(-50.0) + self.assertAlmostEqual(f.kilometerstand, 120.5) + + def test_02_fahrzeug_berechne_restwert(self): + """Prüft Restwertberechnung mit 5% jährlichem Wertverlust und 10% Mindestwert.""" + f = self.Fahrzeug("Audi", "A4", 2020, 40000.0) + + # Gleiches Jahr (Alter 0) + self.assertAlmostEqual(f.berechne_restwert(2020), 40000.0) + + # Nach 4 Jahren (20% Verlust -> 80% von 40000 = 32000) + self.assertAlmostEqual(f.berechne_restwert(2024), 32000.0) + + # Nach 30 Jahren (Mehr als 90% Verlust -> Mindestwert 10% = 4000) + self.assertAlmostEqual(f.berechne_restwert(2050), 4000.0) + + def test_03_fahrzeug_info_und_str(self): + """Prüft info() und __str__() der Basisklasse Fahrzeug.""" + f = self.Fahrzeug("BMW", "320d", 2020, 35000.0) + self.assertEqual(f.info(), "BMW 320d (2020) - 0.0 km") + + f.fahren(5000.0) + self.assertEqual(str(f), "BMW 320d (2020) - 5000.0 km") + + def test_04_auto_vererbung_und_super(self): + """Prüft Vererbung von Fahrzeug an Auto und Aufruf von super().__init__().""" + self.assertTrue(issubclass(self.Auto, self.Fahrzeug), "Auto muss von Fahrzeug erben!") + + a = self.Auto("VW", "Polo", 2022, 20000.0, sitzplaetze=5, anzahl_tueren=3) + self.assertIsInstance(a, self.Fahrzeug) + self.assertIsInstance(a, self.Auto) + + # Geerbte Attribute prüfen + self.assertEqual(a.marke, "VW") + self.assertEqual(a.modell, "Polo") + self.assertEqual(a.baujahr, 2022) + self.assertEqual(a.grundpreis, 20000.0) + self.assertEqual(a.kilometerstand, 0.0) + + # Neue Attribute prüfen + self.assertEqual(a.sitzplaetze, 5) + self.assertEqual(a.anzahl_tueren, 3) + + def test_05_auto_methoden_und_hupen(self): + """Prüft hupen() und die erweiterte info()-Methode von Auto.""" + a = self.Auto("Porsche", "911", 2023, 120000.0, sitzplaetze=2, anzahl_tueren=2) + self.assertEqual(a.hupen(), "Hup hup! Platz da für den Porsche 911!") + + a.fahren(250.0) + self.assertEqual(a.info(), "Porsche 911 (2023) - 250.0 km | 2 Sitze, 2 Türen") + + def test_06_elektroauto_mehrstufige_vererbung(self): + """Prüft mehrstufige Vererbung: ElektroAuto -> Auto -> Fahrzeug.""" + self.assertTrue(issubclass(self.ElektroAuto, self.Auto), "ElektroAuto muss von Auto erben!") + self.assertTrue(issubclass(self.ElektroAuto, self.Fahrzeug), "ElektroAuto muss auch von Fahrzeug erben!") + + e = self.ElektroAuto( + "Tesla", "Model 3", 2023, 42000.0, + batterie_kapazitaet_kwh=60.0, + sitzplaetze=5, + anzahl_tueren=5, + verbrauch_pro_100km=15.0 + ) + self.assertIsInstance(e, self.Fahrzeug) + self.assertIsInstance(e, self.Auto) + self.assertIsInstance(e, self.ElektroAuto) + + self.assertEqual(e.batterie_kapazitaet_kwh, 60.0) + self.assertEqual(e.batterie_ladestand_kwh, 60.0) + self.assertEqual(e.verbrauch_pro_100km, 15.0) + + def test_07_elektroauto_reichweite_und_fahren(self): + """Prüft Reichweitenberechnung, Stromverbrauch und Fahren.""" + e = self.ElektroAuto( + "Tesla", "Model 3", 2023, 42000.0, + batterie_kapazitaet_kwh=60.0, + verbrauch_pro_100km=15.0 + ) + # Voller Akku: (60 / 15) * 100 = 400 km + self.assertAlmostEqual(e.reichweite(), 400.0) + + # 100 km fahren -> verbraucht 15 kWh -> 45 kWh übrig + erfolg = e.fahren(100.0) + self.assertTrue(erfolg, "Fahrt von 100 km sollte bei 400 km Reichweite erfolgreich sein!") + self.assertAlmostEqual(e.kilometerstand, 100.0) + self.assertAlmostEqual(e.batterie_ladestand_kwh, 45.0) + self.assertAlmostEqual(e.reichweite(), 300.0) + + # Versuch, 400 km zu fahren (braucht 60 kWh, aber nur 45 kWh vorhanden) + erfolg_zu_weit = e.fahren(400.0) + self.assertFalse(erfolg_zu_weit, "Fahrt sollte fehlschlagen, wenn der Akku nicht reicht!") + # Werte dürfen sich nicht verändert haben + self.assertAlmostEqual(e.kilometerstand, 100.0) + self.assertAlmostEqual(e.batterie_ladestand_kwh, 45.0) + + def test_08_elektroauto_aufladen_und_info(self): + """Prüft Akku aufladen und die erweiterte info()-Methode.""" + e = self.ElektroAuto( + "Tesla", "Model 3", 2023, 42000.0, + batterie_kapazitaet_kwh=60.0, + verbrauch_pro_100km=15.0 + ) + e.fahren(200.0) # Verbraucht 30 kWh -> 30 kWh übrig + + # 20 kWh laden + geladen = e.aufladen(20.0) + self.assertAlmostEqual(geladen, 20.0) + self.assertAlmostEqual(e.batterie_ladestand_kwh, 50.0) + + # Mehr laden als reinpasst (nur noch 10 kWh frei) + geladen_uebervoll = e.aufladen(50.0) + self.assertAlmostEqual(geladen_uebervoll, 10.0) + self.assertAlmostEqual(e.batterie_ladestand_kwh, 60.0) + + info_text = e.info() + self.assertIn("Tesla Model 3", info_text) + self.assertIn("60.0/60.0 kWh", info_text) + + def test_09_lkw_vererbung_und_ladung(self): + """Prüft Lkw: direkte Vererbung von Fahrzeug, Beladen und Entladen.""" + self.assertTrue(issubclass(self.Lkw, self.Fahrzeug), "Lkw muss von Fahrzeug erben!") + self.assertFalse(issubclass(self.Lkw, self.Auto), "Lkw sollte NICHT von Auto erben!") + + lkw = self.Lkw("MAN", "TGX", 2021, 130000.0, max_zuladung_kg=18000.0) + self.assertIsInstance(lkw, self.Fahrzeug) + self.assertEqual(lkw.aktuelle_ladung_kg, 0.0) + self.assertEqual(lkw.max_zuladung_kg, 18000.0) + + # Erfolgreich beladen + self.assertTrue(lkw.beladen(10000.0)) + self.assertAlmostEqual(lkw.aktuelle_ladung_kg, 10000.0) + + # Überladung ablehnen (10000 + 9000 = 19000 > 18000) + self.assertFalse(lkw.beladen(9000.0)) + self.assertAlmostEqual(lkw.aktuelle_ladung_kg, 10000.0) + + # Teilweise entladen + entladen = lkw.entladen(4000.0) + self.assertAlmostEqual(entladen, 4000.0) + self.assertAlmostEqual(lkw.aktuelle_ladung_kg, 6000.0) + + # Mehr entladen als vorhanden (nur 6000 kg übrig) + entladen_rest = lkw.entladen(10000.0) + self.assertAlmostEqual(entladen_rest, 6000.0) + self.assertAlmostEqual(lkw.aktuelle_ladung_kg, 0.0) + + # Fahren erben + lkw.fahren(300.0) + self.assertAlmostEqual(lkw.kilometerstand, 300.0) + self.assertIn("Ladung: 0.0/18000.0 kg", lkw.info()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/11_polymorphie_und_interfaces/README.md b/11_polymorphie_und_interfaces/README.md new file mode 100644 index 0000000..08a3523 --- /dev/null +++ b/11_polymorphie_und_interfaces/README.md @@ -0,0 +1,87 @@ +# Kapitel 11: Polymorphie & Interfaces 🎭📐 + +Willkommen zu Kapitel 11! In diesem Kapitel lernst du das Konzept der **Polymorphie (Vielgestaltigkeit)** und **Interfaces (abstrakte Basisklassen)** in Python kennen. + +--- + +## 🎯 Was du lernst + +1. **Was bedeutet Polymorphie?** + - Aus dem Griechischen: *poly* = viel, *morph* = Gestalt → "Vielgestaltigkeit". + - Verschiedene Klassen stellen Methoden mit demselben Namen und denselben Parametern bereit (`flaeche()`, `umfang()`, `info()`), berechnen ihr Ergebnis aber völlig unterschiedlich! +2. **Didaktische Analogie: Die Universalfernbedienung & Mediaplayer:** + - Wenn du auf der Fernbedienung auf "Power" drückst, schaltet sich der Fernseher, die Soundbar oder die Spielkonsole ein – jedes Gerät auf seine Art. + - Ein Mediaplayer drückt einfach auf `medium.abspielen()` – egal ob MP3, MP4, FLAC oder Stream. +3. **Abstrakte Basisklassen & Interfaces (`abc.ABC`):** + - Mit `from abc import ABC, abstractmethod` zwingst du abgeleitete Klassen dazu, bestimmte Methoden zu implementieren. + - Wird eine abstrakte Methode nicht implementiert, verhindert Python das Erstellen von Objekten mit einem `TypeError`. +4. **Duck Typing in Python:** + - *"If it walks like a duck and quacks like a duck, it is a duck."* + - In Python zählt das Verhalten eines Objekts: Solange ein Objekt eine Methode `flaeche()` besitzt, kann es in polymorphen Berechnungen verwendet werden. +5. **Polymorphe Sammlungen (Manager-Klassen):** + - Eine Liste mit vielen unterschiedlichen Objekten (`[Rechteck, Kreis, Dreieck]`) durchlaufen und einheitlich `form.flaeche()` aufrufen – ohne eine einzige `if isinstance(...)` Abfrage! + +--- + +## 📊 UML-Klassendiagramm + +``` + ┌───────────────────────────────────────────────┐ + │ «abstract» Form │ + ├───────────────────────────────────────────────┤ + │ - farbe: str │ + ├───────────────────────────────────────────────┤ + │ + __init__(farbe: str = "schwarz") │ + │ + {abstract} flaeche() -> float │ + │ + {abstract} umfang() -> float │ + │ + info() -> str │ + └───────────────────────▲───────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ + ┌───────────┴───────────┐ ┌─────────┴─────────┐ ┌───────────┴───────────┐ + │ Rechteck │ │ Kreis │ │ Dreieck │ + ├───────────────────────┤ ├───────────────────┤ ├───────────────────────┤ + │ - breite: float │ │ - radius: float │ │ - seite_a: float │ + │ - hoehe: float │ ├───────────────────┤ │ - seite_b: float │ + ├───────────────────────┤ │ + flaeche() │ ├───────────────────────┤ + │ + flaeche() │ │ + umfang() │ │ + hypotenuse() │ + │ + umfang() │ │ + info() │ │ + flaeche() │ + │ + info() │ └───────────────────┘ │ + umfang() │ + └───────────────────────┘ │ + info() │ + └───────────────────────┘ + + ┌───────────────────────────────────────────────────────────┐ + │ Zeichenflaeche │ + ├───────────────────────────────────────────────────────────┤ + │ - name: str │ + │ - formen: list[Form] │ + ├───────────────────────────────────────────────────────────┤ + │ + hinzufuegen(form: Form) -> None │ + │ + anzahl_formen() -> int │ + │ + gesamte_flaeche() -> float <-- ruft form.flaeche() auf│ + │ + gesamter_umfang() -> float <-- ruft form.umfang() auf │ + │ + formen_nach_farbe(farbe) -> list[Form] │ + │ + groesste_form() -> Form | None │ + │ + report() -> list[str] │ + └───────────────────────────────────────────────────────────┘ +``` + +--- + +## 📁 Die Dateien in diesem Ordner + +- **`index.html`**: Die interaktive Lernseite mit visuellen Erklärungen, Analogien, UML-Diagrammen und Lösungstipps. +- **`aufgabe.py`**: Dein Arbeitsblatt mit den Klassen `Form`, `Rechteck`, `Kreis`, `Dreieck` und `Zeichenflaeche`. +- **`test_aufgabe.py`**: Automatische Tests (`python3 test_aufgabe.py`). +- **`musterloesung.py`**: Vollständig ausprogrammierte Beispiellösung. + +--- + +## 🚀 Schnellstart + +```bash +# 1. Bearbeite aufgabe.py +# 2. Teste deine Lösung: +python3 test_aufgabe.py +``` diff --git a/11_polymorphie_und_interfaces/aufgabe.py b/11_polymorphie_und_interfaces/aufgabe.py new file mode 100644 index 0000000..b3e7281 --- /dev/null +++ b/11_polymorphie_und_interfaces/aufgabe.py @@ -0,0 +1,309 @@ +""" +Kapitel 11: Polymorphie & Interfaces 🎭📐 +========================================= +Schulabgleich: 25.1 Polymorphie + +Aufgabe: +Erstelle ein geometrisches Grafik- & Zeichenflächen-System mit Polymorphie. +Alle Formen (Rechteck, Kreis, Dreieck) besitzen dieselben Methoden (flaeche, umfang, info), +berechnen ihre Ergebnisse jedoch auf ihre jeweils eigene Weise. +Die Klasse 'Zeichenflaeche' kann beliebige Formen verwalten, ohne wissen zu müssen, +um welche konkrete Form es sich handelt! +""" + +import math +from abc import ABC, abstractmethod +from typing import List, Optional + + +class Form(ABC): + """ + Abstrakte Basisklasse (Interface) für alle geometrischen Formen. + + Attribute: + farbe (str): Die Zeichenfarbe der Form (z.B. "rot", "blau", "gruen") + """ + + # ========================================================================== + # TODO 1: Konstruktor __init__ der Basisklasse Form + # Parameter: farbe (str = "schwarz") + # Setze self.farbe = farbe + # ========================================================================== + def __init__(self, farbe: str = "schwarz"): + pass + + # ========================================================================== + # TODO 2: Abstrakte Methode flaeche(self) -> float + # Deklariere die Methode mit @abstractmethod. + # Da es eine abstrakte Methode ist, enthält der Rumpf nur 'pass'. + # ========================================================================== + @abstractmethod + def flaeche(self) -> float: + pass + + # ========================================================================== + # TODO 3: Abstrakte Methode umfang(self) -> float + # Deklariere die Methode mit @abstractmethod. + # Rumpf: 'pass' + # ========================================================================== + @abstractmethod + def umfang(self) -> float: + pass + + # ========================================================================== + # TODO 4: Methode info(self) -> str + # Gibt einen allgemeinen Infostring zurück: + # Format: "{Klassenname} ({farbe}) - Fläche: {flaeche():.2f}, Umfang: {umfang():.2f}" + # Tipp: self.__class__.__name__ liefert den Namen der jeweiligen Kindklasse! + # Beispiel: "Form (schwarz) - Fläche: 0.00, Umfang: 0.00" + # ========================================================================== + def info(self) -> str: + pass + + def __str__(self) -> str: + return self.info() + + +class Rechteck(Form): + """ + Rechteck-Form (erbt von Form). + + Attribute: + breite (float): Breite des Rechtecks + hoehe (float): Höhe des Rechtecks + farbe (str): Farbe (Standardwert: "rot") + """ + + # ========================================================================== + # TODO 5: Konstruktor __init__ von Rechteck + # Parameter: breite (float), hoehe (float), farbe (str = "rot") + # 1. Rufe super().__init__(farbe) auf. + # 2. Setze self.breite = float(breite) + # 3. Setze self.hoehe = float(hoehe) + # ========================================================================== + def __init__(self, breite: float, hoehe: float, farbe: str = "rot"): + pass + + # ========================================================================== + # TODO 6: Methode flaeche(self) -> float (Überschreiben) + # Formel: breite * hoehe + # ========================================================================== + def flaeche(self) -> float: + pass + + # ========================================================================== + # TODO 7: Methode umfang(self) -> float (Überschreiben) + # Formel: 2 * (breite + hoehe) + # ========================================================================== + def umfang(self) -> float: + pass + + # ========================================================================== + # TODO 8: Methode info(self) -> str (Überschreiben) + # Format: "Rechteck ({farbe}, {breite:.1f}x{hoehe:.1f}) - Fläche: {flaeche():.2f}, Umfang: {umfang():.2f}" + # Beispiel: "Rechteck (rot, 4.0x5.0) - Fläche: 20.00, Umfang: 18.00" + # ========================================================================== + def info(self) -> str: + pass + + +class Kreis(Form): + """ + Kreis-Form (erbt von Form). + + Attribute: + radius (float): Radius des Kreises + farbe (str): Farbe (Standardwert: "blau") + """ + + # ========================================================================== + # TODO 9: Konstruktor __init__ von Kreis + # Parameter: radius (float), farbe (str = "blau") + # 1. Rufe super().__init__(farbe) auf. + # 2. Setze self.radius = float(radius) + # ========================================================================== + def __init__(self, radius: float, farbe: str = "blau"): + pass + + # ========================================================================== + # TODO 10: Methode flaeche(self) -> float (Überschreiben) + # Formel: math.pi * (radius ** 2) + # ========================================================================== + def flaeche(self) -> float: + pass + + # ========================================================================== + # TODO 11: Methode umfang(self) -> float (Überschreiben) + # Formel: 2 * math.pi * radius + # ========================================================================== + def umfang(self) -> float: + pass + + # ========================================================================== + # TODO 12: Methode info(self) -> str (Überschreiben) + # Format: "Kreis ({farbe}, r={radius:.1f}) - Fläche: {flaeche():.2f}, Umfang: {umfang():.2f}" + # Beispiel: "Kreis (blau, r=3.0) - Fläche: 28.27, Umfang: 18.85" + # ========================================================================== + def info(self) -> str: + pass + + +class Dreieck(Form): + """ + Rechtwinkliges Dreieck (erbt von Form) mit Katheten a und b. + + Attribute: + seite_a (float): Länge der ersten Kathete (Grundseite) + seite_b (float): Länge der zweiten Kathete (Höhe) + farbe (str): Farbe (Standardwert: "gruen") + """ + + # ========================================================================== + # TODO 13: Konstruktor __init__ von Dreieck + # Parameter: seite_a (float), seite_b (float), farbe (str = "gruen") + # 1. Rufe super().__init__(farbe) auf. + # 2. Setze self.seite_a = float(seite_a) + # 3. Setze self.seite_b = float(seite_b) + # ========================================================================== + def __init__(self, seite_a: float, seite_b: float, farbe: str = "gruen"): + pass + + # ========================================================================== + # TODO 14: Methode hypotenuse(self) -> float + # Berechnet die dritte Seite c mit dem Satz des Pythagoras: + # Formel: math.sqrt(seite_a**2 + seite_b**2) + # ========================================================================== + def hypotenuse(self) -> float: + pass + + # ========================================================================== + # TODO 15: Methode flaeche(self) -> float (Überschreiben) + # Formel: 0.5 * seite_a * seite_b + # ========================================================================== + def flaeche(self) -> float: + pass + + # ========================================================================== + # TODO 16: Methode umfang(self) -> float (Überschreiben) + # Formel: seite_a + seite_b + self.hypotenuse() + # ========================================================================== + def umfang(self) -> float: + pass + + # ========================================================================== + # TODO 17: Methode info(self) -> str (Überschreiben) + # Format: "Dreieck ({farbe}, a={seite_a:.1f}, b={seite_b:.1f}, c={hypotenuse():.1f}) - Fläche: {flaeche():.2f}, Umfang: {umfang():.2f}" + # Beispiel: "Dreieck (gruen, a=3.0, b=4.0, c=5.0) - Fläche: 6.00, Umfang: 12.00" + # ========================================================================== + def info(self) -> str: + pass + + +class Zeichenflaeche: + """ + Manager-Klasse zur polymorphen Verwaltung beliebig vieler geometrischer Formen. + + Attribute: + name (str): Name der Zeichenfläche + formen (list[Form]): Liste aller hinzugefügten Formen + """ + + # ========================================================================== + # TODO 18: Konstruktor __init__ von Zeichenflaeche + # Parameter: name (str = "Meine Zeichenfläche") + # Attribute: + # - self.name: str = name + # - self.formen: list[Form] = [] (Startwert: leere Liste) + # ========================================================================== + def __init__(self, name: str = "Meine Zeichenfläche"): + pass + + # ========================================================================== + # TODO 19: Methode hinzufuegen(self, form: Form) -> None + # Fügt die übergebene Form zur Liste self.formen hinzu. + # ========================================================================== + def hinzufuegen(self, form: Form) -> None: + pass + + # ========================================================================== + # TODO 20: Methode anzahl_formen(self) -> int + # Gibt die Anzahl der aktuell enthaltenen Formen zurück. + # ========================================================================== + def anzahl_formen(self) -> int: + pass + + # ========================================================================== + # TODO 21: Methode gesamte_flaeche(self) -> float (Polymorphie in Aktion!) + # Berechnet die Summe aller Flächen der enthaltenen Formen. + # Nutze eine Schleife oder sum(...), um form.flaeche() für jede Form aufzurufen. + # Wenn self.formen leer ist: 0.0 zurückgeben. + # ========================================================================== + def gesamte_flaeche(self) -> float: + pass + + # ========================================================================== + # TODO 22: Methode gesamter_umfang(self) -> float (Polymorphie in Aktion!) + # Berechnet die Summe aller Umfänge der enthaltenen Formen. + # Nutze form.umfang() für jedes Objekt in self.formen. + # Wenn self.formen leer ist: 0.0 zurückgeben. + # ========================================================================== + def gesamter_umfang(self) -> float: + pass + + # ========================================================================== + # TODO 23: Methode formen_nach_farbe(self, farbe: str) -> list[Form] + # Filtert alle Formen heraus, die die angegebene Farbe haben. + # WICHTIG: Groß-/Kleinschreibung ignorieren (z.B. mit farbe.lower())! + # ========================================================================== + def formen_nach_farbe(self, farbe: str) -> List[Form]: + pass + + # ========================================================================== + # TODO 24: Methode groesste_form(self) -> Form | None + # Findet die Form mit dem größten Flächeninhalt (form.flaeche()). + # Wenn die Liste leer ist: return None + # Tipp: Nutze max(self.formen, key=lambda f: f.flaeche()) + # ========================================================================== + def groesste_form(self) -> Optional[Form]: + pass + + # ========================================================================== + # TODO 25: Methode report(self) -> list[str] + # Gibt eine Liste der info()-Texte aller enthaltenen Formen zurück. + # ========================================================================== + def report(self) -> List[str]: + pass + + +# ============================================================================== +# Terminal-Test zum Ausprobieren: (python3 aufgabe.py) +# ============================================================================== +if __name__ == "__main__": + print("--- 🎭 Polymorphie Demo: Geometrische Formen ---") + + r = Rechteck(4.0, 5.0, farbe="rot") + k = Kreis(3.0, farbe="blau") + d = Dreieck(3.0, 4.0, farbe="gruen") + + # Polymorphe Liste durchlaufen: + formen_liste: List[Form] = [r, k, d] + + for form in formen_liste: + print(form.info()) + + print("\n--- 🖼️ Zeichenfläche Manager ---") + leinwand = Zeichenflaeche("Mathe-Poster") + leinwand.hinzufuegen(r) + leinwand.hinzufuegen(k) + leinwand.hinzufuegen(d) + leinwand.hinzufuegen(Kreis(1.5, farbe="rot")) + + print(f"Anzahl Formen: {leinwand.anzahl_formen()}") + print(f"Gesamtfläche: {leinwand.gesamte_flaeche():.2f} cm²") + print(f"Gesamtumfang: {leinwand.gesamter_umfang():.2f} cm") + + rote_formen = leinwand.formen_nach_farbe("rot") + print(f"Rote Formen ({len(rote_formen)}): {[f.info() for f in rote_formen]}") + + groesste = leinwand.groesste_form() + print(f"Größte Form: {groesste.info() if groesste else 'keine'}") diff --git a/11_polymorphie_und_interfaces/index.html b/11_polymorphie_und_interfaces/index.html new file mode 100644 index 0000000..6469b26 --- /dev/null +++ b/11_polymorphie_und_interfaces/index.html @@ -0,0 +1,392 @@ + + + + + + Kapitel 11: Polymorphie & Interfaces + + + + + +
+
+ +
Kapitel 11 • Schulabgleich 25.1
+

Polymorphie & Interfaces: Viele Gestalten, ein Befehl 🎭📐

+

Verstehe das Geheimnis der Vielgestaltigkeit: Gleicher Methodenname, unterschiedliches Verhalten und saubere Schnittstellen mit ABC und Duck Typing.

+
+
+ +
+ + +
+

1. Was bedeutet Polymorphie? ("Vielgestaltigkeit")

+ +

+ Das Wort Polymorphie stammt aus dem Griechischen: +
+ poly = viel  |  morphe = Gestalt, Form → "Vielgestaltigkeit". +

+ +

+ In der Objektorientierten Programmierung bedeutet das: + Verschiedene Klassen besitzen Methoden mit demselben Namen und derselben Signatur, + führen die Aufgabe im Hintergrund jedoch auf ihre ganz eigene Art und Weise aus. +

+ +
+
📺 Die Universalfernbedienung-Analogie:
+ Stelle dir eine Universal-Fernbedienung mit einer roten "Power"-Taste vor. +

+ Wenn du auf "Power" drückst, sendet die Fernbedienung an alle Geräte den Befehl einschalten(): +
    +
  • Der Fernseher aktiviert sein OLED-Display.
  • +
  • Die Soundbar startet den Verstärker und verbindet Bluetooth.
  • +
  • Die Spielkonsole lädt das Betriebssystem und meldet den Controller an.
  • +
+
+ Die Fernbedienung muss nicht wissen, wie das Einschalten intern funktioniert – sie weiß nur, dass jedes Gerät eine Methode einschalten() besitzt! +
+
+ + +
+

2. 📊 UML-Klassendiagramm: Formen & Zeichenfläche

+ +

+ In unserem Praxisbeispiel ist Form ein Interface / eine abstrakte Basisklasse. + Rechteck, Kreis und Dreieck implementieren jeweils ihre eigene flaeche() und umfang(). +

+ +
+ +
+
«abstract» Form
+
+ - farbe: str = "schwarz" +
+
+ + __init__(farbe: str)
+ + {abstract} flaeche() -> float
+ + {abstract} umfang() -> float
+ + info() -> str +
+
+ +
+
+
+ +
+ +
+
Rechteck
+
+ - breite: float
+ - hoehe: float +
+
+ + flaeche() -> float
+ + umfang() -> float
+ + info() -> str +
+
+ + +
+
Kreis
+
+ - radius: float +
+
+ + flaeche() -> float
+ + umfang() -> float
+ + info() -> str +
+
+ + +
+
Dreieck
+
+ - seite_a: float
+ - seite_b: float +
+
+ + hypotenuse() -> float
+ + flaeche() -> float
+ + umfang() -> float
+ + info() -> str +
+
+
+ + +
+
Zeichenflaeche (Manager)
+
+ - name: str
+ - formen: list[Form] +
+
+ + hinzufuegen(form: Form) -> None
+ + gesamte_flaeche() -> float  (ruft form.flaeche() auf)
+ + gesamter_umfang() -> float  (ruft form.umfang() auf)
+ + formen_nach_farbe(farbe) -> list[Form]
+ + groesste_form() -> Form | None
+ + report() -> list[str] +
+
+
+
+ + +
+

3. Wie funktioniert Polymorphie in Code?

+ +

Der Clou: Schleifen über gemischte Objekte ohne if/else!

+ +

+ Ohne Polymorphie müsstest du für jeden Typ eine eigene if/elif-Weiche schreiben: +

+ +
+
+ ❌ Schlechter Stil: Starre Typ-Prüfungen +
+
# So bitte NICHT programmieren:
+for form in formen:
+    if isinstance(form, Rechteck):
+        f = form.breite * form.hoehe
+    elif isinstance(form, Kreis):
+        f = 3.14159 * form.radius ** 2
+    # Bei jeder neuen Form (z.B. Dreieck) muss überall neuer Code eingefügt werden!
+
+ +

+ Mit Polymorphie rufen wir einfach dieselbe Methode auf allen Objekten auf – Python wählt automatisch die richtige Version: +

+ +
+
+
+ + + +
+ ✅ Eleganter Stil: Echte Polymorphie +
+
formen = [
+    Rechteck(4.0, 5.0),
+    Kreis(3.0),
+    Dreieck(3.0, 4.0)
+]
+
+# Ein einziger, eleganter Methodenaufruf für alle Formen:
+for form in formen:
+    print(f"{form.info()} -> Fläche: {form.flaeche():.2f}")
+
+
+ + +
+

4. Abstrakte Basisklassen (abc.ABC) & Duck Typing

+ +
+
🦆 Was ist Duck Typing?
+ "If it walks like a duck and quacks like a duck, it's a duck." +

+ Python ist dynamisch typisiert: Solange ein Objekt eine Methode flaeche() besitzt, kann es auf der Zeichenflaeche platziert werden. +
+ +

Verträge erzwingen mit abc.ABC

+

+ Um sicherzustellen, dass Entwickler nicht vergessen, wichtige Methoden wie flaeche() zu schreiben, nutzen wir Pythons abc-Modul (Abstract Base Classes): +

+ +
+
+ Python (Abstrakte Methoden definieren) +
+
from abc import ABC, abstractmethod
+
+class Form(ABC):
+    def __init__(self, farbe: str = "schwarz"):
+        self.farbe = farbe
+
+    @abstractmethod
+    def flaeche(self) -> float:
+        pass   # Jede Kindklasse MUSS diese Methode überschreiben!
+
+ +
+
⚠️ Was passiert, wenn eine Kindklasse die abstrakte Methode vergisst?
+ Wenn eine Kindklasse flaeche() nicht implementiert, verhindert Python das Erstellen von Objekten mit einem klaren Fehler: +
+ TypeError: Can't instantiate abstract class MeineForm with abstract method flaeche +
+
+ + +
+

5. 🎯 Deine Aufgaben in aufgabe.py

+ +

Öffne die Datei 11_polymorphie_und_interfaces/aufgabe.py und vervollständige das System:

+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ +

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zu TODO 14 (Hypotenuse berechnen) +
+
+
def hypotenuse(self) -> float:
+    return math.sqrt(self.seite_a ** 2 + self.seite_b ** 2)
+
+
+
+ +
+ 🔍 Tipp zu TODO 21 (Polymorphe Gesamtfläche) +
+
+
def gesamte_flaeche(self) -> float:
+    return sum(form.flaeche() for form in self.formen)
+
+
+
+ +
+ 🔍 Tipp zu TODO 23 (Farben filtern ohne Groß-/Kleinschreibung) +
+
+
def formen_nach_farbe(self, farbe: str) -> list[Form]:
+    return [f for f in self.formen if f.farbe.lower() == farbe.lower()]
+
+
+
+
+ + +
+

6. 🧪 Lösung überprüfen

+

Führe im Terminal im Ordner 11_polymorphie_und_interfaces folgenden Befehl aus:

+ +
+
+ Terminal +
+
python3 test_aufgabe.py
+
+ + +
+ +
+ + + diff --git a/11_polymorphie_und_interfaces/musterloesung.py b/11_polymorphie_und_interfaces/musterloesung.py new file mode 100644 index 0000000..b6d0a1c --- /dev/null +++ b/11_polymorphie_und_interfaces/musterloesung.py @@ -0,0 +1,151 @@ +""" +Kapitel 11: Polymorphie & Interfaces – Musterlösung 🎭📐 +========================================================= +Schulabgleich: 25.1 Polymorphie +""" + +import math +from abc import ABC, abstractmethod +from typing import List, Optional + + +class Form(ABC): + """Abstrakte Basisklasse (Interface) für alle geometrischen Formen.""" + + def __init__(self, farbe: str = "schwarz"): + self.farbe: str = farbe + + @abstractmethod + def flaeche(self) -> float: + pass + + @abstractmethod + def umfang(self) -> float: + pass + + def info(self) -> str: + return f"{self.__class__.__name__} ({self.farbe}) - Fläche: {self.flaeche():.2f}, Umfang: {self.umfang():.2f}" + + def __str__(self) -> str: + return self.info() + + +class Rechteck(Form): + """Rechteck-Form (erbt von Form).""" + + def __init__(self, breite: float, hoehe: float, farbe: str = "rot"): + super().__init__(farbe) + self.breite: float = float(breite) + self.hoehe: float = float(hoehe) + + def flaeche(self) -> float: + return self.breite * self.hoehe + + def umfang(self) -> float: + return 2.0 * (self.breite + self.hoehe) + + def info(self) -> str: + return f"Rechteck ({self.farbe}, {self.breite:.1f}x{self.hoehe:.1f}) - Fläche: {self.flaeche():.2f}, Umfang: {self.umfang():.2f}" + + +class Kreis(Form): + """Kreis-Form (erbt von Form).""" + + def __init__(self, radius: float, farbe: str = "blau"): + super().__init__(farbe) + self.radius: float = float(radius) + + def flaeche(self) -> float: + return math.pi * (self.radius ** 2) + + def umfang(self) -> float: + return 2.0 * math.pi * self.radius + + def info(self) -> str: + return f"Kreis ({self.farbe}, r={self.radius:.1f}) - Fläche: {self.flaeche():.2f}, Umfang: {self.umfang():.2f}" + + +class Dreieck(Form): + """Rechtwinkliges Dreieck (erbt von Form) mit Katheten a und b.""" + + def __init__(self, seite_a: float, seite_b: float, farbe: str = "gruen"): + super().__init__(farbe) + self.seite_a: float = float(seite_a) + self.seite_b: float = float(seite_b) + + def hypotenuse(self) -> float: + return math.sqrt(self.seite_a ** 2 + self.seite_b ** 2) + + def flaeche(self) -> float: + return 0.5 * self.seite_a * self.seite_b + + def umfang(self) -> float: + return self.seite_a + self.seite_b + self.hypotenuse() + + def info(self) -> str: + return ( + f"Dreieck ({self.farbe}, a={self.seite_a:.1f}, b={self.seite_b:.1f}, " + f"c={self.hypotenuse():.1f}) - Fläche: {self.flaeche():.2f}, Umfang: {self.umfang():.2f}" + ) + + +class Zeichenflaeche: + """Manager-Klasse zur polymorphen Verwaltung beliebig vieler geometrischer Formen.""" + + def __init__(self, name: str = "Meine Zeichenfläche"): + self.name: str = name + self.formen: List[Form] = [] + + def hinzufuegen(self, form: Form) -> None: + self.formen.append(form) + + def anzahl_formen(self) -> int: + return len(self.formen) + + def gesamte_flaeche(self) -> float: + return sum(f.flaeche() for f in self.formen) + + def gesamter_umfang(self) -> float: + return sum(f.umfang() for f in self.formen) + + def formen_nach_farbe(self, farbe: str) -> List[Form]: + return [f for f in self.formen if f.farbe.lower() == farbe.lower()] + + def groesste_form(self) -> Optional[Form]: + if not self.formen: + return None + return max(self.formen, key=lambda f: f.flaeche()) + + def report(self) -> List[str]: + return [f.info() for f in self.formen] + + +if __name__ == "__main__": + print("--- 🎭 Polymorphie Demo: Geometrische Formen ---") + + r = Rechteck(4.0, 5.0, farbe="rot") + k = Kreis(3.0, farbe="blau") + d = Dreieck(3.0, 4.0, farbe="gruen") + + # Polymorphe Liste durchlaufen: + formen_liste: List[Form] = [r, k, d] + + for form in formen_liste: + print(form.info()) + + print("\n--- 🖼️ Zeichenfläche Manager ---") + leinwand = Zeichenflaeche("Mathe-Poster") + leinwand.hinzufuegen(r) + leinwand.hinzufuegen(k) + leinwand.hinzufuegen(d) + leinwand.hinzufuegen(Kreis(1.5, farbe="rot")) + + print(f"Anzahl Formen: {leinwand.anzahl_formen()}") + print(f"Gesamtfläche: {leinwand.gesamte_flaeche():.2f} cm²") + print(f"Gesamtumfang: {leinwand.gesamter_umfang():.2f} cm") + + rote_formen = leinwand.formen_nach_farbe("rot") + print(f"Rote Formen ({len(rote_formen)}): {[f.info() for f in rote_formen]}") + + groesste = leinwand.groesste_form() + print(f"Größte Form: {groesste.info() if groesste else 'keine'}") diff --git a/11_polymorphie_und_interfaces/test_aufgabe.py b/11_polymorphie_und_interfaces/test_aufgabe.py new file mode 100644 index 0000000..4d3031f --- /dev/null +++ b/11_polymorphie_und_interfaces/test_aufgabe.py @@ -0,0 +1,178 @@ +import unittest +import math +import sys +from pathlib import Path + +# Sicherstellen, dass das Kapitel-Verzeichnis im Suchpfad liegt +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import aufgabe +except ImportError as e: + raise ImportError(f"Konnte 'aufgabe.py' nicht importieren: {e}") + + +class TestKapitel11(unittest.TestCase): + + def setUp(self): + self.Form = getattr(aufgabe, "Form", None) + self.Rechteck = getattr(aufgabe, "Rechteck", None) + self.Kreis = getattr(aufgabe, "Kreis", None) + self.Dreieck = getattr(aufgabe, "Dreieck", None) + self.Zeichenflaeche = getattr(aufgabe, "Zeichenflaeche", None) + + self.assertIsNotNone(self.Form, "Klasse 'Form' nicht gefunden!") + self.assertIsNotNone(self.Rechteck, "Klasse 'Rechteck' nicht gefunden!") + self.assertIsNotNone(self.Kreis, "Klasse 'Kreis' nicht gefunden!") + self.assertIsNotNone(self.Dreieck, "Klasse 'Dreieck' nicht gefunden!") + self.assertIsNotNone(self.Zeichenflaeche, "Klasse 'Zeichenflaeche' nicht gefunden!") + + def test_01_form_ist_abstrakt(self): + """Prüft, ob Form eine echte abstrakte Klasse ist (kann nicht direkt instanziiert werden).""" + with self.assertRaises(TypeError, msg="Abstrakte Klasse 'Form' darf nicht direkt instanziiert werden können!"): + self.Form("gelb") + + def test_02_rechteck_berechnungen_und_vererbung(self): + """Prüft Rechteck: Vererbung von Form, Flächen- und Umfangsberechnung.""" + self.assertTrue(issubclass(self.Rechteck, self.Form), "Rechteck muss von Form erben!") + + r = self.Rechteck(5.0, 3.0, farbe="rot") + self.assertIsInstance(r, self.Form) + self.assertEqual(r.farbe, "rot") + self.assertEqual(r.breite, 5.0) + self.assertEqual(r.hoehe, 3.0) + + self.assertAlmostEqual(r.flaeche(), 15.0) + self.assertAlmostEqual(r.umfang(), 16.0) + + info = r.info() + self.assertIn("Rechteck", info) + self.assertIn("rot", info) + self.assertIn("15.00", info) + self.assertIn("16.00", info) + + def test_03_kreis_berechnungen_und_vererbung(self): + """Prüft Kreis: Vererbung von Form, Flächen- und Umfangsberechnung mit math.pi.""" + self.assertTrue(issubclass(self.Kreis, self.Form), "Kreis muss von Form erben!") + + k = self.Kreis(2.0, farbe="blau") + self.assertIsInstance(k, self.Form) + self.assertEqual(k.farbe, "blau") + self.assertEqual(k.radius, 2.0) + + erwartete_flaeche = math.pi * (2.0 ** 2) + erwarteter_umfang = 2.0 * math.pi * 2.0 + self.assertAlmostEqual(k.flaeche(), erwartete_flaeche) + self.assertAlmostEqual(k.umfang(), erwarteter_umfang) + + info = k.info() + self.assertIn("Kreis", info) + self.assertIn("blau", info) + self.assertIn("r=2.0", info) + + def test_04_dreieck_berechnungen_und_pythagoras(self): + """Prüft rechtwinkliges Dreieck: Hypotenuse, Fläche und Umfang.""" + self.assertTrue(issubclass(self.Dreieck, self.Form), "Dreieck muss von Form erben!") + + d = self.Dreieck(3.0, 4.0, farbe="gruen") + self.assertIsInstance(d, self.Form) + self.assertEqual(d.farbe, "gruen") + self.assertEqual(d.seite_a, 3.0) + self.assertEqual(d.seite_b, 4.0) + + # Hypotenuse: sqrt(3^2 + 4^2) = sqrt(9 + 16) = 5.0 + self.assertAlmostEqual(d.hypotenuse(), 5.0) + # Fläche: 0.5 * 3 * 4 = 6.0 + self.assertAlmostEqual(d.flaeche(), 6.0) + # Umfang: 3 + 4 + 5 = 12.0 + self.assertAlmostEqual(d.umfang(), 12.0) + + info = d.info() + self.assertIn("Dreieck", info) + self.assertIn("gruen", info) + self.assertIn("c=5.0", info) + + def test_05_polymorphismus_in_schleife(self): + """Prüft, ob eine heterogene Liste von Formen einheitlich polymorph durchlaufen werden kann.""" + formen = [ + self.Rechteck(4.0, 2.0), + self.Kreis(1.0), + self.Dreieck(3.0, 4.0), + ] + + # Polymorpher Aufruf von flaeche() auf jedem Element + flaechen = [f.flaeche() for f in formen] + self.assertAlmostEqual(flaechen[0], 8.0) + self.assertAlmostEqual(flaechen[1], math.pi) + self.assertAlmostEqual(flaechen[2], 6.0) + + def test_06_zeichenflaeche_grundlagen(self): + """Prüft Zeichenflaeche Initialisierung und leeren Zustand.""" + zf = self.Zeichenflaeche("Mein Bild") + self.assertEqual(zf.name, "Mein Bild") + self.assertEqual(zf.anzahl_formen(), 0) + self.assertAlmostEqual(zf.gesamte_flaeche(), 0.0) + self.assertAlmostEqual(zf.gesamter_umfang(), 0.0) + self.assertIsNone(zf.groesste_form()) + self.assertEqual(zf.report(), []) + + def test_07_zeichenflaeche_summen_berechnung(self): + """Prüft polymorphe Summenberechnung von gesamte_flaeche() und gesamter_umfang().""" + zf = self.Zeichenflaeche() + r = self.Rechteck(4.0, 5.0) # Fläche: 20, Umfang: 18 + d = self.Dreieck(3.0, 4.0) # Fläche: 6, Umfang: 12 + k = self.Kreis(2.0) # Fläche: 4*pi, Umfang: 4*pi + + zf.hinzufuegen(r) + zf.hinzufuegen(d) + zf.hinzufuegen(k) + + self.assertEqual(zf.anzahl_formen(), 3) + self.assertAlmostEqual(zf.gesamte_flaeche(), 20.0 + 6.0 + (math.pi * 4.0)) + self.assertAlmostEqual(zf.gesamter_umfang(), 18.0 + 12.0 + (2.0 * math.pi * 2.0)) + + def test_08_zeichenflaeche_filtern_und_groesste(self): + """Prüft Filtern nach Farbe (case-insensitive) und Ermittlung der größten Form.""" + zf = self.Zeichenflaeche() + r1 = self.Rechteck(2.0, 2.0, farbe="Rot") # Fläche 4 + r2 = self.Rechteck(10.0, 5.0, farbe="rot") # Fläche 50 (Größte) + k = self.Kreis(2.0, farbe="Blau") # Fläche ~12.57 + d = self.Dreieck(3.0, 4.0, farbe="ROT") # Fläche 6 + + zf.hinzufuegen(r1) + zf.hinzufuegen(r2) + zf.hinzufuegen(k) + zf.hinzufuegen(d) + + # Farbfilterung (case-insensitive: Rot, rot, ROT) + rote = zf.formen_nach_farbe("rot") + self.assertEqual(len(rote), 3) + self.assertIn(r1, rote) + self.assertIn(r2, rote) + self.assertIn(d, rote) + + blaue = zf.formen_nach_farbe("BLAU") + self.assertEqual(len(blaue), 1) + self.assertIn(k, blaue) + + gelbe = zf.formen_nach_farbe("gelb") + self.assertEqual(len(gelbe), 0) + + # Größte Form finden + groesste = zf.groesste_form() + self.assertIs(groesste, r2) + + def test_09_zeichenflaeche_report(self): + """Prüft report() Ausgabe.""" + zf = self.Zeichenflaeche() + zf.hinzufuegen(self.Rechteck(2.0, 3.0, farbe="gelb")) + zf.hinzufuegen(self.Kreis(1.0, farbe="pink")) + + berichte = zf.report() + self.assertEqual(len(berichte), 2) + self.assertIn("Rechteck", berichte[0]) + self.assertIn("Kreis", berichte[1]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/12_exceptions_und_fehlerbehandlung/README.md b/12_exceptions_und_fehlerbehandlung/README.md new file mode 100644 index 0000000..4c8e08e --- /dev/null +++ b/12_exceptions_und_fehlerbehandlung/README.md @@ -0,0 +1,44 @@ +# Kapitel 12: Exceptions & Fehlerbehandlung 🛡️🛑 + +In diesem Kapitel lernst du, wie du deine Programme vor unkontrollierten Abstürzen schützt und saubere, selbsterklärende Fehlermeldungen mit **benutzerdefinierten Exceptions** wirfst. + +--- + +## 🎯 Was du lernst + +1. **Die Notbremse im Code (`raise`):** Wenn eine Funktion ungültige Daten erhält (z.B. negativer Betrag), wirft sie gezielt eine Exception aus. +2. **Sauberes Abfangen (`try / except`):** Wie du Fehler auffängst, ohne dass das gesamte Programm abstürzt. +3. **Vollständige Kontrollstruktur:** + - `try`: Code, der fehlschlagen könnte. + - `except ExceptionTyp as e`: Fehler fangen und behandeln. + - `else`: Wird nur ausgeführt, wenn **kein** Fehler aufgetreten ist. + - `finally`: Wird **immer** ausgeführt (z.B. Karte entnehmen, Dateien schließen). +4. **Eigene Exception-Klassen definieren:** + ```python + class BankFehler(Exception): + """Basis-Fehlerklasse für unsere Bank-App.""" + pass + + class KontoGesperrtError(BankFehler): + pass + ``` +5. **Praxisbeispiel:** Ein robuster **Bankkonto- und Geldautomat-Simulator** mit PIN-Sicherheit, Kontosperrung nach 3 Fehlversuchen und Deckungsprüfung. + +--- + +## 📁 Die Dateien in diesem Ordner + +- **`index.html`**: Die interaktive Lernseite mit visueller Notbremse-Analogie, Flowchart, Codebeispielen und Checkliste. +- **`aufgabe.py`**: Dein Arbeitsblatt zum Implementieren der Exception-Klassen, des `Bankkonto`s und der Geldautomat-Funktion. +- **`test_aufgabe.py`**: Automatische Unittest-Suite (`python3 test_aufgabe.py`). +- **`musterloesung.py`**: Vollständig ausprogrammierte Beispiellösung. + +--- + +## 🚀 Schnellstart + +```bash +# 1. Bearbeite aufgabe.py +# 2. Teste deine Lösung: +python3 test_aufgabe.py +``` diff --git a/12_exceptions_und_fehlerbehandlung/aufgabe.py b/12_exceptions_und_fehlerbehandlung/aufgabe.py new file mode 100644 index 0000000..777840f --- /dev/null +++ b/12_exceptions_und_fehlerbehandlung/aufgabe.py @@ -0,0 +1,191 @@ +""" +Kapitel 12: Exceptions & Fehlerbehandlung 🛡️🛑 +============================================= +Schulabgleich: 26.0 Exceptions & Fehlerklassen + +Aufgabe: +Erstelle ein sicheres Bankkonto- & Geldautomat-System mit benutzerdefinierten +Exceptions und sauberer Fehlerbehandlung über try-except-else-finally. +""" + +from typing import Optional + + +# ============================================================================== +# TODO 1: Eigene Exception-Klassen definieren +# ============================================================================== +# Erstelle eine Basis-Exception-Klasse 'BankFehler', die von 'Exception' erbt. +# Erstelle dann 4 abgeleitete Exception-Klassen, die alle von 'BankFehler' erben: +# 1. UngueltigePinError +# 2. KontoGesperrtError +# 3. NichtGenugGuthabenError +# 4. UngueltigerBetragError +# ============================================================================== + +class BankFehler(Exception): + pass + + +class UngueltigePinError(BankFehler): + pass + + +class KontoGesperrtError(BankFehler): + pass + + +class NichtGenugGuthabenError(BankFehler): + pass + + +class UngueltigerBetragError(BankFehler): + pass + + +# ============================================================================== +# TODO 2: Klasse Bankkonto +# ============================================================================== +class Bankkonto: + def __init__(self, inhaber: str, pin: str, kontostand: float = 0.0): + """ + Initialisiert ein Bankkonto. + Attribute: + - self.inhaber: str = inhaber + - self.pin: str = pin + - self.kontostand: float = float(kontostand) + - self.gesperrt: bool = False (Standardwert) + - self.fehlversuche: int = 0 (Zählt falsche PIN-Eingaben) + + WICHTIG: + - Falls kontostand < 0 ist, wirf UngueltigerBetragError! + """ + # TODO: Implementieren + pass + + def pin_pruefen(self, eingegebene_pin: str) -> bool: + """ + Prüft die eingegebene PIN. + + Ablauf: + 1. Wenn self.gesperrt True ist: + -> Wirf sofort KontoGesperrtError("Konto ist gesperrt!") + 2. Wenn eingegebene_pin == self.pin: + -> Setze self.fehlversuche = 0 zurück + -> Gib True zurück + 3. Wenn eingegebene_pin != self.pin: + -> Erhöhe self.fehlversuche um 1 + -> Wenn self.fehlversuche >= 3: + - Setze self.gesperrt = True + - Wirf KontoGesperrtError("PIN 3-mal falsch. Konto wurde gesperrt!") + -> Andernfalls: + - Wirf UngueltigePinError(f"Falsche PIN! Noch {3 - self.fehlversuche} Versuche.") + """ + # TODO: Implementieren + pass + + def einzahlen(self, betrag: float) -> float: + """ + Zahlt einen Betrag auf das Konto ein. + + Regeln: + 1. Wenn self.gesperrt: + -> Wirf KontoGesperrtError + 2. Wenn betrag <= 0: + -> Wirf UngueltigerBetragError + 3. Erhöhe self.kontostand um betrag und gib den neuen kontostand zurück. + """ + # TODO: Implementieren + pass + + def abheben(self, betrag: float, pin: str) -> float: + """ + Hebt Geld vom Konto ab. + + Regeln: + 1. Prüfe zuerst die PIN mit self.pin_pruefen(pin) + (wirft bei falscher PIN oder Sperre automatisch die passende Exception) + 2. Wenn betrag <= 0: + -> Wirf UngueltigerBetragError + 3. Wenn betrag > self.kontostand: + -> Wirf NichtGenugGuthabenError + 4. Ziehe betrag von self.kontostand ab und gib den neuen kontostand zurück. + """ + # TODO: Implementieren + pass + + def sperren(self) -> None: + """Sperrt das Konto manuell.""" + self.gesperrt = True + + def entsperren(self, admin_passwort: str) -> bool: + """ + Entsperrt das Konto, wenn admin_passwort == 'ADMIN123' ist. + Setzt self.gesperrt = False und self.fehlversuche = 0. + Falls das Passwort falsch ist, wirf UngueltigePinError. + """ + # TODO: Implementieren + pass + + def __str__(self) -> str: + """ + Gibt einen Status-String zurück: + z.B. "Bankkonto(Max, Stand: 150.00€, Status: AKTIV)" + bzw. "Bankkonto(Max, Stand: 150.00€, Status: GESPERRT)" + """ + # TODO: Implementieren + pass + + +# ============================================================================== +# TODO 3: Geldautomat-Auszahlung mit try - except - else - finally +# ============================================================================== +def geldautomat_abheben(konto: Bankkonto, betrag: float, pin: str) -> str: + """ + Führt eine Abhebung an einem Geldautomaten durch und fängt alle Fehler sauber ab. + + Verwende die vollständige try-except-else-finally-Struktur: + + Ablauf: + 1. Erstelle eine leere Liste 'meldungen = []' + 2. try: + konto.abheben(betrag, pin) + except UngueltigePinError as e: + meldungen.append(f"PIN-Fehler: {e}") + except KontoGesperrtError as e: + meldungen.append(f"Sicherheits-Fehler: {e}") + except NichtGenugGuthabenError as e: + meldungen.append(f"Deckungs-Fehler: {e}") + except UngueltigerBetragError as e: + meldungen.append(f"Betrags-Fehler: {e}") + except BankFehler as e: + meldungen.append(f"Bank-Fehler: {e}") + else: + meldungen.append(f"Auszahlung erfolgreich: {betrag:.2f}€ ausgezahlt. Neuer Stand: {konto.kontostand:.2f}€") + finally: + meldungen.append("Bitte Karte entnehmen.") + + 3. Gib die Meldungen mit ' | '.join(meldungen) als einzelnen String zurück. + """ + # TODO: Implementieren + pass + + +# ============================================================================== +# Kleiner Test zum Ausprobieren im Terminal: +# (python3 aufgabe.py) +# ============================================================================== +if __name__ == "__main__": + konto = Bankkonto("Anna", "1234", 250.0) + print("Konto erstellt:", konto) + + print("\n--- Test 1: Erfolgreiche Abhebung ---") + resultat = geldautomat_abheben(konto, 50.0, "1234") + print(resultat) + + print("\n--- Test 2: Falsche PIN ---") + resultat = geldautomat_abheben(konto, 50.0, "9999") + print(resultat) + + print("\n--- Test 3: Zu viel abheben ---") + resultat = geldautomat_abheben(konto, 500.0, "1234") + print(resultat) diff --git a/12_exceptions_und_fehlerbehandlung/index.html b/12_exceptions_und_fehlerbehandlung/index.html new file mode 100644 index 0000000..22d7df3 --- /dev/null +++ b/12_exceptions_und_fehlerbehandlung/index.html @@ -0,0 +1,259 @@ + + + + + + Kapitel 12: Exceptions & Fehlerbehandlung + + + + +
+
+ +
Kapitel 12 • Schulabgleich 26.0
+

Exceptions & Fehlerbehandlung 🛡️🛑

+

Lerne, wie du Programme vor unkontrollierten Abstürzen schützt, saubere Kontrollflüsse mit try-except-else-finally baust und eigene Exception-Klassen definierst.

+
+
+ +
+ + +
+

1. Was ist eine Exception? (Die Notbremse im Code)

+ +

+ In der realen Welt läuft nicht immer alles nach Plan: Eine Datei existiert nicht, der Benutzer tippt Buchstaben statt Zahlen ein, oder das Bankkonto hat nicht genug Guthaben. +

+ +
+
🚆 Die Notbremse-Analogie:
+ Stelle dir einen Zug vor. Normalerweise fährt der Zug geradeaus weiter. + Wenn plötzlich ein Baum auf den Schienen liegt, zieht der Lokführer die Notbremse (in Python: raise Exception()). + Statt dass der Zug ungebremst entgleist (Absturz mit rotem Traceback), fängt das Rettungsteam die Situation geordnet ab (in Python: try ... except). +
+ +

Typische Python-Standard-Exceptions im Überblick:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Exception-TypWann tritt sie auf?Beispiel
ValueErrorRichtiger Datentyp, aber unpassender Wertint("hallo")
TypeErrorFalscher Datentyp für eine Operation"Zahl: " + 5
IndexErrorZugriff auf einen nicht existierenden Listen-Indexliste[99]
KeyErrorSchlüssel in einem Dictionary nicht vorhandendict["unbekannt"]
ZeroDivisionErrorDivision durch die Zahl 010 / 0
+
+ + +
+

2. Die 4 Säulen der Fehlerbehandlung

+ +

+ Python bietet mit try, except, else und finally eine extrem saubere Struktur, um auf jede Eventualität vorbereitet zu sein: +

+ +
+
+ Struktur: try - except - else - finally +
+
try:
+    # 1. Versuche diesen Code auszuführen (kann fehlschlagen)
+    eingabe = int(input("Betrag eingeben: "))
+    ergebnis = 100 / eingabe
+
+except ValueError as e:
+    # 2a. Wird NUR ausgeführt, wenn ein ValueError auftrat
+    print(f"Ungültige Eingabe: Keine Zahl! ({e})")
+
+except ZeroDivisionError:
+    # 2b. Wird NUR ausgeführt, wenn durch 0 geteilt wurde
+    print("Division durch 0 ist nicht erlaubt!")
+
+else:
+    # 3. Wird NUR ausgeführt, wenn ALLES GLATT ging (kein Fehler)
+    print(f"Erfolg! Das Ergebnis ist: {ergebnis}")
+
+finally:
+    # 4. Wird IMMER ausgeführt (egal ob Fehler oder Erfolg!)
+    print("Aufräumarbeiten beendet. Vorgang abgeschlossen.")
+
+ +
+
💡 Warum finally?
+ finally ist perfekt für Aufräumarbeiten, die unter keinen Umständen vergessen werden dürfen – z.B. das Auswerfen der EC-Karte am Geldautomaten oder das Schließen einer offenen Datenbankverbindung! +
+
+ + +
+

3. Eigene Exception-Klassen definieren & werfen (raise)

+ +

+ In professionellen OOP-Anwendungen nutzt man nicht nur die Standard-Fehler von Python, sondern baut eigene Fehlerklassen. + Dadurch weiß jeder sofort, worum es in deiner Fachdomäne geht (z.B. KontoGesperrtError statt nur ungenauem Exception). +

+ +
+
+ Eigene Exception-Hierarchie +
+
# 1. Basisklasse für alle Fehler in der Bank-Anwendung
+class BankFehler(Exception):
+    """Basis-Exception für alle Bank-bezogenen Probleme."""
+    pass
+
+# 2. Spezifische Fehler erben von unserer Basisklasse
+class KontoGesperrtError(BankFehler):
+    pass
+
+class NichtGenugGuthabenError(BankFehler):
+    pass
+
+class UngueltigePinError(BankFehler):
+    pass
+
+ +

Einen Fehler mit raise auslösen:

+ +
+
+ Fehler gezielt werfen +
+
def abheben(self, betrag: float) -> float:
+    if betrag > self.kontostand:
+        raise NichtGenugGuthabenError(f"Nur noch {self.kontostand}€ verfügbar!")
+    
+    self.kontostand -= betrag
+    return self.kontostand
+
+
+ + +
+

4. 🎯 Deine Aufgaben in aufgabe.py

+ +

Öffne die Datei 12_exceptions_und_fehlerbehandlung/aufgabe.py und baue den sicheren Geldautomaten-Simulator:

+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ +

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zur PIN-Prüfung & Fehlversuchen +
+
+
if self.gesperrt:
+    raise KontoGesperrtError("Konto ist gesperrt!")
+
+if eingegebene_pin == self.pin:
+    self.fehlversuche = 0
+    return True
+else:
+    self.fehlversuche += 1
+    if self.fehlversuche >= 3:
+        self.gesperrt = True
+        raise KontoGesperrtError("PIN 3-mal falsch. Konto wurde gesperrt!")
+    raise UngueltigePinError(f"Falsche PIN! Noch {3 - self.fehlversuche} Versuche.")
+
+
+
+ +
+ 🔍 Tipp zu geldautomat_abheben (try-except-else-finally) +
+

+ Im try-Block rufst du einfach konto.abheben(betrag, pin) auf. Fange die verschiedenen Exception-Typen in separaten except-Blöcken ab und füge die Meldung zu deiner Liste hinzu. + Im else-Block meldest du den Erfolg und im finally-Block "Bitte Karte entnehmen.". +

+
+
+
+ + +
+

5. 🧪 Lösung überprüfen

+

Führe im Terminal im Ordner 12_exceptions_und_fehlerbehandlung folgenden Befehl aus:

+
+
+ Terminal +
+
python3 test_aufgabe.py
+
+ + +
+ +
+ + + diff --git a/12_exceptions_und_fehlerbehandlung/musterloesung.py b/12_exceptions_und_fehlerbehandlung/musterloesung.py new file mode 100644 index 0000000..7a0f84e --- /dev/null +++ b/12_exceptions_und_fehlerbehandlung/musterloesung.py @@ -0,0 +1,141 @@ +""" +Kapitel 12: Exceptions & Fehlerbehandlung 🛡️🛑 +============================================= +Musterlösung für die Bankkonto- und Geldautomat-Simulation. +""" + +from typing import Optional + + +# ============================================================================== +# 1. Eigene Exception-Hierarchie +# ============================================================================== +class BankFehler(Exception): + """Basisklasse für alle Fehler im Bankkonto-System.""" + pass + + +class UngueltigePinError(BankFehler): + """Wird geworfen, wenn die eingegebene PIN falsch ist.""" + pass + + +class KontoGesperrtError(BankFehler): + """Wird geworfen, wenn das Konto gesperrt ist (oder nach 3 Fehlversuchen gesperrt wird).""" + pass + + +class NichtGenugGuthabenError(BankFehler): + """Wird geworfen, wenn das Guthaben für eine Abhebung nicht ausreicht.""" + pass + + +class UngueltigerBetragError(BankFehler): + """Wird geworfen, wenn ein ungültiger (z.B. <= 0) Betrag übergeben wird.""" + pass + + +# ============================================================================== +# 2. Klasse Bankkonto +# ============================================================================== +class Bankkonto: + def __init__(self, inhaber: str, pin: str, kontostand: float = 0.0): + """Initialisiert ein Bankkonto mit Inhaber, PIN und Startguthaben.""" + if kontostand < 0: + raise UngueltigerBetragError("Der Anfangskontostand darf nicht negativ sein!") + self.inhaber: str = inhaber + self.pin: str = pin + self.kontostand: float = float(kontostand) + self.gesperrt: bool = False + self.fehlversuche: int = 0 + + def pin_pruefen(self, eingegebene_pin: str) -> bool: + """Prüft die PIN, zählt Fehlversuche und sperrt ggf. das Konto.""" + if self.gesperrt: + raise KontoGesperrtError(f"Das Konto von {self.inhaber} ist gesperrt!") + + if eingegebene_pin == self.pin: + self.fehlversuche = 0 + return True + else: + self.fehlversuche += 1 + if self.fehlversuche >= 3: + self.gesperrt = True + raise KontoGesperrtError("PIN 3-mal falsch eingegeben! Das Konto wurde aus Sicherheitsgründen gesperrt.") + raise UngueltigePinError(f"Falsche PIN! Verbleibende Versuche: {3 - self.fehlversuche}") + + def einzahlen(self, betrag: float) -> float: + """Zahlt Geld auf das Konto ein.""" + if self.gesperrt: + raise KontoGesperrtError(f"Das Konto von {self.inhaber} ist gesperrt!") + if betrag <= 0: + raise UngueltigerBetragError("Einzahlungsbetrag muss größer als 0 sein!") + self.kontostand += betrag + return self.kontostand + + def abheben(self, betrag: float, pin: str) -> float: + """Hebt Geld nach erfolgreicher PIN- und Guthabenprüfung ab.""" + # 1. PIN & Sperre prüfen (wirft automatisch passende Fehler) + self.pin_pruefen(pin) + + # 2. Betrag prüfen + if betrag <= 0: + raise UngueltigerBetragError("Abhebebetrag muss größer als 0 sein!") + + # 3. Deckung prüfen + if betrag > self.kontostand: + raise NichtGenugGuthabenError(f"Nicht genug Guthaben! Verfügbar: {self.kontostand:.2f}€, Angefordert: {betrag:.2f}€") + + self.kontostand -= betrag + return self.kontostand + + def sperren(self) -> None: + """Sperrt das Konto manuell.""" + self.gesperrt = True + + def entsperren(self, admin_passwort: str) -> bool: + """Entsperrt das Konto bei korrektem Admin-Passwort.""" + if admin_passwort == "ADMIN123": + self.gesperrt = False + self.fehlversuche = 0 + return True + raise UngueltigePinError("Falsches Admin-Passwort zum Entsperren!") + + def __str__(self) -> str: + """String-Repräsentation des Kontos.""" + status = "GESPERRT" if self.gesperrt else "AKTIV" + return f"Bankkonto({self.inhaber}, Stand: {self.kontostand:.2f}€, Status: {status})" + + +# ============================================================================== +# 3. Geldautomat mit vollständiger try-except-else-finally-Struktur +# ============================================================================== +def geldautomat_abheben(konto: Bankkonto, betrag: float, pin: str) -> str: + """Führt eine sichere Abhebung mit try-except-else-finally durch.""" + meldungen = [] + try: + konto.abheben(betrag, pin) + except UngueltigePinError as e: + meldungen.append(f"PIN-Fehler: {e}") + except KontoGesperrtError as e: + meldungen.append(f"Sicherheits-Fehler: {e}") + except NichtGenugGuthabenError as e: + meldungen.append(f"Deckungs-Fehler: {e}") + except UngueltigerBetragError as e: + meldungen.append(f"Betrags-Fehler: {e}") + except BankFehler as e: + meldungen.append(f"Bank-Fehler: {e}") + else: + meldungen.append(f"Auszahlung erfolgreich: {betrag:.2f}€ ausgezahlt. Neuer Stand: {konto.kontostand:.2f}€") + finally: + meldungen.append("Bitte Karte entnehmen.") + + return " | ".join(meldungen) + + +if __name__ == "__main__": + konto = Bankkonto("Anna", "1234", 250.0) + print(konto) + print(geldautomat_abheben(konto, 50.0, "1234")) + print(geldautomat_abheben(konto, 50.0, "9999")) + print(geldautomat_abheben(konto, 500.0, "1234")) diff --git a/12_exceptions_und_fehlerbehandlung/test_aufgabe.py b/12_exceptions_und_fehlerbehandlung/test_aufgabe.py new file mode 100644 index 0000000..9757690 --- /dev/null +++ b/12_exceptions_und_fehlerbehandlung/test_aufgabe.py @@ -0,0 +1,166 @@ +import unittest +import sys +from pathlib import Path + +# Sicherstellen, dass das aktuelle Verzeichnis im Python-Pfad liegt +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import aufgabe +except ImportError as e: + raise ImportError(f"Konnte 'aufgabe.py' nicht importieren: {e}") + + +class TestKapitel12(unittest.TestCase): + + def setUp(self): + # Klassen und Funktionen dynamisch aus aufgabe laden + self.BankFehler = getattr(aufgabe, "BankFehler", None) + self.UngueltigePinError = getattr(aufgabe, "UngueltigePinError", None) + self.KontoGesperrtError = getattr(aufgabe, "KontoGesperrtError", None) + self.NichtGenugGuthabenError = getattr(aufgabe, "NichtGenugGuthabenError", None) + self.UngueltigerBetragError = getattr(aufgabe, "UngueltigerBetragError", None) + self.Bankkonto = getattr(aufgabe, "Bankkonto", None) + self.geldautomat_abheben = getattr(aufgabe, "geldautomat_abheben", None) + + self.assertIsNotNone(self.BankFehler, "Klasse 'BankFehler' nicht gefunden!") + self.assertIsNotNone(self.UngueltigePinError, "Klasse 'UngueltigePinError' nicht gefunden!") + self.assertIsNotNone(self.KontoGesperrtError, "Klasse 'KontoGesperrtError' nicht gefunden!") + self.assertIsNotNone(self.NichtGenugGuthabenError, "Klasse 'NichtGenugGuthabenError' nicht gefunden!") + self.assertIsNotNone(self.UngueltigerBetragError, "Klasse 'UngueltigerBetragError' nicht gefunden!") + self.assertIsNotNone(self.Bankkonto, "Klasse 'Bankkonto' nicht gefunden!") + + def test_01_exception_hierarchie(self): + """Prüft, ob die Exception-Klassen korrekt voneinander und von Exception erben.""" + self.assertTrue(issubclass(self.BankFehler, Exception), "BankFehler muss von Exception erben!") + self.assertTrue(issubclass(self.UngueltigePinError, self.BankFehler), "UngueltigePinError muss von BankFehler erben!") + self.assertTrue(issubclass(self.KontoGesperrtError, self.BankFehler), "KontoGesperrtError muss von BankFehler erben!") + self.assertTrue(issubclass(self.NichtGenugGuthabenError, self.BankFehler), "NichtGenugGuthabenError muss von BankFehler erben!") + self.assertTrue(issubclass(self.UngueltigerBetragError, self.BankFehler), "UngueltigerBetragError muss von BankFehler erben!") + + def test_02_bankkonto_init(self): + """Prüft Initialisierung des Bankkontos und Validierung des Startguthabens.""" + k = self.Bankkonto("Max Mustermann", "4321", 100.0) + self.assertEqual(k.inhaber, "Max Mustermann") + self.assertEqual(k.pin, "4321") + self.assertEqual(k.kontostand, 100.0) + self.assertFalse(k.gesperrt) + self.assertEqual(k.fehlversuche, 0) + + # Negatives Startguthaben muss UngueltigerBetragError werfen + with self.assertRaises(self.UngueltigerBetragError): + self.Bankkonto("Schuldner", "1111", -50.0) + + def test_03_pin_pruefen_und_sperre(self): + """Prüft PIN-Validierung, Fehlversuch-Zähler und automatische Kontosperre nach 3 Versuchen.""" + k = self.Bankkonto("Lisa", "1234", 200.0) + + # Richtige PIN + self.assertTrue(k.pin_pruefen("1234")) + self.assertEqual(k.fehlversuche, 0) + + # 1. Fehlversuch + with self.assertRaises(self.UngueltigePinError): + k.pin_pruefen("0000") + self.assertEqual(k.fehlversuche, 1) + self.assertFalse(k.gesperrt) + + # 2. Fehlversuch + with self.assertRaises(self.UngueltigePinError): + k.pin_pruefen("1111") + self.assertEqual(k.fehlversuche, 2) + self.assertFalse(k.gesperrt) + + # 3. Fehlversuch -> muss KontoGesperrtError werfen und Konto sperren + with self.assertRaises(self.KontoGesperrtError): + k.pin_pruefen("2222") + self.assertEqual(k.fehlversuche, 3) + self.assertTrue(k.gesperrt) + + # Weiterer Versuch auf gesperrtem Konto wirft sofort KontoGesperrtError (selbst bei richtiger PIN) + with self.assertRaises(self.KontoGesperrtError): + k.pin_pruefen("1234") + + def test_04_einzahlen(self): + """Prüft Einzahlungen und Validierung von Beträgen.""" + k = self.Bankkonto("Tom", "1234", 50.0) + neuer_stand = k.einzahlen(30.0) + self.assertEqual(neuer_stand, 80.0) + self.assertEqual(k.kontostand, 80.0) + + # Ungültige Beträge + with self.assertRaises(self.UngueltigerBetragError): + k.einzahlen(0.0) + with self.assertRaises(self.UngueltigerBetragError): + k.einzahlen(-20.0) + + # Einzahlen auf gesperrtem Konto + k.sperren() + with self.assertRaises(self.KontoGesperrtError): + k.einzahlen(50.0) + + def test_05_abheben(self): + """Prüft Abhebungen, PIN-Prüfung, Guthaben-Checks und Betragsprüfung.""" + k = self.Bankkonto("Sarah", "9876", 150.0) + + # Erfolgreiche Abhebung + neuer_stand = k.abheben(50.0, "9876") + self.assertEqual(neuer_stand, 100.0) + self.assertEqual(k.kontostand, 100.0) + + # Falsche PIN beim Abheben + with self.assertRaises(self.UngueltigePinError): + k.abheben(20.0, "0000") + + # Zu viel Geld abheben (Nicht genug Guthaben) + with self.assertRaises(self.NichtGenugGuthabenError): + k.abheben(500.0, "9876") + + # Ungültiger Abhebebetrag + with self.assertRaises(self.UngueltigerBetragError): + k.abheben(-10.0, "9876") + with self.assertRaises(self.UngueltigerBetragError): + k.abheben(0.0, "9876") + + def test_06_sperren_und_entsperren(self): + """Prüft manuelles Sperren und Entsperren mit Admin-Code.""" + k = self.Bankkonto("Felix", "1234", 100.0) + k.sperren() + self.assertTrue(k.gesperrt) + + # Entsperren mit falschem Passwort + with self.assertRaises(self.UngueltigePinError): + k.entsperren("FALSCH") + self.assertTrue(k.gesperrt) + + # Entsperren mit korrektem Passwort + erfolg = k.entsperren("ADMIN123") + self.assertTrue(erfolg) + self.assertFalse(k.gesperrt) + self.assertEqual(k.fehlversuche, 0) + + def test_07_geldautomat_abheben_funktion(self): + """Prüft die Geldautomat-Funktion mit try-except-else-finally.""" + self.assertIsNotNone(self.geldautomat_abheben, "Funktion 'geldautomat_abheben' fehlt!") + + k = self.Bankkonto("Elena", "5555", 200.0) + + # 1. Erfolgreicher Vorgang (else + finally aktiv) + msg_erfolg = self.geldautomat_abheben(k, 50.0, "5555") + self.assertIn("Auszahlung erfolgreich", msg_erfolg) + self.assertIn("Bitte Karte entnehmen", msg_erfolg) + self.assertEqual(k.kontostand, 150.0) + + # 2. Falsche PIN (except UngueltigePinError + finally aktiv) + msg_pin_err = self.geldautomat_abheben(k, 50.0, "1111") + self.assertIn("PIN-Fehler", msg_pin_err) + self.assertIn("Bitte Karte entnehmen", msg_pin_err) + + # 3. Nicht genug Guthaben (except NichtGenugGuthabenError + finally aktiv) + msg_deck_err = self.geldautomat_abheben(k, 1000.0, "5555") + self.assertIn("Deckungs-Fehler", msg_deck_err) + self.assertIn("Bitte Karte entnehmen", msg_deck_err) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/13_persistenz_json_und_csv/README.md b/13_persistenz_json_und_csv/README.md new file mode 100644 index 0000000..5a3619a --- /dev/null +++ b/13_persistenz_json_und_csv/README.md @@ -0,0 +1,44 @@ +# Kapitel 13: Datei-Persistenz (JSON & CSV) 💾📁 + +In diesem Kapitel lernst du, wie du deine Python-Objekte dauerhaft auf der Festplatte speicherst, sodass deine Daten auch nach dem Beenden des Skripts oder Ausschalten des Computers erhalten bleiben. + +--- + +## 🎯 Was du lernst + +1. **Flüchtiger RAM vs. Dauerhafte Festplatte:** Warum Daten im Arbeitsspeicher beim Programmende verschwinden und wie Dateien für dauerhafte Speicherung (Persistenz) sorgen. +2. **Sauberer Dateizugriff mit `with open(...)`:** + ```python + with open("savegame.json", "w", encoding="utf-8") as f: + # Datei wird automatisch sicher geschlossen, selbst bei Fehlern! + ``` +3. **JSON (JavaScript Object Notation):** + - Das Standard-Format für verschachtelte Daten und Konfigurationen. + - `json.dump(daten, f, indent=4, ensure_ascii=False)` zum Schreiben. + - `daten = json.load(f)` zum Einlesen. +4. **Objekt-Serialisierung in OOP:** + - `to_dict(self)`: Wandelt ein Objekt in ein serialisierbares Dictionary um. + - `from_dict(cls, data)`: Erstellt aus einem Dictionary wieder eine echte Klasseninstanz. +5. **CSV (Comma-Separated Values):** + - Ideal für flache Tabellendaten (z.B. für Excel, Google Sheets oder Numbers). + - `csv.DictWriter` und `csv.DictReader`. +6. **Praxisbeispiel:** Ein **Savegame- und Highscore-Manager** für ein RPG / Spiel. + +--- + +## 📁 Die Dateien in diesem Ordner + +- **`index.html`**: Die interaktive Lernseite mit Tagebuch-Analogie, JSON- vs. CSV-Vergleichstabelle, Codebeispielen und Checkliste. +- **`aufgabe.py`**: Dein Arbeitsblatt zum Implementieren der Klassen `Spieler` und `Spielstand`. +- **`test_aufgabe.py`**: Automatische Unittest-Suite (`python3 test_aufgabe.py`). +- **`musterloesung.py`**: Vollständig ausprogrammierte Beispiellösung. + +--- + +## 🚀 Schnellstart + +```bash +# 1. Bearbeite aufgabe.py +# 2. Teste deine Lösung: +python3 test_aufgabe.py +``` diff --git a/13_persistenz_json_und_csv/aufgabe.py b/13_persistenz_json_und_csv/aufgabe.py new file mode 100644 index 0000000..d3c3739 --- /dev/null +++ b/13_persistenz_json_und_csv/aufgabe.py @@ -0,0 +1,194 @@ +""" +Kapitel 13: Datei-Persistenz (JSON & CSV) 💾📁 +============================================== +Schulabgleich: 14.0 CSV & Dateien / Savegame-Persistenz + +Aufgabe: +Implementiere ein modulares Savegame- und Highscore-System: +1. Klasse 'Spieler': mit Serialisierung (to_dict, from_dict). +2. Klasse 'Spielstand': mit JSON-Speichern/Laden und CSV-Tabellen-Export. +""" + +import json +import csv +from pathlib import Path +from typing import List, Optional, Dict, Any + + +# ============================================================================== +# TODO 1: Klasse Spieler mit Serialisierung +# ============================================================================== +class Spieler: + def __init__(self, name: str, level: int, punkte: int, inventar: Optional[List[str]] = None): + """ + Initialisiert einen Spieler. + Attribute: + - self.name: str = name + - self.level: int = int(level) + - self.punkte: int = int(punkte) + - self.inventar: list[str] = inventar (oder [] falls None) + """ + # TODO: Implementieren + pass + + def to_dict(self) -> Dict[str, Any]: + """ + Wandelt das Spieler-Objekt in ein Dictionary um, damit es als JSON + gespeichert werden kann. + + Rückgabe-Format: + { + "name": self.name, + "level": self.level, + "punkte": self.punkte, + "inventar": self.inventar + } + """ + # TODO: Implementieren + pass + + @classmethod + def from_dict(cls, daten: Dict[str, Any]) -> "Spieler": + """ + Erstellt ein neues Spieler-Objekt aus einem Dictionary. + Beispiel: Spieler.from_dict({"name": "Alex", "level": 5, "punkte": 1200, "inventar": ["Schwert"]}) + """ + # TODO: Implementieren + pass + + def __repr__(self) -> str: + return f"Spieler(name='{self.name}', level={self.level}, punkte={self.punkte}, inventar={self.inventar})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Spieler): + return False + return ( + self.name == other.name + and self.level == other.level + and self.punkte == other.punkte + and self.inventar == other.inventar + ) + + +# ============================================================================== +# TODO 2: Klasse Spielstand mit JSON- und CSV-Persistenz +# ============================================================================== +class Spielstand: + def __init__(self, titel: str = "Neuer Spielstand"): + """ + Initialisiert einen Spielstand. + Attribute: + - self.titel: str = titel + - self.spieler_liste: list[Spieler] = [] + """ + # TODO: Implementieren + pass + + def spieler_hinzufuegen(self, spieler: Spieler) -> None: + """Fügt ein Spieler-Objekt zur internen Spieler-Liste hinzu.""" + # TODO: Implementieren + pass + + def spieler_suchen(self, name: str) -> Optional[Spieler]: + """ + Sucht einen Spieler nach Name. + Gibt das gefundene Spieler-Objekt zurück oder None, wenn nicht vorhanden. + """ + # TODO: Implementieren + pass + + def bester_spieler(self) -> Optional[Spieler]: + """ + Gibt den Spieler mit den meisten Punkten zurück. + Gibt None zurück, wenn die Liste leer ist. + """ + # TODO: Implementieren + pass + + def speichern_als_json(self, dateipfad: str | Path) -> None: + """ + Speichert den gesamten Spielstand als formatierte JSON-Datei ab. + + Struktur der JSON-Datei: + { + "titel": self.titel, + "spieler": [ + { "name": ..., "level": ..., "punkte": ..., "inventar": [...] }, + ... + ] + } + + WICHTIG: + - Nutze 'with open(dateipfad, "w", encoding="utf-8") as f:' + - Nutze 'json.dump(daten, f, indent=4, ensure_ascii=False)' + """ + # TODO: Implementieren + pass + + def laden_aus_json(self, dateipfad: str | Path) -> None: + """ + Lädt den Spielstand aus einer JSON-Datei und stellt alle Spieler wieder her. + + Ablauf: + 1. Öffne die Datei mit 'with open(dateipfad, "r", encoding="utf-8") as f:' + 2. Lade die Daten mit 'json.load(f)' + 3. Setze self.titel auf den geladenen Titel + 4. Erstelle für jeden Eintrag in der Spieler-Liste ein neues Spieler-Objekt + mittels 'Spieler.from_dict(d)' und weise die neue Liste 'self.spieler_liste' zu. + """ + # TODO: Implementieren + pass + + def exportiere_als_csv(self, dateipfad: str | Path) -> None: + """ + Exportiert eine Tabellen-Übersicht aller Spieler als CSV-Datei. + + Spalten (Header): + ["name", "level", "punkte", "inventar_anzahl"] + + WICHTIG: + - Nutze 'with open(dateipfad, "w", newline="", encoding="utf-8") as f:' + - Nutze 'csv.DictWriter(f, fieldnames=...)' + - Schreibe zuerst den Header mit 'writer.writeheader()' + - Schreibe dann für jeden Spieler eine Zeile (inventar_anzahl = len(s.inventar)) + """ + # TODO: Implementieren + pass + + def importiere_aus_csv(self, dateipfad: str | Path) -> int: + """ + Liest Spieler aus einer CSV-Datei ein und fügt sie zur spieler_liste hinzu. + (Hinweis: Das Inventar wird beim einfachen CSV-Import als leere Liste [] initialisiert). + + Gibt die Anzahl der neu importierten Spieler zurück. + """ + # TODO: Implementieren + pass + + +# ============================================================================== +# Kleiner Test zum Ausprobieren im Terminal: +# (python3 aufgabe.py) +# ============================================================================== +if __name__ == "__main__": + spielstand = Spielstand("Highscore-Tabelle RPG") + + p1 = Spieler("Arthur", 12, 4500, ["Excalibur", "Heiltrank"]) + p2 = Spieler("Morgana", 15, 6200, ["Zauberstab", "Manatrank", "Schriftrolle"]) + p3 = Spieler("Lancelot", 10, 3800, ["Schild"]) + + spielstand.spieler_hinzufuegen(p1) + spielstand.spieler_hinzufuegen(p2) + spielstand.spieler_hinzufuegen(p3) + + print("Bester Spieler:", spielstand.bester_spieler()) + + # JSON Test + json_pfad = Path("test_savegame.json") + spielstand.speichern_als_json(json_pfad) + print(f"Gespeichert in {json_pfad}!") + + # CSV Test + csv_pfad = Path("test_export.csv") + spielstand.exportiere_als_csv(csv_pfad) + print(f"Exportiert nach {csv_pfad}!") diff --git a/13_persistenz_json_und_csv/index.html b/13_persistenz_json_und_csv/index.html new file mode 100644 index 0000000..901379d --- /dev/null +++ b/13_persistenz_json_und_csv/index.html @@ -0,0 +1,247 @@ + + + + + + Kapitel 13: Datei-Persistenz (JSON & CSV) + + + + +
+
+ +
Kapitel 13 • Schulabgleich 14.0
+

Datei-Persistenz: JSON & CSV 💾📁

+

Lerne, wie du Daten dauerhaft auf der Festplatte speicherst, Objekte mit to_dict() und from_dict() serialisierst und Tabellen als CSV exportierst.

+
+
+ +
+ + +
+

1. Warum brauchen wir Persistenz? (Die Tagebuch-Analogie)

+ +

+ Bislang haben alle unsere Programme ihre Daten nur im Arbeitsspeicher (RAM) gehalten. + Das Problem: Sobald das Python-Programm beendet wird oder der Strom ausfällt, ist der gesamte RAM blitzschnell gelöscht! +

+ +
+
📓 Die Tagebuch-Analogie:
+ Der Arbeitsspeicher (RAM) ist wie dein Kurzzeitgedächtnis: blitzschnell, aber nach dem Schlafen (Programmende) ist alles weg. + Die Festplatte (SSD) ist dein Tagebuch / Notizbuch: Was du einmal mit Tinte hineingeschrieben hast, bleibt auch Jahre später noch genau so stehen! +
+ +

Der goldene Standard: Dateien sicher öffnen mit with open()

+ +
+
+ Kontext-Manager mit open() +
+
# 'w' = Write (Schreiben / Überschreiben)
+# 'r' = Read (Lesen)
+# 'a' = Append (Am Ende anhängen)
+
+with open("notizen.txt", "w", encoding="utf-8") as datei:
+    datei.write("Hallo Welt mit Umlauten: ä, ö, ü!\n")
+# Hier wird die Datei automatisch und sicher geschlossen – selbst wenn ein Fehler auftritt!
+
+
+ + +
+

2. JSON vs. CSV: Welches Format für welchen Zweck?

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KriteriumJSON (JavaScript Object Notation)CSV (Comma-Separated Values)
Ideal für...Komplexe, verschachtelte Objekte & SavegamesFlache Tabellen (Excel, Google Sheets)
VerschachtelungJa (Listen von Objekten mit Unter-Listen)Nein (nur Zeilen und Spalten)
Python-Modulimport jsonimport csv
Hauptbefehlejson.dump() / json.load()csv.DictWriter / csv.DictReader
+
+ + +
+

3. Objekt-Serialisierung: to_dict() und from_dict()

+ +

+ Ein Python-Klassenobjekt (z.B. Spieler) kann nicht direkt als reiner Text gespeichert werden. + Daher übersetzen wir das Objekt in ein einfaches Dictionary (Serialisierung) und bauen es beim Laden wieder als echtes Objekt zusammen (Deserialisierung). +

+ +
+
+ Serialisierungsmuster in Klassen +
+
class Spieler:
+    def __init__(self, name: str, level: int, punkte: int):
+        self.name = name
+        self.level = level
+        self.punkte = punkte
+
+    # 1. In Dictionary umwandeln (zum Speichern)
+    def to_dict(self) -> dict:
+        return {
+            "name": self.name,
+            "level": self.level,
+            "punkte": self.punkte
+        }
+
+    # 2. Aus Dictionary neues Objekt instanziieren (beim Laden)
+    @classmethod
+    def from_dict(cls, daten: dict) -> 'Spieler':
+        return cls(
+            name=daten["name"],
+            level=daten["level"],
+            punkte=daten["punkte"]
+        )
+
+ +

Speichern und Laden mit JSON

+ +
+
+ JSON Speichern und Laden +
+
import json
+
+# Speichern:
+with open("savegame.json", "w", encoding="utf-8") as f:
+    json.dump(spieler.to_dict(), f, indent=4, ensure_ascii=False)
+
+# Laden:
+with open("savegame.json", "r", encoding="utf-8") as f:
+    daten = json.load(f)
+    geladener_spieler = Spieler.from_dict(daten)
+
+
+ + +
+

4. 🎯 Deine Aufgaben in aufgabe.py

+ +

Öffne die Datei 13_persistenz_json_und_csv/aufgabe.py und vervollständige das Savegame-System:

+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ +

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zum JSON-Speichern +
+
+
daten = {
+    "titel": self.titel,
+    "spieler": [s.to_dict() for s in self.spieler_liste]
+}
+with open(dateipfad, "w", encoding="utf-8") as f:
+    json.dump(daten, f, indent=4, ensure_ascii=False)
+
+
+
+ +
+ 🔍 Tipp zum CSV-Export +
+
+
feldnamen = ["name", "level", "punkte", "inventar_anzahl"]
+with open(dateipfad, "w", newline="", encoding="utf-8") as f:
+    writer = csv.DictWriter(f, fieldnames=feldnamen)
+    writer.writeheader()
+    for s in self.spieler_liste:
+        writer.writerow({
+            "name": s.name,
+            "level": s.level,
+            "punkte": s.punkte,
+            "inventar_anzahl": len(s.inventar)
+        })
+
+
+
+
+ + +
+

5. 🧪 Lösung überprüfen

+

Führe im Terminal im Ordner 13_persistenz_json_und_csv folgenden Befehl aus:

+
+
+ Terminal +
+
python3 test_aufgabe.py
+
+ + +
+ +
+ + + diff --git a/13_persistenz_json_und_csv/musterloesung.py b/13_persistenz_json_und_csv/musterloesung.py new file mode 100644 index 0000000..6e94166 --- /dev/null +++ b/13_persistenz_json_und_csv/musterloesung.py @@ -0,0 +1,143 @@ +""" +Kapitel 13: Datei-Persistenz (JSON & CSV) 💾📁 +============================================== +Musterlösung für JSON- und CSV-Persistenz in Python OOP. +""" + +import json +import csv +from pathlib import Path +from typing import List, Optional, Dict, Any + + +# ============================================================================== +# 1. Klasse Spieler mit to_dict / from_dict +# ============================================================================== +class Spieler: + def __init__(self, name: str, level: int, punkte: int, inventar: Optional[List[str]] = None): + """Initialisiert ein Spieler-Objekt.""" + self.name: str = name + self.level: int = int(level) + self.punkte: int = int(punkte) + self.inventar: List[str] = list(inventar) if inventar is not None else [] + + def to_dict(self) -> Dict[str, Any]: + """Konvertiert das Objekt in ein speicherbares Dictionary.""" + return { + "name": self.name, + "level": self.level, + "punkte": self.punkte, + "inventar": self.inventar, + } + + @classmethod + def from_dict(cls, daten: Dict[str, Any]) -> "Spieler": + """Erstellt ein neues Spieler-Objekt aus einem Dictionary.""" + return cls( + name=daten["name"], + level=int(daten["level"]), + punkte=int(daten["punkte"]), + inventar=list(daten.get("inventar", [])), + ) + + def __repr__(self) -> str: + return f"Spieler(name='{self.name}', level={self.level}, punkte={self.punkte}, inventar={self.inventar})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Spieler): + return False + return ( + self.name == other.name + and self.level == other.level + and self.punkte == other.punkte + and self.inventar == other.inventar + ) + + +# ============================================================================== +# 2. Klasse Spielstand mit JSON & CSV Persistenz +# ============================================================================== +class Spielstand: + def __init__(self, titel: str = "Neuer Spielstand"): + """Initialisiert einen Spielstand mit Titel und leerer Spielerliste.""" + self.titel: str = titel + self.spieler_liste: List[Spieler] = [] + + def spieler_hinzufuegen(self, spieler: Spieler) -> None: + """Fügt einen Spieler hinzu.""" + self.spieler_liste.append(spieler) + + def spieler_suchen(self, name: str) -> Optional[Spieler]: + """Findet einen Spieler anhand seines Namens.""" + for s in self.spieler_liste: + if s.name == name: + return s + return None + + def bester_spieler(self) -> Optional[Spieler]: + """Gibt den Spieler mit der höchsten Punktzahl zurück.""" + if not self.spieler_liste: + return None + return max(self.spieler_liste, key=lambda s: s.punkte) + + def speichern_als_json(self, dateipfad: str | Path) -> None: + """Speichert den gesamten Zustand als JSON auf der Festplatte.""" + pfad = Path(dateipfad) + daten = { + "titel": self.titel, + "spieler": [s.to_dict() for s in self.spieler_liste], + } + with open(pfad, "w", encoding="utf-8") as f: + json.dump(daten, f, indent=4, ensure_ascii=False) + + def laden_aus_json(self, dateipfad: str | Path) -> None: + """Lädt den Spielstand aus einer JSON-Datei.""" + pfad = Path(dateipfad) + with open(pfad, "r", encoding="utf-8") as f: + daten = json.load(f) + + self.titel = daten.get("titel", self.titel) + spieler_rohdaten = daten.get("spieler", []) + self.spieler_liste = [Spieler.from_dict(d) for d in spieler_rohdaten] + + def exportiere_als_csv(self, dateipfad: str | Path) -> None: + """Exportiert eine tabellarische Zusammenfassung als CSV-Datei.""" + pfad = Path(dateipfad) + feldnamen = ["name", "level", "punkte", "inventar_anzahl"] + + with open(pfad, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=feldnamen) + writer.writeheader() + for s in self.spieler_liste: + writer.writerow({ + "name": s.name, + "level": s.level, + "punkte": s.punkte, + "inventar_anzahl": len(s.inventar), + }) + + def importiere_aus_csv(self, dateipfad: str | Path) -> int: + """Importiert Spieler aus einer CSV-Datei.""" + pfad = Path(dateipfad) + importierte_anzahl = 0 + + with open(pfad, "r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + for zeile in reader: + neuer_spieler = Spieler( + name=zeile["name"], + level=int(zeile["level"]), + punkte=int(zeile["punkte"]), + inventar=[] + ) + self.spieler_hinzufuegen(neuer_spieler) + importierte_anzahl += 1 + + return importierte_anzahl + + +if __name__ == "__main__": + spielstand = Spielstand("Musterloesung Spielstand") + spielstand.spieler_hinzufuegen(Spieler("Gandalf", 99, 99999, ["Stab", "Ring"])) + spielstand.speichern_als_json("test_muster.json") + print("Erfolgreich ausgeführt!") diff --git a/13_persistenz_json_und_csv/test_aufgabe.py b/13_persistenz_json_und_csv/test_aufgabe.py new file mode 100644 index 0000000..aa5c6e6 --- /dev/null +++ b/13_persistenz_json_und_csv/test_aufgabe.py @@ -0,0 +1,144 @@ +import unittest +import sys +import json +import csv +import tempfile +from pathlib import Path + +# Sicherstellen, dass das aktuelle Verzeichnis im Python-Pfad liegt +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import aufgabe +except ImportError as e: + raise ImportError(f"Konnte 'aufgabe.py' nicht importieren: {e}") + + +class TestKapitel13(unittest.TestCase): + + def setUp(self): + self.Spieler = getattr(aufgabe, "Spieler", None) + self.Spielstand = getattr(aufgabe, "Spielstand", None) + + self.assertIsNotNone(self.Spieler, "Klasse 'Spieler' nicht gefunden!") + self.assertIsNotNone(self.Spielstand, "Klasse 'Spielstand' nicht gefunden!") + + def test_01_spieler_init_und_serialisierung(self): + """Prüft Spieler-Initialisierung, to_dict() und from_dict().""" + s = self.Spieler("Geralt", 20, 8500, ["Silberschwert", "Trank"]) + self.assertEqual(s.name, "Geralt") + self.assertEqual(s.level, 20) + self.assertEqual(s.punkte, 8500) + self.assertEqual(s.inventar, ["Silberschwert", "Trank"]) + + # to_dict + d = s.to_dict() + self.assertIsInstance(d, dict, "to_dict() muss ein Dictionary zurückgeben!") + self.assertEqual(d["name"], "Geralt") + self.assertEqual(d["level"], 20) + self.assertEqual(d["punkte"], 8500) + self.assertEqual(d["inventar"], ["Silberschwert", "Trank"]) + + # from_dict + reconstructed = self.Spieler.from_dict(d) + self.assertEqual(reconstructed.name, s.name) + self.assertEqual(reconstructed.level, s.level) + self.assertEqual(reconstructed.punkte, s.punkte) + self.assertEqual(reconstructed.inventar, s.inventar) + + def test_02_spielstand_management(self): + """Prüft Hinzufügen, Suchen und Ermittlung des besten Spielers.""" + spielstand = self.Spielstand("Turnier 2026") + self.assertIsNone(spielstand.bester_spieler()) + + s1 = self.Spieler("Alice", 5, 1200) + s2 = self.Spieler("Bob", 8, 3400) + s3 = self.Spieler("Charlie", 6, 2100) + + spielstand.spieler_hinzufuegen(s1) + spielstand.spieler_hinzufuegen(s2) + spielstand.spieler_hinzufuegen(s3) + + self.assertEqual(len(spielstand.spieler_liste), 3) + self.assertEqual(spielstand.bester_spieler().name, "Bob") + self.assertEqual(spielstand.spieler_suchen("Alice").level, 5) + self.assertIsNone(spielstand.spieler_suchen("Unbekannt")) + + def test_03_json_speichern_und_laden(self): + """Prüft Speichern und Laden eines Spielstands im JSON-Format über temporäre Dateien.""" + with tempfile.TemporaryDirectory() as tmpdir: + json_file = Path(tmpdir) / "savegame.json" + + original_stand = self.Spielstand("Episches RPG") + original_stand.spieler_hinzufuegen(self.Spieler("Zelda", 30, 9999, ["Bogen", "Rubin"])) + original_stand.spieler_hinzufuegen(self.Spieler("Link", 25, 7500, ["Master-Schwert"])) + + # Speichern + original_stand.speichern_als_json(json_file) + self.assertTrue(json_file.exists(), "JSON-Datei wurde nicht erstellt!") + + # Rohinhalt der JSON prüfen + with open(json_file, "r", encoding="utf-8") as f: + rohdialog = json.load(f) + self.assertEqual(rohdialog["titel"], "Episches RPG") + self.assertEqual(len(rohdialog["spieler"]), 2) + self.assertEqual(rohdialog["spieler"][0]["name"], "Zelda") + + # In neues Spielstand-Objekt laden + geladener_stand = self.Spielstand() + geladener_stand.laden_aus_json(json_file) + + self.assertEqual(geladener_stand.titel, "Episches RPG") + self.assertEqual(len(geladener_stand.spieler_liste), 2) + self.assertEqual(geladener_stand.spieler_liste[0].name, "Zelda") + self.assertEqual(geladener_stand.spieler_liste[0].inventar, ["Bogen", "Rubin"]) + self.assertEqual(geladener_stand.spieler_liste[1].punkte, 7500) + + def test_04_csv_export(self): + """Prüft den Export der Spieler-Tabelle als CSV-Datei.""" + with tempfile.TemporaryDirectory() as tmpdir: + csv_file = Path(tmpdir) / "export.csv" + + stand = self.Spielstand("Liga") + stand.spieler_hinzufuegen(self.Spieler("Mario", 10, 5000, ["Pilz", "Stern", "Blume"])) + stand.spieler_hinzufuegen(self.Spieler("Luigi", 8, 3200, ["Pilz"])) + + stand.exportiere_als_csv(csv_file) + self.assertTrue(csv_file.exists(), "CSV-Datei wurde nicht erstellt!") + + with open(csv_file, "r", newline="", encoding="utf-8") as f: + reader = list(csv.DictReader(f)) + + self.assertEqual(len(reader), 2) + self.assertEqual(reader[0]["name"], "Mario") + self.assertEqual(reader[0]["level"], "10") + self.assertEqual(reader[0]["punkte"], "5000") + self.assertEqual(reader[0]["inventar_anzahl"], "3") + + self.assertEqual(reader[1]["name"], "Luigi") + self.assertEqual(reader[1]["inventar_anzahl"], "1") + + def test_05_csv_import(self): + """Prüft den Import von Daten aus einer CSV-Datei.""" + with tempfile.TemporaryDirectory() as tmpdir: + csv_file = Path(tmpdir) / "import_test.csv" + + # Manuell CSV-Datei vorbereiten + with open(csv_file, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=["name", "level", "punkte", "inventar_anzahl"]) + writer.writeheader() + writer.writerow({"name": "Peach", "level": "12", "punkte": "4400", "inventar_anzahl": "0"}) + writer.writerow({"name": "Toad", "level": "5", "punkte": "1500", "inventar_anzahl": "0"}) + + stand = self.Spielstand("Neue Runde") + anzahl = stand.importiere_aus_csv(csv_file) + + self.assertEqual(anzahl, 2) + self.assertEqual(len(stand.spieler_liste), 2) + self.assertEqual(stand.spieler_liste[0].name, "Peach") + self.assertEqual(stand.spieler_liste[0].level, 12) + self.assertEqual(stand.spieler_liste[0].punkte, 4400) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/14_gui_mit_tkinter/README.md b/14_gui_mit_tkinter/README.md new file mode 100644 index 0000000..81c349c --- /dev/null +++ b/14_gui_mit_tkinter/README.md @@ -0,0 +1,126 @@ +# Kapitel 14: Desktop-GUIs mit Tkinter & OOP 🖥️✨ + +Bisher liefen alle unsere Python-Programme im Terminal (der Konsole) ab: Text eingeben mit `input()`, Text ausgeben mit `print()`. Das ist super für den Einstieg, aber echte moderne Desktop-Programme haben grafische Benutzeroberflächen (**GUI = Graphical User Interface**) mit Fenstern, Buttons, Eingabefeldern und Maus-Klicks! + +In diesem Kapitel lernst du, wie du mit Pythons integriertem Modul **`tkinter`** und **Objektorientierter Programmierung (OOP)** professionelle Desktop-Programme baust. + +--- + +## 📺 1. Vom Schwarz-Weiß-Fernseher zum Touchscreen (Event-Driven) + +| Terminal (Konsole) | Grafische Oberfläche (Tkinter GUI) | +| :--- | :--- | +| **Linearer Ablauf:** Schritt 1 → Schritt 2 → `input()` → Ende | **Ereignisgesteuert (Event-Driven):** Das Fenster wartet in einer Dauerschleife (`mainloop`) auf Benutzeraktionen | +| Nur reiner Text | Buttons, Eingabefelder, Farben, Fenster | +| Programm beendet sich nach Ausführung | Programm bleibt geöffnet, bis das Fenster geschlossen wird | + +### Der Event-Loop (`root.mainloop()`): +Ein GUI-Programm läuft nicht einfach von oben nach unten durch. Sobald das Fenster aufgebaut ist, übergibt Python die Kontrolle an den **Event-Loop**: +```python +root.mainloop() # Wartet geduldig auf Klicks, Tastaturanschläge oder Fenster-Schließen +``` + +--- + +## 🧱 2. Die wichtigsten Tkinter-Widgets + +Ein **Widget** (Window Gadget) ist ein grafisches Bauelement: + +| Widget | Klasse | Zweck | Wichtigste Optionen / Methoden | +| :--- | :--- | :--- | :--- | +| **Hauptfenster** | `tk.Tk()` | Das Betriebssystem-Fenster | `.title("Titel")`, `.geometry("400x300")`, `.mainloop()` | +| **Textanzeige** | `tk.Label` | Zeigt Text oder Zahlen an | `text="Hallo"`, `font=("Arial", 14)`, `fg="blue"`, `bg="white"` | +| **Schaltfläche** | `tk.Button` | Klickbarer Knopf | `text="Klick"`, `command=self.methode` | +| **Eingabefeld** | `tk.Entry` | Einzeilige Texteingabe | `.get()`, `.insert(0, "Start")`, `.delete(0, "end")` | +| **Container** | `tk.Frame` | Gruppiert mehrere Widgets | Hilft beim sauberen Verschachteln von Layouts | + +--- + +## 📐 3. Das Grid-Layout-System (Raster) + +Um Elemente auf dem Bildschirm auszurichten, nutzen wir den **Grid-Layout-Manager**. Er funktioniert wie eine Tabelle oder ein Schachbrett: + +``` + Spalte 0 Spalte 1 + ┌──────────────────────┬──────────────────────┐ +Zeile 0 │ 🔢 Überschrift (columnspan=2) │ + ├──────────────────────┼──────────────────────┤ +Zeile 1 │ Große Zähleranzeige: "0" (columnspan=2) │ + ├──────────────────────┼──────────────────────┤ +Zeile 2 │ Status: "Klicks: 0" (columnspan=2) │ + ├──────────────────────┬──────────────────────┤ +Zeile 3 │ ➕ Erhöhen │ ➖ Verringern │ + ├──────────────────────┴──────────────────────┤ +Zeile 4 │ ↺ Zurücksetzen (columnspan=2) │ + └─────────────────────────────────────────────┘ +``` + +### Die wichtigsten Parameter bei `.grid()`: +- `row=0, column=1`: Bestimmt Zeile und Spalte. +- `columnspan=2`: Lässt das Element über 2 Spalten spannen. +- `padx=10, pady=5`: Äußerer Abstand (Padding) in Pixeln. +- `sticky="ew"`: Dehnt das Element nach Osten und Westen (links & rechts) aus. + +> ⚠️ **Goldene Regel:** Mische niemals `.pack()` und `.grid()` im selben Eltern-Container! Verwende innerhalb eines Fensters oder Frames konsistent einen Layout-Manager. + +--- + +## ⚙️ 4. Event-Driven Programming: Was passiert beim Klick? + +Wenn ein Button geklickt wird, führt Tkinter die bei `command` hinterlegte Methode aus: + +```python +# RICHTIG: Referenz auf die Methode übergeben (ohne Klammern!) +self.btn_plus = tk.Button(self.root, text="+", command=self.klick_plus) + +# FALSCH: Würde die Methode SOFORT beim Start aufrufen, statt erst beim Klick! +self.btn_plus = tk.Button(self.root, text="+", command=self.klick_plus()) +``` + +--- + +## 🏛️ 5. Clean Architecture: Model-View-Controller (MVC) + +Warum packen wir nicht alles in eine einzige Datei/Funktion? Weil professioneller Code modular und automatisiert testbar sein muss! + +``` +┌────────────────────────────────┐ ┌────────────────────────────────┐ +│ Model (ZaehlerLogik) │ │ View/Controller (ZaehlerApp) │ +│ - wert: int │ │ - root: tk.Tk │ +│ - schrittweite: int │ ◄────── │ - label_anzeige: tk.Label │ +│ - erhoehen() -> int │ │ - btn_plus: tk.Button │ +│ - verringern() -> int │ │ - klick_plus() │ +│ (100% reines Python, keine GUI) │ │ (Tkinter-Widgets & Ereignisse)│ +└────────────────────────────────┘ └────────────────────────────────┘ +``` + +1. **`ZaehlerLogik` (Model):** Verwaltet die Daten (Zahlen, Grenzen, Schrittweiten). Es weiß nichts von Fenstern oder Buttons und kann in Unittests in Millisekunden geprüft werden. +2. **`ZaehlerApp` (View & Controller):** Erstellt die Oberfläche und ruft bei Klicks die Methoden von `self.logik` auf. + +--- + +## 🎯 6. Deine Aufgaben in `aufgabe.py` + +Öffne `14_gui_mit_tkinter/aufgabe.py` und vervollständige die Klassen: + +1. **TODO 1:** `ZaehlerLogik.__init__` mit `startwert`, `min_wert`, `max_wert`, `schrittweite=1`, `klick_anzahl=0`. +2. **TODO 2:** `erhoehen()`, `verringern()` (mit Clamping an Min/Max) und `zuruecksetzen()`. +3. **TODO 3:** `setze_schrittweite()`, `ist_gerade()` und `__str__()`. +4. **TODO 4:** `ZaehlerApp.__init__` (Fenster-Konfiguration, Widgets anlegen und im Grid platzieren). +5. **TODO 5:** Event-Handler `klick_plus()`, `klick_minus()`, `klick_reset()`, `klick_schritt_setzen()`. +6. **TODO 6:** `aktualisiere_anzeige()` (Labels synchronisieren und farblich hervorheben). + +--- + +## 🧪 7. Testen & Ausführen + +### Automatische Tests ausführen: +```bash +python3 test_aufgabe.py +``` + +### Startbare grafische Applikation ausprobieren: +```bash +python3 gui_app.py +``` +*(Hinweis: Die grafische Oberfläche benötigt eine Desktop-Umgebung mit Display)* diff --git a/14_gui_mit_tkinter/aufgabe.py b/14_gui_mit_tkinter/aufgabe.py new file mode 100644 index 0000000..25f8b94 --- /dev/null +++ b/14_gui_mit_tkinter/aufgabe.py @@ -0,0 +1,303 @@ +""" +Kapitel 14: Desktop-GUIs mit Tkinter & OOP 🖥️🔢 +================================================= +Aufgabe: Erstelle eine modulare Klick-Zähler- & Rechner-Applikation +mit sauberer Trennung von Logik (Model) und Benutzeroberfläche (View/Controller). + +1. ZaehlerLogik: Verwaltet den Wert, Grenzwerte und Schrittweiten (Headless / Reines Python). +2. ZaehlerApp: Erzeugt das Tkinter-Fenster, platziert Buttons & Labels und fängt Klicks ab. +""" + +from typing import Optional + +# ============================================================================== +# Tkinter Import mit Fallback für Headless- / Server-Umgebungen +# ============================================================================== +try: + import tkinter as tk +except ImportError: + # Headless Mock-Objekte (ermöglicht Ausführung und Tests auch ohne GUI-Server) + class _MockWidget: + def __init__(self, master=None, text="", **kwargs): + self.master = master + self._config = dict(kwargs) + if text: + self._config["text"] = text + self._entry_text = str(kwargs.get("textvariable", text)) + + def config(self, **kwargs): + self._config.update(kwargs) + + def configure(self, **kwargs): + self.config(**kwargs) + + def cget(self, key): + return self._config.get(key, "") + + def __getitem__(self, key): + return self._config.get(key, "") + + def __setitem__(self, key, value): + self._config[key] = value + + def grid(self, **kwargs): + pass + + def pack(self, **kwargs): + pass + + def get(self): + return self._entry_text + + def delete(self, first=0, last=None): + self._entry_text = "" + + def insert(self, index, string): + self._entry_text = str(string) + + def invoke(self): + cmd = self._config.get("command") + if callable(cmd): + return cmd() + + class _MockTk(_MockWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._title = "" + self._geometry = "" + + def title(self, title: str): + self._title = title + + def geometry(self, geom: str): + self._geometry = geom + + def mainloop(self): + pass + + def destroy(self): + pass + + class _MockTkModule: + Tk = _MockTk + Label = _MockWidget + Button = _MockWidget + Entry = _MockWidget + Frame = _MockWidget + + tk = _MockTkModule() + + +# ============================================================================== +# TEIL 1: DIE LOGIK-KLASSE (MODEL) +# ============================================================================== +class ZaehlerLogik: + """ + Das Datenmodell (Model) des Klick-Zählers. + Verwaltet den aktuellen Zählerstand, Grenzwerte und Schrittweiten. + Komplett unabhängig von Tkinter (reines Python). + """ + + # ========================================================================== + # TODO 1: Konstruktor __init__ + # Parameter: + # - startwert (int, Standard: 0) + # - min_wert (int, Standard: -100) + # - max_wert (int, Standard: 100) + # Attribute initialisieren: + # - self.wert: int = startwert + # - self.min_wert: int = min_wert + # - self.max_wert: int = max_wert + # - self.schrittweite: int = 1 + # - self.klick_anzahl: int = 0 + # ========================================================================== + def __init__(self, startwert: int = 0, min_wert: int = -100, max_wert: int = 100): + pass + + # ========================================================================== + # TODO 2a: Methode erhoehen(self) -> int + # 1. Erhöhe self.klick_anzahl um 1. + # 2. Addiere self.schrittweite zu self.wert, aber maximal bis self.max_wert! + # Tipp: min(self.max_wert, self.wert + self.schrittweite) + # 3. Gib den neuen self.wert zurück. + # ========================================================================== + def erhoehen(self) -> int: + pass + + # ========================================================================== + # TODO 2b: Methode verringern(self) -> int + # 1. Erhöhe self.klick_anzahl um 1. + # 2. Subtrahiere self.schrittweite von self.wert, aber minimal bis self.min_wert! + # Tipp: max(self.min_wert, self.wert - self.schrittweite) + # 3. Gib den neuen self.wert zurück. + # ========================================================================== + def verringern(self) -> int: + pass + + # ========================================================================== + # TODO 2c: Methode zuruecksetzen(self) -> int + # 1. Erhöhe self.klick_anzahl um 1. + # 2. Setze self.wert auf 0 zurück. + # 3. Gib den neuen self.wert (0) zurück. + # ========================================================================== + def zuruecksetzen(self) -> int: + pass + + # ========================================================================== + # TODO 3a: Methode setze_schrittweite(self, schritt: int) -> bool + # Wenn schritt > 0 ist: + # - Setze self.schrittweite = schritt + # - Gib True zurück + # Wenn schritt <= 0 ist: + # - Verändere die Schrittweite nicht und gib False zurück + # ========================================================================== + def setze_schrittweite(self, schritt: int) -> bool: + pass + + # ========================================================================== + # TODO 3b: Methode ist_gerade(self) -> bool + # Gibt True zurück, wenn self.wert eine gerade Zahl ist (wert % 2 == 0), + # andernfalls False. + # ========================================================================== + def ist_gerade(self) -> bool: + pass + + # ========================================================================== + # TODO 3c: Dunder-Methode __str__(self) -> str + # Gibt einen formatierten String zurück: + # "Zählerstand: (Schrittweite: , Klicks: )" + # ========================================================================== + def __str__(self) -> str: + pass + + +# ============================================================================== +# TEIL 2: DIE GUI-KLASSE (VIEW & CONTROLLER) +# ============================================================================== +class ZaehlerApp: + """ + Die Benutzeroberfläche (GUI) für den Klick-Zähler mit Tkinter. + Erstellt Fenster, Labels, Buttons und Eingabefelder. + """ + + # ========================================================================== + # TODO 4: Konstruktor __init__(self, root: tk.Tk) + # Parameter: root (das Tkinter-Hauptfenster) + # + # 1. Speichere das Fenster: + # self.root = root + # self.root.title("Python OOP Zähler 🔢") + # self.root.geometry("380x360") + # + # 2. Erstelle eine Instanz der Logik-Klasse: + # self.logik = ZaehlerLogik(startwert=0, min_wert=-100, max_wert=100) + # + # 3. Erstelle und platziere alle Widgets: + # - self.label_titel: tk.Label(self.root, text="🔢 Python Klick-Zähler", font=("Arial", 16, "bold")) + # Platzierung mit .grid(row=0, column=0, columnspan=2, padx=15, pady=(15, 5)) + # + # - self.label_anzeige: tk.Label(self.root, text="0", font=("Arial", 36, "bold")) + # Platzierung mit .grid(row=1, column=0, columnspan=2, padx=15, pady=10) + # + # - self.label_info: tk.Label(self.root, text="Klicks: 0 | Gerade Zahl", font=("Arial", 10)) + # Platzierung mit .grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 15)) + # + # - self.btn_plus: tk.Button(self.root, text="➕ Erhöhen", font=("Arial", 11, "bold"), command=self.klick_plus) + # Platzierung mit .grid(row=3, column=0, padx=10, pady=5, sticky="ew") + # + # - self.btn_minus: tk.Button(self.root, text="➖ Verringern", font=("Arial", 11, "bold"), command=self.klick_minus) + # Platzierung mit .grid(row=3, column=1, padx=10, pady=5, sticky="ew") + # + # - self.btn_reset: tk.Button(self.root, text="↺ Zurücksetzen", font=("Arial", 10), command=self.klick_reset) + # Platzierung mit .grid(row=4, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + # + # - Container für Schrittweite: + # self.frame_schritt = tk.Frame(self.root) + # self.frame_schritt.grid(row=5, column=0, columnspan=2, padx=10, pady=(15, 10)) + # + # self.label_schritt = tk.Label(self.frame_schritt, text="Schrittweite:") + # self.label_schritt.pack(side="left", padx=5) + # + # self.entry_schritt = tk.Entry(self.frame_schritt, width=6, font=("Arial", 10)) + # self.entry_schritt.insert(0, "1") + # self.entry_schritt.pack(side="left", padx=5) + # + # self.btn_schritt = tk.Button(self.frame_schritt, text="Setzen", command=self.klick_schritt_setzen) + # self.btn_schritt.pack(side="left", padx=5) + # + # 4. Rufe am Ende self.aktualisiere_anzeige() auf, um die Startwerte anzuzeigen! + # ========================================================================== + def __init__(self, root: tk.Tk): + pass + + # ========================================================================== + # TODO 5a: Methode klick_plus(self) -> None + # 1. Rufe self.logik.erhoehen() auf. + # 2. Rufe self.aktualisiere_anzeige() auf. + # ========================================================================== + def klick_plus(self) -> None: + pass + + # ========================================================================== + # TODO 5b: Methode klick_minus(self) -> None + # 1. Rufe self.logik.verringern() auf. + # 2. Rufe self.aktualisiere_anzeige() auf. + # ========================================================================== + def klick_minus(self) -> None: + pass + + # ========================================================================== + # TODO 5c: Methode klick_reset(self) -> None + # 1. Rufe self.logik.zuruecksetzen() auf. + # 2. Rufe self.aktualisiere_anzeige() auf. + # ========================================================================== + def klick_reset(self) -> None: + pass + + # ========================================================================== + # TODO 5d: Methode klick_schritt_setzen(self) -> None + # 1. Hole den Text aus self.entry_schritt mit self.entry_schritt.get().strip() + # 2. Versuche (try-except) den Text mit int() in eine Ganzzahl umzuwandeln. + # 3. Wenn die Umwandlung klappt: + # - Rufe self.logik.setze_schrittweite(neuer_wert) auf. + # - Wenn setze_schrittweite True liefert: self.aktualisiere_anzeige() aufrufen. + # - Wenn False geliefert wird: self.label_info.config(text="⚠️ Schrittweite muss > 0 sein!") + # 4. Wenn ein ValueError auftritt (z.B. Buchstabe eingegeben): + # - Setze self.label_info.config(text="⚠️ Bitte eine ganze Zahl eingeben!") + # ========================================================================== + def klick_schritt_setzen(self) -> None: + pass + + # ========================================================================== + # TODO 6: Methode aktualisiere_anzeige(self) -> None + # Synchronisiert die Oberfläche mit den aktuellen Werten aus self.logik: + # + # 1. Hauptanzeige: + # self.label_anzeige.config(text=str(self.logik.wert)) + # + # 2. Info-Text zusammenbauen: + # paritaet = "Gerade" if self.logik.ist_gerade() else "Ungerade" + # info = f"Klicks: {self.logik.klick_anzahl} | {paritaet} | Schritt: {self.logik.schrittweite}" + # if self.logik.wert >= self.logik.max_wert: + # info += " (Maximum erreicht!)" + # elif self.logik.wert <= self.logik.min_wert: + # info += " (Minimum erreicht!)" + # self.label_info.config(text=info) + # ========================================================================== + def aktualisiere_anzeige(self) -> None: + pass + + +# ============================================================================== +# Startblock: Führe dieses Skript aus (python3 aufgabe.py), um die GUI zu testen! +# ============================================================================== +if __name__ == "__main__": + print("🚀 Starte Tkinter Klick-Zähler...") + try: + root = tk.Tk() + app = ZaehlerApp(root) + print("💡 Fenster geöffnet! Schließe das Fenster, um das Programm zu beenden.") + root.mainloop() + except Exception as e: + print(f"⚠️ GUI konnte nicht im Display geöffnet werden ({e}).") + print("💡 Führe 'python3 test_aufgabe.py' aus, um deine Lösung automatisch zu prüfen!") diff --git a/14_gui_mit_tkinter/gui_app.py b/14_gui_mit_tkinter/gui_app.py new file mode 100644 index 0000000..3641b16 --- /dev/null +++ b/14_gui_mit_tkinter/gui_app.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +""" +Kapitel 14: Grafische Desktop-Anwendung (Startbares Skript) 🖥️✨ +================================================================ +Führe diese Datei auf deinem Desktop aus (z.B. in VS Code auf Mac/Windows/Linux): + python3 gui_app.py + +Features dieser erweiterten Version: +- Saubere Model-View-Controller (MVC) Architektur +- Farb-Feedback (Grün bei positiven, Rot bei negativen Zahlen) +- Tastatur-Shortcuts: + [Pfeil nach oben] -> Erhöhen + [Pfeil nach unten] -> Verringern + [R] -> Zurücksetzen +- Warnung bei Grenzwert-Überschreitungen +""" + +import sys +from typing import Optional + +try: + import tkinter as tk + from tkinter import messagebox + TK_AVAILABLE = True +except ImportError: + TK_AVAILABLE = False + class _MockWidget: + def __init__(self, *args, **kwargs): pass + def pack(self, *args, **kwargs): pass + def grid(self, *args, **kwargs): pass + def columnconfigure(self, *args, **kwargs): pass + def bind(self, *args, **kwargs): pass + def config(self, *args, **kwargs): pass + def insert(self, *args, **kwargs): pass + def delete(self, *args, **kwargs): pass + def get(self): return "1" + + class _MockTkModule: + Tk = _MockWidget + Label = _MockWidget + Button = _MockWidget + Entry = _MockWidget + Frame = _MockWidget + LabelFrame = _MockWidget + + tk = _MockTkModule() + messagebox = None + + +class ZaehlerLogik: + """Modell für Zähler, Grenzwerte und Schrittweite.""" + + def __init__(self, startwert: int = 0, min_wert: int = -100, max_wert: int = 100): + self.wert: int = startwert + self.min_wert: int = min_wert + self.max_wert: int = max_wert + self.schrittweite: int = 1 + self.klick_anzahl: int = 0 + + def erhoehen(self) -> int: + self.klick_anzahl += 1 + self.wert = min(self.max_wert, self.wert + self.schrittweite) + return self.wert + + def verringern(self) -> int: + self.klick_anzahl += 1 + self.wert = max(self.min_wert, self.wert - self.schrittweite) + return self.wert + + def zuruecksetzen(self) -> int: + self.klick_anzahl += 1 + self.wert = 0 + return self.wert + + def setze_schrittweite(self, schritt: int) -> bool: + if schritt > 0: + self.schrittweite = schritt + return True + return False + + def ist_gerade(self) -> bool: + return self.wert % 2 == 0 + + def __str__(self) -> str: + return f"Zählerstand: {self.wert} (Schritt: {self.schrittweite})" + + +class DesktopZaehlerGUI: + """Moderne grafische Desktop-Benutzeroberfläche mit Tkinter.""" + + def __init__(self, root: tk.Tk): + self.root = root + self.root.title("Python OOP • Desktop Zähler 🔢") + self.root.geometry("420x440") + self.root.minsize(360, 400) + self.root.configure(bg="#f8fafc") + + self.logik = ZaehlerLogik(startwert=0, min_wert=-100, max_wert=100) + + # UI Komponenten aufbauen + self._erstelle_widgets() + self._binde_tasten() + self.aktualisiere_anzeige() + + def _erstelle_widgets(self): + # 1. Header Frame + frame_header = tk.Frame(self.root, bg="#4f46e5", pady=14) + frame_header.pack(fill="x") + + self.label_titel = tk.Label( + frame_header, + text="🔢 Python Desktop Zähler", + font=("Helvetica", 16, "bold"), + bg="#4f46e5", + fg="white", + ) + self.label_titel.pack() + + self.label_sub = tk.Label( + frame_header, + text="Kapitel 14 • Tkinter & OOP", + font=("Helvetica", 9), + bg="#4f46e5", + fg="#c7d2fe", + ) + self.label_sub.pack() + + # 2. Haupt-Anzeige Frame + frame_card = tk.Frame(self.root, bg="#ffffff", bd=1, relief="solid", padx=20, pady=15) + frame_card.pack(fill="x", padx=25, pady=15) + + self.label_anzeige = tk.Label( + frame_card, + text="0", + font=("Helvetica", 42, "bold"), + bg="#ffffff", + fg="#4f46e5", + ) + self.label_anzeige.pack(pady=(5, 0)) + + self.label_info = tk.Label( + frame_card, + text="Klicks: 0 | Gerade Zahl", + font=("Helvetica", 10), + bg="#ffffff", + fg="#64748b", + ) + self.label_info.pack(pady=(0, 5)) + + # 3. Button Steuerungsbereich + frame_buttons = tk.Frame(self.root, bg="#f8fafc") + frame_buttons.pack(fill="x", padx=25, pady=5) + frame_buttons.columnconfigure(0, weight=1) + frame_buttons.columnconfigure(1, weight=1) + + self.btn_plus = tk.Button( + frame_buttons, + text="➕ Erhöhen [↑]", + font=("Helvetica", 11, "bold"), + bg="#10b981", + fg="white", + activebackground="#059669", + activeforeground="white", + relief="flat", + pady=8, + cursor="hand2", + command=self.klick_plus, + ) + self.btn_plus.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + self.btn_minus = tk.Button( + frame_buttons, + text="➖ Verringern [↓]", + font=("Helvetica", 11, "bold"), + bg="#ef4444", + fg="white", + activebackground="#dc2626", + activeforeground="white", + relief="flat", + pady=8, + cursor="hand2", + command=self.klick_minus, + ) + self.btn_minus.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + self.btn_reset = tk.Button( + frame_buttons, + text="↺ Zurücksetzen [R]", + font=("Helvetica", 10), + bg="#e2e8f0", + fg="#1e293b", + activebackground="#cbd5e1", + relief="flat", + pady=6, + cursor="hand2", + command=self.klick_reset, + ) + self.btn_reset.grid(row=1, column=0, columnspan=2, padx=5, pady=5, sticky="ew") + + # 4. Schrittweite Konfiguration + frame_settings = tk.LabelFrame( + self.root, + text=" ⚙️ Einstellungen ", + font=("Helvetica", 9, "bold"), + bg="#f8fafc", + fg="#475569", + padx=10, + pady=8, + ) + frame_settings.pack(fill="x", padx=25, pady=10) + + tk.Label(frame_settings, text="Schrittweite:", bg="#f8fafc", font=("Helvetica", 10)).pack( + side="left", padx=5 + ) + + self.entry_schritt = tk.Entry( + frame_settings, width=6, font=("Helvetica", 11), justify="center" + ) + self.entry_schritt.insert(0, "1") + self.entry_schritt.pack(side="left", padx=5) + + self.btn_schritt = tk.Button( + frame_settings, + text="Übernehmen", + font=("Helvetica", 9), + bg="#6366f1", + fg="white", + relief="flat", + cursor="hand2", + command=self.klick_schritt_setzen, + ) + self.btn_schritt.pack(side="left", padx=5) + + def _binde_tasten(self): + """Bindet bequeme Tastatur-Shortcuts an das Fenster.""" + self.root.bind("", lambda event: self.klick_plus()) + self.root.bind("", lambda event: self.klick_minus()) + self.root.bind("", lambda event: self.klick_reset()) + self.root.bind("", lambda event: self.klick_reset()) + self.root.bind("", lambda event: self.klick_schritt_setzen()) + + def klick_plus(self) -> None: + self.logik.erhoehen() + self.aktualisiere_anzeige() + + def klick_minus(self) -> None: + self.logik.verringern() + self.aktualisiere_anzeige() + + def klick_reset(self) -> None: + self.logik.zuruecksetzen() + self.aktualisiere_anzeige() + + def klick_schritt_setzen(self) -> None: + eingabe = self.entry_schritt.get().strip() + try: + schritt = int(eingabe) + if self.logik.setze_schrittweite(schritt): + self.aktualisiere_anzeige() + else: + self.label_info.config( + text="⚠️ Schrittweite muss größer als 0 sein!", fg="#ef4444" + ) + except ValueError: + self.label_info.config( + text="⚠️ Bitte eine ganze Zahl eingeben!", fg="#ef4444" + ) + + def aktualisiere_anzeige(self) -> None: + # Zählerwert darstellen + self.label_anzeige.config(text=str(self.logik.wert)) + + # Farb-Feedback + if self.logik.wert > 0: + self.label_anzeige.config(fg="#10b981") # Grün + elif self.logik.wert < 0: + self.label_anzeige.config(fg="#ef4444") # Rot + else: + self.label_anzeige.config(fg="#4f46e5") # Neutral + + # Info-Text + paritaet = "Gerade" if self.logik.ist_gerade() else "Ungerade" + info = f"Klicks: {self.logik.klick_anzahl} | {paritaet} | Schritt: {self.logik.schrittweite}" + + if self.logik.wert >= self.logik.max_wert: + info += " ⚠️ Max erreicht!" + elif self.logik.wert <= self.logik.min_wert: + info += " ⚠️ Min erreicht!" + + self.label_info.config(text=info, fg="#64748b") + + +def main(): + if not TK_AVAILABLE: + print("=" * 60) + print("⚠️ Hinweis: Tkinter ist in dieser Linux-Umgebung nicht installiert.") + print("Um die grafische Oberfläche mit echtem Fenster zu sehen:") + print(" 1. Klone oder öffne diesen Ordner auf deinem PC/Laptop (Windows/Mac/Linux).") + print(" 2. Führe 'python3 gui_app.py' aus!") + print("=" * 60) + return + + try: + root = tk.Tk() + app = DesktopZaehlerGUI(root) + print("🖥️ GUI-Fenster gestartet! Schließe das Fenster zum Beenden.") + root.mainloop() + except Exception as e: + print(f"⚠️ Konnte GUI nicht initialisieren (kein X11-Display gefunden): {e}") + print("💡 Teste deinen Code headless mit: python3 test_aufgabe.py") + + +if __name__ == "__main__": + main() diff --git a/14_gui_mit_tkinter/index.html b/14_gui_mit_tkinter/index.html new file mode 100644 index 0000000..9981896 --- /dev/null +++ b/14_gui_mit_tkinter/index.html @@ -0,0 +1,351 @@ + + + + + + Kapitel 14: Desktop-GUIs mit Tkinter & OOP + + + + +
+
+ +
Kapitel 14 • Schulabgleich 27.0 Tkinter
+

Desktop-GUIs mit Tkinter & OOP 🖥️✨

+

Vom Text-Terminal zum echten Betriebssystem-Fenster mit Buttons, Eingabefeldern, Layout-Grids und ereignisgesteuerter OOP-Architektur.

+
+
+ +
+ + +
+

1. Vom Terminal zur grafischen Oberfläche (GUI)

+ +

+ Bisher liefen all deine Python-Programme linear im schwarzen Terminal ab: Text einlesen mit input(), Text ausgeben mit print(). + Echte Desktop-Software (wie Spotify, VS Code oder ein Taschenrechner) funktioniert jedoch völlig anders: Sie besitzt ein Fenster, das geduldig auf Mausklicks und Tastatureingaben wartet. +

+ +
+
📺 Die Fernseh-Analogie: Vom Schwarz-Weiß-Fernseher zum Touchscreen
+
    +
  • Terminal (Linear): Wie ein alter Filmstreifen. Bild für Bild läuft starr von oben nach unten ab. Am Ende ist der Film vorbei.
  • +
  • GUI (Ereignisgesteuert / Event-Driven): Wie ein interaktiver Touchscreen. Das System schläft im Hintergrund (im sogenannten Event-Loop). Sobald du auf einen Knopf drückst, feuert ein Signal und deine Methode reagiert blitzschnell darauf!
  • +
+
+ +

Der Herzschlag einer GUI: Der Event-Loop

+

+ Ein GUI-Programm beendet sich nicht sofort, sondern übergibt die Kontrolle an root.mainloop(): +

+ +
+
+
+ + + +
+ Python (Minimales Tkinter-Fenster) +
+
import tkinter as tk
+
+root = tk.Tk()                      # 1. Hauptfenster erzeugen
+root.title("Mein erstes GUI-Fenster")  # 2. Fenstertitel festlegen
+root.geometry("400x300")             # 3. Fenstergröße (Breite x Höhe)
+
+label = tk.Label(root, text="Hallo Welt! 👋", font=("Arial", 16))
+label.pack(pady=20)                  # 4. Widget im Fenster platzieren
+
+root.mainloop()                     # 5. Ereignisschleife starten (wartet auf Events)
+
+
+ + +
+

2. Bausteine einer GUI: Die wichtigsten Widgets

+ +

+ In Tkinter heißen alle visuellen Bedienelemente Widgets (Window Gadgets). Jedes Widget ist ein Python-Objekt, das wir in unserem Fenster platzieren können: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
WidgetKlasseZweckWichtige Parameter & Methoden
Hauptfenstertk.Tk()Rahmen & Titelleiste des Betriebssystems.title(), .geometry("BxH"), .mainloop()
Textanzeigetk.LabelZeigt Texte, Titel oder Zahlenwertetext="...", font=("Arial", 14), fg="color", .config(text=...)
Schaltflächetk.ButtonKnopf, der beim Anklicken eine Methode ausführttext="...", command=self.klick_methode, bg="color"
Eingabefeldtk.EntryEinzeiliges Textfeld für Benutzereingaben.get(), .insert(0, text), .delete(0, "end")
Containertk.FrameUnsichtbare Box zum Gruppieren von WidgetsUnterteilt komplexe Layouts in saubere Blöcke
+
+ + +
+

3. Layout-Manager: Das 2D-Grid-System (Raster)

+ +

+ Widgets müssen auf dem Bildschirm positioniert werden. Der mächtigste und sauberste Layout-Manager in Tkinter ist grid(). + Er teilt den Raum wie eine Excel-Tabelle oder ein Schachbrett in Zeilen (row) und Spalten (column) ein: +

+ +
+
+
+ row=0, column=0, columnspan=2 → 🔢 Titel: "Python Klick-Zähler" +
+
+ row=1, column=0, columnspan=2 → Zählerstand: "0" +
+
+ row=2, column=0, columnspan=2 → Status-Info: "Klicks: 0 | Gerade Zahl" +
+
+ row=3, column=0
➕ Erhöhen +
+
+ row=3, column=1
➖ Verringern +
+
+ row=4, column=0, columnspan=2 → ↺ Zurücksetzen (Reset) +
+
+
+ +
+
⚠️ Häufigster Anfängerfehler: .pack() und .grid() niemals mischen!
+ Verwende innerhalb eines Fensters (oder innerhalb eines Frames) entweder nur grid() oder nur pack(). + Wenn du beide Layout-Manager im selben übergeordneten Fenster mischst, friert Python in einer Endlosschleife ein! +
+
+ + +
+

4. Event-Driven Programming: Was geschieht beim Klick?

+ +

+ Wenn der Benutzer auf einen Button klickt, soll ein Stück Code ausgeführt werden. In Tkinter übergeben wir dafür das command-Attribut: +

+ +
+
+ Python: Button-Befehle richtig übergeben +
+
# ✅ RICHTIG: Nur den Methodennamen übergeben (OHNE Klammern!)
+self.btn_plus = tk.Button(self.root, text="➕", command=self.klick_plus)
+
+# ❌ FALSCH: Mit Klammern wird die Methode SOFORT beim Erstellen aufgerufen!
+self.btn_plus = tk.Button(self.root, text="➕", command=self.klick_plus())
+
+ +
+
💡 Warum ohne Klammern?
+ Mit self.klick_plus übergibst du dem Button eine Visitenkarte (Referenz) der Methode. Der Button behält diese Visitenkarte und ruft die Methode erst dann an, wenn der Benutzer wirklich mit der Maus daraufklickt. +
+
+ + +
+

5. Saubere Software-Architektur: Model-View-Trennung (MVC) 🏛️

+ +

+ In der professionellen Entwicklung schreibt man GUI-Apps niemals als wilden Spaghetticode, sondern trennt die Logik (Berechnung & Daten) strikt von der Präsentation (Fenster & Buttons): +

+ +
+
+

1. Model: ZaehlerLogik

+

+ Reines Python! Verwaltet wert, schrittweite, Grenzwerte und Berechnungen.
+ Vorteil: 100% unabhängig von Bildschirmen und in Sekundenbruchteilen per Unit Test prüfbar! +

+
+ +
+

2. View / Controller: ZaehlerApp

+

+ Hält die Widgets (Label, Button, Entry).
+ Bei einem Button-Klick ruft sie Methoden der ZaehlerLogik auf und spiegelt das Ergebnis im Label wider. +

+
+
+ +
+
+ Struktur einer OOP-GUI-Klasse +
+
class ZaehlerApp:
+    def __init__(self, root: tk.Tk):
+        self.root = root
+        self.root.title("Python Zähler")
+        
+        # 1. Datenmodell einbinden
+        self.logik = ZaehlerLogik()
+
+        # 2. Widgets erstellen
+        self.label_anzeige = tk.Label(self.root, text="0", font=("Arial", 32))
+        self.label_anzeige.grid(row=0, column=0)
+
+        self.btn_plus = tk.Button(self.root, text="➕", command=self.klick_plus)
+        self.btn_plus.grid(row=1, column=0)
+
+    def klick_plus(self) -> None:
+        self.logik.erhoehen()
+        self.aktualisiere_anzeige()
+
+    def aktualisiere_anzeige(self) -> None:
+        self.label_anzeige.config(text=str(self.logik.wert))
+
+
+ + +
+

6. 🎯 Deine Aufgaben in aufgabe.py

+ +

Öffne die Datei 14_gui_mit_tkinter/aufgabe.py und implementiere die Klassen Schritt für Schritt:

+ +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ +

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zu TODO 2: Clamping an Grenzwerte (min/max) +
+

Um einen Wert nach oben und unten sauber zu deckeln:

+
+
# Erhöhen mit oberer Grenze:
+self.wert = min(self.max_wert, self.wert + self.schrittweite)
+
+# Verringern mit unterer Grenze:
+self.wert = max(self.min_wert, self.wert - self.schrittweite)
+
+
+
+ +
+ 🔍 Tipp zu TODO 5d: Fehler beim Entry-Auslesen abfangen +
+

Benutzer können Tippfehler machen (z.B. Buchstaben eingeben). Fange das mit try-except ab:

+
+
eingabe = self.entry_schritt.get().strip()
+try:
+    neuer_schritt = int(eingabe)
+    if self.logik.setze_schrittweite(neuer_schritt):
+        self.aktualisiere_anzeige()
+    else:
+        self.label_info.config(text="⚠️ Schrittweite muss > 0 sein!")
+except ValueError:
+    self.label_info.config(text="⚠️ Bitte eine ganze Zahl eingeben!")
+
+
+
+
+ + +
+

7. 🧪 Lösung überprüfen & Starten

+ +

1. Automatische Tests ausführen:

+
+
+ Terminal (im Ordner 14_gui_mit_tkinter) +
+
python3 test_aufgabe.py
+
+ +

2. Fertige grafische Applikation starten:

+
+
+ Terminal +
+
python3 gui_app.py
+
+ + +
+ +
+ + + diff --git a/14_gui_mit_tkinter/musterloesung.py b/14_gui_mit_tkinter/musterloesung.py new file mode 100644 index 0000000..b5d4205 --- /dev/null +++ b/14_gui_mit_tkinter/musterloesung.py @@ -0,0 +1,296 @@ +""" +Kapitel 14: Desktop-GUIs mit Tkinter & OOP 🖥️🔢 +================================================= +Musterlösung: Vollständige Implementierung von ZaehlerLogik und ZaehlerApp. +""" + +from typing import Optional + +# ============================================================================== +# Tkinter Import mit Fallback für Headless- / Server-Umgebungen +# ============================================================================== +try: + import tkinter as tk +except ImportError: + class _MockWidget: + def __init__(self, master=None, text="", **kwargs): + self.master = master + self._config = dict(kwargs) + if text: + self._config["text"] = text + self._entry_text = str(kwargs.get("textvariable", text)) + + def config(self, **kwargs): + self._config.update(kwargs) + + def configure(self, **kwargs): + self.config(**kwargs) + + def cget(self, key): + return self._config.get(key, "") + + def __getitem__(self, key): + return self._config.get(key, "") + + def __setitem__(self, key, value): + self._config[key] = value + + def grid(self, **kwargs): + pass + + def pack(self, **kwargs): + pass + + def get(self): + return self._entry_text + + def delete(self, first=0, last=None): + self._entry_text = "" + + def insert(self, index, string): + self._entry_text = str(string) + + def invoke(self): + cmd = self._config.get("command") + if callable(cmd): + return cmd() + + class _MockTk(_MockWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._title = "" + self._geometry = "" + + def title(self, title: str): + self._title = title + + def geometry(self, geom: str): + self._geometry = geom + + def mainloop(self): + pass + + def destroy(self): + pass + + class _MockTkModule: + Tk = _MockTk + Label = _MockWidget + Button = _MockWidget + Entry = _MockWidget + Frame = _MockWidget + + tk = _MockTkModule() + + +# ============================================================================== +# TEIL 1: DIE LOGIK-KLASSE (MODEL) +# ============================================================================== +class ZaehlerLogik: + """ + Das Datenmodell (Model) des Klick-Zählers. + Verwaltet den aktuellen Zählerstand, Grenzwerte und Schrittweiten. + Komplett unabhängig von Tkinter (reines Python). + """ + + def __init__(self, startwert: int = 0, min_wert: int = -100, max_wert: int = 100): + self.wert: int = startwert + self.min_wert: int = min_wert + self.max_wert: int = max_wert + self.schrittweite: int = 1 + self.klick_anzahl: int = 0 + + def erhoehen(self) -> int: + """Erhöht den Zählerstand um die Schrittweite (maximal bis max_wert).""" + self.klick_anzahl += 1 + self.wert = min(self.max_wert, self.wert + self.schrittweite) + return self.wert + + def verringern(self) -> int: + """Verringert den Zählerstand um die Schrittweite (minimal bis min_wert).""" + self.klick_anzahl += 1 + self.wert = max(self.min_wert, self.wert - self.schrittweite) + return self.wert + + def zuruecksetzen(self) -> int: + """Setzt den Zählerstand auf 0 zurück.""" + self.klick_anzahl += 1 + self.wert = 0 + return self.wert + + def setze_schrittweite(self, schritt: int) -> bool: + """Setzt eine neue positive Schrittweite (> 0).""" + if schritt > 0: + self.schrittweite = schritt + return True + return False + + def ist_gerade(self) -> bool: + """Gibt True zurück, wenn der Zählerstand gerade ist.""" + return self.wert % 2 == 0 + + def __str__(self) -> str: + return f"Zählerstand: {self.wert} (Schrittweite: {self.schrittweite}, Klicks: {self.klick_anzahl})" + + +# ============================================================================== +# TEIL 2: DIE GUI-KLASSE (VIEW & CONTROLLER) +# ============================================================================== +class ZaehlerApp: + """ + Die Benutzeroberfläche (GUI) für den Klick-Zähler mit Tkinter. + Erstellt Fenster, Labels, Buttons und Eingabefelder. + """ + + def __init__(self, root: tk.Tk): + self.root = root + self.root.title("Python OOP Zähler 🔢") + self.root.geometry("380x360") + + # 1. Datenmodell (Logik) + self.logik = ZaehlerLogik(startwert=0, min_wert=-100, max_wert=100) + + # 2. Widgets erstellen & per Grid anordnen + # Titel-Label + self.label_titel = tk.Label( + self.root, + text="🔢 Python Klick-Zähler", + font=("Arial", 16, "bold"), + ) + self.label_titel.grid(row=0, column=0, columnspan=2, padx=15, pady=(15, 5)) + + # Große Zähleranzeige + self.label_anzeige = tk.Label( + self.root, + text="0", + font=("Arial", 36, "bold"), + fg="#4f46e5", + ) + self.label_anzeige.grid(row=1, column=0, columnspan=2, padx=15, pady=10) + + # Info- / Statusanzeige + self.label_info = tk.Label( + self.root, + text="Klicks: 0 | Gerade Zahl", + font=("Arial", 10), + fg="#64748b", + ) + self.label_info.grid(row=2, column=0, columnspan=2, padx=15, pady=(0, 15)) + + # Buttons zum Erhöhen und Verringern + self.btn_plus = tk.Button( + self.root, + text="➕ Erhöhen", + font=("Arial", 11, "bold"), + bg="#10b981", + fg="white", + command=self.klick_plus, + ) + self.btn_plus.grid(row=3, column=0, padx=10, pady=5, sticky="ew") + + self.btn_minus = tk.Button( + self.root, + text="➖ Verringern", + font=("Arial", 11, "bold"), + bg="#ef4444", + fg="white", + command=self.klick_minus, + ) + self.btn_minus.grid(row=3, column=1, padx=10, pady=5, sticky="ew") + + # Reset-Button + self.btn_reset = tk.Button( + self.root, + text="↺ Zurücksetzen", + font=("Arial", 10), + command=self.klick_reset, + ) + self.btn_reset.grid(row=4, column=0, columnspan=2, padx=10, pady=5, sticky="ew") + + # Container für die Schrittweite + self.frame_schritt = tk.Frame(self.root) + self.frame_schritt.grid(row=5, column=0, columnspan=2, padx=10, pady=(15, 10)) + + self.label_schritt = tk.Label(self.frame_schritt, text="Schrittweite:") + self.label_schritt.pack(side="left", padx=5) + + self.entry_schritt = tk.Entry(self.frame_schritt, width=6, font=("Arial", 10)) + self.entry_schritt.insert(0, "1") + self.entry_schritt.pack(side="left", padx=5) + + self.btn_schritt = tk.Button( + self.frame_schritt, + text="Setzen", + font=("Arial", 9), + command=self.klick_schritt_setzen, + ) + self.btn_schritt.pack(side="left", padx=5) + + # Initiale Anzeige synchronisieren + self.aktualisiere_anzeige() + + def klick_plus(self) -> None: + """Handler für Klick auf den Plus-Button.""" + self.logik.erhoehen() + self.aktualisiere_anzeige() + + def klick_minus(self) -> None: + """Handler für Klick auf den Minus-Button.""" + self.logik.verringern() + self.aktualisiere_anzeige() + + def klick_reset(self) -> None: + """Handler für Klick auf den Reset-Button.""" + self.logik.zuruecksetzen() + self.aktualisiere_anzeige() + + def klick_schritt_setzen(self) -> None: + """Handler zum Auslesen und Anwenden der Schrittweite.""" + eingabe = self.entry_schritt.get().strip() + try: + neuer_schritt = int(eingabe) + if self.logik.setze_schrittweite(neuer_schritt): + self.aktualisiere_anzeige() + else: + self.label_info.config(text="⚠️ Schrittweite muss größer als 0 sein!", fg="#ef4444") + except ValueError: + self.label_info.config(text="⚠️ Bitte eine ganze Zahl eingeben!", fg="#ef4444") + + def aktualisiere_anzeige(self) -> None: + """Aktualisiert alle Anzeige-Elemente mit den Daten aus dem Logik-Modell.""" + # 1. Zählerstand + self.label_anzeige.config(text=str(self.logik.wert)) + + # 2. Farb-Feedback + if self.logik.wert > 0: + self.label_anzeige.config(fg="#10b981") # Grün + elif self.logik.wert < 0: + self.label_anzeige.config(fg="#ef4444") # Rot + else: + self.label_anzeige.config(fg="#4f46e5") # Indigo + + # 3. Statusinfo + paritaet = "Gerade" if self.logik.ist_gerade() else "Ungerade" + info = f"Klicks: {self.logik.klick_anzahl} | {paritaet} | Schritt: {self.logik.schrittweite}" + + if self.logik.wert >= self.logik.max_wert: + info += " (Maximum erreicht!)" + elif self.logik.wert <= self.logik.min_wert: + info += " (Minimum erreicht!)" + + self.label_info.config(text=info, fg="#64748b") + + +# ============================================================================== +# Startblock +# ============================================================================== +if __name__ == "__main__": + print("🚀 Starte Tkinter Klick-Zähler (Musterlösung)...") + try: + root = tk.Tk() + app = ZaehlerApp(root) + print("💡 Fenster geöffnet! Schließe das Fenster, um das Programm zu beenden.") + root.mainloop() + except Exception as e: + print(f"⚠️ GUI konnte nicht im Display geöffnet werden ({e}).") + print("💡 Führe 'python3 test_aufgabe.py' aus, um deine Lösung automatisch zu prüfen!") diff --git a/14_gui_mit_tkinter/test_aufgabe.py b/14_gui_mit_tkinter/test_aufgabe.py new file mode 100644 index 0000000..731a471 --- /dev/null +++ b/14_gui_mit_tkinter/test_aufgabe.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +🧪 Testsuite für Kapitel 14: Desktop-GUIs mit Tkinter & OOP +============================================================ +Überprüft: +- ZaehlerLogik: Reines Datenmodell (Werte, Grenzen, Schrittweiten, Klicks) +- ZaehlerApp: Tkinter-Oberfläche, Widget-Erstellung, Event-Handling, MVC-Synchronisation +- Headless-Kompatibilität: Läuft ohne GUI-Blockade in jeder Testumgebung! +""" + +import sys +import unittest +from pathlib import Path + +# Pfad zum aktuellen Kapitel hinzufügen +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import aufgabe +except ImportError as e: + raise ImportError(f"Konnte 'aufgabe.py' nicht importieren: {e}") + + +class TestKapitel14Logik(unittest.TestCase): + """Prüft das reine Datenmodell ZaehlerLogik.""" + + def setUp(self): + ZaehlerLogik = getattr(aufgabe, "ZaehlerLogik", None) + self.assertIsNotNone( + ZaehlerLogik, "Klasse 'ZaehlerLogik' in aufgabe.py nicht gefunden!" + ) + self.ZaehlerLogik = ZaehlerLogik + + def test_01_logik_initialisierung(self): + """Prüft Default-Werte und benutzerdefinierte Parameter.""" + # Standard-Instanziierung + z1 = self.ZaehlerLogik() + self.assertEqual(z1.wert, 0, "Default-Startwert sollte 0 sein.") + self.assertEqual(z1.min_wert, -100, "Default min_wert sollte -100 sein.") + self.assertEqual(z1.max_wert, 100, "Default max_wert sollte 100 sein.") + self.assertEqual(z1.schrittweite, 1, "Default-Schrittweite sollte 1 sein.") + self.assertEqual(z1.klick_anzahl, 0, "Default-Klickanzahl sollte 0 sein.") + + # Custom Instanziierung + z2 = self.ZaehlerLogik(startwert=10, min_wert=-20, max_wert=50) + self.assertEqual(z2.wert, 10) + self.assertEqual(z2.min_wert, -20) + self.assertEqual(z2.max_wert, 50) + + def test_02_logik_erhoehen_und_verringern(self): + """Prüft das Erhöhen und Verringern des Zählerstands und Zählen der Klicks.""" + z = self.ZaehlerLogik(startwert=0) + + # 1. Erhöhen + res = z.erhoehen() + self.assertEqual(res, 1, "erhoehen() sollte den neuen Wert (1) zurückgeben.") + self.assertEqual(z.wert, 1) + self.assertEqual(z.klick_anzahl, 1) + + z.erhoehen() + self.assertEqual(z.wert, 2) + self.assertEqual(z.klick_anzahl, 2) + + # 2. Verringern + res_v = z.verringern() + self.assertEqual(res_v, 1, "verringern() sollte den neuen Wert (1) zurückgeben.") + self.assertEqual(z.wert, 1) + self.assertEqual(z.klick_anzahl, 3) + + z.verringern() + z.verringern() + self.assertEqual(z.wert, -1) + self.assertEqual(z.klick_anzahl, 5) + + def test_03_logik_grenzwerte_clamping(self): + """Prüft, dass der Wert min_wert und max_wert nicht überschreitet.""" + z = self.ZaehlerLogik(startwert=9, min_wert=-5, max_wert=10) + + # Erhöhen bis zum Limit + z.erhoehen() # wert = 10 + self.assertEqual(z.wert, 10) + + # Weiteres Erhöhen darf 10 nicht überschreiten + z.erhoehen() + self.assertEqual(z.wert, 10, "Zählerstand darf max_wert nicht überschreiten!") + + # Verringern bis zum Minimal-Limit + z2 = self.ZaehlerLogik(startwert=-4, min_wert=-5, max_wert=10) + z2.verringern() # wert = -5 + self.assertEqual(z2.wert, -5) + + z2.verringern() # bleibt bei -5 + self.assertEqual(z2.wert, -5, "Zählerstand darf min_wert nicht unterschreiten!") + + def test_04_logik_schrittweite(self): + """Prüft das Setzen und Anwenden der Schrittweite.""" + z = self.ZaehlerLogik(startwert=0) + + # Gültige Schrittweite + ok = z.setze_schrittweite(5) + self.assertTrue(ok, "setze_schrittweite(5) sollte True zurückgeben.") + self.assertEqual(z.schrittweite, 5) + + z.erhoehen() + self.assertEqual(z.wert, 5, "erhoehen() sollte bei Schrittweite 5 um 5 steigen.") + + z.verringern() + self.assertEqual(z.wert, 0) + + # Ungültige Schrittweiten (0 oder negativ) + ok_null = z.setze_schrittweite(0) + self.assertFalse(ok_null, "setze_schrittweite(0) sollte False zurückgeben.") + self.assertEqual(z.schrittweite, 5, "Schrittweite darf sich bei 0 nicht ändern.") + + ok_neg = z.setze_schrittweite(-3) + self.assertFalse(ok_neg, "setze_schrittweite(-3) sollte False zurückgeben.") + self.assertEqual(z.schrittweite, 5, "Schrittweite darf sich bei negativen Werten nicht ändern.") + + def test_05_logik_zuruecksetzen(self): + """Prüft das Zurücksetzen auf 0.""" + z = self.ZaehlerLogik(startwert=42) + res = z.zuruecksetzen() + self.assertEqual(res, 0, "zuruecksetzen() sollte 0 zurückgeben.") + self.assertEqual(z.wert, 0) + self.assertEqual(z.klick_anzahl, 1) + + def test_06_logik_ist_gerade_und_str(self): + """Prüft die Geradheits-Prüfung und String-Repräsentation.""" + z = self.ZaehlerLogik(startwert=0) + self.assertTrue(z.ist_gerade(), "0 ist gerade.") + + z.erhoehen() # 1 + self.assertFalse(z.ist_gerade(), "1 ist ungerade.") + + z.erhoehen() # 2 + self.assertTrue(z.ist_gerade(), "2 ist gerade.") + + # __str__ Prüfung + text = str(z) + self.assertIn("2", text, "__str__ sollte den aktuellen Zählerstand enthalten.") + + +class TestKapitel14GUI(unittest.TestCase): + """Prüft die Tkinter-Benutzeroberfläche ZaehlerApp im Headless-Modus.""" + + def setUp(self): + ZaehlerApp = getattr(aufgabe, "ZaehlerApp", None) + self.assertIsNotNone( + ZaehlerApp, "Klasse 'ZaehlerApp' in aufgabe.py nicht gefunden!" + ) + self.ZaehlerApp = ZaehlerApp + + # Hauptfenster (oder MockTk) erzeugen + tk_mod = getattr(aufgabe, "tk", None) + self.assertIsNotNone(tk_mod, "Tkinter-Modul 'tk' in aufgabe.py nicht gefunden!") + self.root = tk_mod.Tk() + self.app = self.ZaehlerApp(self.root) + + def _get_widget_text(self, widget) -> str: + """Hilfsfunktion zum Auslesen des Texts eines Widgets.""" + if widget is None: + return "" + if hasattr(widget, "cget"): + try: + return str(widget.cget("text")) + except Exception: + pass + if hasattr(widget, "_text"): + return str(widget._text) + if isinstance(widget, dict): + return str(widget.get("text", "")) + return "" + + def test_07_gui_initialisierung(self): + """Prüft, ob alle Widgets angelegt wurden und die Startwerte stimmen.""" + self.assertIsNotNone(getattr(self.app, "root", None), "app.root fehlt!") + self.assertIsNotNone(getattr(self.app, "logik", None), "app.logik fehlt!") + + # Widgets überprüfen + self.assertTrue( + hasattr(self.app, "label_anzeige"), "app.label_anzeige fehlt!" + ) + self.assertTrue(hasattr(self.app, "label_info"), "app.label_info fehlt!") + self.assertTrue(hasattr(self.app, "btn_plus"), "app.btn_plus fehlt!") + self.assertTrue(hasattr(self.app, "btn_minus"), "app.btn_minus fehlt!") + self.assertTrue(hasattr(self.app, "btn_reset"), "app.btn_reset fehlt!") + self.assertTrue(hasattr(self.app, "entry_schritt"), "app.entry_schritt fehlt!") + + # Initialer Text im Anzeige-Label sollte "0" sein + anzeige_text = self._get_widget_text(self.app.label_anzeige) + self.assertEqual( + anzeige_text, "0", f"Startwert im Anzeige-Label sollte '0' sein, war aber '{anzeige_text}'" + ) + + def test_08_gui_klick_plus_und_minus(self): + """Prüft, ob klick_plus und klick_minus die Logik und das Label aktualisieren.""" + # 1. Plus-Klick auslösen + self.app.klick_plus() + self.assertEqual(self.app.logik.wert, 1, "Logik-Wert sollte 1 sein.") + self.assertEqual( + self._get_widget_text(self.app.label_anzeige), + "1", + "Anzeige-Label sollte nach Plus-Klick '1' zeigen.", + ) + + # 2. Weiterer Plus-Klick + self.app.klick_plus() + self.assertEqual(self.app.logik.wert, 2) + self.assertEqual(self._get_widget_text(self.app.label_anzeige), "2") + + # 3. Minus-Klick + self.app.klick_minus() + self.assertEqual(self.app.logik.wert, 1) + self.assertEqual(self._get_widget_text(self.app.label_anzeige), "1") + + def test_09_gui_klick_reset(self): + """Prüft, ob klick_reset den Zähler und das Label zurücksetzt.""" + self.app.klick_plus() + self.app.klick_plus() + self.assertEqual(self.app.logik.wert, 2) + + self.app.klick_reset() + self.assertEqual(self.app.logik.wert, 0, "Logik-Wert sollte 0 nach Reset sein.") + self.assertEqual( + self._get_widget_text(self.app.label_anzeige), + "0", + "Anzeige-Label sollte nach Reset '0' zeigen.", + ) + + def test_10_gui_schrittweite_setzen(self): + """Prüft, ob klick_schritt_setzen den Wert aus entry_schritt übernimmt.""" + # Wert im Entry-Feld anpassen + self.app.entry_schritt.delete(0, "end") + self.app.entry_schritt.insert(0, "10") + + # Setzen auslösen + self.app.klick_schritt_setzen() + self.assertEqual( + self.app.logik.schrittweite, + 10, + "Schrittweite in Logik sollte auf 10 gesetzt werden.", + ) + + # Plus klicken -> Sprung um 10 + self.app.klick_plus() + self.assertEqual(self.app.logik.wert, 10) + self.assertEqual(self._get_widget_text(self.app.label_anzeige), "10") + + def test_11_gui_ungueltige_eingabe_abfangen(self): + """Prüft robuste Fehlerbehandlung bei ungültigen Entry-Eingaben.""" + # Text statt Zahl eingeben + self.app.entry_schritt.delete(0, "end") + self.app.entry_schritt.insert(0, "keine_zahl") + + # Darf keinen Absturz/Exception erzeugen + try: + self.app.klick_schritt_setzen() + except Exception as e: + self.fail(f"klick_schritt_setzen() ist bei ungültiger Eingabe abgestürzt: {e}") + + # Schrittweite sollte unverändert geblieben sein + self.assertEqual(self.app.logik.schrittweite, 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/README.md b/README.md index 5e12399..260942d 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,13 @@ Diese Übungsreihe ist didaktisch aufbereitet und **1:1 mit dem offiziellen Lehr | Kapitel | Thema | Was du lernst | | :--- | :--- | :--- | | **[07. Referenzen, Speicher & Stammbäume](./07_referenzen_und_speicher/)** | **Speicher & RAM** | **Referenzen, Aliasing (`b = a`), Mutable vs. Immutable, `is` vs. `==` & Familienstammbaum** | -| *08. Operator Overloading* | *Dunder-Methoden* | *`__add__`, `__eq__`, `__lt__` (2D-Vektoren & Kartenspiele)* | -| *09. Eigene Unit Tests & TDD* | *Softwarequalität* | *Testsuiten schreiben mit `unittest`, `assertEqual`, `assertTrue`, `assertRaises`* | -| *10. Vererbung & `super()`* | *Inheritance* | *Basisklassen, Kindklassen & `super().__init__()` (Fahrzeuge & RPG-Helden)* | -| *11. Polymorphie & Interfaces* | *Polymorphism* | *Methoden überschreiben, Geometrie- & Grafik-Engine* | -| *12. Exceptions & Fehler* | *Robustes Coding* | *`try/except/else/finally`, `raise`, eigene Exception-Klassen* | -| *13. JSON & CSV Speichern* | *Persistenz* | *Spielstände dauerhaft mit JSON/CSV auf der Festplatte sichern* | +| **[08. Operator Overloading](./08_operator_overloading_dunder/)** | **Dunder-Methoden** | **`__add__`, `__mul__`, `__eq__`, `__lt__` (2D-Vektoren & Wegstrecken)** | +| **[09. Eigene Unit Tests & TDD](./09_eigene_unit_tests_schreiben/)** | **Softwarequalität** | **Testsuiten schreiben mit `unittest`, `assertEqual`, `assertTrue`, `assertRaises`** | +| **[10. Vererbung & `super()`](./10_vererbung_und_super/)** | **Inheritance** | **Basisklassen, Kindklassen, DRY-Prinzip & `super().__init__()` (Fahrzeugflotte)** | +| **[11. Polymorphie & Interfaces](./11_polymorphie_und_interfaces/)** | **Polymorphism** | **Vielgestaltigkeit, Duck Typing, abstrakte Basisklassen (`ABC`) & Geometrie-Engine** | +| **[12. Exceptions & Fehler](./12_exceptions_und_fehlerbehandlung/)** | **Robustes Coding** | **`try/except/else/finally`, `raise`, eigene Exception-Klassen (Bankkonto)** | +| **[13. JSON & CSV Speichern](./13_persistenz_json_und_csv/)** | **Persistenz** | **Spielstände dauerhaft mit JSON/CSV auf der Festplatte sichern** | +| **[14. Desktop-GUIs mit Tkinter](./14_gui_mit_tkinter/)** | **Grafische Oberflächen** | **Fenster, Buttons, Labels, Grid-Layout & MVC-Architektur** | *(Den vollständigen didaktischen Masterplan für alle Kapitel bis 16 findest du in [`LEHRPLAN.md`](./LEHRPLAN.md).)* diff --git a/index.html b/index.html index 093d0a1..18cd813 100644 --- a/index.html +++ b/index.html @@ -149,32 +149,82 @@ - - + + - - + + - + + +
+ Kapitel 10 • Neu +
🧬 Vererbung & super()
+
DRY-Prinzip, Basis- & Kindklassen, super().__init__(), Methoden erweitern & Fahrzeugflotten-Hierarchie.
+
+ +
+ + + +
+ Kapitel 11 • Neu +
🎭 Polymorphie & Interfaces
+
Vielgestaltigkeit, Duck Typing, abstrakte Basisklassen (ABC), Geometrie-Engine & Zeichenflächen-Manager.
+
+ +
+ + + +
+ Kapitel 12 • Neu +
🛡️ Exceptions & Fehlerbehandlung
+
Robuste Fehlerkontrolle mit try-except-else-finally, raise und eigene Exception-Klassen im Bankkonto-Simulator.
+
+ +
+ + + +
+ Kapitel 13 • Neu +
💾 Datei-Persistenz: JSON & CSV
+
Dauerhaftes Speichern & Laden: Savegames mit to_dict()/from_dict(), json.dump und CSV-Tabellen-Export.
+
+ +
+ + + +
+ Kapitel 14 • Neu +
🖥️ Desktop-GUIs mit Tkinter
+
Fenster, Buttons, Labels, Grid-Layout, Event-Loop und MVC-Architektur im interaktiven Zähler & Rechner.
+
+ +
+ +
- Kapitel 10 - 13 • Geplant -
🧬 Vererbung, Exceptions & JSON
-
Vererbung & super() (10), Polymorphie (11), Eigene Fehlerklassen (12) und Savegames mit JSON/CSV sichern (13).
+ Weitere Kapitel • Geplant +
🧬 Vererbung, Polymorphie & Master-Projekt
+
Operator Overloading (08), Unit Testing (09), Vererbung (10), Polymorphie (11), Parameter (15) und Master-Projekt (16).
diff --git a/test_all.py b/test_all.py index 4854224..d78b05f 100644 --- a/test_all.py +++ b/test_all.py @@ -19,6 +19,13 @@ KAPITEL = [ ("Kapitel 05: Objekte kombinieren", "05_objekte_kombinieren"), ("Kapitel 06: Abschlussprojekt Tamagotchi", "06_abschlussprojekt_tamagotchi"), ("Kapitel 07: Referenzen & Stammbäume", "07_referenzen_und_speicher"), + ("Kapitel 08: Operator Overloading & Dunder", "08_operator_overloading_dunder"), + ("Kapitel 09: Eigene Unit Tests & TDD", "09_eigene_unit_tests_schreiben"), + ("Kapitel 10: Vererbung & super()", "10_vererbung_und_super"), + ("Kapitel 11: Polymorphie & Interfaces", "11_polymorphie_und_interfaces"), + ("Kapitel 12: Exceptions & Fehlerbehandlung", "12_exceptions_und_fehlerbehandlung"), + ("Kapitel 13: Persistenz (JSON & CSV)", "13_persistenz_json_und_csv"), + ("Kapitel 14: Desktop-GUIs mit Tkinter", "14_gui_mit_tkinter"), ] def main():