diff --git a/14_gui_mit_tkinter/index.html b/14_gui_mit_tkinter/index.html index 9981896..ea16cd9 100644 --- a/14_gui_mit_tkinter/index.html +++ b/14_gui_mit_tkinter/index.html @@ -341,7 +341,8 @@ diff --git a/15_parameter_und_container/README.md b/15_parameter_und_container/README.md new file mode 100644 index 0000000..4c300fe --- /dev/null +++ b/15_parameter_und_container/README.md @@ -0,0 +1,157 @@ +# Kapitel 15: Parameter (*args, **kwargs) & Eigene Container 🎒📦 + +Willkommen zu **Kapitel 15**! In diesem Modul meisterst du zwei der mächtigsten und flexibelsten Sprachfeatures von Python: +1. **Variable Parameterlisten** (`*args` und `**kwargs`) sowie **Argument-Unpacking** (`*liste`, `**dict`). +2. **Eigene Container-Klassen**, die sich dank magischer **Dunder-Methoden** genau wie Pythons eingebaute Listen und Dictionaries verhalten (`len()`, `[]`, `del`, `in`, `for`). + +--- + +## 🧭 Didaktischer Hintergrund (Schulabgleich 29.0 & 30.0) + +In der professionellen Softwareentwicklung und im Lehrplan sind flexible Schnittstellen und eigene Datenstrukturen essenziell: +- **Schulabgleich 29.0:** Variable Parameterlisten (`*args`, `**kwargs`) und Argument-Unpacking. +- **Schulabgleich 30.0:** Eigene Sequenzen und Container-Klassen mit dem Python-Container-Protokoll (`__len__`, `__getitem__`, `__setitem__`, `__delitem__`, `__contains__`, `__iter__`). + +--- + +## 🧰 1. Variable Parameterlisten: `*args` und `**kwargs` + +Normalerweise hat eine Python-Funktion eine feste Anzahl von Parametern: +```python +def addiere_zwei(a: float, b: float) -> float: + return a + b +``` +Was aber, wenn du eine Funktion schreiben möchtest, die **2, 5 oder 100 Zahlen** zusammenrechnet? Oder eine Funktion, die beliebige Spieler-Attribute entgegennimmt? + +--- + +### A) `*args` (Beliebig viele Positionsargumente als Tupel) +Das Sternchen `*` vor einem Parameternamen signalisiert Python: *„Sammle alle überschüssigen Positionsargumente in einem unveränderlichen **Tupel**!“* + +```python +def berechne_summe(*zahlen: float) -> float: + print(type(zahlen)) # + return sum(zahlen) + +print(berechne_summe(10, 20)) # -> 30 (zahlen ist (10, 20)) +print(berechne_summe(1, 2, 3, 4, 5)) # -> 15 (zahlen ist (1, 2, 3, 4, 5)) +print(berechne_summe()) # -> 0 (zahlen ist ()) +``` + +> 💡 **Analogie:** `*args` ist wie ein **unendlicher Werkzeuggürtel**. Egal ob du 1 Schraubendreher oder 10 Hämmer hineinsteckst – alle werden gebündelt in ein Tupel gelegt. + +--- + +### B) `**kwargs` (Beliebig viele Schlüsselwortargumente als Dict) +Zwei Sternchen `**` signalisieren Python: *„Sammle alle benannten Argumente (`schluessel=wert`) in einem **Dictionary**!“* + +```python +def erstelle_held(name: str, **attribute) -> dict: + print(type(attribute)) # + return {"name": name, **attribute} + +held = erstelle_held("Legolas", klasse="Waldlaeufer", level=15, waffe="Elbenbogen") +# -> {'name': 'Legolas', 'klasse': 'Waldlaeufer', 'level': 15, 'waffe': 'Elbenbogen'} +``` + +> 💡 **Analogie:** `**kwargs` ist wie ein **ausfüllbares Formular**. Der Aufrufer kann beliebig viele zusätzliche Zeilen mit Label und Wert ergänzen. + +--- + +### C) Die goldene Reihenfolge bei Parametern +Wenn du normale Parameter, `*args`, Default-Werte und `**kwargs` kombinierst, gilt stets diese feste Reihenfolge: + +```python +def super_funktion(pos1, pos2, *args, standard_wert=10, **kwargs): + pass +``` + +1. **Positionsargumente** (`pos1`, `pos2`) +2. **`*args`** (Restliche Positionsargumente) +3. **Keyword-Only / Default-Parameter** (`standard_wert=10`) +4. **`**kwargs`** (Restliche Keyword-Argumente) + +--- + +## 🎁 2. Argument-Unpacking: `*liste` und `**dict` + +Der Stern-Operator funktioniert nicht nur bei der **Definition** von Funktionen (Packen), sondern auch beim **Aufruf** (Entpacken / Unpacking): + +```python +# 1. Listen-Unpacking mit * +preise = [19.99, 49.99, 9.99] +# Statt berechne_summe(preise[0], preise[1], preise[2]): +gesamt = berechne_summe(*preise) + +# 2. Dictionary-Unpacking mit ** +config = {"klasse": "Magier", "level": 20, "element": "Feuer"} +# Statt erstelle_held("Merlin", klasse="Magier", level=20, element="Feuer"): +magier = erstelle_held("Merlin", **config) +``` + +--- + +## 🎒 3. Eigene Container-Klassen mit Dunder-Methoden + +Warum fühlt sich Python so elegant an? Weil eingebaute Typen (`list`, `dict`) und eigene Klassen **exakt dieselben Operatoren** nutzen können! + +Wenn du eine Klasse `Inventar` erstellst, möchtest du: +- `len(inventar)` statt `inventar.get_anzahl()` +- `inventar[0]` oder `inventar["Schwert"]` statt `inventar.get_element(0)` +- `inventar[0] = trank` statt `inventar.set_element(0, trank)` +- `del inventar["Schwert"]` statt `inventar.remove_element("Schwert")` +- `'Heiltrank' in inventar` statt `inventar.enthaelt("Heiltrank")` +- `for item in inventar:` statt umständlicher Index-Schleifen + +### Die magischen Container-Dunder im Überblick: + +| Dunder-Methode | Ausgelöst durch | Zweck | +| :--- | :--- | :--- | +| `__len__(self)` | `len(container)` | Gibt die Anzahl der Elemente als `int` zurück. | +| `__getitem__(self, key)` | `container[key]` | Liest Element an Index `key` (z.B. int `0` oder str `"Schwert"`). | +| `__setitem__(self, key, val)` | `container[key] = val` | Setzt/Ersetzt Element an Index `key`. | +| `__delitem__(self, key)` | `del container[key]` | Löscht das Element an Index `key`. | +| `__contains__(self, item)` | `item in container` | Gibt `True` zurück, wenn `item` enthalten ist, sonst `False`. | +| `__iter__(self)` | `for x in container:` | Gibt einen Iterator zurück (`iter(self._items)`). | + +--- + +## 🎮 4. Praxisbeispiel: Das Gaming-Inventar + +In `aufgabe.py` baust du das RPG-Inventar eines Helden: + +``` +┌────────────────────────────────────────────────────────┐ +│ 🎒 Inventar (Maximalgewicht: 20.0 kg) │ +│ ├── [0] Eisenschwert (3.5 kg, 120 G) │ +│ ├── [1] Holzschild (2.0 kg, 45 G) │ +│ └── [2] Heiltrank (0.5 kg, 20 G) │ +│ ───────────────────────────────────────────────── │ +│ Gesamtgewicht: 6.0 / 20.0 kg (Frei: 14.0 kg) │ +│ len(inv) -> 3 │ +│ "Eisenschwert" in inv -> True │ +│ inv["Heiltrank"] -> Gegenstand('Heiltrank', ...) │ +└────────────────────────────────────────────────────────┘ +``` + +--- + +## 📁 Die Dateien in diesem Ordner + +- **`index.html`**: Interaktive Web-Lernseite mit visuellen Tabellen, Rucksack-Analogie, Cheat-Sheets und Checkliste. +- **`aufgabe.py`**: Dein Arbeitsblatt mit Type Hints und TODOs für `*args`, `**kwargs` und die Klasse `Inventar`. +- **`test_aufgabe.py`**: Automatische Unittest-Suite zur Überprüfung aller Anforderungen. +- **`musterloesung.py`**: Vollständig ausprogrammierte Referenzlösung. + +--- + +## 🚀 Schnellstart + +```bash +# 1. Bearbeite aufgabe.py +# 2. Führe die automatischen Tests aus: +python3 test_aufgabe.py + +# 3. Oder starte aufgabe.py direkt: +python3 aufgabe.py +``` diff --git a/15_parameter_und_container/aufgabe.py b/15_parameter_und_container/aufgabe.py new file mode 100644 index 0000000..5f7b4aa --- /dev/null +++ b/15_parameter_und_container/aufgabe.py @@ -0,0 +1,360 @@ +""" +Kapitel 15: Parameter (*args, **kwargs) & Eigene Container-Klassen 🎒📦 +====================================================================== +Aufgabe: Lerne flexible Funktionsparameter kennen und baue eine +vollwertige Gaming-Inventar-Containerklasse mit magischen Dunder-Methoden! + +Themen: +1. *args (beliebig viele Positionsargumente als Tupel) +2. **kwargs (beliebig viele Schlüsselwortargumente als Dictionary) +3. Argument-Unpacking (*liste, **dict) +4. Eigene Container mit __len__, __getitem__, __setitem__, __delitem__, __contains__, __iter__ +""" + +import math +from typing import Union, List, Optional, Dict, Any, Iterator + + +# ============================================================================== +# TEIL 1: FUNKTIONEN MIT *ARGS UND **KWARGS +# ============================================================================== + +def berechne_gesamtsumme(*zahlen: float, rabatt_prozent: float = 0.0) -> float: + """ + Berechnet die Gesamtsumme beliebig vieler Zahlen (*args) und wendet + einen optionalen prozentualen Rabatt an. + + Parameter: + *zahlen (float): Beliebig viele numerische Werte (als Tupel empfangen). + rabatt_prozent (float): Rabatt in Prozent (0.0 bis 100.0). Standard ist 0.0. + + Rückgabe: + float: Die berechnete Gesamtsumme nach Rabatt, gerundet auf 2 Nachkommastellen. + + Exceptions: + ValueError: Falls rabatt_prozent < 0.0 oder > 100.0 ist. + + Beispiele: + berechne_gesamtsumme(10.0, 20.0, 30.0) -> 60.0 + berechne_gesamtsumme(100.0, 50.0, rabatt_prozent=10.0) -> 135.0 + berechne_gesamtsumme() -> 0.0 + """ + # ========================================================================== + # TODO 1: Implementiere berechne_gesamtsumme + # 1. Validiere rabatt_prozent: Liegt der Wert nicht zwischen 0.0 und 100.0, + # löse einen ValueError mit passender Nachricht aus. + # 2. Summiere alle Werte in `zahlen` (wenn zahlen leer ist, ist die Summe 0.0). + # 3. Ziehe den Rabatt ab: summe * (1.0 - rabatt_prozent / 100.0) + # 4. Runde das Ergebnis mit round(..., 2) und gib es zurück. + # ========================================================================== + pass + + +def erstelle_profil(name: str, **details: Any) -> Dict[str, Any]: + """ + Erstellt ein Profil-Dictionary mit dem Namen und beliebigen weiteren + Schlüsselwort-Attributen (**kwargs). + + Parameter: + name (str): Der Name der Person oder Spielfigur. + **details (Any): Beliebige Zusatzinformationen (z.B. alter=25, klasse="Krieger"). + + Rückgabe: + Dict[str, Any]: Ein Dictionary, das {"name": name} enthält, ergänzt um + alle übergebenen Keyword-Argumente. + + Beispiele: + erstelle_profil("Aragorn", klasse="Waldlaeufer", level=20) + -> {"name": "Aragorn", "klasse": "Waldlaeufer", "level": 20} + """ + # ========================================================================== + # TODO 2: Implementiere erstelle_profil + # 1. Erstelle ein Dictionary mit {"name": str(name)}. + # 2. Füge alle Schlüssel-Wert-Paare aus `details` hinzu. + # 3. Gib das fertige Dictionary zurück. + # ========================================================================== + pass + + +# ============================================================================== +# TEIL 2: DIE DATENKLASSE GEGENSTAND (ITEM) +# ============================================================================== + +class Gegenstand: + """ + Repräsentiert einen Gegenstand im Spielinventar. + """ + + def __init__(self, name: str, gewicht: float, wert: int = 0): + """ + Initialisiert einen Gegenstand. + + Parameter: + name (str): Bezeichnung des Gegenstands. + gewicht (float): Gewicht in Kilogramm (muss >= 0 sein). + wert (int): Verkaufswert in Goldstücken (muss >= 0 sein, Standard 0). + + Exceptions: + ValueError: Falls gewicht < 0 oder wert < 0 ist. + """ + # ========================================================================== + # TODO 3: Implementiere Gegenstand.__init__, __repr__, __str__ und __eq__ + # 1. Validiere gewicht >= 0 und wert >= 0 (sonst ValueError). + # 2. Setze self.name = str(name), self.gewicht = float(gewicht), self.wert = int(wert). + # ========================================================================== + pass + + def __repr__(self) -> str: + """ + Entwickler-Darstellung, z.B. "Gegenstand(name='Heiltrank', gewicht=0.5, wert=25)" + """ + pass + + def __str__(self) -> str: + """ + Benutzerfreundliche Darstellung, z.B. "Heiltrank (0.5kg, 25G)" + """ + pass + + def __eq__(self, other: object) -> bool: + """ + Zwei Gegenstände sind gleich, wenn Name, Gewicht und Wert übereinstimmen. + """ + pass + + +# ============================================================================== +# TEIL 3: DIE EIGENE CONTAINER-KLASSE INVENTAR +# ============================================================================== + +class Inventar: + """ + Eine maßgeschneiderte Container-Klasse für ein Gaming-Rucksack-Inventar. + Unterstützt Maximalgewicht, *args-Hinzufügen und alle Python-Container-Dunder! + """ + + def __init__(self, max_gewicht: float = 20.0, gegenstaende: Optional[List[Gegenstand]] = None): + """ + Initialisiert das Inventar mit einem Maximalgewicht und optionalen Start-Gegenständen. + + Parameter: + max_gewicht (float): Maximal tragbares Gewicht (muss > 0 sein, Standard 20.0). + gegenstaende (Optional[List[Gegenstand]]): Optionale Liste von Gegenständen. + + Exceptions: + ValueError: Falls max_gewicht <= 0 oder die Start-Gegenstände das Maximalgewicht überschreiten. + """ + # ========================================================================== + # TODO 4: Inventar-Initialisierung + # 1. Validiere max_gewicht > 0 (sonst ValueError). + # 2. Speichere self.max_gewicht = float(max_gewicht). + # 3. Initialisiere eine interne Liste self._items = []. + # 4. Falls `gegenstaende` übergeben wurden, füge sie mit self.hinzufuegen(*gegenstaende) hinzu. + # ========================================================================== + pass + + @property + def gesamtgewicht(self) -> float: + """ + Berechnet das aktuelle Gesamtgewicht aller enthaltenen Gegenstände, + gerundet auf 2 Nachkommastellen. + """ + # ========================================================================== + # TODO 5a: Berechne Gesamtgewicht (Summe aller item.gewicht) + # ========================================================================== + pass + + @property + def freie_kapazitaet(self) -> float: + """ + Gibt das noch verfügbare Restgewicht bis zum Maximalgewicht zurück. + """ + # ========================================================================== + # TODO 5b: Berechne freie Kapazität (max_gewicht - gesamtgewicht, gerundet auf 2 Stellen) + # ========================================================================== + pass + + def hinzufuegen(self, *items: Gegenstand) -> None: + """ + Fügt ein oder mehrere Gegenstände (*args) zum Inventar hinzu. + + Parameter: + *items (Gegenstand): Beliebig viele Gegenstand-Objekte. + + Exceptions: + TypeError: Falls ein Argument kein `Gegenstand`-Objekt ist. + ValueError: Falls das Hinzufügen aller übergebenen Gegenstände + das Maximalgewicht des Inventars überschreiten würde. + """ + # ========================================================================== + # TODO 6: hinzufuegen mit *args + # 1. Prüfe für jedes item in `items`, ob es eine Instanz von Gegenstand ist (sonst TypeError). + # 2. Berechne das Zusatzgewicht aller neuen Gegenstände. + # 3. Wenn self.gesamtgewicht + zusatz_gewicht > self.max_gewicht: + # Löse einen ValueError("Maximalgewicht überschritten!") aus. + # 4. Hänge alle items an self._items an. + # ========================================================================== + pass + + def __len__(self) -> int: + """ + Ermöglicht len(inventar) -> Gibt die Anzahl der Gegenstände zurück. + """ + # ========================================================================== + # TODO 7: __len__ + # ========================================================================== + pass + + def __getitem__(self, key: Union[int, str]) -> Gegenstand: + """ + Ermöglicht Index- und Namenszugriff: + - inventar[0] -> Gibt den Gegenstand an Position 0 zurück (int-Index). + - inventar["Heiltrank"] -> Sucht den ersten Gegenstand mit diesem Namen (case-insensitive). + + Exceptions: + IndexError: Bei ungültigem Integer-Index. + KeyError: Falls kein Gegenstand mit dem übergebenen String-Namen gefunden wurde. + TypeError: Falls key weder int noch str ist. + """ + # ========================================================================== + # TODO 8: __getitem__ (Multi-Type Lookup: int und str) + # 1. Wenn isinstance(key, int): gib self._items[key] zurück. + # 2. Wenn isinstance(key, str): suche den ersten Gegenstand mit passendem Namen + # (Vergleich mit g.name.lower() == key.lower()). Wenn gefunden, zurückgeben. + # Wurde kein Item gefunden, löse KeyError aus. + # 3. Andernfalls löse TypeError aus. + # ========================================================================== + pass + + def __setitem__(self, index: int, value: Gegenstand) -> None: + """ + Ermöglicht Zuweisung per Index: inventar[0] = neuer_gegenstand. + + Exceptions: + TypeError: Falls index kein int oder value kein Gegenstand ist. + IndexError: Falls index außerhalb des gültigen Bereichs liegt. + ValueError: Falls durch den Austausch das Maximalgewicht überschritten würde. + """ + # ========================================================================== + # TODO 9: __setitem__ + # 1. Prüfe Typen (index muss int sein, value muss Gegenstand sein, sonst TypeError). + # 2. Prüfe Indexgrenzen: -len(self._items) <= index < len(self._items) (sonst IndexError). + # 3. Berechne neues Gesamtgewicht: gesamtgewicht - altes_item.gewicht + value.gewicht. + # Wenn neues_gesamtgewicht > max_gewicht: löse ValueError aus. + # 4. Ersetze das Element: self._items[index] = value. + # ========================================================================== + pass + + def __delitem__(self, key: Union[int, str]) -> None: + """ + Ermöglicht das Löschen per del-Keyword: + - del inventar[0] -> Löscht per Integer-Index. + - del inventar["Heiltrank"] -> Löscht das erste gefundene Item mit diesem Namen. + + Exceptions: + IndexError: Bei ungültigem int-Index. + KeyError: Wenn String-Name nicht existiert. + TypeError: Falls key weder int noch str ist. + """ + # ========================================================================== + # TODO 10: __delitem__ + # 1. Wenn int: del self._items[key]. + # 2. Wenn str: suche erstes Element mit passendem Namen (case-insensitive) und entferne es. + # Falls nicht vorhanden -> KeyError. + # 3. Andernfalls -> TypeError. + # ========================================================================== + pass + + def __contains__(self, item: Union[Gegenstand, str]) -> bool: + """ + Ermöglicht den `in`-Operator: + - "Heiltrank" in inventar -> True/False (Name-Suche, case-insensitive) + - gegenstand_objekt in inventar -> True/False (Objekt-Prüfung) + """ + # ========================================================================== + # TODO 11: __contains__ + # 1. Wenn str: prüfe, ob ein Gegenstand mit diesem Namen existiert (case-insensitive). + # 2. Wenn Gegenstand: prüfe `item in self._items`. + # 3. Andernfalls gebe False zurück. + # ========================================================================== + pass + + def __iter__(self) -> Iterator[Gegenstand]: + """ + Ermöglicht Iteration: for item in inventar: ... + """ + # ========================================================================== + # TODO 12: __iter__ + # Gib einen Iterator über self._items zurück (z.B. iter(self._items)). + # ========================================================================== + pass + + def __repr__(self) -> str: + """Entwickler-Darstellung.""" + return f"Inventar(max_gewicht={self.max_gewicht}, gegenstaende={self._items!r})" + + def __str__(self) -> str: + """Benutzerfreundliche Zusammenfassung.""" + return f"Inventar ({len(self)} Gegenstände, {self.gesamtgewicht:.1f}/{self.max_gewicht:.1f} kg)" + + +# ============================================================================== +# TEIL 4: UNPACKING-HELPER-FUNKTION +# ============================================================================== + +def packe_inventar(*gegenstaende: Gegenstand, max_gewicht: float = 20.0) -> Inventar: + """ + Erstellt ein neues Inventar und packt alle übergebenen Gegenstände (*args) hinein. + Demonstriert Argument-Unpacking und flexible Parameter. + """ + # ========================================================================== + # TODO 13: packe_inventar + # 1. Erstelle ein Inventar mit `max_gewicht=max_gewicht`. + # 2. Füge alle `gegenstaende` mit inv.hinzufuegen(*gegenstaende) hinzu. + # 3. Gib das befüllte Inventar zurück. + # ========================================================================== + pass + + +# ============================================================================== +# INTERAKTIVER TERMINAL-TEST +# (python3 aufgabe.py) +# ============================================================================== +if __name__ == "__main__": + print("🎒 Kapitel 15: Parameter & Eigene Container-Klassen\n" + "=" * 55) + + # 1. Test *args & Rabatt + preise = [19.99, 49.99, 9.99, 29.50] + summe_rabatt = berechne_gesamtsumme(*preise, rabatt_prozent=15.0) + print(f"1. *args Summe mit 15% Rabatt: {summe_rabatt} €") + + # 2. Test **kwargs Profil + held_daten = {"klasse": "Magier", "stufe": 12, "mana": 350, "element": "Feuer"} + profil = erstelle_profil("Ignis", **held_daten) + print(f"2. **kwargs Profil: {profil}") + + # 3. Test Container Inventar + schwert = Gegenstand("Eisenschwert", 4.5, 120) + schild = Gegenstand("Holzschild", 3.0, 45) + trank = Gegenstand("Heiltrank", 0.5, 20) + + inv = Inventar(max_gewicht=15.0) + try: + inv.hinzufuegen(schwert, schild, trank) + print(f"\n3. Inventar initialisiert: {inv}") + print(f" Anzahl Gegenstände (len): {len(inv)}") + print(f" Gesamtgewicht: {inv.gesamtgewicht} / {inv.max_gewicht} kg") + print(f" Erster Gegenstand [0]: {inv[0]}") + print(f" Suche nach Name ['Heiltrank']: {inv['Heiltrank']}") + print(f" Ist 'Holzschild' im Rucksack? {'Holzschild' in inv}") + + print("\n Alle Gegenstände im Rucksack:") + for item in inv: + print(f" - {item}") + + print("\n Lösche Heiltrank...") + del inv["Heiltrank"] + print(f" Neues Gesamtgewicht: {inv.gesamtgewicht} kg (Anzahl: {len(inv)})") + + except Exception as e: + print(f" ⏳ Noch nicht vollständig implementiert: {e}") diff --git a/15_parameter_und_container/index.html b/15_parameter_und_container/index.html new file mode 100644 index 0000000..2a25bfa --- /dev/null +++ b/15_parameter_und_container/index.html @@ -0,0 +1,316 @@ + + + + + + Kapitel 15: Parameter (*args, **kwargs) & Eigene Container + + + + +
+
+ +
Kapitel 15 • Schulabgleich 29.0 & 30.0
+

Parameter (*args, **kwargs) & Eigene Container 🎒📦

+

Mache Funktionen maximal flexibel mit variablen Argumenten und baue eigene Datenstrukturen mit den magischen Container-Dunder-Methoden von Python.

+
+
+ +
+ + +
+

1. Warum flexible Parameter & eigene Container?

+ +

+ In einfachen Programmen hat jede Funktion eine starre Anzahl von Parametern (z.B. def addiere(a, b)). + In realen Software-Projekten möchten wir jedoch oft: +

+
    +
  • Funktionen aufrufen, die beliebig viele Argumente annehmen (wie print() oder max()).
  • +
  • Optionale Konfigurationen und Attribute flexibel übergeben (wie bei Profilen oder Einstellungen).
  • +
  • Eigene Klassen bauen, die sich wie Listen oder Dictionaries anfühlen (mit len(), [index], in und for-Schleifen).
  • +
+ +
+
🧰 Die Werkzeugkoffer- & Rucksack-Analogie:
+
    +
  • *args (Der Werkzeuggürtel): Du packst beliebig viele Werkzeuge hinein. Python schnürt sie automatisch zu einem unveränderlichen Tupel zusammen.
  • +
  • **kwargs (Das Etiketten-Fach): Du übergibst Dinge mit Beschriftung (z.B. schluessel=wert). Python bündelt sie zu einem Dictionary.
  • +
  • Container-Klasse (Der smarte Rucksack): Dein Rucksack weiß, wie schwer er maximal sein darf, wie viele Gegenstände hineinpassen (len()), lässt dich per Griff hineinfassen ([0] oder ["Schwert"]) und prüft, ob Proviant eingepackt ist (in).
  • +
+
+
+ + +
+

2. Cheat-Sheet: *args, **kwargs & Unpacking

+ +

+ Python unterscheidet zwischen dem Packen (in der Funktionsdefinition) und dem Entpacken (beim Funktionsaufruf): +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SyntaxTyp in PythonBedeutung & VerwendungCode-Beispiel
*argstupleNimmt beliebig viele Positionsargumente entgegen.def f(*zahlen): return sum(zahlen)
**kwargsdictNimmt beliebige Schlüsselwortargumente (k=v) entgegen.def f(**opt): return opt.get("lang", "de")
*listeUnpackingEntpackt Elemente einer Liste/Sequenz als einzelne Argumente.werte = [10, 20]; f(*werte)
**dictUnpackingEntpackt Dictionary-Paare als benannte Schlüsselwortargumente.daten = {"alter": 25}; f(**daten)
+ +
+
⚠️ Die feste Parameter-Reihenfolge in Funktionsköpfen:
+ def meine_funktion(pos1, pos2, *args, standard_option=True, **kwargs): +
    +
  1. Feste Positionsargumente (pos1, pos2)
  2. +
  3. Variable Positionsargumente (*args)
  4. +
  5. Optionale Argumente mit Default-Werten / Keyword-Only (standard_option=True)
  6. +
  7. Variable Keyword-Argumente (**kwargs)
  8. +
+
+
+ + +
+

3. Das Python Container-Protokoll: Magische Dunder-Methoden

+ +

+ Damit sich eine eigene Klasse wie ein nativer Python-Container verhält, überschreiben wir die speziellen Double-Underscore (Dunder) Methoden: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Dunder-MethodeAuslöser (Python-Syntax)Rückgabe / VerhaltenFehler bei Ungültigkeit
__len__(self)len(container)Gibt die Anzahl der Elemente als int >= 0 zurück.TypeError bei nicht-int
__getitem__(self, key)container[key]Liest Element an Index key (z.B. int oder str-Name).IndexError / KeyError
__setitem__(self, key, val)container[key] = valErsetzt oder setzt das Element an Position key.IndexError / ValueError
__delitem__(self, key)del container[key]Entfernt das Element an Position oder mit Name key.IndexError / KeyError
__contains__(self, item)item in containerGibt True zurück, wenn Element existiert, sonst False.-
__iter__(self)for x in container:Gibt einen Iterator zurück (z.B. iter(self._items)).-
+
+ + +
+

4. Das Gaming-Inventar im Detail

+ +

+ In dieser Übung implementierst du ein vollwertiges Rollenspiel-Inventar: +

+ +
+
🎒 Klasse: Inventar (max_gewicht=15.0 kg)
+
+
├── [0] Eisenschwert (4.5 kg, 120 G)
+
├── [1] Holzschild (3.0 kg, 45 G)
+
└── [2] Heiltrank (0.5 kg, 20 G)
+
+
+ 📊 Status: 3 Gegenstände | Gewicht: 8.0 / 15.0 kg (Frei: 7.0 kg) +
+
+ • inv["Heiltrank"] → Findet Trank per Namen!
+ • "Holzschild" in inv → True
+ • inv.hinzufuegen(*items) → Prüft Maximalgewicht automatisch! +
+
+
+ + +
+

5. 📋 Deine Aufgaben-Checkliste

+ +

Öffne die Datei 15_parameter_und_container/aufgabe.py und hake die Schritte nach und nach ab:

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

💡 Lösungshinweise (Aufklappen bei Bedarf)

+ +
+ 🔍 Tipp zu TODO 8: __getitem__ mit int und str unterstützen +
+

Prüfe mit isinstance(), welcher Suchtyp übergeben wurde:

+
+
def __getitem__(self, key):
+    if isinstance(key, int):
+        return self._items[key]  # Wirft automatisch IndexError bei ungültigem Index
+    elif isinstance(key, str):
+        for g in self._items:
+            if g.name.lower() == key.lower():
+                return g
+        raise KeyError(f"Gegenstand '{key}' nicht im Inventar gefunden!")
+    else:
+        raise TypeError("Index muss int oder str sein!")
+
+
+
+ +
+ 🔍 Tipp zu TODO 9: Gewichtsberechnung bei __setitem__ +
+

Beim Ersetzen eines Elements musst du das Gewicht des alten Elements abziehen und das neue hinzurechnen:

+
+
altes_gewicht = self._items[index].gewicht
+neues_gesamtgewicht = self.gesamtgewicht - altes_gewicht + value.gewicht
+if neues_gesamtgewicht > self.max_gewicht:
+    raise ValueError("Austausch überschreitet Maximalgewicht!")
+self._items[index] = value
+
+
+
+
+ + +
+

6. 🧪 Lösung überprüfen & Testen

+ +

1. Automatische Tests ausführen:

+
+
+ Terminal (im Ordner 15_parameter_und_container) +
+
python3 test_aufgabe.py
+
+ +

2. Interaktives Terminal-Skript starten:

+
+
+ Terminal +
+
python3 aufgabe.py
+
+ + +
+ +
+ + + diff --git a/15_parameter_und_container/musterloesung.py b/15_parameter_und_container/musterloesung.py new file mode 100644 index 0000000..e3840be --- /dev/null +++ b/15_parameter_und_container/musterloesung.py @@ -0,0 +1,232 @@ +""" +Kapitel 15: Parameter (*args, **kwargs) & Eigene Container-Klassen 🎒📦 +====================================================================== +Musterlösung für flexible Parameterlisten, Unpacking und magische +Container-Dunder-Methoden. +""" + +import math +from typing import Union, List, Optional, Dict, Any, Iterator + + +# ============================================================================== +# TEIL 1: FUNKTIONEN MIT *ARGS UND **KWARGS +# ============================================================================== + +def berechne_gesamtsumme(*zahlen: float, rabatt_prozent: float = 0.0) -> float: + """ + Berechnet die Gesamtsumme beliebig vieler Zahlen (*args) und wendet + einen optionalen prozentualen Rabatt an. + """ + if not (0.0 <= rabatt_prozent <= 100.0): + raise ValueError("Rabatt muss zwischen 0.0 und 100.0 Prozent liegen!") + + summe = sum(float(z) for z in zahlen) + rabatt_faktor = 1.0 - (rabatt_prozent / 100.0) + return round(summe * rabatt_faktor, 2) + + +def erstelle_profil(name: str, **details: Any) -> Dict[str, Any]: + """ + Erstellt ein Profil-Dictionary mit dem Namen und beliebigen weiteren + Schlüsselwort-Attributen (**kwargs). + """ + profil: Dict[str, Any] = {"name": str(name)} + profil.update(details) + return profil + + +# ============================================================================== +# TEIL 2: DIE DATENKLASSE GEGENSTAND (ITEM) +# ============================================================================== + +class Gegenstand: + """ + Repräsentiert einen Gegenstand im Spielinventar. + """ + + def __init__(self, name: str, gewicht: float, wert: int = 0): + if float(gewicht) < 0: + raise ValueError("Gewicht darf nicht negativ sein!") + if int(wert) < 0: + raise ValueError("Wert darf nicht negativ sein!") + + self.name: str = str(name) + self.gewicht: float = float(gewicht) + self.wert: int = int(wert) + + def __repr__(self) -> str: + return f"Gegenstand(name='{self.name}', gewicht={self.gewicht}, wert={self.wert})" + + def __str__(self) -> str: + return f"{self.name} ({self.gewicht}kg, {self.wert}G)" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Gegenstand): + return False + return ( + self.name == other.name + and math.isclose(self.gewicht, other.gewicht) + and self.wert == other.wert + ) + + +# ============================================================================== +# TEIL 3: DIE EIGENE CONTAINER-KLASSE INVENTAR +# ============================================================================== + +class Inventar: + """ + Eine maßgeschneiderte Container-Klasse für ein Gaming-Rucksack-Inventar. + Unterstützt Maximalgewicht, *args-Hinzufügen und alle Python-Container-Dunder! + """ + + def __init__(self, max_gewicht: float = 20.0, gegenstaende: Optional[List[Gegenstand]] = None): + if float(max_gewicht) <= 0: + raise ValueError("Maximalgewicht muss positiv sein!") + + self.max_gewicht: float = float(max_gewicht) + self._items: List[Gegenstand] = [] + + if gegenstaende: + self.hinzufuegen(*gegenstaende) + + @property + def gesamtgewicht(self) -> float: + """ + Berechnet das aktuelle Gesamtgewicht aller enthaltenen Gegenstände, + gerundet auf 2 Nachkommastellen. + """ + return round(sum(g.gewicht for g in self._items), 2) + + @property + def freie_kapazitaet(self) -> float: + """ + Gibt das noch verfügbare Restgewicht bis zum Maximalgewicht zurück. + """ + return round(self.max_gewicht - self.gesamtgewicht, 2) + + def hinzufuegen(self, *items: Gegenstand) -> None: + """ + Fügt ein oder mehrere Gegenstände (*args) zum Inventar hinzu. + """ + for item in items: + if not isinstance(item, Gegenstand): + raise TypeError(f"Erwartet 'Gegenstand', erhielt '{type(item).__name__}'") + + zusatz_gewicht = sum(item.gewicht for item in items) + if self.gesamtgewicht + zusatz_gewicht > self.max_gewicht: + raise ValueError( + f"Maximalgewicht von {self.max_gewicht}kg überschritten! " + f"(Aktuell: {self.gesamtgewicht}kg + Neu: {zusatz_gewicht:.2f}kg)" + ) + + self._items.extend(items) + + def __len__(self) -> int: + """Ermöglicht len(inventar).""" + return len(self._items) + + def __getitem__(self, key: Union[int, str]) -> Gegenstand: + """ + Ermöglicht Index- und Namenszugriff: + - inventar[0] -> Item an Position 0 (IndexError bei ungültigem Index). + - inventar["Heiltrank"] -> Erstes Item mit diesem Namen (KeyError bei Nicht-Auffinden). + """ + if isinstance(key, int): + return self._items[key] + elif isinstance(key, str): + for g in self._items: + if g.name.lower() == key.lower(): + return g + raise KeyError(f"Gegenstand '{key}' nicht im Inventar gefunden!") + else: + raise TypeError("Index muss int (Position) oder str (Name) sein!") + + def __setitem__(self, index: int, value: Gegenstand) -> None: + """ + Ermöglicht Zuweisung per Index: inventar[0] = neuer_gegenstand. + """ + if not isinstance(index, int): + raise TypeError("Index muss int sein!") + if not isinstance(value, Gegenstand): + raise TypeError("Wert muss ein Gegenstand sein!") + + if index < -len(self._items) or index >= len(self._items): + raise IndexError("Listenindex außerhalb des gültigen Bereichs!") + + altes_gewicht = self._items[index].gewicht + neues_gesamtgewicht = self.gesamtgewicht - altes_gewicht + value.gewicht + if neues_gesamtgewicht > self.max_gewicht: + raise ValueError( + f"Austausch überschreitet Maximalgewicht von {self.max_gewicht}kg!" + ) + + self._items[index] = value + + def __delitem__(self, key: Union[int, str]) -> None: + """ + Ermöglicht das Löschen per del-Keyword: + - del inventar[0] (per int-Index) + - del inventar["Heiltrank"] (per str-Name) + """ + if isinstance(key, int): + del self._items[key] + elif isinstance(key, str): + for i, g in enumerate(self._items): + if g.name.lower() == key.lower(): + del self._items[i] + return + raise KeyError(f"Gegenstand '{key}' nicht im Inventar gefunden!") + else: + raise TypeError("Schlüssel muss int oder str sein!") + + def __contains__(self, item: Union[Gegenstand, str]) -> bool: + """ + Ermöglicht den in-Operator (Name oder Objekt). + """ + if isinstance(item, str): + return any(g.name.lower() == item.lower() for g in self._items) + elif isinstance(item, Gegenstand): + return item in self._items + return False + + def __iter__(self) -> Iterator[Gegenstand]: + """Ermöglicht for item in inventar: ...""" + return iter(self._items) + + def __repr__(self) -> str: + """Entwickler-Darstellung.""" + return f"Inventar(max_gewicht={self.max_gewicht}, gegenstaende={self._items!r})" + + def __str__(self) -> str: + """Benutzerfreundliche Zusammenfassung.""" + return f"Inventar ({len(self)} Gegenstände, {self.gesamtgewicht:.1f}/{self.max_gewicht:.1f} kg)" + + +# ============================================================================== +# TEIL 4: UNPACKING-HELPER-FUNKTION +# ============================================================================== + +def packe_inventar(*gegenstaende: Gegenstand, max_gewicht: float = 20.0) -> Inventar: + """ + Erstellt ein neues Inventar und packt alle übergebenen Gegenstände (*args) hinein. + """ + inv = Inventar(max_gewicht=max_gewicht) + inv.hinzufuegen(*gegenstaende) + return inv + + +if __name__ == "__main__": + print("🎒 Kapitel 15: Musterlösung erfolgreich ausgeführt!\n" + "=" * 55) + preise = [19.99, 49.99, 9.99, 29.50] + print(f"Summe mit 15% Rabatt: {berechne_gesamtsumme(*preise, rabatt_prozent=15.0)} €") + profil = erstelle_profil("Ignis", klasse="Magier", level=12, element="Feuer") + print(f"Profil: {profil}") + + schwert = Gegenstand("Eisenschwert", 4.5, 120) + trank = Gegenstand("Heiltrank", 0.5, 20) + inv = packe_inventar(schwert, trank, max_gewicht=10.0) + print(f"Rucksack: {inv}") + for item in inv: + print(f" - {item}") diff --git a/15_parameter_und_container/test_aufgabe.py b/15_parameter_und_container/test_aufgabe.py new file mode 100644 index 0000000..963b079 --- /dev/null +++ b/15_parameter_und_container/test_aufgabe.py @@ -0,0 +1,341 @@ +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 TestKapitel15(unittest.TestCase): + """ + Testsuite für Kapitel 15: Parameter (*args, **kwargs) & Eigene Container. + """ + + def setUp(self): + self.berechne_gesamtsumme = getattr(aufgabe, "berechne_gesamtsumme", None) + self.erstelle_profil = getattr(aufgabe, "erstelle_profil", None) + self.packe_inventar = getattr(aufgabe, "packe_inventar", None) + self.Gegenstand = getattr(aufgabe, "Gegenstand", None) + self.Inventar = getattr(aufgabe, "Inventar", None) + + self.assertIsNotNone(self.berechne_gesamtsumme, "Funktion 'berechne_gesamtsumme' nicht gefunden!") + self.assertIsNotNone(self.erstelle_profil, "Funktion 'erstelle_profil' nicht gefunden!") + self.assertIsNotNone(self.packe_inventar, "Funktion 'packe_inventar' nicht gefunden!") + self.assertIsNotNone(self.Gegenstand, "Klasse 'Gegenstand' nicht gefunden!") + self.assertIsNotNone(self.Inventar, "Klasse 'Inventar' nicht gefunden!") + + # ========================================================================== + # 1. *args & Rabatt-Funktion + # ========================================================================== + def test_01_berechne_gesamtsumme_args(self): + """Prüft *args, leere Parameterliste, Rabattberechnung und Validierung.""" + # Keine Argumente + self.assertEqual(self.berechne_gesamtsumme(), 0.0) + + # Einzelne und mehrere Zahlen + self.assertEqual(self.berechne_gesamtsumme(42.5), 42.5) + self.assertEqual(self.berechne_gesamtsumme(10.0, 20.0, 30.0), 60.0) + self.assertEqual(self.berechne_gesamtsumme(5, 15, 25, 35, 20), 100.0) + + # Unpacking mit *liste + zahlen_liste = [12.50, 7.50, 30.00] + self.assertEqual(self.berechne_gesamtsumme(*zahlen_liste), 50.0) + + # Rabatt-Berechnungen + self.assertEqual(self.berechne_gesamtsumme(100.0, 50.0, rabatt_prozent=10.0), 135.0) + self.assertEqual(self.berechne_gesamtsumme(200.0, rabatt_prozent=50.0), 100.0) + self.assertEqual(self.berechne_gesamtsumme(80.0, rabatt_prozent=100.0), 0.0) + self.assertEqual(self.berechne_gesamtsumme(80.0, rabatt_prozent=0.0), 80.0) + self.assertEqual(self.berechne_gesamtsumme(19.99, 29.99, rabatt_prozent=15.0), 42.48) + + # Ungültige Rabatte müssen ValueError werfen + with self.assertRaises(ValueError, msg="Negativer Rabatt muss ValueError auslösen!"): + self.berechne_gesamtsumme(100.0, rabatt_prozent=-5.0) + + with self.assertRaises(ValueError, msg="Rabatt > 100% muss ValueError auslösen!"): + self.berechne_gesamtsumme(100.0, rabatt_prozent=105.0) + + # ========================================================================== + # 2. **kwargs & Profil-Erstellung + # ========================================================================== + def test_02_erstelle_profil_kwargs(self): + """Prüft **kwargs und Dictionary-Unpacking bei der Profil-Erstellung.""" + # Minimales Profil (nur Name) + p1 = self.erstelle_profil("Arthur") + self.assertEqual(p1, {"name": "Arthur"}) + + # Profil mit verschiedenen Schlüsselwortargumenten + p2 = self.erstelle_profil("Gandalf", klasse="Zauberer", stufe=99, mana=5000, aktiv=True) + self.assertEqual(p2["name"], "Gandalf") + self.assertEqual(p2["klasse"], "Zauberer") + self.assertEqual(p2["stufe"], 99) + self.assertEqual(p2["mana"], 5000) + self.assertTrue(p2["aktiv"]) + + # Unpacking mit **dict + zusatz = {"leben": 100, "ruestung": 45, "gilde": "Gefaehrten"} + p3 = self.erstelle_profil("Gimli", **zusatz) + self.assertEqual(p3, {"name": "Gimli", "leben": 100, "ruestung": 45, "gilde": "Gefaehrten"}) + + # ========================================================================== + # 3. Klasse Gegenstand + # ========================================================================== + def test_03_gegenstand_klasse(self): + """Prüft Gegenstand-Attribute, Validierung, __repr__, __str__ und __eq__.""" + schwert = self.Gegenstand("Eisenschwert", 3.5, 150) + self.assertEqual(schwert.name, "Eisenschwert") + self.assertEqual(schwert.gewicht, 3.5) + self.assertEqual(schwert.wert, 150) + + # Standardwert + kiesel = self.Gegenstand("Kieselstein", 0.1) + self.assertEqual(kiesel.wert, 0) + + # Validierung negative Werte + with self.assertRaises(ValueError, msg="Negatives Gewicht muss ValueError werfen"): + self.Gegenstand("FehlerItem", -1.0, 10) + + with self.assertRaises(ValueError, msg="Negativer Wert muss ValueError werfen"): + self.Gegenstand("FehlerItem", 1.0, -10) + + # Strings & Repräsentation + self.assertIn("Eisenschwert", repr(schwert)) + self.assertIn("3.5", repr(schwert)) + self.assertIn("Eisenschwert", str(schwert)) + + # Gleichheit __eq__ + gleiches_schwert = self.Gegenstand("Eisenschwert", 3.5, 150) + anderes_schwert = self.Gegenstand("Eisenschwert", 4.0, 150) + self.assertEqual(schwert, gleiches_schwert) + self.assertNotEqual(schwert, anderes_schwert) + self.assertNotEqual(schwert, "Kein Gegenstand") + + # ========================================================================== + # 4. Klasse Inventar - Initialisierung & Gewichtsberechnung + # ========================================================================== + def test_04_inventar_init_und_gewicht(self): + """Prüft Konstruktor, Maximalgewicht, Start-Items und Kapazitätsberechnung.""" + inv = self.Inventar(max_gewicht=15.0) + self.assertEqual(inv.max_gewicht, 15.0) + self.assertEqual(inv.gesamtgewicht, 0.0) + self.assertEqual(inv.freie_kapazitaet, 15.0) + + # Ungültiges Maximalgewicht + with self.assertRaises(ValueError): + self.Inventar(max_gewicht=0.0) + + with self.assertRaises(ValueError): + self.Inventar(max_gewicht=-10.0) + + # Init mit Start-Gegenständen + g1 = self.Gegenstand("Buch", 1.2, 30) + g2 = self.Gegenstand("Fackel", 0.8, 5) + inv2 = self.Inventar(max_gewicht=10.0, gegenstaende=[g1, g2]) + self.assertEqual(inv2.gesamtgewicht, 2.0) + self.assertEqual(inv2.freie_kapazitaet, 8.0) + self.assertEqual(len(inv2), 2) + + # Start-Gegenstände zu schwer + schwer = self.Gegenstand("Amboss", 50.0, 500) + with self.assertRaises(ValueError, msg="Überladenes Startinventar muss ValueError werfen"): + self.Inventar(max_gewicht=20.0, gegenstaende=[schwer]) + + # ========================================================================== + # 5. Inventar - hinzufuegen mit *args + # ========================================================================== + def test_05_inventar_hinzufuegen_args(self): + """Prüft hinzufuegen mit variablen Argumenten (*args), Unpacking und Überladung.""" + inv = self.Inventar(max_gewicht=10.0) + g1 = self.Gegenstand("Trank", 0.5, 20) + g2 = self.Gegenstand("Brot", 0.3, 5) + g3 = self.Gegenstand("Dolch", 1.2, 80) + + # Einzeln und mehrere mit *args + inv.hinzufuegen(g1) + self.assertEqual(inv.gesamtgewicht, 0.5) + + inv.hinzufuegen(g2, g3) + self.assertEqual(inv.gesamtgewicht, 2.0) + self.assertEqual(len(inv), 3) + + # Unpacking beim Aufruf von hinzufuegen + nachschub = [self.Gegenstand("Pfeil", 0.1, 1), self.Gegenstand("Bogen", 2.0, 100)] + inv.hinzufuegen(*nachschub) + self.assertEqual(inv.gesamtgewicht, 4.1) + self.assertEqual(len(inv), 5) + + # Überladung prüfen + riesenstein = self.Gegenstand("Felsbrocken", 10.0, 0) + with self.assertRaises(ValueError, msg="Überschreiten des Maximalgewichts muss ValueError werfen"): + inv.hinzufuegen(riesenstein) + + # Ungültiger Typ + with self.assertRaises(TypeError, msg="Nicht-Gegenstand muss TypeError werfen"): + inv.hinzufuegen("Kein Gegenstand") + + # ========================================================================== + # 6. Container-Dunder: __len__ und __iter__ + # ========================================================================== + def test_06_container_len_und_iter(self): + """Prüft len() und Iterierbarkeit (for item in inventar:).""" + inv = self.Inventar(max_gewicht=20.0) + self.assertEqual(len(inv), 0) + + items = [ + self.Gegenstand("Item 1", 1.0, 10), + self.Gegenstand("Item 2", 2.0, 20), + self.Gegenstand("Item 3", 3.0, 30), + ] + inv.hinzufuegen(*items) + + self.assertEqual(len(inv), 3) + + # Iteration mit for-Schleife & List Comprehension + namen = [item.name for item in inv] + self.assertEqual(namen, ["Item 1", "Item 2", "Item 3"]) + + # Konvertierung zu list(inv) + self.assertEqual(list(inv), items) + + # ========================================================================== + # 7. Container-Dunder: __getitem__ (int Index & str Name-Suche) + # ========================================================================== + def test_07_container_getitem(self): + """Prüft Index-Zugriff [i] und Namenssuche ['Name'].""" + g1 = self.Gegenstand("Heiltrank", 0.5, 25) + g2 = self.Gegenstand("Langschwert", 4.0, 200) + g3 = self.Gegenstand("Manatrank", 0.5, 30) + + inv = self.Inventar(max_gewicht=20.0, gegenstaende=[g1, g2, g3]) + + # Integer-Index + self.assertEqual(inv[0], g1) + self.assertEqual(inv[1], g2) + self.assertEqual(inv[-1], g3) + + with self.assertRaises(IndexError): + _ = inv[10] + + # String-Namenssuche (case-insensitive) + self.assertEqual(inv["Heiltrank"], g1) + self.assertEqual(inv["heiltrank"], g1) + self.assertEqual(inv["LANGSCHWERT"], g2) + + with self.assertRaises(KeyError, msg="Unbekannter Name muss KeyError werfen"): + _ = inv["NichtImInventar"] + + # Ungültiger Key-Typ + with self.assertRaises(TypeError): + _ = inv[3.14] + + # ========================================================================== + # 8. Container-Dunder: __setitem__ + # ========================================================================== + def test_08_container_setitem(self): + """Prüft Ersetzen per Index [i] = item und Gewichtsvalidierung.""" + g1 = self.Gegenstand("Heiltrank", 1.0, 25) + g2 = self.Gegenstand("Holzschild", 3.0, 40) + inv = self.Inventar(max_gewicht=10.0, gegenstaende=[g1, g2]) + self.assertEqual(inv.gesamtgewicht, 4.0) + + # Gültiger Austausch + superschild = self.Gegenstand("Stahlschild", 5.0, 150) + inv[1] = superschild + self.assertEqual(inv[1], superschild) + self.assertEqual(inv.gesamtgewicht, 6.0) + + # Austausch, der Maximalgewicht sprengt (6 - 1 + 8 = 13 > 10) + riesenhammer = self.Gegenstand("Riesenhammer", 8.0, 300) + with self.assertRaises(ValueError, msg="Austausch über Maximalgewicht muss ValueError werfen"): + inv[0] = riesenhammer + + # Typ- & Index-Prüfung + with self.assertRaises(TypeError): + inv["kein_int"] = superschild + + with self.assertRaises(TypeError): + inv[0] = "Kein Gegenstand" + + with self.assertRaises(IndexError): + inv[10] = superschild + + # ========================================================================== + # 9. Container-Dunder: __delitem__ + # ========================================================================== + def test_09_container_delitem(self): + """Prüft Löschen per del inv[i] und del inv['Name'].""" + g1 = self.Gegenstand("Heiltrank", 0.5, 20) + g2 = self.Gegenstand("Schwert", 3.5, 120) + g3 = self.Gegenstand("Fackel", 0.5, 5) + inv = self.Inventar(max_gewicht=20.0, gegenstaende=[g1, g2, g3]) + self.assertEqual(len(inv), 3) + self.assertEqual(inv.gesamtgewicht, 4.5) + + # Löschen per Integer-Index + del inv[0] + self.assertEqual(len(inv), 2) + self.assertEqual(inv[0], g2) + self.assertEqual(inv.gesamtgewicht, 4.0) + + # Löschen per Name (case-insensitive) + del inv["fackel"] + self.assertEqual(len(inv), 1) + self.assertEqual(inv.gesamtgewicht, 3.5) + + # Fehler beim Löschen + with self.assertRaises(KeyError): + del inv["Unbekannt"] + + with self.assertRaises(IndexError): + del inv[99] + + # ========================================================================== + # 10. Container-Dunder: __contains__ (in Operator) + # ========================================================================== + def test_10_container_contains(self): + """Prüft Mitgliedschaftsprüfung ('Item' in inv und obj in inv).""" + schwert = self.Gegenstand("Eisenschwert", 3.0, 100) + trank = self.Gegenstand("Heiltrank", 0.5, 25) + fremdes_item = self.Gegenstand("Diamant", 0.1, 1000) + + inv = self.Inventar(max_gewicht=20.0, gegenstaende=[schwert, trank]) + + # Suche nach String-Name + self.assertTrue("Eisenschwert" in inv) + self.assertTrue("eisenschwert" in inv) + self.assertTrue("HEILTRANK" in inv) + self.assertFalse("Diamant" in inv) + self.assertFalse("Drachenei" in inv) + + # Suche nach Objekt-Referenz + self.assertTrue(schwert in inv) + self.assertTrue(trank in inv) + self.assertFalse(fremdes_item in inv) + + # Suche nach Fremdtyp + self.assertFalse(12345 in inv) + + # ========================================================================== + # 11. Helper-Funktion packe_inventar + # ========================================================================== + def test_11_packe_inventar_helper(self): + """Prüft packe_inventar mit *args und max_gewicht.""" + g1 = self.Gegenstand("Seil", 1.5, 15) + g2 = self.Gegenstand("Proviant", 2.0, 10) + + rucksack = self.packe_inventar(g1, g2, max_gewicht=25.0) + self.assertIsInstance(rucksack, self.Inventar) + self.assertEqual(rucksack.max_gewicht, 25.0) + self.assertEqual(len(rucksack), 2) + self.assertEqual(rucksack.gesamtgewicht, 3.5) + self.assertTrue("Seil" in rucksack) + self.assertTrue("Proviant" in rucksack) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/16_master_abschlussprojekt/README.md b/16_master_abschlussprojekt/README.md new file mode 100644 index 0000000..14577f1 --- /dev/null +++ b/16_master_abschlussprojekt/README.md @@ -0,0 +1,178 @@ +# Kapitel 16: Master-Abschlussprojekt – PetCare & Tierheim-Manager 🐾🏥🖥️ + +Herzlichen Glückwunsch! Du bist am **großen Finale der gesamten Python OOP-Übungsreihe** angekommen. +Hier führst du alles zusammen, was du in den drei Lehrpfaden gelernt hast – von Datentypen und Schleifen über Vererbung, Polymorphie, Exceptions und JSON-Persistenz bis hin zur interaktiven **Tkinter Desktop-Applikation** mit professioneller **Model-View-Controller (MVC)** Architektur. + +--- + +## 🧭 Die 3 Lehrpfade im Masterprojekt vereint + +``` + [ 🏆 KAPITEL 16: MEISTERSTÜCK ] + │ + ┌───────────────────────────────┼──────────────────────────────┐ + ▼ ▼ ▼ +[ 🛣️ LEHRPFAD 1: GRUNDLAGEN ] [ 🛣️ LEHRPFAD 2: OOP & TOOLS ] [ 🛣️ LEHRPFAD 3: SOFTWARETECHNIK ] + • Datentypen, Strings & Mathe • Klassen, __init__, self • Vererbung & super() + • Schleifen, Listen & Dicts • Methoden & Interaktionen • Polymorphie & Interfaces + • Typsichere Validierung • __str__ & Repräsentation • Eigene Exception-Klassen + • CSV-Tabellenexport • Modularer Aufbau • JSON Savegame-Persistenz + • Tkinter Desktop-GUI (MVC) + • 100% TDD / Unittest-Suite +``` + +--- + +## 🏛️ Die Architektur: Model-View-Controller (MVC) + +In professioneller Software wird die **Geschäftslogik (Model)** strikt von der **grafischen Oberfläche (View & Controller)** getrennt. +Dadurch kann die gesamte Fachlogik zu 100% in automatischen Unittests ohne Bildschirm/Fenster getestet werden! + +``` + ┌────────────────────────────────────────────────────────────────────────┐ + │ BENUTZER │ + └──────────────────┬─────────────────────────────────▲───────────────────┘ + │ 1. Klickt Button / gibt Text ein│ 4. Sieht Update + ▼ │ + ┌────────────────────────────────────────────────────┴───────────────────┐ + │ VIEW & CONTROLLER (TierheimApp) │ + │ • Tkinter-Fenster, Formular, Listbox, Aktionsbuttons, Statusleiste │ + │ • Liest Formulareingaben aus & fängt ValidierungsFehler ab │ + └──────────────────┬─────────────────────────────────▲───────────────────┘ + │ 2. Ruft Methoden auf │ 3. Liefert Daten + ▼ │ + ┌────────────────────────────────────────────────────┴───────────────────┐ + │ MODEL (Tierheim) │ + │ • Verwaltet Tierbestand (Hund, Katze, Vogel) │ + │ • Filter (Art, ungeimpft, hungrig) & Statistiken (Ø Alter, Gewicht) │ + │ • Persistenz: JSON Speichern/Laden & CSV-Export │ + └────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📊 UML-Klassendiagramm + +``` + ┌──────────────────────────────────────────────┐ + │ Exception: TierheimFehler │ + └──────────────────────▲───────────────────────┘ + │ + ┌────────────────────────┼────────────────────────┐ + │ │ │ +┌────────┴─────────────┐ ┌────────┴────────────┐ ┌─────────┴───────────────┐ +│ ValidierungsFehler │ │TierNichtGefunden │ │KapazitaetUeberschritten │ +└──────────────────────┘ └─────────────────────┘ └─────────────────────────┘ + + ┌───────────────────────────────┐ + │ «class» │ + │ Tier │ + ├───────────────────────────────┤ + │ + name: str │ + │ + alter: int │ + │ + gewicht: float │ + │ + geimpft: bool │ + │ + hunger: int (0..100) │ + ├───────────────────────────────┤ + │ + fuettern(menge_g: int): str │ + │ + impfen(): bool │ + │ + mache_laut(): str │ + │ + get_details(): str │ + │ + to_dict(): dict │ + │ + from_dict(data: dict): Tier │ + └───────────────▲───────────────┘ + │ (Vererbung) + ┌─────────────────────────────────┼─────────────────────────────────┐ + │ │ │ + ┌──────────────┴──────────────┐ ┌──────────────┴──────────────┐ ┌──────────────┴──────────────┐ + │ Hund │ │ Katze │ │ Vogel │ + ├─────────────────────────────┤ ├─────────────────────────────┤ ├─────────────────────────────┤ + │ + rasse: str │ │ + stubenrein: bool │ │ + spannweite_cm: float │ + │ + gassigegangen: bool │ │ + kratzbaum_benutzt: bool │ │ + kann_sprechen: bool │ + ├─────────────────────────────┤ ├─────────────────────────────┤ ├─────────────────────────────┤ + │ + gassi_gehen(minuten): str │ │ + kratzen(): str │ │ + fliegen(runden): str │ + │ + mache_laut(): str (Wuff!) │ │ + mache_laut(): str (Miau!) │ │ + mache_laut(): str (Tsch!) │ + └─────────────────────────────┘ └─────────────────────────────┘ └─────────────────────────────┘ + ▲ + │ 1:n Komposition + ┌────────────────────────────────────────────────┴────────────────────────┐ + │ Tierheim │ + ├─────────────────────────────────────────────────────────────────────────┤ + │ + name: str │ + │ + max_kapazitaet: int │ + │ + tiere: List[Tier] │ + ├─────────────────────────────────────────────────────────────────────────┤ + │ + tier_aufnehmen(tier: Tier): None │ + │ + tier_entlassen(name: str): Tier │ + │ + finde_tier(name: str): Optional[Tier] │ + │ + filtriere_nach_art(art: str): List[Tier] │ + │ + ungeimpfte_tiere(): List[Tier] │ + │ + hungrige_tiere(schwellenwert: int): List[Tier] │ + │ + durchschnittsalter(): float │ + │ + gesamtgewicht(): float │ + │ + alle_fuettern(menge: int): dict │ + │ + alle_impfen(): int │ + │ + speichern_json(pfad: str): None │ + │ + laden_json(pfad: str): None │ + │ + exportiere_csv(pfad: str): None │ + └─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📝 Aufgabenstellung (Schritt für Schritt) + +Öffne die Datei `aufgabe.py` und bearbeite die nummerierten Abschnitte: + +### Schritt 1: Eigene Exceptions definieren +Erstelle die Exception-Klassen: +- `TierheimFehler(Exception)` als Basis +- `ValidierungsFehler(TierheimFehler)` +- `TierNichtGefundenFehler(TierheimFehler)` +- `KapazitaetUeberschrittenFehler(TierheimFehler)` + +### Schritt 2: Basisklasse `Tier` implementieren +- Validiere im `__init__`: `name` darf nicht leer sein, `alter >= 0`, `gewicht > 0`. +- Implementiere `fuettern(futtermenge_gramm)`: Senkt Hunger um `futtermenge_gramm * 0.2` (Minimum 0). +- Implementiere `impfen()`: Setzt `geimpft = True` (gibt `True` bei Neugeimpften, `False` bei bereits Geimpften). +- Implementiere `to_dict()` und die Factory-Methode `from_dict(cls, data)` (stellt automatisch die korrekte Kindklasse wieder her!). + +### Schritt 3: Spezialisierte Kindklassen `Hund`, `Katze`, `Vogel` +- Nutze `super().__init__(...)` für gemeinsame Attribute. +- Überschreibe `mache_laut()` polymorph (Hund bellt, Katze miaut/schnurrt, Vogel zwitschert/plappert). +- Implementiere artspezifische Methoden (`gassi_gehen()`, `kratzen()`, `fliegen()`). +- Erweitere `to_dict()` um die Kindklassen-Attribute. + +### Schritt 4: Das Model `Tierheim` +- Aufnahme von Tieren mit Kapazitätsprüfung (`max_kapazitaet`). +- Entlassen von Tieren nach Name (mit Exception-Handling falls nicht gefunden). +- Filterfunktionen (`filtriere_nach_art`, `ungeimpfte_tiere`, `hungrige_tiere`). +- Statistiken: `durchschnittsalter()`, `gesamtgewicht()`. +- Massenaktionen: `alle_fuettern()`, `alle_impfen()`. +- Persistenz: `speichern_json()`, `laden_json()` und `exportiere_csv()`. + +### Schritt 5: Die Desktop-GUI `TierheimApp` (Tkinter) +- Verknüpfe Buttons und Formularfelder mit den Model-Methoden. +- Fange Fehler (`ValidierungsFehler`, etc.) in `try-except`-Blöcken ab und gib Feedback in der dynamischen Statusleiste. +- Halte die Listbox und Kapazitätsanzeige synchron mit dem Datenmodell. + +--- + +## 🧪 Tests ausführen & App starten + +### 1. Automatische Unittests ausführen: +```bash +python3 test_aufgabe.py +``` + +### 2. Desktop-App mit grafischer Oberfläche starten: +```bash +python3 app.py +``` + +--- + +## 💡 Best Practices für dein Meisterstück +1. **Never trust user input:** Prüfe Datentypen und Grenzwerte frühzeitig im Konstruktor. +2. **Polymorphie nutzen:** Anstatt mit vielen `if/elif`-Abfragen nach dem Typ zu unterscheiden, rufe einfach `tier.mache_laut()` oder `tier.fuettern()` auf! +3. **Model & View trennen:** Halte Berechnungen und Daten im `Tierheim`-Objekt und nutze Tkinter nur zur Anzeige. diff --git a/16_master_abschlussprojekt/app.py b/16_master_abschlussprojekt/app.py new file mode 100644 index 0000000..e67a9aa --- /dev/null +++ b/16_master_abschlussprojekt/app.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Kapitel 16: Master-Abschlussprojekt – Desktop App Launcher 🐾🖥️ +=============================================================== +Startet die vollwertige grafische Desktop-Applikation 'Tierheim & PetCare Pro'. + +Ausführen mit: + python3 app.py +""" + +import sys +from pathlib import Path + +# Pfad hinzufügen, um Module des Kapitels sauber zu importieren +sys.path.insert(0, str(Path(__file__).parent)) + +try: + import tkinter as tk + from tkinter import messagebox + TK_AVAILABLE = True +except ImportError: + TK_AVAILABLE = False + +# Importiere Klassen aus aufgabe oder musterloesung +try: + from musterloesung import Tierheim, TierheimApp, Hund, Katze, Vogel +except ImportError: + from aufgabe import Tierheim, TierheimApp, Hund, Katze, Vogel + + +def starte_musterdaten_tierheim() -> Tierheim: + """Erstellt ein Tierheim mit liebevoll gestalteten Beispieltieren.""" + heim = Tierheim(name="Tierheim Sonnenschein & Pfotenglück", max_kapazitaet=15) + + # Beispieldaten hinzufügen + bello = Hund("Bello", alter=3, gewicht=16.5, rasse="Golden Retriever", geimpft=True, hunger=30, gassigegangen=True) + luna = Katze("Luna", alter=2, gewicht=4.1, stubenrein=True, geimpft=True, hunger=40) + rocky = Hund("Rocky", alter=5, gewicht=22.0, rasse="Deutscher Schäferhund", geimpft=False, hunger=70) + mimi = Katze("Mimi", alter=1, gewicht=3.2, stubenrein=True, geimpft=False, hunger=60) + tweety = Vogel("Tweety", alter=1, gewicht=0.25, spannweite_cm=18.5, kann_sprechen=True, geimpft=True, hunger=20) + charly = Vogel("Charly", alter=4, gewicht=0.45, spannweite_cm=28.0, kann_sprechen=False, geimpft=False, hunger=80) + + for t in (bello, luna, rocky, mimi, tweety, charly): + heim.tier_aufnehmen(t) + + return heim + + +def main(): + print("=" * 65) + print("🐾 Tierheim- & PetCare-Manager Pro (Master-Abschlussprojekt)") + print("=" * 65) + + if not TK_AVAILABLE: + print("⚠️ Hinweis: Tkinter ist auf diesem System nicht verfügbar.") + print("💡 Führe 'python3 test_aufgabe.py' aus, um das System headless zu testen!") + return + + try: + root = tk.Tk() + heim = starte_musterdaten_tierheim() + app = TierheimApp(root, heim) + print(f"✨ GUI erfolgreich gestartet mit {len(heim)} Beispieltieren.") + print("🖥️ Schließe das Fenster, um die Anwendung zu beenden.") + root.mainloop() + except Exception as e: + print(f"⚠️ Konnte GUI nicht im Display anzeigen: {e}") + print("💡 Auf Servern ohne Monitor (Headless) kannst du die Logik mit 'python3 test_aufgabe.py' prüfen.") + + +if __name__ == "__main__": + main() diff --git a/16_master_abschlussprojekt/aufgabe.py b/16_master_abschlussprojekt/aufgabe.py new file mode 100644 index 0000000..acb8178 --- /dev/null +++ b/16_master_abschlussprojekt/aufgabe.py @@ -0,0 +1,790 @@ +""" +Kapitel 16: Master-Abschlussprojekt – Tierheim- & PetCare-Manager 🐾🏥 +===================================================================== +Das große Meisterstück: Die Vereinigung aller drei Lehrpfade! + +1. Modellierung & Vererbung: + - Basisklasse 'Tier' mit Polymorphie und Validierung + - Spezialisierte Kindklassen: 'Hund', 'Katze', 'Vogel' +2. Robuste Fehlerbehandlung: + - Eigene Exception-Hierarchie ('TierheimFehler', 'ValidierungsFehler', etc.) +3. Persistenz: + - Vollständiges JSON Savegame (Speichern & Laden mit Factory-Muster) + - Tabellarischer CSV-Export +4. Architektur & GUI (MVC): + - 'Tierheim' als reines Daten- & Geschäftslogik-Modell (Model) + - 'TierheimApp' als interaktive Tkinter Desktop-Applikation (View & Controller) +""" + +from __future__ import annotations +import csv +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +# ============================================================================== +# Tkinter Import mit sicherem Fallback für Headless- & Server-Umgebungen +# ============================================================================== +try: + import tkinter as tk + from tkinter import ttk, messagebox +except ImportError: + # Headless Mock-Objekte (ermöglicht Ausführung und Tests ohne X11-Display) + 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)) + self._items: list[Any] = [] + self._selection: list[int] = [] + + 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 set(self, val): + self._entry_text = str(val) + + def delete(self, first=0, last=None): + if hasattr(self, "_items") and self._items: + self._items.clear() + self._entry_text = "" + + def insert(self, index, string): + if hasattr(self, "_items"): + self._items.append(str(string)) + self._entry_text = str(string) + + def curselection(self): + return tuple(self._selection) if self._selection else (0,) if self._items else () + + def size(self): + return len(self._items) + + def get_item(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return "" + + 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 _MockStringVar: + def __init__(self, value=""): + self._val = str(value) + + def get(self): + return self._val + + def set(self, val): + self._val = str(val) + + class _MockBooleanVar: + def __init__(self, value=False): + self._val = bool(value) + + def get(self): + return self._val + + def set(self, val): + self._val = bool(val) + + class _MockMessageBox: + @staticmethod + def showinfo(title, message): + pass + + @staticmethod + def showwarning(title, message): + pass + + @staticmethod + def showerror(title, message): + pass + + class _MockTkModule: + Tk = _MockTk + Label = _MockWidget + Button = _MockWidget + Entry = _MockWidget + Frame = _MockWidget + Listbox = _MockWidget + Scrollbar = _MockWidget + Checkbutton = _MockWidget + StringVar = _MockStringVar + BooleanVar = _MockBooleanVar + OptionMenu = _MockWidget + END = "end" + SINGLE = "single" + BOTH = "both" + Y = "y" + W = "w" + E = "e" + + class _MockTtkModule: + Combobox = _MockWidget + LabelFrame = _MockWidget + Button = _MockWidget + Label = _MockWidget + Entry = _MockWidget + Frame = _MockWidget + + tk = _MockTkModule() + ttk = _MockTtkModule() + messagebox = _MockMessageBox() + + +# ============================================================================== +# TEIL 1: EIGENE EXCEPTION-HIERARCHIE +# ============================================================================== +class TierheimFehler(Exception): + """Basis-Exception für alle fachlichen Fehler im Tierheim.""" + pass + + +class ValidierungsFehler(TierheimFehler): + """Wird ausgelöst, wenn eingegebene Daten (Name, Alter, Gewicht) unzulässig sind.""" + pass + + +class TierNichtGefundenFehler(TierheimFehler): + """Wird ausgelöst, wenn ein gesuchtes Tier nicht im Tierheim existiert.""" + pass + + +class KapazitaetUeberschrittenFehler(TierheimFehler): + """Wird ausgelöst, wenn das Tierheim voll ist und kein Tier mehr aufgenommen werden kann.""" + pass + + +# ============================================================================== +# TEIL 2: DAS DATENMODELL (BASISKLASSE & KINDKLASSEN) +# ============================================================================== +class Tier: + """ + Abstrakte Basisklasse für alle Tiere im Tierheim. + + Attribute: + name (str): Name des Tiers (darf nicht leer sein). + alter (int): Alter in Jahren (muss >= 0 sein). + gewicht (float): Gewicht in kg (muss > 0 sein). + geimpft (bool): Impfstatus (Standard: False). + hunger (int): Hunger-Level von 0 (satt) bis 100 (verhungert) (Standard: 50). + """ + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + geimpft: bool = False, + hunger: int = 50, + ) -> None: + """ + Initialisiert ein Tier und validiert alle Attribute. + Löst ValidierungsFehler aus bei: + - leerem oder nur aus Leerzeichen bestehendem Namen + - alter < 0 + - gewicht <= 0 + Hunger wird auf den Bereich [0, 100] begrenzt (geclampt). + """ + # TODO: Validierung & Zuweisung implementieren + pass + + def fuettern(self, futtermenge_gramm: int) -> str: + """ + Reduziert das Hunger-Level des Tiers. + Jede 100g Futter senken den Hunger um 20 Punkte (Minimum: 0). + Beispiel: Bei 250g Futter sinkt der Hunger um int(250 * 0.2) = 50. + Gibt einen Bestätigungstext zurück, z.B.: + "Bello wurde mit 200g gefüttert. Neuer Hunger-Level: 10/100." + """ + # TODO: Implementieren + pass + + def impfen(self) -> bool: + """ + Setzt self.geimpft auf True. + Gibt True zurück, wenn das Tier frisch geimpft wurde, + oder False, falls es bereits geimpft war. + """ + # TODO: Implementieren + pass + + def mache_laut(self) -> str: + """Polymorphe Methode: Gibt den artspezifischen Laut des Tiers zurück.""" + # Basis-Standard + return f"{self.name} macht ein unbestimmtes Geräusch." + + def get_details(self) -> str: + """Gibt eine Zusammenfassung der artspezifischen Zusatzmerkmale zurück.""" + return "Keine speziellen Merkmale" + + def to_dict(self) -> Dict[str, Any]: + """ + Wandelt das Tier in ein Dictionary für den JSON-Export um. + Enthält zwingend den Schlüssel 'art': 'Tier'. + """ + # TODO: Implementieren + pass + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Tier: + """ + Factory-Methode: Erstellt anhand des 'art'-Schlüssels + die korrekte Instanz (Hund, Katze, Vogel oder Tier). + """ + # TODO: Implementieren + pass + + def __str__(self) -> str: + impf_icon = "💉 Geimpft" if self.geimpft else "❌ Ungeimpft" + return f"[{self.__class__.__name__}] {self.name} ({self.alter} Jahre, {self.gewicht:.1f} kg, Hunger: {self.hunger}/100, {impf_icon})" + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name='{self.name}', alter={self.alter}, gewicht={self.gewicht}, geimpft={self.geimpft}, hunger={self.hunger})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Tier): + return False + return ( + self.name == other.name + and self.alter == other.alter + and abs(self.gewicht - other.gewicht) < 0.001 + and self.geimpft == other.geimpft + and self.hunger == other.hunger + ) + + +class Hund(Tier): + """ + Spezialisierte Kindklasse für Hunde. + + Zusatzattribute: + rasse (str): Hunderasse (Standard: "Mischling", darf nicht leer sein). + gassigegangen (bool): Ob der Hund heute schon Gassi war (Standard: False). + """ + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + rasse: str = "Mischling", + geimpft: bool = False, + hunger: int = 50, + gassigegangen: bool = False, + ) -> None: + """Initialisiert einen Hund mit super().__init__() und validiert rasse.""" + # TODO: Implementieren + pass + + def gassi_gehen(self, minuten: int) -> str: + """ + Führt den Hund Gassi. + - Setzt self.gassigegangen auf True. + - Erhöht den Hunger um 1 Punkt pro 5 Minuten (Hunger maximal 100). + - Gibt Rückgabetext zurück: "Bello war 30 Minuten Gassi! 🦮" + """ + # TODO: Implementieren + pass + + def mache_laut(self) -> str: + """Gibt Hundegebell zurück: '{self.name} bellt: Wuff! Wuff! 🐶'""" + # TODO: Implementieren + pass + + def get_details(self) -> str: + status = "war Gassi" if self.gassigegangen else "muss noch raus" + return f"Rasse: {self.rasse} ({status})" + + def to_dict(self) -> Dict[str, Any]: + """Erweitert das Basis-Dictionary um 'art': 'Hund', 'rasse' und 'gassigegangen'.""" + # TODO: Implementieren + pass + + +class Katze(Tier): + """ + Spezialisierte Kindklasse für Katzen. + + Zusatzattribute: + stubenrein (bool): Ob die Katze ein Katzenklo benutzt (Standard: True). + kratzbaum_benutzt (bool): Ob sie heute gekratzt hat (Standard: False). + """ + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + stubenrein: bool = True, + geimpft: bool = False, + hunger: int = 50, + kratzbaum_benutzt: bool = False, + ) -> None: + """Initialisiert eine Katze mit super().__init__().""" + # TODO: Implementieren + pass + + def kratzen(self) -> str: + """ + Katze wetzt ihre Krallen. + - Setzt self.kratzbaum_benutzt auf True. + - Gibt Rückgabetext zurück: "{self.name} wetzt die Krallen am Kratzbaum! 🐾" + """ + # TODO: Implementieren + pass + + def mache_laut(self) -> str: + """Gibt Katzenlaut zurück: '{self.name} schnurrt: Miau! Schnurr... 🐱'""" + # TODO: Implementieren + pass + + def get_details(self) -> str: + sauber = "stubenrein" if self.stubenrein else "nicht stubenrein" + return f"Stubenrein: {sauber}" + + def to_dict(self) -> Dict[str, Any]: + """Erweitert das Basis-Dictionary um 'art': 'Katze', 'stubenrein' und 'kratzbaum_benutzt'.""" + # TODO: Implementieren + pass + + +class Vogel(Tier): + """ + Spezialisierte Kindklasse für Vögel. + + Zusatzattribute: + spannweite_cm (float): Flügelspannweite in cm (muss > 0 sein, Standard: 25.0). + kann_sprechen (bool): Kann der Vogel sprechen/nachplappern (Standard: False). + """ + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + spannweite_cm: float = 25.0, + kann_sprechen: bool = False, + geimpft: bool = False, + hunger: int = 50, + ) -> None: + """Initialisiert einen Vogel mit super().__init__() und validiert spannweite_cm.""" + # TODO: Implementieren + pass + + def fliegen(self, runden: int) -> str: + """ + Lässt den Vogel Runden in der Voliere fliegen. + - Erhöht den Hunger um 2 Punkte pro Runde (maximal 100). + - Gibt Text zurück: "{self.name} dreht {runden} elegante Runden in der Voliere! 🕊️" + """ + # TODO: Implementieren + pass + + def mache_laut(self) -> str: + """ + Gibt Laut zurück: + - Falls self.kann_sprechen: "{self.name} plappert: 'Hallo Mensch! Tschilp!' 🦜" + - Sonst: "{self.name} zwitschert: Tschilp! Tschilp! 🐦" + """ + # TODO: Implementieren + pass + + def get_details(self) -> str: + spricht = "kann sprechen" if self.kann_sprechen else "singt Melodien" + return f"Spannweite: {self.spannweite_cm:.1f} cm ({spricht})" + + def to_dict(self) -> Dict[str, Any]: + """Erweitert das Basis-Dictionary um 'art': 'Vogel', 'spannweite_cm' und 'kann_sprechen'.""" + # TODO: Implementieren + pass + + +# ============================================================================== +# TEIL 3: DIE GESCHÄFTSLOGIK & DER VERWALTER (MODEL) +# ============================================================================== +class Tierheim: + """ + Das Model des Tierheims: Verwaltet Tierbestände, Kapazitäten, + Filter, Berechnungen und Dateipersistenz (JSON & CSV). + Komplett unabhängig von der Benutzeroberfläche (100% testbar). + """ + + def __init__(self, name: str = "Tierheim Sonnenschein", max_kapazitaet: int = 20) -> None: + """ + Initialisiert das Tierheim. + - self.name = name + - self.max_kapazitaet = max_kapazitaet (muss > 0 sein, sonst ValidierungsFehler) + - self.tiere: List[Tier] = [] + """ + # TODO: Implementieren + pass + + def tier_aufnehmen(self, tier: Tier) -> None: + """ + Nimmt ein Tier auf. + - Löst ValidierungsFehler aus, wenn tier kein Objekt der Klasse Tier (oder Subklasse) ist. + - Löst KapazitaetUeberschrittenFehler aus, wenn len(self.tiere) >= self.max_kapazitaet. + - Fügt das Tier zu self.tiere hinzu. + """ + # TODO: Implementieren + pass + + def tier_entlassen(self, name: str) -> Tier: + """ + Entlässt (vermittelt) ein Tier anhand seines Namens (case-insensitive). + - Findet das Tier, entfernt es aus self.tiere und gibt das entfernte Tier zurück. + - Löst TierNichtGefundenFehler aus, falls kein Tier mit diesem Namen existiert. + """ + # TODO: Implementieren + pass + + def finde_tier(self, name: str) -> Optional[Tier]: + """ + Sucht ein Tier nach Namen (case-insensitive). + Gibt das Tier-Objekt zurück oder None, falls nicht gefunden. + """ + # TODO: Implementieren + pass + + def filtriere_nach_art(self, art_name: str) -> List[Tier]: + """ + Gibt eine Liste aller Tiere zurück, deren Klassenname (z.B. 'Hund', 'Katze', 'Vogel') + mit art_name (case-insensitive) übereinstimmt. + Bei art_name == 'Alle' oder leerer Eingabe wird die gesamte Liste zurückgegeben. + """ + # TODO: Implementieren + pass + + def ungeimpfte_tiere(self) -> List[Tier]: + """Gibt eine Liste aller Tiere zurück, bei denen self.geimpft == False ist.""" + # TODO: Implementieren + pass + + def hungrige_tiere(self, schwellenwert: int = 50) -> List[Tier]: + """Gibt alle Tiere zurück, deren Hunger-Level >= schwellenwert ist.""" + # TODO: Implementieren + pass + + def durchschnittsalter(self) -> float: + """ + Berechnet das durchschnittliche Alter aller Tiere im Heim. + Gibt 0.0 zurück, wenn das Tierheim leer ist. + """ + # TODO: Implementieren + pass + + def gesamtgewicht(self) -> float: + """ + Berechnet das Gesamtgewicht aller Tiere im Heim. + Gibt 0.0 zurück, wenn das Tierheim leer ist. + """ + # TODO: Implementieren + pass + + def alle_fuettern(self, menge_gramm: int = 100) -> Dict[str, str]: + """ + Füttert jedes Tier im Heim mit der angegebenen Futtermenge. + Gibt ein Dictionary {tier.name: bestaetigungstext} zurück. + """ + # TODO: Implementieren + pass + + def alle_impfen(self) -> int: + """ + Impft alle noch ungeimpften Tiere im Heim. + Gibt die Anzahl der frisch geimpften Tiere zurück. + """ + # TODO: Implementieren + pass + + def to_dict(self) -> Dict[str, Any]: + """ + Serialisiert das gesamte Tierheim in ein Dictionary. + Format: + { + "name": self.name, + "max_kapazitaet": self.max_kapazitaet, + "tiere": [tier.to_dict() for tier in self.tiere] + } + """ + # TODO: Implementieren + pass + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Tierheim: + """ + Erstellt ein Tierheim-Objekt aus einem Dictionary + und rekonstruiert alle Tiere via Tier.from_dict(). + """ + # TODO: Implementieren + pass + + def speichern_json(self, dateipfad: Union[str, Path]) -> None: + """ + Speichert den gesamten Datenbestand als formatierte JSON-Datei (indent=2, utf-8). + """ + # TODO: Implementieren + pass + + def laden_json(self, dateipfad: Union[str, Path]) -> None: + """ + Lädt Tierheimdaten aus einer JSON-Datei. + Aktualisiert Name, max_kapazitaet und ersetzt self.tiere mit den geladenen Tieren. + Löst FileNotFoundError aus, wenn die Datei nicht existiert. + """ + # TODO: Implementieren + pass + + def exportiere_csv(self, dateipfad: Union[str, Path]) -> None: + """ + Exportiert den Tierbestand in eine CSV-Datei mit folgenden Spalten: + Art,Name,Alter,Gewicht,Geimpft,Hunger,Details + """ + # TODO: Implementieren + pass + + def __len__(self) -> int: + return len(self.tiere) + + def __str__(self) -> str: + return f"Tierheim '{self.name}' ({len(self.tiere)}/{self.max_kapazitaet} Tiere)" + + +# ============================================================================== +# TEIL 4: DIE DESKTOP-BENUTZEROBERFLÄCHE (VIEW & CONTROLLER) +# ============================================================================== +class TierheimApp: + """ + Die grafische Benutzeroberfläche (Tkinter) nach dem MVC-Muster. + Verbindet GUI-Ereignisse mit den Methoden der Model-Klasse Tierheim. + """ + + def __init__(self, root: Any, tierheim: Optional[Tierheim] = None) -> None: + """ + Initialisiert das GUI-Fenster und alle Widgets. + """ + self.root = root + self.tierheim = tierheim if tierheim is not None else Tierheim() + + if hasattr(self.root, "title"): + self.root.title("🐾 PetCare & Tierheim-Manager Pro") + if hasattr(self.root, "geometry"): + self.root.geometry("850x620") + + self._aktiver_filter = "Alle" + self._erzeuge_widgets() + self.aktualisiere_ansicht() + + def _erzeuge_widgets(self) -> None: + """Erzeugt alle visuellen Komponenten (Formular, Liste, Buttons, Statusleiste).""" + self.label_titel = tk.Label( + self.root, + text=f"🏥 {self.tierheim.name}", + font=("Arial", 16, "bold"), + ) + self.label_titel.pack(pady=5) + + self.label_kapazitaet = tk.Label( + self.root, + text="Belegung: 0 / 20 Tiere", + font=("Arial", 11), + ) + self.label_kapazitaet.pack(pady=2) + + # Formular-Variablen + self.var_art = tk.StringVar(value="Hund") + self.var_name = tk.StringVar() + self.var_alter = tk.StringVar(value="2") + self.var_gewicht = tk.StringVar(value="10.5") + self.var_extra = tk.StringVar(value="Golden Retriever") + self.var_geimpft = tk.BooleanVar(value=False) + + # Listbox für Tierbestand + self.listbox_tiere = tk.Listbox(self.root, font=("Courier", 10), height=12) + self.listbox_tiere.pack(fill=tk.BOTH, expand=True, padx=15, pady=5) + + # Statusleiste + self.label_status = tk.Label( + self.root, + text="Bereit.", + bd=1, + relief="sunken", + anchor="w", + font=("Arial", 9), + ) + self.label_status.pack(side="bottom", fill="x") + + def aktualisiere_ansicht(self) -> None: + """ + Aktualisiert die Anzeige in der Listbox und den Kapazitätszähler + basierend auf dem aktuellen Datenmodell und Filter. + """ + # 1. Kapazitätstext aktualisieren + if hasattr(self.label_kapazitaet, "config"): + self.label_kapazitaet.config( + text=f"Belegung: {len(self.tierheim)} / {self.tierheim.max_kapazitaet} Tiere (Ø {self.tierheim.durchschnittsalter():.1f} Jahre, Σ {self.tierheim.gesamtgewicht():.1f} kg)" + ) + + # 2. Listbox leeren und neu befüllen + self.listbox_tiere.delete(0, tk.END) + + if self._aktiver_filter == "Ungeimpft": + tiere_anzeige = self.tierheim.ungeimpfte_tiere() + elif self._aktiver_filter == "Hungrig": + tiere_anzeige = self.tierheim.hungrige_tiere() + elif self._aktiver_filter in ("Hund", "Katze", "Vogel"): + tiere_anzeige = self.tierheim.filtriere_nach_art(self._aktiver_filter) + else: + tiere_anzeige = self.tierheim.tiere + + for tier in tiere_anzeige: + impf_symbol = "💉" if tier.geimpft else "❌" + eintrag = f"{tier.__class__.__name__:5s} | {tier.name:12s} | {tier.alter:2d} J. | {tier.gewicht:5.1f} kg | Hunger: {tier.hunger:3d}% | {impf_symbol} | {tier.get_details()}" + self.listbox_tiere.insert(tk.END, eintrag) + + def setze_status(self, nachricht: str, typ: str = "info") -> None: + """Setzt den Text und die Farbe der Statusleiste.""" + farben = { + "info": "#1e293b", + "success": "#166534", + "warning": "#854d0e", + "error": "#991b1b", + } + icon = {"info": "ℹ️ ", "success": "✅ ", "warning": "⚠️ ", "error": "❌ "}.get(typ, "") + if hasattr(self.label_status, "config"): + self.label_status.config(text=f"{icon}{nachricht}", fg=farben.get(typ, "#1e293b")) + + def get_ausgewaehltes_tier(self) -> Optional[Tier]: + """Ermittelt das aktuell in der Listbox ausgewählte Tier-Objekt.""" + try: + selektion = self.listbox_tiere.curselection() + if not selektion: + return None + index = selektion[0] + if 0 <= index < len(self.tierheim.tiere): + return self.tierheim.tiere[index] + return None + except Exception: + return None + + def tier_aufnehmen_klick(self) -> None: + """ + Liest die Formulardaten aus, instanziiert das passende Tier-Objekt (Hund, Katze, Vogel) + und fügt es dem Tierheim hinzu. Fängt ValidierungsFehler und KapazitaetUeberschrittenFehler ab. + """ + # TODO: Implementieren + pass + + def tier_fuettern_klick(self) -> None: + """Füttert das ausgewählte Tier und aktualisiert die Ansicht.""" + # TODO: Implementieren + pass + + def tier_impfen_klick(self) -> None: + """Impft das ausgewählte Tier und aktualisiert die Ansicht.""" + # TODO: Implementieren + pass + + def tier_laut_klick(self) -> None: + """Lässt das ausgewählte Tier seinen artspezifischen Laut von sich geben.""" + # TODO: Implementieren + pass + + def tier_vermitteln_klick(self) -> None: + """Vermittelt (entlässt) das ausgewählte Tier aus dem Heim.""" + # TODO: Implementieren + pass + + def speichern_klick(self, dateipfad: str = "tierheim_save.json") -> None: + """Speichert den aktuellen Stand als JSON-Datei.""" + try: + self.tierheim.speichern_json(dateipfad) + self.setze_status(f"Erfolgreich gespeichert in '{dateipfad}'.", typ="success") + except Exception as e: + self.setze_status(f"Fehler beim Speichern: {e}", typ="error") + + def laden_klick(self, dateipfad: str = "tierheim_save.json") -> None: + """Lädt den Stand aus einer JSON-Datei.""" + try: + self.tierheim.laden_json(dateipfad) + self.aktualisiere_ansicht() + self.setze_status(f"Erfolgreich geladen aus '{dateipfad}'.", typ="success") + except Exception as e: + self.setze_status(f"Fehler beim Laden: {e}", typ="error") + + def csv_export_klick(self, dateipfad: str = "tierheim_export.csv") -> None: + """Exportiert den Tierbestand als CSV-Tabelle.""" + try: + self.tierheim.exportiere_csv(dateipfad) + self.setze_status(f"CSV-Tabelle erfolgreich exportiert nach '{dateipfad}'.", typ="success") + except Exception as e: + self.setze_status(f"Fehler beim CSV-Export: {e}", typ="error") + + +# ============================================================================== +# HAUPTPROGRAMM (FÜR MANUELLES TESTEN) +# ============================================================================== +if __name__ == "__main__": + print("🐾 Initialisiere Tierheim Sonnenschein...") + heim = Tierheim("Tierheim Sonnenschein", max_kapazitaet=10) + + try: + bello = Hund("Bello", alter=3, gewicht=14.5, rasse="Beagle") + luna = Katze("Luna", alter=2, gewicht=4.2, stubenrein=True) + tweety = Vogel("Tweety", alter=1, gewicht=0.3, spannweite_cm=18.0, kann_sprechen=True) + + heim.tier_aufnehmen(bello) + heim.tier_aufnehmen(luna) + heim.tier_aufnehmen(tweety) + + print(f"✅ {heim}") + for t in heim.tiere: + print(f" • {t} -> Laut: {t.mache_laut()}") + + except TierheimFehler as e: + print(f"⚠️ Tierheim-Fehler: {e}") diff --git a/16_master_abschlussprojekt/index.html b/16_master_abschlussprojekt/index.html new file mode 100644 index 0000000..d4b2c58 --- /dev/null +++ b/16_master_abschlussprojekt/index.html @@ -0,0 +1,327 @@ + + + + + + Kapitel 16: Master-Abschlussprojekt – PetCare & Tierheim-Manager + + + + + +
+
+ +
🏆 Kapitel 16 • Das Große Meisterstück
+

PetCare & Tierheim-Manager Pro 🐾🏥🖥️

+

Die Krönung aller 3 Lehrpfade: Model-View-Controller (MVC), Vererbung, Polymorphie, JSON- & CSV-Persistenz, robuste Exceptions und eine vollwertige Tkinter Desktop-Applikation!

+
+
+ +
+ + +
+

1. 🏆 Dein Meisterstück: Alle 3 Lehrpfade vereint!

+

+ Herzlichen Glückwunsch zu diesem Meilenstein! Du hast gelernt, wie man Variablen deklariert, Schleifen baut, Klassen als Baupläne entwirft, Datenstrukturen serialisiert und echte Desktop-Fenster mit Tkinter zum Leben erweckt. +

+

+ In diesem Master-Abschlussprojekt baust du eine vollwertige, professionelle Software für ein Tierheim: Den PetCare- & Tierheim-Manager. Hier greifen alle Rädchen nahtlos ineinander: +

+ +
+
+

🛣️ Lehrpfad 1: Grundlagen

+

+ Typsichere Datenverarbeitung, String-Formatierung für Berichte, Rechnen von Statistiken (Mittelwert, Summen) und sauberer CSV-Export. +

+
+
+

🛣️ Lehrpfad 2: OOP & Clean Code

+

+ Objekt-Instanziierung, Kapselung, self, __str__, __repr__, 1:n Beziehungen und modulare Methoden. +

+
+
+

🛣️ Lehrpfad 3: Professionelle Software

+

+ Vererbung (super()), Polymorphie (Tierlaute), eigene Exceptions, JSON-Savegames, Tkinter Desktop-GUI und 100% Unittest-Abdeckung! +

+
+
+
+ + +
+

2. 🏛️ Die Architektur: Model-View-Controller (MVC)

+

+ In professionellen Software-Unternehmen schreibt man niemals Geschäftslogik und Benutzeroberfläche in einen einzigen großen Code-Klotz ("Spaghetti-Code"). Stattdessen trennt man das System strikt nach dem MVC-Muster: +

+ +
+
+ 1. Das Model (Daten & Logik) +

Klassen Tier & Tierheim

+

+ Verwaltet alle Tiere, berechnet Statistiken (Ø Alter, Gewicht), filtert Bestände und speichert/lädt JSON & CSV. +

+
+ 💡 100% Headless testbar: Weiß nichts von Tkinter und läuft komplett ohne Bildschirm! +
+
+ +
+ 2. Die View (Anzeige) +

Tkinter Widgets

+

+ Das visuelle Fenster: Header, Formular-Eingabefelder, Listbox für den Tierbestand, Aktionsbuttons und die dynamische Statusleiste. +

+
+ 👀 Dumb View: Zeigt nur an, was das Model ihr mitteilt. +
+
+ +
+ 3. Der Controller (Steuerung) +

Klasse TierheimApp

+

+ Reagiert auf Benutzerklicks, validiert Eingaben mit try-except, ruft Methoden des Tierheims auf und aktualisiert die Anzeige. +

+
+ 🛡️ Fehler-Abfangjäger: Verhindert App-Abstürze bei Fehleingaben. +
+
+
+ +
+
🍽️ Die Restaurant-Analogie für MVC:
+
    +
  • Model (Die Küche): Kocht das Essen, verwaltet die Zutaten und Rezepte (Logik & Daten).
  • +
  • View (Die Speisekarte & Teller): Das, was der Gast sieht und liest (Grafik).
  • +
  • Controller (Der Kellner): Nimmt die Bestellung vom Gast auf, gibt sie an die Küche weiter und bringt das fertige Gericht an den Tisch!
  • +
+
+
+ + +
+

3. 🧬 Das Datenmodell: Vererbung & Polymorphie

+

+ Ein Tierheim beherbergt verschiedene Tierarten: Hunde, Katzen und Vögel. + Sie alle teilen gemeinsame Eigenschaften (Name, Alter, Gewicht, Impfstatus, Hunger), besitzen jedoch jeweils eigene Spezialmerkmale und Verhaltensweisen: +

+ +
+
+
+ + + +
+ Python (Vererbungshierarchie & super()) +
+
class Tier:
+    def __init__(self, name: str, alter: int, gewicht: float, geimpft: bool = False, hunger: int = 50):
+        if not name.strip():
+            raise ValidierungsFehler("Name darf nicht leer sein!")
+        self.name = name.strip()
+        self.alter = alter
+        self.gewicht = gewicht
+        self.geimpft = geimpft
+        self.hunger = max(0, min(100, hunger))
+
+    def mache_laut(self) -> str:
+        return f"{self.name} macht ein Geräusch."
+
+class Hund(Tier):
+    def __init__(self, name: str, alter: int, gewicht: float, rasse: str = "Mischling", ...):
+        super().__init__(name, alter, gewicht, ...)
+        self.rasse = rasse
+        self.gassigegangen = False
+
+    def mache_laut(self) -> str:
+        return f"{self.name} bellt: Wuff! Wuff! 🐶"
+
+ +
+
🎭 Polymorphie in der Praxis:
+ Wenn wir im Tierheim for tier in self.tiere: print(tier.mache_laut()) aufrufen, weiß Python dank Polymorphie zur Laufzeit automatisch, welches Tier welchen Laut von sich gibt – ohne eine einzige if/elif-Typabfrage! +
+
+ + +
+

4. 💾 Savegame-Persistenz mit dem Factory-Muster

+

+ Beim Speichern als JSON-Datei wandeln wir jedes Tier-Objekt in ein Dictionary um und ergänzen den Schlüssel "art": "Hund". + Beim Laden liest die Factory-Methode Tier.from_dict(data) diesen Schlüssel aus und erzeugt automatisch die exakte Unterklasse: +

+ +
+
+
+ + + +
+ Python (Factory-Methode mit Type-Discriminator) +
+
@classmethod
+def from_dict(cls, data: dict) -> Tier:
+    art = data.get("art", "Tier")
+    if art == "Hund":
+        return Hund(name=data["name"], alter=data["alter"], gewicht=data["gewicht"], rasse=data.get("rasse", "Mischling"), ...)
+    elif art == "Katze":
+        return Katze(...)
+    elif art == "Vogel":
+        return Vogel(...)
+    else:
+        return Tier(...)
+
+
+ + +
+

5. ✅ Deine Projekt-Checkliste

+

Arbeite die folgenden 5 Abschnitte in der Datei aufgabe.py der Reihe nach ab:

+ +
    +
  • + 1. Eigene Exceptions: TierheimFehler, ValidierungsFehler, TierNichtGefundenFehler, KapazitaetUeberschrittenFehler. +
  • +
  • + 2. Basisklasse Tier: Validierung von Name, Alter, Gewicht im __init__, Methoden fuettern(), impfen(), to_dict() und Factory-Methode from_dict(). +
  • +
  • + 3. Kindklassen Hund, Katze, Vogel: Vererbung mit super(), artspezifische Attribute & Methoden (gassi_gehen(), kratzen(), fliegen()) und polymorphes mache_laut(). +
  • +
  • + 4. Geschäftslogik Tierheim: Tieraufnahme mit Kapazitätsprüfung, Vermittlung/Entlassen, Filter nach Art/Impfung/Hunger, Statistiken (Ø Alter, Gewicht) und JSON-/CSV-Persistenz. +
  • +
  • + 5. Benutzeroberfläche TierheimApp: Tkinter-Events mit dem Model verknüpfen, try-except Fehlerabfang und dynamische Statusleisten-Meldungen. +
  • +
+
+ + +
+

6. 💡 Ausklappbare Hilfestellungen & Tipps

+ +
+ 🔍 Tipp zu Schritt 1: Eigene Exceptions definieren +
+

Eigene Exception-Klassen erben in Python einfach von Exception oder einer übergeordneten Basis-Exception:

+
+
class TierheimFehler(Exception):
+    pass
+
+class ValidierungsFehler(TierheimFehler):
+    pass
+
+
+
+ +
+ 🔍 Tipp zu Schritt 4: CSV-Export mit dem csv-Modul +
+

Nutze csv.writer mit newline="" für saubere Tabellenausgabe:

+
+
with open(dateipfad, "w", newline="", encoding="utf-8") as f:
+    writer = csv.writer(f)
+    writer.writerow(["Art", "Name", "Alter", "Gewicht", "Geimpft", "Hunger", "Details"])
+    for t in self.tiere:
+        writer.writerow([t.__class__.__name__, t.name, t.alter, f"{t.gewicht:.1f}", "Ja" if t.geimpft else "Nein", f"{t.hunger}%", t.get_details()])
+
+
+
+ +
+ 🔍 Tipp zu Schritt 5: Fehler im GUI-Controller sanft abfangen +
+

Damit die Desktop-App bei einer Fehleingabe nicht abstürzt, fange Exceptions im Event-Handler ab:

+
+
try:
+    self.tierheim.tier_aufnehmen(neues_tier)
+    self.aktualisiere_ansicht()
+    self.setze_status(f"{neues_tier.name} aufgenommen! 🎉", typ="success")
+except TierheimFehler as e:
+    self.setze_status(str(e), typ="warning")
+
+
+
+
+ + +
+

7. 🧪 Lösung überprüfen & App starten

+ +

1. Automatische Unittests ausführen:

+
+
+ Terminal (im Ordner 16_master_abschlussprojekt) +
+
python3 test_aufgabe.py
+
+ +

2. Grafische Desktop-Applikation starten:

+
+
+ Terminal +
+
python3 app.py
+
+ + +
+ +
+ + + diff --git a/16_master_abschlussprojekt/musterloesung.py b/16_master_abschlussprojekt/musterloesung.py new file mode 100644 index 0000000..6c85304 --- /dev/null +++ b/16_master_abschlussprojekt/musterloesung.py @@ -0,0 +1,1035 @@ +""" +Kapitel 16: Master-Abschlussprojekt – Referenz-Musterlösung 🐾🏥 +================================================================ +Vollständige, fehlerfreie Musterlösung aller Klassen und Komponenten. +""" + +from __future__ import annotations +import csv +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +# ============================================================================== +# Tkinter Import mit sicherem Fallback für Headless- & Server-Umgebungen +# ============================================================================== +try: + import tkinter as tk + from tkinter import ttk, messagebox +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)) + self._items: list[Any] = [] + self._selection: list[int] = [] + + 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 set(self, val): + self._entry_text = str(val) + + def delete(self, first=0, last=None): + if hasattr(self, "_items") and self._items: + self._items.clear() + self._entry_text = "" + + def insert(self, index, string): + if hasattr(self, "_items"): + self._items.append(str(string)) + self._entry_text = str(string) + + def curselection(self): + return tuple(self._selection) if self._selection else (0,) if self._items else () + + def size(self): + return len(self._items) + + def get_item(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return "" + + 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 _MockStringVar: + def __init__(self, value=""): + self._val = str(value) + + def get(self): + return self._val + + def set(self, val): + self._val = str(val) + + class _MockBooleanVar: + def __init__(self, value=False): + self._val = bool(value) + + def get(self): + return self._val + + def set(self, val): + self._val = bool(val) + + class _MockMessageBox: + @staticmethod + def showinfo(title, message): + pass + + @staticmethod + def showwarning(title, message): + pass + + @staticmethod + def showerror(title, message): + pass + + class _MockTkModule: + Tk = _MockTk + Label = _MockWidget + Button = _MockWidget + Entry = _MockWidget + Frame = _MockWidget + Listbox = _MockWidget + Scrollbar = _MockWidget + Checkbutton = _MockWidget + StringVar = _MockStringVar + BooleanVar = _MockBooleanVar + OptionMenu = _MockWidget + END = "end" + SINGLE = "single" + BOTH = "both" + Y = "y" + W = "w" + E = "e" + + class _MockTtkModule: + Combobox = _MockWidget + LabelFrame = _MockWidget + Button = _MockWidget + Label = _MockWidget + Entry = _MockWidget + Frame = _MockWidget + + tk = _MockTkModule() + ttk = _MockTtkModule() + messagebox = _MockMessageBox() + + +# ============================================================================== +# TEIL 1: EIGENE EXCEPTION-HIERARCHIE +# ============================================================================== +class TierheimFehler(Exception): + """Basis-Exception für alle fachlichen Fehler im Tierheim.""" + pass + + +class ValidierungsFehler(TierheimFehler): + """Wird ausgelöst, wenn eingegebene Daten unzulässig sind.""" + pass + + +class TierNichtGefundenFehler(TierheimFehler): + """Wird ausgelöst, wenn ein gesuchtes Tier nicht existiert.""" + pass + + +class KapazitaetUeberschrittenFehler(TierheimFehler): + """Wird ausgelöst, wenn das Tierheim voll ist.""" + pass + + +# ============================================================================== +# TEIL 2: DAS DATENMODELL (BASISKLASSE & KINDKLASSEN) +# ============================================================================== +class Tier: + """ + Basisklasse für alle Tiere im Tierheim. + """ + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + geimpft: bool = False, + hunger: int = 50, + ) -> None: + if not isinstance(name, str) or not name.strip(): + raise ValidierungsFehler("Name darf nicht leer sein!") + try: + alter_val = int(alter) + except (ValueError, TypeError): + raise ValidierungsFehler("Alter muss eine gültige ganze Zahl sein!") + if alter_val < 0: + raise ValidierungsFehler("Alter darf nicht negativ sein!") + + try: + gewicht_val = float(gewicht) + except (ValueError, TypeError): + raise ValidierungsFehler("Gewicht muss eine gültige Kommazahl sein!") + if gewicht_val <= 0: + raise ValidierungsFehler("Gewicht muss größer als 0 sein!") + + try: + hunger_val = int(hunger) + except (ValueError, TypeError): + hunger_val = 50 + + self.name: str = name.strip() + self.alter: int = alter_val + self.gewicht: float = gewicht_val + self.geimpft: bool = bool(geimpft) + self.hunger: int = max(0, min(100, hunger_val)) + + def fuettern(self, futtermenge_gramm: int) -> str: + """Reduziert den Hunger (20 Punkte pro 100g Futter).""" + if futtermenge_gramm <= 0: + return f"{self.name} hat kein Futter erhalten." + reduktion = int(futtermenge_gramm * 0.2) + self.hunger = max(0, self.hunger - reduktion) + return f"{self.name} wurde mit {futtermenge_gramm}g gefüttert. Neuer Hunger-Level: {self.hunger}/100." + + def impfen(self) -> bool: + """Impft das Tier (setzt geimpft=True).""" + if self.geimpft: + return False + self.geimpft = True + return True + + def mache_laut(self) -> str: + """Polymorpher Standardlaut.""" + return f"{self.name} macht ein unbestimmtes Geräusch." + + def get_details(self) -> str: + return "Keine speziellen Merkmale" + + def to_dict(self) -> Dict[str, Any]: + """Serialisiert das Tier in ein Dictionary.""" + return { + "art": "Tier", + "name": self.name, + "alter": self.alter, + "gewicht": self.gewicht, + "geimpft": self.geimpft, + "hunger": self.hunger, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Tier: + """Factory-Methode zur Wiederherstellung der korrekten Tier-Klasse.""" + art = data.get("art", "Tier") + name = data.get("name", "Unbekannt") + alter = data.get("alter", 0) + gewicht = data.get("gewicht", 1.0) + geimpft = data.get("geimpft", False) + hunger = data.get("hunger", 50) + + if art == "Hund": + return Hund( + name=name, + alter=alter, + gewicht=gewicht, + rasse=data.get("rasse", "Mischling"), + geimpft=geimpft, + hunger=hunger, + gassigegangen=data.get("gassigegangen", False), + ) + elif art == "Katze": + return Katze( + name=name, + alter=alter, + gewicht=gewicht, + stubenrein=data.get("stubenrein", True), + geimpft=geimpft, + hunger=hunger, + kratzbaum_benutzt=data.get("kratzbaum_benutzt", False), + ) + elif art == "Vogel": + return Vogel( + name=name, + alter=alter, + gewicht=gewicht, + spannweite_cm=data.get("spannweite_cm", 25.0), + kann_sprechen=data.get("kann_sprechen", False), + geimpft=geimpft, + hunger=hunger, + ) + else: + return cls( + name=name, + alter=alter, + gewicht=gewicht, + geimpft=geimpft, + hunger=hunger, + ) + + def __str__(self) -> str: + impf_icon = "💉 Geimpft" if self.geimpft else "❌ Ungeimpft" + return f"[{self.__class__.__name__}] {self.name} ({self.alter} Jahre, {self.gewicht:.1f} kg, Hunger: {self.hunger}/100, {impf_icon})" + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name='{self.name}', alter={self.alter}, gewicht={self.gewicht}, geimpft={self.geimpft}, hunger={self.hunger})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Tier): + return False + return ( + self.name == other.name + and self.alter == other.alter + and abs(self.gewicht - other.gewicht) < 0.001 + and self.geimpft == other.geimpft + and self.hunger == other.hunger + ) + + +class Hund(Tier): + """Spezialisierte Kindklasse für Hunde.""" + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + rasse: str = "Mischling", + geimpft: bool = False, + hunger: int = 50, + gassigegangen: bool = False, + ) -> None: + super().__init__(name, alter, gewicht, geimpft, hunger) + if not isinstance(rasse, str) or not rasse.strip(): + raise ValidierungsFehler("Rasse darf nicht leer sein!") + self.rasse: str = rasse.strip() + self.gassigegangen: bool = bool(gassigegangen) + + def gassi_gehen(self, minuten: int) -> str: + self.gassigegangen = True + self.hunger = min(100, self.hunger + max(0, int(minuten) // 5)) + return f"{self.name} war {minuten} Minuten Gassi! 🦮" + + def mache_laut(self) -> str: + return f"{self.name} bellt: Wuff! Wuff! 🐶" + + def get_details(self) -> str: + status = "war Gassi" if self.gassigegangen else "muss noch raus" + return f"Rasse: {self.rasse} ({status})" + + def to_dict(self) -> Dict[str, Any]: + d = super().to_dict() + d["art"] = "Hund" + d["rasse"] = self.rasse + d["gassigegangen"] = self.gassigegangen + return d + + +class Katze(Tier): + """Spezialisierte Kindklasse für Katzen.""" + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + stubenrein: bool = True, + geimpft: bool = False, + hunger: int = 50, + kratzbaum_benutzt: bool = False, + ) -> None: + super().__init__(name, alter, gewicht, geimpft, hunger) + self.stubenrein: bool = bool(stubenrein) + self.kratzbaum_benutzt: bool = bool(kratzbaum_benutzt) + + def kratzen(self) -> str: + self.kratzbaum_benutzt = True + return f"{self.name} wetzt die Krallen am Kratzbaum! 🐾" + + def mache_laut(self) -> str: + return f"{self.name} schnurrt: Miau! Schnurr... 🐱" + + def get_details(self) -> str: + sauber = "stubenrein" if self.stubenrein else "nicht stubenrein" + return f"Stubenrein: {sauber}" + + def to_dict(self) -> Dict[str, Any]: + d = super().to_dict() + d["art"] = "Katze" + d["stubenrein"] = self.stubenrein + d["kratzbaum_benutzt"] = self.kratzbaum_benutzt + return d + + +class Vogel(Tier): + """Spezialisierte Kindklasse für Vögel.""" + + def __init__( + self, + name: str, + alter: int, + gewicht: float, + spannweite_cm: float = 25.0, + kann_sprechen: bool = False, + geimpft: bool = False, + hunger: int = 50, + ) -> None: + super().__init__(name, alter, gewicht, geimpft, hunger) + try: + sw = float(spannweite_cm) + except (ValueError, TypeError): + raise ValidierungsFehler("Spannweite muss eine Zahl sein!") + if sw <= 0: + raise ValidierungsFehler("Spannweite muss > 0 sein!") + self.spannweite_cm: float = sw + self.kann_sprechen: bool = bool(kann_sprechen) + + def fliegen(self, runden: int) -> str: + self.hunger = min(100, self.hunger + max(0, int(runden) * 2)) + return f"{self.name} dreht {runden} elegante Runden in der Voliere! 🕊️" + + def mache_laut(self) -> str: + if self.kann_sprechen: + return f"{self.name} plappert: 'Hallo Mensch! Tschilp!' 🦜" + return f"{self.name} zwitschert: Tschilp! Tschilp! 🐦" + + def get_details(self) -> str: + spricht = "kann sprechen" if self.kann_sprechen else "singt Melodien" + return f"Spannweite: {self.spannweite_cm:.1f} cm ({spricht})" + + def to_dict(self) -> Dict[str, Any]: + d = super().to_dict() + d["art"] = "Vogel" + d["spannweite_cm"] = self.spannweite_cm + d["kann_sprechen"] = self.kann_sprechen + return d + + +# ============================================================================== +# TEIL 3: DIE GESCHÄFTSLOGIK & DER VERWALTER (MODEL) +# ============================================================================== +class Tierheim: + """ + Das Model des Tierheims: Verwaltet Bestände, Filter, Berechnungen und Persistenz. + """ + + def __init__(self, name: str = "Tierheim Sonnenschein", max_kapazitaet: int = 20) -> None: + if not isinstance(name, str) or not name.strip(): + raise ValidierungsFehler("Tierheim-Name darf nicht leer sein!") + try: + kap = int(max_kapazitaet) + except (ValueError, TypeError): + raise ValidierungsFehler("Kapazität muss eine positive ganze Zahl sein!") + if kap <= 0: + raise ValidierungsFehler("Kapazität muss größer als 0 sein!") + + self.name: str = name.strip() + self.max_kapazitaet: int = kap + self.tiere: List[Tier] = [] + + def tier_aufnehmen(self, tier: Tier) -> None: + if not isinstance(tier, Tier): + raise ValidierungsFehler("Nur Tier-Objekte können aufgenommen werden!") + if len(self.tiere) >= self.max_kapazitaet: + raise KapazitaetUeberschrittenFehler( + f"Tierheim ist voll! Maximale Kapazität von {self.max_kapazitaet} erreicht." + ) + self.tiere.append(tier) + + def tier_entlassen(self, name: str) -> Tier: + suchname = name.strip().lower() + for i, t in enumerate(self.tiere): + if t.name.lower() == suchname: + return self.tiere.pop(i) + raise TierNichtGefundenFehler(f"Kein Tier mit dem Namen '{name}' gefunden!") + + def finde_tier(self, name: str) -> Optional[Tier]: + suchname = name.strip().lower() + for t in self.tiere: + if t.name.lower() == suchname: + return t + return None + + def filtriere_nach_art(self, art_name: str) -> List[Tier]: + if not art_name or art_name.strip().lower() == "alle": + return list(self.tiere) + such_art = art_name.strip().lower() + return [t for t in self.tiere if t.__class__.__name__.lower() == such_art] + + def ungeimpfte_tiere(self) -> List[Tier]: + return [t for t in self.tiere if not t.geimpft] + + def hungrige_tiere(self, schwellenwert: int = 50) -> List[Tier]: + return [t for t in self.tiere if t.hunger >= schwellenwert] + + def durchschnittsalter(self) -> float: + if not self.tiere: + return 0.0 + return sum(t.alter for t in self.tiere) / len(self.tiere) + + def gesamtgewicht(self) -> float: + return sum(t.gewicht for t in self.tiere) + + def alle_fuettern(self, menge_gramm: int = 100) -> Dict[str, str]: + ergebnisse = {} + for t in self.tiere: + ergebnisse[t.name] = t.fuettern(menge_gramm) + return ergebnisse + + def alle_impfen(self) -> int: + frisch_geimpft = 0 + for t in self.tiere: + if t.impfen(): + frisch_geimpft += 1 + return frisch_geimpft + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "max_kapazitaet": self.max_kapazitaet, + "tiere": [t.to_dict() for t in self.tiere], + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Tierheim: + heim = cls( + name=data.get("name", "Tierheim Sonnenschein"), + max_kapazitaet=data.get("max_kapazitaet", 20), + ) + for t_dict in data.get("tiere", []): + heim.tier_aufnehmen(Tier.from_dict(t_dict)) + return heim + + def speichern_json(self, dateipfad: Union[str, Path]) -> None: + p = Path(dateipfad) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) + + def laden_json(self, dateipfad: Union[str, Path]) -> None: + p = Path(dateipfad) + if not p.exists(): + raise FileNotFoundError(f"Datei '{dateipfad}' nicht gefunden!") + with open(p, "r", encoding="utf-8") as f: + data = json.load(f) + self.name = data.get("name", self.name) + self.max_kapazitaet = data.get("max_kapazitaet", self.max_kapazitaet) + self.tiere = [Tier.from_dict(t) for t in data.get("tiere", [])] + + def exportiere_csv(self, dateipfad: Union[str, Path]) -> None: + p = Path(dateipfad) + p.parent.mkdir(parents=True, exist_ok=True) + with open(p, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["Art", "Name", "Alter", "Gewicht", "Geimpft", "Hunger", "Details"]) + for t in self.tiere: + writer.writerow([ + t.__class__.__name__, + t.name, + t.alter, + f"{t.gewicht:.1f}", + "Ja" if t.geimpft else "Nein", + f"{t.hunger}%", + t.get_details(), + ]) + + def __len__(self) -> int: + return len(self.tiere) + + def __str__(self) -> str: + return f"Tierheim '{self.name}' ({len(self.tiere)}/{self.max_kapazitaet} Tiere)" + + +# ============================================================================== +# TEIL 4: DIE DESKTOP-BENUTZEROBERFLÄCHE (VIEW & CONTROLLER) +# ============================================================================== +class TierheimApp: + """ + Die grafische Benutzeroberfläche (Tkinter) nach dem MVC-Muster. + """ + + def __init__(self, root: Any, tierheim: Optional[Tierheim] = None) -> None: + self.root = root + self.tierheim = tierheim if tierheim is not None else Tierheim() + + if hasattr(self.root, "title"): + self.root.title("🐾 PetCare & Tierheim-Manager Pro") + if hasattr(self.root, "geometry"): + self.root.geometry("850x620") + + self._aktiver_filter = "Alle" + self._erzeuge_widgets() + self.aktualisiere_ansicht() + + def _erzeuge_widgets(self) -> None: + """Erzeugt das vollständige GUI-Layout mit Widgets.""" + # Oberer Titelbereich + self.frame_header = tk.Frame(self.root, pady=6) + self.frame_header.pack(fill="x", padx=10) + + self.label_titel = tk.Label( + self.frame_header, + text=f"🏥 {self.tierheim.name}", + font=("Arial", 16, "bold"), + fg="#4338ca", + ) + self.label_titel.pack() + + self.label_kapazitaet = tk.Label( + self.frame_header, + text="Belegung: 0 / 20 Tiere", + font=("Arial", 10), + fg="#64748b", + ) + self.label_kapazitaet.pack() + + # Hauptbereich: Split in Formular (Links) und Liste/Aktionen (Rechts) + self.frame_main = tk.Frame(self.root) + self.frame_main.pack(fill="both", expand=True, padx=10, pady=5) + + # ---------------- Formular (Links) ---------------- + self.frame_form = tk.Frame(self.frame_main, relief="groove", bd=1, padx=10, pady=10) + self.frame_form.pack(side="left", fill="y", padx=5) + + tk.Label(self.frame_form, text="➕ Neues Tier aufnehmen", font=("Arial", 11, "bold")).pack(pady=4) + + # Art + tk.Label(self.frame_form, text="Tierart:", anchor="w").pack(fill="x", pady=(4, 0)) + self.var_art = tk.StringVar(value="Hund") + self.combo_art = ttk.Combobox( + self.frame_form, + textvariable=self.var_art, + values=["Hund", "Katze", "Vogel"], + state="readonly", + ) + self.combo_art.pack(fill="x", pady=2) + + # Name + tk.Label(self.frame_form, text="Name:", anchor="w").pack(fill="x", pady=(4, 0)) + self.var_name = tk.StringVar(value="") + self.entry_name = tk.Entry(self.frame_form, textvariable=self.var_name) + self.entry_name.pack(fill="x", pady=2) + + # Alter + tk.Label(self.frame_form, text="Alter (Jahre):", anchor="w").pack(fill="x", pady=(4, 0)) + self.var_alter = tk.StringVar(value="2") + self.entry_alter = tk.Entry(self.frame_form, textvariable=self.var_alter) + self.entry_alter.pack(fill="x", pady=2) + + # Gewicht + tk.Label(self.frame_form, text="Gewicht (kg):", anchor="w").pack(fill="x", pady=(4, 0)) + self.var_gewicht = tk.StringVar(value="10.5") + self.entry_gewicht = tk.Entry(self.frame_form, textvariable=self.var_gewicht) + self.entry_gewicht.pack(fill="x", pady=2) + + # Rasse / Extra + tk.Label(self.frame_form, text="Rasse / Spannweite / Info:", anchor="w").pack(fill="x", pady=(4, 0)) + self.var_extra = tk.StringVar(value="Golden Retriever") + self.entry_extra = tk.Entry(self.frame_form, textvariable=self.var_extra) + self.entry_extra.pack(fill="x", pady=2) + + # Geimpft + self.var_geimpft = tk.BooleanVar(value=False) + self.chk_geimpft = tk.Checkbutton(self.frame_form, text="Bereits geimpft 💉", variable=self.var_geimpft) + self.chk_geimpft.pack(anchor="w", pady=4) + + # Button Aufnahme + self.btn_aufnehmen = tk.Button( + self.frame_form, + text="Tier aufnehmen ➕", + bg="#10b981", + fg="white", + font=("Arial", 10, "bold"), + command=self.tier_aufnehmen_klick, + ) + self.btn_aufnehmen.pack(fill="x", pady=8) + + # ---------------- Listenbereich & Filter (Rechts) ---------------- + self.frame_rechts = tk.Frame(self.frame_main) + self.frame_rechts.pack(side="right", fill="both", expand=True, padx=5) + + # Filterleiste + self.frame_filter = tk.Frame(self.frame_rechts) + self.frame_filter.pack(fill="x", pady=2) + + tk.Label(self.frame_filter, text="Filter:", font=("Arial", 9, "bold")).pack(side="left", padx=4) + for f_name, f_label in [ + ("Alle", "Alle"), + ("Hund", "Hunde 🐶"), + ("Katze", "Katzen 🐱"), + ("Vogel", "Vögel 🐦"), + ("Ungeimpft", "Ungeimpft 💉"), + ("Hungrig", "Hungrig 🍖"), + ]: + b = tk.Button( + self.frame_filter, + text=f_label, + font=("Arial", 8), + command=lambda fn=f_name: self.filter_setzen(fn), + ) + b.pack(side="left", padx=2) + + # Listbox für Tiere + self.listbox_tiere = tk.Listbox(self.frame_rechts, font=("Courier", 9), height=11) + self.listbox_tiere.pack(fill="both", expand=True, pady=4) + + # Aktionsbuttons für ausgewähltes Tier + self.frame_aktionen = tk.Frame(self.frame_rechts) + self.frame_aktionen.pack(fill="x", pady=3) + + self.btn_fuettern = tk.Button( + self.frame_aktionen, + text="Füttern (100g) 🍖", + bg="#f59e0b", + fg="white", + command=self.tier_fuettern_klick, + ) + self.btn_fuettern.pack(side="left", fill="x", expand=True, padx=2) + + self.btn_impfen = tk.Button( + self.frame_aktionen, + text="Impfen 💉", + bg="#3b82f6", + fg="white", + command=self.tier_impfen_klick, + ) + self.btn_impfen.pack(side="left", fill="x", expand=True, padx=2) + + self.btn_laut = tk.Button( + self.frame_aktionen, + text="Laut machen 🔊", + bg="#8b5cf6", + fg="white", + command=self.tier_laut_klick, + ) + self.btn_laut.pack(side="left", fill="x", expand=True, padx=2) + + self.btn_vermitteln = tk.Button( + self.frame_aktionen, + text="Vermitteln 🏠", + bg="#ef4444", + fg="white", + command=self.tier_vermitteln_klick, + ) + self.btn_vermitteln.pack(side="left", fill="x", expand=True, padx=2) + + # Massen- & Dateiaufsatz + self.frame_datei = tk.Frame(self.frame_rechts) + self.frame_datei.pack(fill="x", pady=3) + + self.btn_alle_fuettern = tk.Button( + self.frame_datei, + text="Alle füttern 🍲", + command=self.alle_fuettern_klick, + ) + self.btn_alle_fuettern.pack(side="left", fill="x", expand=True, padx=2) + + self.btn_alle_impfen = tk.Button( + self.frame_datei, + text="Alle impfen 🩺", + command=self.alle_impfen_klick, + ) + self.btn_alle_impfen.pack(side="left", fill="x", expand=True, padx=2) + + self.btn_speichern = tk.Button( + self.frame_datei, + text="JSON Speichern 💾", + command=self.speichern_klick, + ) + self.btn_speichern.pack(side="left", fill="x", expand=True, padx=2) + + self.btn_laden = tk.Button( + self.frame_datei, + text="JSON Laden 📂", + command=self.laden_klick, + ) + self.btn_laden.pack(side="left", fill="x", expand=True, padx=2) + + self.btn_csv = tk.Button( + self.frame_datei, + text="CSV Export 📊", + command=self.csv_export_klick, + ) + self.btn_csv.pack(side="left", fill="x", expand=True, padx=2) + + # Statusleiste (Ganz unten) + self.label_status = tk.Label( + self.root, + text="Bereit.", + bd=1, + relief="sunken", + anchor="w", + font=("Arial", 9), + padx=5, + pady=3, + ) + self.label_status.pack(side="bottom", fill="x") + + def filter_setzen(self, filter_name: str) -> None: + """Setzt den aktiven Ansichts-Filter und frischt die Listbox auf.""" + self._aktiver_filter = filter_name + self.aktualisiere_ansicht() + self.setze_status(f"Filter aktiv: {filter_name}", typ="info") + + def aktualisiere_ansicht(self) -> None: + """Aktualisiert die Listbox und die Statistiken im Header.""" + if hasattr(self.label_kapazitaet, "config"): + self.label_kapazitaet.config( + text=f"Belegung: {len(self.tierheim)} / {self.tierheim.max_kapazitaet} Tiere | " + f"Ø {self.tierheim.durchschnittsalter():.1f} Jahre | Σ {self.tierheim.gesamtgewicht():.1f} kg" + ) + + self.listbox_tiere.delete(0, tk.END) + + if self._aktiver_filter == "Ungeimpft": + tiere_anzeige = self.tierheim.ungeimpfte_tiere() + elif self._aktiver_filter == "Hungrig": + tiere_anzeige = self.tierheim.hungrige_tiere() + elif self._aktiver_filter in ("Hund", "Katze", "Vogel"): + tiere_anzeige = self.tierheim.filtriere_nach_art(self._aktiver_filter) + else: + tiere_anzeige = self.tierheim.tiere + + for tier in tiere_anzeige: + impf_symbol = "💉" if tier.geimpft else "❌" + eintrag = f"{tier.__class__.__name__:5s} | {tier.name:12s} | {tier.alter:2d} J. | {tier.gewicht:5.1f} kg | Hunger: {tier.hunger:3d}% | {impf_symbol} | {tier.get_details()}" + self.listbox_tiere.insert(tk.END, eintrag) + + def setze_status(self, nachricht: str, typ: str = "info") -> None: + """Setzt Nachricht und Farb-Theme der Statusleiste.""" + farben = { + "info": "#1e293b", + "success": "#166534", + "warning": "#854d0e", + "error": "#991b1b", + } + icon = {"info": "ℹ️ ", "success": "✅ ", "warning": "⚠️ ", "error": "❌ "}.get(typ, "") + if hasattr(self.label_status, "config"): + self.label_status.config(text=f"{icon}{nachricht}", fg=farben.get(typ, "#1e293b")) + + def get_ausgewaehltes_tier(self) -> Optional[Tier]: + """Ermittelt das aktuell ausgewählte Tier.""" + try: + selektion = self.listbox_tiere.curselection() + if not selektion: + return None + index = selektion[0] + + # Liste abhängig vom aktiven Filter ermitteln + if self._aktiver_filter == "Ungeimpft": + tiere_anzeige = self.tierheim.ungeimpfte_tiere() + elif self._aktiver_filter == "Hungrig": + tiere_anzeige = self.tierheim.hungrige_tiere() + elif self._aktiver_filter in ("Hund", "Katze", "Vogel"): + tiere_anzeige = self.tierheim.filtriere_nach_art(self._aktiver_filter) + else: + tiere_anzeige = self.tierheim.tiere + + if 0 <= index < len(tiere_anzeige): + return tiere_anzeige[index] + return None + except Exception: + return None + + def tier_aufnehmen_klick(self) -> None: + """Verarbeitet das Formular und nimmt ein neues Tier auf.""" + try: + art = self.var_art.get().strip() + name = self.var_name.get().strip() + alter_str = self.var_alter.get().strip() + gewicht_str = self.var_gewicht.get().strip() + extra = self.var_extra.get().strip() + geimpft = self.var_geimpft.get() + + if not name: + raise ValidierungsFehler("Bitte einen Namen für das Tier eingeben!") + + try: + alter = int(alter_str) + except ValueError: + raise ValidierungsFehler("Alter muss eine ganze Zahl sein!") + + try: + gewicht = float(gewicht_str) + except ValueError: + raise ValidierungsFehler("Gewicht muss eine Zahl sein (z.B. 12.5)!") + + neues_tier: Tier + if art == "Hund": + neues_tier = Hund( + name=name, + alter=alter, + gewicht=gewicht, + rasse=extra if extra else "Mischling", + geimpft=geimpft, + ) + elif art == "Katze": + stubenrein = extra.lower() not in ("nein", "false", "0") + neues_tier = Katze( + name=name, + alter=alter, + gewicht=gewicht, + stubenrein=stubenrein, + geimpft=geimpft, + ) + elif art == "Vogel": + try: + spannweite = float(extra) if extra else 25.0 + except ValueError: + spannweite = 25.0 + neues_tier = Vogel( + name=name, + alter=alter, + gewicht=gewicht, + spannweite_cm=spannweite, + kann_sprechen=False, + geimpft=geimpft, + ) + else: + neues_tier = Tier(name=name, alter=alter, gewicht=gewicht, geimpft=geimpft) + + self.tierheim.tier_aufnehmen(neues_tier) + self.aktualisiere_ansicht() + self.setze_status(f"{neues_tier.name} ({art}) erfolgreich aufgenommen!", typ="success") + + # Formular zurücksetzen + self.var_name.set("") + + except TierheimFehler as e: + self.setze_status(str(e), typ="warning") + except Exception as e: + self.setze_status(f"Unerwarteter Fehler: {e}", typ="error") + + def tier_fuettern_klick(self) -> None: + tier = self.get_ausgewaehltes_tier() + if not tier: + self.setze_status("Bitte wähle zuerst ein Tier in der Liste aus!", typ="warning") + return + msg = tier.fuettern(100) + self.aktualisiere_ansicht() + self.setze_status(msg, typ="success") + + def tier_impfen_klick(self) -> None: + tier = self.get_ausgewaehltes_tier() + if not tier: + self.setze_status("Bitte wähle zuerst ein Tier in der Liste aus!", typ="warning") + return + frisch = tier.impfen() + self.aktualisiere_ansicht() + if frisch: + self.setze_status(f"{tier.name} wurde erfolgreich geimpft! 💉", typ="success") + else: + self.setze_status(f"{tier.name} war bereits geimpft.", typ="info") + + def tier_laut_klick(self) -> None: + tier = self.get_ausgewaehltes_tier() + if not tier: + self.setze_status("Bitte wähle zuerst ein Tier in der Liste aus!", typ="warning") + return + laut = tier.mache_laut() + self.setze_status(laut, typ="info") + + def tier_vermitteln_klick(self) -> None: + tier = self.get_ausgewaehltes_tier() + if not tier: + self.setze_status("Bitte wähle zuerst ein Tier in der Liste aus!", typ="warning") + return + try: + entlassen = self.tierheim.tier_entlassen(tier.name) + self.aktualisiere_ansicht() + self.setze_status(f"{entlassen.name} wurde erfolgreich vermittelt! 🏠🎉", typ="success") + except TierheimFehler as e: + self.setze_status(str(e), typ="warning") + + def alle_fuettern_klick(self) -> None: + if not self.tierheim.tiere: + self.setze_status("Keine Tiere zum Füttern im Heim.", typ="info") + return + self.tierheim.alle_fuettern(100) + self.aktualisiere_ansicht() + self.setze_status(f"Alle {len(self.tierheim)} Tiere wurden gefüttert! 🍲", typ="success") + + def alle_impfen_klick(self) -> None: + anzahl = self.tierheim.alle_impfen() + self.aktualisiere_ansicht() + self.setze_status(f"Impfaktion beendet: {anzahl} Tiere frisch geimpft! 🩺", typ="success") + + def speichern_klick(self, dateipfad: str = "tierheim_save.json") -> None: + try: + self.tierheim.speichern_json(dateipfad) + self.setze_status(f"Gespeichert: {len(self.tierheim)} Tiere in '{dateipfad}'.", typ="success") + except Exception as e: + self.setze_status(f"Fehler beim Speichern: {e}", typ="error") + + def laden_klick(self, dateipfad: str = "tierheim_save.json") -> None: + try: + self.tierheim.laden_json(dateipfad) + self.aktualisiere_ansicht() + self.setze_status(f"Geladen: {len(self.tierheim)} Tiere aus '{dateipfad}'.", typ="success") + except Exception as e: + self.setze_status(f"Fehler beim Laden: {e}", typ="error") + + def csv_export_klick(self, dateipfad: str = "tierheim_export.csv") -> None: + try: + self.tierheim.exportiere_csv(dateipfad) + self.setze_status(f"CSV-Export erfolgreich erstellt: '{dateipfad}'.", typ="success") + except Exception as e: + self.setze_status(f"Fehler beim CSV-Export: {e}", typ="error") + + +# ============================================================================== +# HAUPTPROGRAMM (DEMO) +# ============================================================================== +if __name__ == "__main__": + heim = Tierheim("Tierheim Sonnenschein", max_kapazitaet=15) + heim.tier_aufnehmen(Hund("Bello", 3, 14.5, rasse="Beagle", geimpft=True)) + heim.tier_aufnehmen(Katze("Luna", 2, 4.2, stubenrein=True, geimpft=False)) + heim.tier_aufnehmen(Vogel("Tweety", 1, 0.3, spannweite_cm=18.0, kann_sprechen=True)) + + root = tk.Tk() + app = TierheimApp(root, heim) + root.mainloop() diff --git a/16_master_abschlussprojekt/test_aufgabe.py b/16_master_abschlussprojekt/test_aufgabe.py new file mode 100644 index 0000000..cd20304 --- /dev/null +++ b/16_master_abschlussprojekt/test_aufgabe.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +""" +🧪 Umfassende Unittest-Suite für Kapitel 16 (Master-Abschlussprojekt) +===================================================================== +Prüft: +- Teil 1: Exception-Hierarchie & Vererbung +- Teil 2: Datenmodelle (Basisklasse Tier & Kindklassen Hund, Katze, Vogel) +- Teil 3: Polymorphie & Factory-Muster (Tier.from_dict) +- Teil 4: Geschäftslogik (Tierheim, Kapazität, Filter, Massenoperationen, Statistik) +- Teil 5: Persistenz (JSON Savegame-Roundtrip & CSV-Export) +- Teil 6: GUI-Controller (TierheimApp im Headless-Modus) +""" + +import csv +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +# Aktuellen Ordner in sys.path einfü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 Test01Exceptions(unittest.TestCase): + """Prüft die Exception-Klassen und deren Vererbungshierarchie.""" + + def test_01_exceptions_existieren_und_erben(self): + self.assertTrue(hasattr(aufgabe, "TierheimFehler"), "TierheimFehler fehlt!") + self.assertTrue(hasattr(aufgabe, "ValidierungsFehler"), "ValidierungsFehler fehlt!") + self.assertTrue(hasattr(aufgabe, "TierNichtGefundenFehler"), "TierNichtGefundenFehler fehlt!") + self.assertTrue(hasattr(aufgabe, "KapazitaetUeberschrittenFehler"), "KapazitaetUeberschrittenFehler fehlt!") + + self.assertTrue(issubclass(aufgabe.TierheimFehler, Exception)) + self.assertTrue(issubclass(aufgabe.ValidierungsFehler, aufgabe.TierheimFehler)) + self.assertTrue(issubclass(aufgabe.TierNichtGefundenFehler, aufgabe.TierheimFehler)) + self.assertTrue(issubclass(aufgabe.KapazitaetUeberschrittenFehler, aufgabe.TierheimFehler)) + + +class Test02TierBasisklasse(unittest.TestCase): + """Prüft die Basisklasse Tier inkl. Validierung und Basis-Methoden.""" + + def test_02_tier_initialisierung_und_attribute(self): + t = aufgabe.Tier("Sammy", alter=4, gewicht=12.5, geimpft=False, hunger=40) + self.assertEqual(t.name, "Sammy") + self.assertEqual(t.alter, 4) + self.assertAlmostEqual(t.gewicht, 12.5) + self.assertFalse(t.geimpft) + self.assertEqual(t.hunger, 40) + + def test_03_tier_validierung_leerer_name(self): + with self.assertRaises(aufgabe.ValidierungsFehler): + aufgabe.Tier("", alter=3, gewicht=5.0) + with self.assertRaises(aufgabe.ValidierungsFehler): + aufgabe.Tier(" ", alter=3, gewicht=5.0) + + def test_04_tier_validierung_negatives_alter(self): + with self.assertRaises(aufgabe.ValidierungsFehler): + aufgabe.Tier("Sammy", alter=-1, gewicht=5.0) + + def test_05_tier_validierung_ungueltiges_gewicht(self): + with self.assertRaises(aufgabe.ValidierungsFehler): + aufgabe.Tier("Sammy", alter=2, gewicht=0.0) + with self.assertRaises(aufgabe.ValidierungsFehler): + aufgabe.Tier("Sammy", alter=2, gewicht=-4.5) + + def test_06_tier_fuettern_und_clamping(self): + t = aufgabe.Tier("Sammy", alter=2, gewicht=5.0, hunger=60) + msg = t.fuettern(100) # -20 Hunger + self.assertEqual(t.hunger, 40) + self.assertIn("Sammy", msg) + + # Überfüttern darf nicht unter 0 fallen + t.fuettern(500) + self.assertEqual(t.hunger, 0) + + def test_07_tier_impfen(self): + t = aufgabe.Tier("Sammy", alter=2, gewicht=5.0, geimpft=False) + erstes_mal = t.impfen() + self.assertTrue(erstes_mal, "Erstmaliges Impfen sollte True zurückgeben.") + self.assertTrue(t.geimpft) + + zweites_mal = t.impfen() + self.assertFalse(zweites_mal, "Erneutes Impfen sollte False zurückgeben.") + + def test_08_tier_to_dict_und_str(self): + t = aufgabe.Tier("Sammy", alter=2, gewicht=5.0, geimpft=True, hunger=30) + d = t.to_dict() + self.assertEqual(d.get("name"), "Sammy") + self.assertEqual(d.get("alter"), 2) + self.assertEqual(d.get("gewicht"), 5.0) + self.assertEqual(d.get("geimpft"), True) + self.assertEqual(d.get("hunger"), 30) + self.assertIn("Sammy", str(t)) + + +class Test03Kindklassen(unittest.TestCase): + """Prüft Vererbung und Spezialisierungen für Hund, Katze und Vogel.""" + + def test_09_hund_spezifisch(self): + h = aufgabe.Hund("Bello", alter=3, gewicht=15.0, rasse="Labrador", geimpft=False, hunger=40) + self.assertTrue(isinstance(h, aufgabe.Tier), "Hund muss von Tier erben!") + self.assertEqual(h.rasse, "Labrador") + self.assertFalse(h.gassigegangen) + + # Gassigehen + msg = h.gassi_gehen(20) # +4 Hunger + self.assertTrue(h.gassigegangen) + self.assertEqual(h.hunger, 44) + self.assertIn("Bello", msg) + + # Laut + laut = h.mache_laut() + self.assertIn("Wuff", laut) + + # to_dict + d = h.to_dict() + self.assertEqual(d.get("art"), "Hund") + self.assertEqual(d.get("rasse"), "Labrador") + self.assertTrue(d.get("gassigegangen")) + + def test_10_katze_spezifisch(self): + k = aufgabe.Katze("Luna", alter=2, gewicht=4.0, stubenrein=True, hunger=30) + self.assertTrue(isinstance(k, aufgabe.Tier), "Katze muss von Tier erben!") + self.assertTrue(k.stubenrein) + self.assertFalse(k.kratzbaum_benutzt) + + # Kratzen + k.kratzen() + self.assertTrue(k.kratzbaum_benutzt) + + # Laut + laut = k.mache_laut() + self.assertIn("Miau", laut) + + # to_dict + d = k.to_dict() + self.assertEqual(d.get("art"), "Katze") + self.assertTrue(d.get("stubenrein")) + self.assertTrue(d.get("kratzbaum_benutzt")) + + def test_11_vogel_spezifisch(self): + v = aufgabe.Vogel("Tweety", alter=1, gewicht=0.3, spannweite_cm=18.0, kann_sprechen=True, hunger=20) + self.assertTrue(isinstance(v, aufgabe.Tier), "Vogel muss von Tier erben!") + self.assertEqual(v.spannweite_cm, 18.0) + self.assertTrue(v.kann_sprechen) + + # Fliegen + v.fliegen(5) # +10 Hunger + self.assertEqual(v.hunger, 30) + + # Laut mit Sprechen + laut = v.mache_laut() + self.assertIn("Hallo Mensch", laut) + + # Vogel ohne Sprechen + v2 = aufgabe.Vogel("Charly", alter=2, gewicht=0.4, kann_sprechen=False) + laut2 = v2.mache_laut() + self.assertIn("Tschilp", laut2) + + +class Test04PolymorphieUndFactory(unittest.TestCase): + """Prüft das Factory-Pattern Tier.from_dict für alle Subklassen.""" + + def test_12_factory_hund_wiederherstellen(self): + data = { + "art": "Hund", + "name": "Rex", + "alter": 5, + "gewicht": 20.0, + "rasse": "Schäferhund", + "geimpft": True, + "hunger": 35, + "gassigegangen": True, + } + obj = aufgabe.Tier.from_dict(data) + self.assertIsInstance(obj, aufgabe.Hund) + self.assertEqual(obj.name, "Rex") + self.assertEqual(obj.rasse, "Schäferhund") + self.assertTrue(obj.geimpft) + self.assertTrue(obj.gassigegangen) + + def test_13_factory_katze_wiederherstellen(self): + data = { + "art": "Katze", + "name": "Minka", + "alter": 3, + "gewicht": 3.8, + "stubenrein": True, + "geimpft": False, + "hunger": 50, + "kratzbaum_benutzt": False, + } + obj = aufgabe.Tier.from_dict(data) + self.assertIsInstance(obj, aufgabe.Katze) + self.assertEqual(obj.name, "Minka") + self.assertTrue(obj.stubenrein) + + def test_14_factory_vogel_wiederherstellen(self): + data = { + "art": "Vogel", + "name": "Kiki", + "alter": 2, + "gewicht": 0.2, + "spannweite_cm": 15.5, + "kann_sprechen": True, + "geimpft": True, + "hunger": 10, + } + obj = aufgabe.Tier.from_dict(data) + self.assertIsInstance(obj, aufgabe.Vogel) + self.assertEqual(obj.name, "Kiki") + self.assertEqual(obj.spannweite_cm, 15.5) + self.assertTrue(obj.kann_sprechen) + + +class Test05TierheimGeschaeftslogik(unittest.TestCase): + """Prüft die Model-Klasse Tierheim (Aufnahme, Entlassen, Filter, Statistik).""" + + def setUp(self): + self.heim = aufgabe.Tierheim("Tierheim Test", max_kapazitaet=4) + self.bello = aufgabe.Hund("Bello", alter=4, gewicht=15.0, geimpft=True, hunger=20) + self.luna = aufgabe.Katze("Luna", alter=2, gewicht=4.0, geimpft=False, hunger=60) + self.tweety = aufgabe.Vogel("Tweety", alter=1, gewicht=0.3, geimpft=False, hunger=70) + + def test_15_aufnahme_und_kapazitaet(self): + self.heim.tier_aufnehmen(self.bello) + self.heim.tier_aufnehmen(self.luna) + self.heim.tier_aufnehmen(self.tweety) + self.assertEqual(len(self.heim), 3) + + # 4. Tier aufnehmen (Max erreicht) + h2 = aufgabe.Hund("Rocky", alter=1, gewicht=10.0) + self.heim.tier_aufnehmen(h2) + self.assertEqual(len(self.heim), 4) + + # 5. Tier aufnehmen -> KapazitaetUeberschrittenFehler + h3 = aufgabe.Hund("Pluto", alter=2, gewicht=8.0) + with self.assertRaises(aufgabe.KapazitaetUeberschrittenFehler): + self.heim.tier_aufnehmen(h3) + + def test_16_tier_entlassen_und_suche(self): + self.heim.tier_aufnehmen(self.bello) + self.heim.tier_aufnehmen(self.luna) + + # Suchen + gefunden = self.heim.finde_tier("bello") + self.assertIsNotNone(gefunden) + self.assertEqual(gefunden.name, "Bello") + + # Entlassen + entlassen = self.heim.tier_entlassen("Bello") + self.assertEqual(entlassen.name, "Bello") + self.assertEqual(len(self.heim), 1) + + # Erneutes Entlassen -> TierNichtGefundenFehler + with self.assertRaises(aufgabe.TierNichtGefundenFehler): + self.heim.tier_entlassen("Bello") + + def test_17_filter_nach_art(self): + self.heim.tier_aufnehmen(self.bello) + self.heim.tier_aufnehmen(self.luna) + self.heim.tier_aufnehmen(self.tweety) + + hunde = self.heim.filtriere_nach_art("Hund") + self.assertEqual(len(hunde), 1) + self.assertEqual(hunde[0].name, "Bello") + + katzen = self.heim.filtriere_nach_art("Katze") + self.assertEqual(len(katzen), 1) + self.assertEqual(katzen[0].name, "Luna") + + alle = self.heim.filtriere_nach_art("Alle") + self.assertEqual(len(alle), 3) + + def test_18_filter_ungeimpft_und_hungrig(self): + self.heim.tier_aufnehmen(self.bello) # Geimpft, Hunger 20 + self.heim.tier_aufnehmen(self.luna) # Ungeimpft, Hunger 60 + self.heim.tier_aufnehmen(self.tweety) # Ungeimpft, Hunger 70 + + ungeimpft = self.heim.ungeimpfte_tiere() + self.assertEqual(len(ungeimpft), 2) + + hungrig = self.heim.hungrige_tiere(schwellenwert=50) + self.assertEqual(len(hungrig), 2) + + def test_19_statistik_berechnungen(self): + self.heim.tier_aufnehmen(self.bello) # 4 J., 15.0 kg + self.heim.tier_aufnehmen(self.luna) # 2 J., 4.0 kg + self.heim.tier_aufnehmen(self.tweety) # 1 J., 0.3 kg + + # Alter: (4 + 2 + 1) / 3 = 2.333 + self.assertAlmostEqual(self.heim.durchschnittsalter(), 7.0 / 3.0, places=2) + # Gewicht: 15.0 + 4.0 + 0.3 = 19.3 + self.assertAlmostEqual(self.heim.gesamtgewicht(), 19.3, places=2) + + def test_20_massenaktionen_fuettern_und_impfen(self): + self.heim.tier_aufnehmen(self.bello) # Geimpft, Hunger 20 + self.heim.tier_aufnehmen(self.luna) # Ungeimpft, Hunger 60 + self.heim.tier_aufnehmen(self.tweety) # Ungeimpft, Hunger 70 + + # Alle impfen -> 2 neu geimpft + frisch = self.heim.alle_impfen() + self.assertEqual(frisch, 2) + self.assertEqual(len(self.heim.ungeimpfte_tiere()), 0) + + # Alle füttern (100g -> -20 Hunger) + ergebnisse = self.heim.alle_fuettern(100) + self.assertEqual(len(ergebnisse), 3) + self.assertEqual(self.bello.hunger, 0) + self.assertEqual(self.luna.hunger, 40) + self.assertEqual(self.tweety.hunger, 50) + + +class Test06Persistenz(unittest.TestCase): + """Prüft JSON Savegame und CSV Export.""" + + def test_21_json_speichern_und_laden(self): + heim = aufgabe.Tierheim("Savegame Heim", max_kapazitaet=10) + heim.tier_aufnehmen(aufgabe.Hund("Bello", 3, 14.5, rasse="Husky", geimpft=True)) + heim.tier_aufnehmen(aufgabe.Katze("Luna", 2, 4.2, stubenrein=True, geimpft=False)) + heim.tier_aufnehmen(aufgabe.Vogel("Tweety", 1, 0.3, spannweite_cm=19.0, kann_sprechen=True)) + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tf: + pfad = tf.name + + try: + heim.speichern_json(pfad) + self.assertTrue(os.path.exists(pfad)) + + # Neues leeres Heim lädt den Stand + neues_heim = aufgabe.Tierheim("Leer", max_kapazitaet=5) + neues_heim.laden_json(pfad) + + self.assertEqual(neues_heim.name, "Savegame Heim") + self.assertEqual(len(neues_heim), 3) + + # Prüfen ob Typen erhalten blieben (Polymorphie) + self.assertIsInstance(neues_heim.tiere[0], aufgabe.Hund) + self.assertEqual(neues_heim.tiere[0].rasse, "Husky") + self.assertIsInstance(neues_heim.tiere[1], aufgabe.Katze) + self.assertIsInstance(neues_heim.tiere[2], aufgabe.Vogel) + self.assertTrue(neues_heim.tiere[2].kann_sprechen) + finally: + if os.path.exists(pfad): + os.remove(pfad) + + def test_22_csv_export(self): + heim = aufgabe.Tierheim("CSV Heim", max_kapazitaet=10) + heim.tier_aufnehmen(aufgabe.Hund("Bello", 3, 14.5, rasse="Husky", geimpft=True)) + heim.tier_aufnehmen(aufgabe.Katze("Luna", 2, 4.2, stubenrein=True, geimpft=False)) + + with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tf: + pfad = tf.name + + try: + heim.exportiere_csv(pfad) + self.assertTrue(os.path.exists(pfad)) + + with open(pfad, "r", encoding="utf-8") as f: + reader = list(csv.reader(f)) + + # Header prüfen + self.assertGreaterEqual(len(reader), 3) + self.assertEqual(reader[0], ["Art", "Name", "Alter", "Gewicht", "Geimpft", "Hunger", "Details"]) + + # Erste Zeile (Bello) + self.assertEqual(reader[1][0], "Hund") + self.assertEqual(reader[1][1], "Bello") + finally: + if os.path.exists(pfad): + os.remove(pfad) + + +class Test07GUIHeadless(unittest.TestCase): + """Prüft die Benutzeroberfläche TierheimApp im Headless-Modus.""" + + def setUp(self): + tk_mod = getattr(aufgabe, "tk", None) + self.assertIsNotNone(tk_mod, "tk Modul in aufgabe.py fehlt!") + self.root = tk_mod.Tk() + self.heim = aufgabe.Tierheim("GUI Test Heim", max_kapazitaet=10) + self.heim.tier_aufnehmen(aufgabe.Hund("Bello", 3, 14.5, rasse="Beagle", geimpft=True)) + self.app = aufgabe.TierheimApp(self.root, self.heim) + + def test_23_gui_initialisierung_und_widgets(self): + self.assertTrue(hasattr(self.app, "listbox_tiere")) + self.assertTrue(hasattr(self.app, "label_status")) + self.assertTrue(hasattr(self.app, "label_kapazitaet")) + self.assertEqual(self.app.listbox_tiere.size(), 1) + + def test_24_gui_tier_aufnehmen_formular(self): + # Formular mit neuem Hund befüllen + self.app.var_art.set("Katze") + self.app.var_name.set("Mimi") + self.app.var_alter.set("1") + self.app.var_gewicht.set("3.5") + self.app.var_extra.set("ja") + self.app.var_geimpft.set(False) + + # Aufnahmeklick auslösen + self.app.tier_aufnehmen_klick() + + self.assertEqual(len(self.heim), 2) + mimi = self.heim.finde_tier("Mimi") + self.assertIsNotNone(mimi) + self.assertIsInstance(mimi, aufgabe.Katze) + + def test_25_gui_validierungsfehler_abfangen(self): + # Leerer Name -> Darf nicht abstürzen + self.app.var_name.set("") + try: + self.app.tier_aufnehmen_klick() + except Exception as e: + self.fail(f"tier_aufnehmen_klick hat bei leerem Namen Exception nicht abgefangen: {e}") + + self.assertEqual(len(self.heim), 1, "Bestand darf sich bei Fehleingabe nicht verändern.") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/LEHRPLAN.md b/LEHRPLAN.md index 21f7f7a..ec49768 100644 --- a/LEHRPLAN.md +++ b/LEHRPLAN.md @@ -39,7 +39,9 @@ Es gleicht die gesamte Übungsreihe (Grundlagen & Fortgeschrittene Objektori ├── 11: Polymorphie & Interfaces (ABC, Geometrie & Zeichenfläche) (Lehrer 25.1) ├── 12: Exceptions & Robuste Fehlerbehandlung (try-except, Bankkonto) (Lehrer 26.0) ├── 13: Datei-Persistenz: JSON & CSV Speichern (Savegames) (Lehrer 14.0) - └── 14: Desktop-GUIs mit Tkinter & MVC-Architektur (Zähler & Rechner) (Lehrer 27.0) + ├── 14: Desktop-GUIs mit Tkinter & MVC-Architektur (Zähler & Rechner) (Lehrer 27.0) + ├── 15: Parameter (*args, **kwargs) & Eigene Container (Gaming-Inventar) (Lehrer 29.0 & 30.0) + └── 16: Master-Abschlussprojekt: Professionelle Desktop-App (PetCare) (Meisterstück & MVC) ``` --- @@ -75,3 +77,6 @@ Es gleicht die gesamte Übungsreihe (Grundlagen & Fortgeschrittene Objektori | **12** | **Exceptions** | `try-except-else-finally`, `raise`, eigene Fehler | Bankkonto- & Geldautomat-Simulator | | **13** | **Persistenz** | `with open()`, `json.dump`/`load`, CSV-Export | Savegames & Notenbuch | | **14** | **Tkinter GUI** | Echte Desktop-Apps, Widgets, `grid()`, MVC-Muster | Interaktive Zähler- & Rechner-App | +| **15** | **Parameter & Container** | `*args`, `**kwargs`, Unpacking & Container-Dunder (`__getitem__`, `__setitem__`, `__delitem__`, `__contains__`, `__iter__`) | Gaming-Rucksack & Inventar | +| **16** | **Master-Abschlussprojekt** | Vollständiges Desktop-Meisterstück: MVC-Architektur, Vererbung & Polymorphie, JSON-Savegames, CSV-Export, Exceptions & Tkinter-GUI | PetCare- & Tierheim-Manager Pro | + diff --git a/README.md b/README.md index 69855b4..2c5bb1e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Dieses Portal ist didaktisch aufbereitet, voll **abgeglichen mit dem Informatik- | **[12. Exceptions & Fehler](./12_exceptions_und_fehlerbehandlung/)** | **Robustes Coding** | `try-except-else-finally`, `raise`, eigene Exception-Klassen im Banking | | **[13. Datei-Persistenz](./13_persistenz_json_und_csv/)** | **JSON & CSV** | Dauerhaftes Speichern & Laden: Savegames & Tabellen-Export | | **[14. Desktop-GUIs mit Tkinter](./14_gui_mit_tkinter/)** | **Tkinter Desktop-Apps** | Echte Fenster, Buttons, Labels, Grid-Layout & MVC-Architektur | +| **[15. Parameter & Container](./15_parameter_und_container/)** | **Dunder-Container** | Variable Argumente (`*args`, `**kwargs`), Unpacking & Container (`len`, `[]`, `in`, `iter`) | +| **[16. Master-Abschlussprojekt](./16_master_abschlussprojekt/)** | **🏆 Meisterstück: PetCare** | Vollständige Desktop-App: MVC, Vererbung, Polymorphie, JSON-Savegames & Tkinter GUI | --- @@ -70,7 +72,7 @@ python3 test_aufgabe.py - ❌ **`FAIL` (AssertionError)**: Dein Code läuft, liefert aber einen falschen Wert zurück (z.B. Rechenfehler oder falsche Bedingung). Lies die letzte Zeile der Fehlermeldung! - 💥 **`ERROR` (Syntax / AttributeError)**: Dein Code hat einen Absturz (z.B. Tippfehler im Funktionsnamen). -### 3. Gesamten Fortschritt aller 25 Kapitel prüfen: +### 3. Gesamten Fortschritt aller 27 Kapitel prüfen: Führe im Hauptordner folgenden Befehl aus: ```bash python3 test_all.py diff --git a/index.html b/index.html index 7ecc761..c9baa65 100644 --- a/index.html +++ b/index.html @@ -73,8 +73,8 @@
Lehrpfad 3 -

🚀 Fortgeschrittenes OOP & GUIs

-

Speicher-Referenzen, Dunder-Methoden, eigene Unit Tests (TDD), Vererbung, Polymorphie, Exceptions, JSON-Persistenz & Tkinter (07–14).

+

🚀 Fortgeschrittenes OOP, Container & GUIs

+

Speicher-Referenzen, Dunder-Methoden, Unit Tests (TDD), Vererbung, Polymorphie, Exceptions, JSON, Tkinter & Container (07–15).

@@ -311,7 +311,7 @@

🛣️ Lehrpfad 3: Fortgeschrittenes OOP, Testing & Desktop-GUIs

Professionelle Softwaretechnik: Speicherverwaltung, Vererbung, TDD-Tests, Persistenz und GUIs.
- Kapitel 07 bis 14 + Kapitel 07 bis 16 diff --git a/test_all.py b/test_all.py index 18110db..22d926f 100644 --- a/test_all.py +++ b/test_all.py @@ -36,6 +36,8 @@ KAPITEL = [ ("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"), + ("Kapitel 15: Parameter & Eigene Container", "15_parameter_und_container"), + ("Kapitel 16: Master-Abschlussprojekt", "16_master_abschlussprojekt"), ] def main():