"""Tests für die Spielmechanik und den Ingest. Bewusst auf das konzentriert, was leise falsch sein kann: XP-Berechnung, Erstfang-Erkennung, Abzeichen-Vergabe, Autorisierung. """ from __future__ import annotations import json import re import shutil import tempfile from datetime import timedelta from io import BytesIO, StringIO from unittest import mock from django.conf import settings from django.core.management import call_command from django.core.management.base import CommandError from django.core.files.uploadedfile import SimpleUploadedFile from django.test import SimpleTestCase, TestCase, override_settings from django.utils import timezone from . import scoring from .models import Badge, Camera, DexEntry, EarnedBadge, Sighting, Species _SKRIPT_UND_STIL = re.compile( r"<(script|style)\b.*?", re.IGNORECASE | re.DOTALL) def nur_sichtbares(resp) -> str: """Die Antwort ohne Skript- und Stilblöcke. 🔑 Für Sprachproben: Deutsche Kommentare im JavaScript stehen zwar im Quelltext, aber nicht auf der Seite. Ohne diesen Schnitt schlägt eine Sprachprobe fehl, sobald jemand einen Kommentar schreibt — und dann ändert man den Kommentar statt den Fehler zu suchen. """ return _SKRIPT_UND_STIL.sub("", resp.content.decode("utf-8")) def make_camera(slug="vogelhaus", token="kamera-token-1", **kw) -> Camera: cam = Camera(slug=slug, name=kw.pop("name", slug.title()), **kw) cam.set_token(token) cam.save() return cam def make_species(**kw) -> Species: defaults = { "slug": "kohlmeise", "german": "Kohlmeise", "scientific": "Parus major", "rarity": "haeufig", "xp": 10, } return Species.objects.create(**(defaults | kw)) def make_sighting(species=None, confidence=1.0, when=None, duration=10) -> Sighting: start = when or timezone.now() return Sighting.objects.create( species=species, confidence=confidence, started_at=start, ended_at=start + timedelta(seconds=duration), ) class LevelCurveTests(TestCase): def test_level_one_starts_at_zero(self): self.assertEqual(scoring.xp_for_level(1), 0) self.assertEqual(scoring.level_for_xp(0), 1) def test_curve_is_monotonic(self): thresholds = [scoring.xp_for_level(n) for n in range(1, 40)] self.assertEqual(thresholds, sorted(thresholds)) def test_level_matches_its_own_threshold(self): for level in range(1, 30): self.assertEqual(scoring.level_for_xp(scoring.xp_for_level(level)), level) def test_one_xp_below_threshold_stays_on_lower_level(self): threshold = scoring.xp_for_level(5) self.assertEqual(scoring.level_for_xp(threshold - 1), 4) def test_rank_is_capped(self): # Sprache festnageln: rank_name übersetzt inzwischen, RANKS enthält # die deutschen Ausgangstexte. Ohne override hängt der Test davon ab, # welche Sprache ein vorheriger Test zuletzt aktiviert hat. from django.utils import translation with translation.override("de"): self.assertEqual(scoring.rank_name(1), "Nestling") self.assertEqual(scoring.rank_name(999), scoring.RANKS[-1]) class RegisterTests(TestCase): def setUp(self): scoring.sync_badges() self.species = make_species() def test_first_catch_gets_triple_xp(self): reward = scoring.register(make_sighting(self.species)) self.assertTrue(reward.is_first) self.assertEqual(reward.xp, 30) # 10 XP × 3, Confidence 1.0 def test_second_catch_is_normal_xp(self): scoring.register(make_sighting(self.species)) reward = scoring.register(make_sighting(self.species)) self.assertFalse(reward.is_first) self.assertEqual(reward.xp, 10) def test_low_confidence_reduces_xp(self): reward = scoring.register(make_sighting(self.species, confidence=0.5)) # 10 × 3 (Erstfang) × (0.5 + 0.5×0.5) = 22.5 -> 22 self.assertEqual(reward.xp, 22) def test_unidentified_sighting_gives_no_xp_and_no_entry(self): reward = scoring.register(make_sighting(None)) self.assertEqual(reward.xp, 0) self.assertFalse(reward.is_first) self.assertEqual(DexEntry.objects.count(), 0) def test_dex_entry_counts_up_and_tracks_last_seen(self): first = timezone.now() - timedelta(days=2) scoring.register(make_sighting(self.species, when=first)) later = timezone.now() scoring.register(make_sighting(self.species, when=later)) entry = DexEntry.objects.get(species=self.species) self.assertEqual(entry.count, 2) self.assertEqual(entry.first_seen, first) self.assertEqual(entry.last_seen, later) def test_best_sighting_follows_highest_confidence(self): """Ohne Bildzähler entscheidet die Sicherheit — der Rückfall. ⚠️ Seit dem 30.09.2026 wählt das beste Foto nach Bildgüte, nicht nach Sicherheit (siehe BildgueteTests). make_sighting setzt keine Bildzähler, deshalb greift hier der Rückfall — und der soll weiterhin die Sicherheit nehmen. """ weak = make_sighting(self.species, confidence=0.6) scoring.register(weak) strong = make_sighting(self.species, confidence=0.95) scoring.register(strong) entry = DexEntry.objects.get(species=self.species) self.assertEqual(entry.best_sighting_id, strong.id) def test_hand_correction_wins_over_model(self): amsel = make_species(slug="amsel", german="Amsel", scientific="Turdus merula") sighting = make_sighting(self.species) sighting.confirmed_species = amsel sighting.save() scoring.register(sighting) self.assertTrue(DexEntry.objects.filter(species=amsel).exists()) self.assertFalse(DexEntry.objects.filter(species=self.species).exists()) class BadgeTests(TestCase): def setUp(self): scoring.sync_badges() self.species = make_species() def test_sync_is_idempotent(self): before = Badge.objects.count() scoring.sync_badges() self.assertEqual(Badge.objects.count(), before) def test_first_sighting_earns_first_guest(self): scoring.register(make_sighting(self.species)) self.assertTrue(EarnedBadge.objects.filter(badge__code="erster_gast").exists()) def test_badge_is_awarded_only_once(self): scoring.register(make_sighting(self.species)) scoring.register(make_sighting(self.species)) self.assertEqual( EarnedBadge.objects.filter(badge__code="erster_gast").count(), 1 ) def test_rare_species_earns_rare_guest(self): gimpel = make_species( slug="gimpel", german="Gimpel", scientific="Pyrrhula pyrrhula", rarity="selten", xp=60, ) scoring.register(make_sighting(gimpel)) self.assertTrue(EarnedBadge.objects.filter(badge__code="seltener_gast").exists()) def test_unbestimmte_sichtung_verdient_kein_abzeichen(self): """Eine Spiegelung ist kein Besuch — und kein Abzeichen wert. 🔑 Der Fall aus dem Betrieb: Am 27.09.2026 meldete die Vogeltränke abends 24 Spiegelungen des Mondes auf dem Wasser. Die erste brachte „Nachtschicht" samt 100 XP ein, weil die Abzeichen für Uhrzeit und Dauer allein am Zeitstempel hingen und nicht an einer Art. """ abends = timezone.localtime(timezone.now()).replace(hour=21, minute=30) scoring.register( make_sighting(None, confidence=0.0, when=abends, duration=90) ) self.assertFalse(EarnedBadge.objects.exists()) def test_bestimmte_sichtung_nach_21_uhr_verdient_nachtschicht(self): """Die Gegenprobe: mit Art soll das Abzeichen sehr wohl kommen.""" abends = timezone.localtime(timezone.now()).replace(hour=21, minute=30) scoring.register(make_sighting(self.species, when=abends)) self.assertTrue( EarnedBadge.objects.filter(badge__code="nachtschicht").exists() ) def test_long_visit_earns_dauergast(self): scoring.register(make_sighting(self.species, duration=90)) self.assertTrue(EarnedBadge.objects.filter(badge__code="dauergast").exists()) def test_short_visit_does_not_earn_dauergast(self): scoring.register(make_sighting(self.species, duration=20)) self.assertFalse(EarnedBadge.objects.filter(badge__code="dauergast").exists()) def test_badge_xp_counts_towards_total(self): reward = scoring.register(make_sighting(self.species)) badge_xp = sum(b.xp for b in reward.badges) self.assertEqual(scoring.total_xp(), reward.xp + badge_xp) class IngestApiTests(TestCase): def setUp(self): scoring.sync_badges() self.species = make_species() # Registrierte Kamera fuer alles, was je Kamera einstellbar ist. # Die uebrigen Tests posten weiter mit dem Sammel-Token aus den # Settings und bleiben davon unberuehrt. self.camera = make_camera(slug="ingest-kamera", token="kamera-token-1") self.url = "/api/sightings/" now = timezone.now() self.payload = { "species": "kohlmeise", "confidence": 0.9, "started_at": now.isoformat(), "ended_at": (now + timedelta(seconds=12)).isoformat(), "frames_total": 40, "frames_with_bird": 31, "votes": {"kohlmeise": 0.9}, # ⚠️ Gehört dazu, weil der Detector es immer mitschickt # (detector/sink.py, payload_of). Ohne dieses Feld prüfte die # Testnutzlast einen Fall, den es im Betrieb nicht gibt. "mean_bird_score": 0.62, } def post(self, payload=None, token="dev-token"): return self.client.post( self.url, {"data": json.dumps(payload if payload is not None else self.payload)}, headers={"Authorization": f"Bearer {token}"}, ) def test_kamera_grenze_weist_schwache_sichtung_ab(self): """Die je Kamera einstellbare Untergrenze (Issue #6). 🔑 Der Fall aus dem Betrieb: eine „Kohlmeise" aus einer Befestigungsschraube, 1 von 73 Bildern, mittlerer Vogelwert 0,098. Mit einer Grenze von 0,15 entsteht sie gar nicht erst. """ self.camera.min_mean_bird_score = 0.15 self.camera.save() resp = self.post(self.payload | {"mean_bird_score": 0.098}, token="kamera-token-1") self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json()["grenze"], 0.15) self.assertEqual(Sighting.objects.count(), 0) def test_kamera_grenze_laesst_echte_sichtung_durch(self): """Gegenprobe: echte Sichtungen lagen bei 0,269 bis 0,600.""" self.camera.min_mean_bird_score = 0.15 self.camera.save() resp = self.post(self.payload | {"mean_bird_score": 0.269}, token="kamera-token-1") self.assertEqual(resp.status_code, 201) self.assertEqual(Sighting.objects.count(), 1) def test_grenze_steht_in_der_konfiguration(self): """Der Detector muss sie kennen, sonst sendet er umsonst.""" self.camera.min_mean_bird_score = 0.15 self.camera.save() resp = self.client.get( "/api/config/", headers={"Authorization": "Bearer kamera-token-1"}) self.assertEqual(resp.json()["min_mean_bird_score"], 0.15) def test_ohne_vogel_keine_sichtung(self): """Vogelwert 0 wird abgewiesen — das waren die Mondspiegelungen. Am 27.09.2026 meldete die Vogeltränke abends 24 „Besuche" in vierzig Minuten, je ein Bild von 31 mit vermeintlicher Bewegung und auf keinem ein Vogel. """ resp = self.post(self.payload | {"mean_bird_score": 0.0}) self.assertEqual(resp.status_code, 200) self.assertIn("ignoriert", resp.json()) self.assertEqual(Sighting.objects.count(), 0) def test_unbestimmt_aber_mit_vogel_zaehlt(self): """Die Gegenprobe: Art unbekannt ist etwas anderes als kein Vogel. ⚠️ Artsicherheit 0 heißt nur „ich weiß nicht, welche Art". So eine Sichtung ist ein echter Besuch und muss erhalten bleiben — sonst verliert man genau die Fälle, die man nachbestimmen möchte. """ resp = self.post( self.payload | {"species": None, "confidence": 0.0, "mean_bird_score": 0.41, "votes": {}} ) self.assertEqual(resp.status_code, 201) self.assertEqual(Sighting.objects.count(), 1) def test_alte_detector_fassung_ohne_feld_wird_durchgelassen(self): """Fehlt das Feld ganz, ist es eine ältere Fassung — nicht abweisen. Web-App und Detector sind getrennt deploybar; ein Deploy darf eine ältere Kamera nicht stumm aussperren. """ ohne = {k: v for k, v in self.payload.items() if k != "mean_bird_score"} resp = self.post(ohne) self.assertEqual(resp.status_code, 201) self.assertEqual(Sighting.objects.count(), 1) def test_ort_wird_gerundet_uebernommen(self): """Ein Foto von unterwegs bringt seinen Ort mit — aber nur grob. 🔑 Gerundet wird beim Speichern, nicht beim Anzeigen: Die genaue Position darf gar nicht erst in der Datenbank stehen, sonst liegt die Wohnadresse in jeder Sicherung und jedem Auszug. """ resp = self.post( self.payload | {"latitude": 49.48753, "longitude": 8.46601} ) self.assertEqual(resp.status_code, 201) s = Sighting.objects.get() self.assertEqual(s.latitude, 49.49) self.assertEqual(s.longitude, 8.47) def test_ohne_ort_bleibt_leer(self): """Das Vogelhaus hängt fest und schickt keinen Ort.""" self.assertEqual(self.post().status_code, 201) s = Sighting.objects.get() self.assertIsNone(s.latitude) self.assertIsNone(s.longitude) def test_halbe_koordinate_wird_verworfen(self): """Nur die Breite ist keine Position — dann lieber gar keine.""" self.post(self.payload | {"latitude": 49.4875}) s = Sighting.objects.get() self.assertIsNone(s.latitude) self.assertIsNone(s.longitude) def test_unsinnige_koordinaten_werden_verworfen(self): """0/0 und Werte außerhalb des Bereichs zählen nicht. ⚠️ 0/0 ist ein gültiger Punkt im Atlantik, als Wert aus einer App aber praktisch immer ein leeres Feld. Eine Sammlung mit Sichtungen vor Westafrika fiele niemandem rechtzeitig auf. """ for lat, lon in ((0.0, 0.0), (91.0, 8.0), (49.0, 181.0)): Sighting.objects.all().delete() self.post(self.payload | {"latitude": lat, "longitude": lon}) s = Sighting.objects.get() self.assertIsNone(s.latitude, f"lat {lat} hätte nicht zählen dürfen") self.assertIsNone(s.longitude, f"lon {lon} hätte nicht zählen dürfen") def test_valid_post_creates_sighting(self): resp = self.post() self.assertEqual(resp.status_code, 201) body = resp.json() self.assertEqual(body["art"], "Kohlmeise") self.assertTrue(body["erstfang"]) self.assertEqual(Sighting.objects.count(), 1) def test_wrong_token_is_rejected(self): self.assertEqual(self.post(token="falsch").status_code, 401) self.assertEqual(Sighting.objects.count(), 0) def test_missing_authorization_is_rejected(self): resp = self.client.post(self.url, {"data": json.dumps(self.payload)}) self.assertEqual(resp.status_code, 401) def test_unknown_species_is_rejected(self): resp = self.post(self.payload | {"species": "dodo"}) self.assertEqual(resp.status_code, 400) self.assertIn("dodo", resp.json()["fehler"]) def test_unidentified_sighting_is_accepted(self): resp = self.post(self.payload | {"species": None, "confidence": 0.0}) self.assertEqual(resp.status_code, 201) self.assertIsNone(resp.json()["art"]) def test_broken_timestamps_are_rejected(self): resp = self.post(self.payload | {"started_at": "gestern"}) self.assertEqual(resp.status_code, 400) def test_empty_token_setting_closes_the_legacy_path(self): """Ohne konfigurierten Token darf nur noch über registrierte Kameras etwas reinkommen — und ein leerer Header erst recht nicht.""" from django.test import override_settings with override_settings(INGEST_TOKEN=""): self.assertEqual(self.post(token="").status_code, 401) self.assertEqual(self.post(token="dev-token").status_code, 401) self.assertEqual(Sighting.objects.count(), 0) cam = make_camera("garten", "echter-token") resp = self.post(token="echter-token") self.assertEqual(resp.status_code, 201) self.assertEqual(Sighting.objects.get().camera, cam) def test_get_is_not_allowed(self): self.assertEqual(self.client.get(self.url).status_code, 405) class CameraTests(TestCase): """Mehrere Kameras melden an dieselbe Sammlung — jede mit eigenem Token. Das ist der Kern der getrennten Architektur: die Erkennung läuft im Heimnetz und meldet ausgehend, die Web-App muss keine Kamera erreichen. """ def setUp(self): scoring.sync_badges() self.species = make_species() self.garten = make_camera("garten", "token-garten", name="Garten") self.balkon = make_camera("balkon", "token-balkon", name="Balkon") now = timezone.now() self.payload = { "species": "kohlmeise", "confidence": 0.9, "started_at": now.isoformat(), "ended_at": (now + timedelta(seconds=12)).isoformat(), } def post(self, token): return self.client.post( "/api/sightings/", {"data": json.dumps(self.payload)}, headers={"Authorization": f"Bearer {token}"}, ) def test_token_only_stored_as_hash(self): self.assertNotIn("token-garten", self.garten.token_hash) self.assertEqual(len(self.garten.token_hash), 64) def test_camera_add_ohne_mobil_ist_ein_geraet(self): call_command("camera", "add", "neues-haus", stdout=StringIO()) self.assertFalse(Camera.objects.get(slug="neues-haus").mobil) def test_camera_add_mobil_setzt_das_feld(self): """Ein Handy darf schweigen — sonst trägt es dauerhaft ein ⚠. Das Feld gab es bisher nur im Admin. Wer eine Handy-Kamera auf der Kommandozeile anlegt (und genau das steht in der App-Anleitung), hatte sie zwangsläufig als Gerät eingetragen, das als gestört gilt, sobald es eine halbe Stunde nichts liefert. """ ausgabe = StringIO() call_command("camera", "add", "handy", "--mobil", stdout=ausgabe) kamera = Camera.objects.get(slug="handy") self.assertTrue(kamera.mobil) # Der Hinweis auf den Erkennungs-Host wäre hier eine falsche Fährte. self.assertNotIn("VOGELALBUM_TOKEN=", ausgabe.getvalue()) self.assertIn("App", ausgabe.getvalue()) def test_each_token_resolves_to_its_own_camera(self): self.assertEqual(Camera.by_token("token-garten"), self.garten) self.assertEqual(Camera.by_token("token-balkon"), self.balkon) def test_unknown_token_resolves_to_nothing(self): self.assertIsNone(Camera.by_token("token-erfunden")) def test_sighting_is_attributed_to_the_sending_camera(self): resp = self.post("token-balkon") self.assertEqual(resp.status_code, 201) self.assertEqual(resp.json()["kamera"], "Balkon") self.assertEqual(Sighting.objects.get().camera, self.balkon) def test_cameras_share_one_collection(self): """Zwei Kameras, dieselbe Art: nur der erste Fund ist ein Erstfang.""" first = self.post("token-garten") second = self.post("token-balkon") self.assertTrue(first.json()["erstfang"]) self.assertFalse(second.json()["erstfang"]) self.assertEqual(DexEntry.objects.count(), 1) self.assertEqual(DexEntry.objects.get().count, 2) def test_disabled_camera_is_rejected(self): self.garten.active = False self.garten.save() self.assertEqual(self.post("token-garten").status_code, 401) def test_rotating_a_token_invalidates_the_old_one(self): self.garten.set_token("token-neu") self.garten.save() self.assertEqual(self.post("token-garten").status_code, 401) self.assertEqual(self.post("token-neu").status_code, 201) def test_ingest_updates_last_seen(self): self.assertIsNone(self.garten.last_seen) self.post("token-garten") self.garten.refresh_from_db() self.assertIsNotNone(self.garten.last_seen) self.assertFalse(self.garten.is_stale) def test_camera_without_contact_is_stale(self): self.assertTrue(self.garten.is_stale) self.garten.last_seen = timezone.now() - timedelta(hours=3) self.assertTrue(self.garten.is_stale) def test_settings_token_still_works_without_a_camera(self): """Einzelkamera-Betrieb über VOGELALBUM_INGEST_TOKEN bleibt möglich.""" resp = self.post("dev-token") self.assertEqual(resp.status_code, 201) self.assertIsNone(resp.json()["kamera"]) def test_sightings_page_filters_by_camera(self): self.post("token-garten") self.post("token-balkon") resp = self.client.get("/sichtungen/?kamera=balkon") self.assertEqual(resp.status_code, 200) self.assertEqual(len(resp.context["sightings"]), 1) self.assertEqual(resp.context["sightings"][0].camera, self.balkon) class LanguageTests(TestCase): """Deutsch und Englisch — Oberfläche und Inhalte. Der Rückfall auf Deutsch ist der wichtige Teil: der Katalog wächst schneller, als die englischen Texte nachkommen, und eine Art ohne Übersetzung soll den deutschen Text zeigen statt eine Lücke. """ def setUp(self): scoring.sync_badges() self.species = make_species( english="Great Tit", gewicht_g=18, spannweite_cm=24, size_cm="14 cm", kategorie="Die Lauteste im Garten", kategorie_en="The Loudest in the Garden", beschreibung="Erster Absatz.\n\nZweiter Absatz.", beschreibung_en="First paragraph.\n\nSecond paragraph.", nahrung="Insekten", nahrung_en="Insects", typ="meise", zug="stand", number=7, ) self.ohne_en = make_species( slug="dohle", german="Dohle", english="Eurasian Jackdaw", scientific="Corvus monedula", kategorie="Die mit den blauen Augen", beschreibung="Nur auf Deutsch.", typ="rabenvogel", number=8, ) def test_german_is_the_default(self): resp = self.client.get(f"/sammlung/{self.species.slug}/") self.assertContains(resp, "Kohlmeise") self.assertContains(resp, "Die Lauteste im Garten") def test_english_switches_names_and_profile(self): resp = self.client.get( f"/sammlung/{self.species.slug}/", headers={"Accept-Language": "en"} ) self.assertContains(resp, "Great Tit") self.assertContains(resp, "The Loudest in the Garden") self.assertContains(resp, "First paragraph.") self.assertNotContains(resp, "Erster Absatz.") def test_interface_strings_are_translated(self): resp = self.client.get("/sammlung/", headers={"Accept-Language": "en"}) self.assertContains(resp, "The Collection") self.assertNotContains(resp, "Die Sammlung") def test_missing_translation_falls_back_to_german(self): resp = self.client.get( f"/sammlung/{self.ohne_en.slug}/", headers={"Accept-Language": "en"} ) # Der englische Artname existiert, der Steckbrief nicht. self.assertContains(resp, "Eurasian Jackdaw") self.assertContains(resp, "Nur auf Deutsch.") def test_language_switch_sets_a_cookie(self): resp = self.client.post( "/i18n/setlang/", {"language": "en", "next": "/sammlung/"} ) self.assertEqual(resp.status_code, 302) self.assertEqual(self.client.cookies["django_language"].value, "en") follow = self.client.get("/sammlung/") self.assertContains(follow, "The Collection") def test_stat_bar_labels_are_translated(self): from django.utils import translation with translation.override("en"): labels = [b["label"] for b in self.species.balken()] self.assertIn("Weight", labels) self.assertIn("Rarity", labels) with translation.override("de"): labels = [b["label"] for b in self.species.balken()] self.assertIn("Gewicht", labels) def test_badges_and_ranks_are_translated(self): """Abzeichen und Ränge liegen als deutscher Text in der Datenbank und werden erst bei der Ausgabe übersetzt. Ein Regressionstest lohnt sich: makemessages hängt neue Texte gern per Fuzzy-Match an ähnliche alte, msgfmt wirft die dann weg, und übrig bleibt stiller deutscher Rest.""" from django.utils import translation from .models import Badge resp = self.client.get("/abzeichen/", headers={"Accept-Language": "en"}) self.assertContains(resp, "First Guest") self.assertContains(resp, "Collector") self.assertContains(resp, "Early Bird") self.assertNotContains(resp, "Erster Gast") self.assertNotContains(resp, "verschiedene Arten gesammelt") with translation.override("en"): self.assertEqual(scoring.rank_name(1), "Nestling") self.assertEqual(scoring.rank_name(16), "Blackbird Whisperer") with translation.override("de"): self.assertEqual(scoring.rank_name(16), "Amselflüsterer") def test_badge_codes_are_ascii_slugs(self): """Ein Umlaut im Code ist in einem SlugField nicht validierbar — und hat mir einmal die Übersetzung stillschweigend übersprungen.""" from .models import Badge for badge in Badge.objects.all(): with self.subTest(code=badge.code): self.assertTrue(badge.code.isascii(), badge.code) def test_sync_removes_unknown_badges(self): from .models import Badge Badge.objects.create(code="veraltet", name="Alt", description="x", xp=1) scoring.sync_badges() self.assertFalse(Badge.objects.filter(code="veraltet").exists()) def test_type_and_rarity_labels_switch(self): from django.utils import translation with translation.override("en"): self.assertEqual(self.species.typ_label, "Tit") self.assertEqual(self.species.rarity_label, "Common") self.assertEqual(self.species.zug_label, "Resident") with translation.override("de"): self.assertEqual(self.species.typ_label, "Meise") self.assertEqual(self.species.rarity_label, "Häufig") class TypeCoverageTests(TestCase): """Die Web-App spiegelt die Typtabelle aus dem Detector. Fehlt dort ein Typ, rendert die Karte einen Strich — sichtbar nur, wenn man hinschaut.""" def test_every_catalog_type_has_a_style_and_an_english_name(self): import sys from pathlib import Path wurzel = Path(__file__).resolve().parents[2] sys.path.insert(0, str(wurzel)) try: from detector.species_extra import EXTRA from detector.species_info import TYPES except ImportError: self.skipTest("Detector-Katalog nicht erreichbar") from .species_meta import TYPE_LABEL_EN, TYPE_STYLE benutzt = set(TYPES) | {e.typ for e in EXTRA} self.assertEqual(benutzt - set(TYPE_STYLE), set()) self.assertEqual(benutzt - set(TYPE_LABEL_EN), set()) class HabitatFilterTests(TestCase): """Der Lebensraum-Filter. Ohne ihn ersäuft die Gartensammlung in Wasservögeln, die nie vor der Kamera landen.""" def setUp(self): scoring.sync_badges() self.meise = make_species(lebensraum_typ="garten", typ="meise", number=1) self.reiher = make_species( slug="graureiher", german="Graureiher", scientific="Ardea cinerea", english="Grey Heron", rarity="episch", typ="schreitvogel", lebensraum_typ="wasser", number=2, ) def test_collection_defaults_to_garden(self): resp = self.client.get("/sammlung/") slugs = [c["species"].slug for c in resp.context["cards"]] self.assertIn("kohlmeise", slugs) self.assertNotIn("graureiher", slugs) def test_all_shows_every_species(self): resp = self.client.get("/sammlung/?lebensraum=alle") slugs = [c["species"].slug for c in resp.context["cards"]] self.assertIn("kohlmeise", slugs) self.assertIn("graureiher", slugs) def test_filtering_by_water_hides_garden_birds(self): resp = self.client.get("/sammlung/?lebensraum=wasser") slugs = [c["species"].slug for c in resp.context["cards"]] self.assertEqual(slugs, ["graureiher"]) def test_habitat_survives_a_rarity_filter(self): resp = self.client.get("/sammlung/?lebensraum=wasser&seltenheit=episch") slugs = [c["species"].slug for c in resp.context["cards"]] self.assertEqual(slugs, ["graureiher"]) def test_group_choices_only_list_types_present_in_the_habitat(self): """Sonst steht „Möwe" im Garten zur Auswahl und liefert null Treffer.""" resp = self.client.get("/sammlung/?lebensraum=garten") codes = {t["code"] for t in resp.context["typen"]} self.assertIn("meise", codes) self.assertNotIn("schreitvogel", codes) resp = self.client.get("/sammlung/?lebensraum=wasser") codes = {t["code"] for t in resp.context["typen"]} self.assertIn("schreitvogel", codes) self.assertNotIn("meise", codes) def test_group_choices_carry_counts(self): resp = self.client.get("/sammlung/?lebensraum=garten") meise = next(t for t in resp.context["typen"] if t["code"] == "meise") self.assertEqual(meise["n"], 1) def test_empty_combination_renders_without_error(self): resp = self.client.get("/sammlung/?lebensraum=wasser&typ=meise") self.assertEqual(resp.status_code, 200) self.assertEqual(list(resp.context["cards"]), []) def test_counts_reflect_the_active_filter(self): resp = self.client.get("/sammlung/?lebensraum=garten") self.assertEqual(resp.context["total_count"], 1) self.assertEqual(resp.context["gesamt_alle"], 2) def test_habitat_label_switches_language(self): from django.utils import translation with translation.override("en"): self.assertEqual(self.reiher.habitat_label, "Water & shoreline") with translation.override("de"): self.assertEqual(self.reiher.habitat_label, "Wasser & Ufer") class ViewTests(TestCase): def setUp(self): scoring.sync_badges() self.species = make_species() def test_pages_render_when_empty(self): for path in ("/", "/sammlung/", "/sichtungen/", "/abzeichen/", "/widget/"): with self.subTest(path=path): self.assertEqual(self.client.get(path).status_code, 200) def test_species_page_renders_before_first_sighting(self): resp = self.client.get(f"/sammlung/{self.species.slug}/") self.assertEqual(resp.status_code, 200) self.assertContains(resp, "Noch nicht gesammelt") def test_pages_render_with_data(self): scoring.register(make_sighting(self.species)) for path in ("/", "/sammlung/", "/sichtungen/", f"/sammlung/{self.species.slug}/"): with self.subTest(path=path): self.assertEqual(self.client.get(path).status_code, 200) def test_stats_api_reports_progress(self): scoring.register(make_sighting(self.species)) body = self.client.get("/api/stats/").json() self.assertEqual(body["arten_gesehen"], 1) self.assertEqual(body["letzte"]["art"], "Kohlmeise") class LiveViewTests(TestCase): """Die Live-Seite. Der Punkt ist nicht, dass sie erscheint, sondern dass sie ohne ausdrückliche Freigabe *nicht* erscheint — ein Livebild aus einem Privatgarten gehört nicht auf eine öffentliche Instanz.""" def setUp(self): self.cam = make_camera(live_url="http://10.0.0.9:8131/live") def test_ohne_freigabe_gibt_es_die_seite_nicht(self): """Standard ist aus: eine frisch angelegte Kamera zeigt nichts.""" self.assertFalse(self.cam.live_enabled) self.assertEqual(self.client.get("/live/").status_code, 404) def test_mit_freigabe_erscheint_das_bild(self): self.cam.live_enabled = True self.cam.save() resp = self.client.get("/live/") self.assertEqual(resp.status_code, 200) # ⚠ Auf der Seite steht das EINZELBILD, nicht der Dauerstrom -- # siehe Camera.live_bild_url. Der Test hing noch an der alten # Adresse und schlug seit dem Umbau fehl. self.assertContains(resp, self.cam.live_bild_url) def test_haekchen_ohne_adresse_zeigt_nichts(self): """Sonst stünde da ein kaputtes Bild statt einer klaren Aussage.""" self.cam.live_url = "" self.cam.live_enabled = True self.cam.save() self.assertEqual(self.client.get("/live/").status_code, 404) def test_stillgelegte_kamera_zeigt_nichts(self): self.cam.live_enabled = True self.cam.active = False self.cam.save() self.assertEqual(self.client.get("/live/").status_code, 404) def test_umgebung_sticht_das_haekchen_aus(self): """Die eigentliche Sicherung: auf der öffentlichen Instanz steht VOGELALBUM_LIVE_ERLAUBT=0, und dann nützt kein Häkchen im Admin.""" self.cam.live_enabled = True self.cam.save() self.assertEqual(self.client.get("/live/").status_code, 200) with self.settings(LIVE_ALLOWED=False): self.assertEqual(self.client.get("/live/").status_code, 404) self.assertFalse(self.cam.shows_live) def test_link_erscheint_nur_mit_freigabe(self): self.assertNotContains(self.client.get("/"), 'href="/live/"') self.cam.live_enabled = True self.cam.save() self.assertContains(self.client.get("/"), 'href="/live/"') def test_zwei_kameras_landen_beide_auf_der_seite(self): self.cam.live_enabled = True self.cam.save() zweite = make_camera( slug="traenke", token="kamera-token-2", live_url="http://10.0.0.10:8131/live", live_enabled=True, ) resp = self.client.get("/live/") self.assertContains(resp, self.cam.live_bild_url) self.assertContains(resp, zweite.live_bild_url) class AdminLinkTests(TestCase): """Das Zahnrad im Footer. Kameras, Live-Bild und Arten stellt man im Admin ein — ohne Weg dorthin muss man die Adresse kennen.""" def test_zahnrad_ist_auf_jeder_seite(self): make_species() for pfad in ("/", "/sammlung/", "/sichtungen/", "/abzeichen/"): with self.subTest(pfad=pfad): self.assertContains(self.client.get(pfad), 'href="/admin/"') def test_widget_bleibt_ohne_zahnrad(self): """Das Widget wird auf gaertner.onl eingebettet — ein Admin-Link hat in einem fremden iframe nichts zu suchen.""" self.assertNotContains(self.client.get("/widget/"), 'href="/admin/"') class EnglishTests(TestCase): """Fehlende Übersetzungen fallen auf den deutschen Text zurück, ohne dass irgendwo ein Fehler auftaucht — auf Englisch stand deshalb eine Zeit lang 'Einstellungen' im Footer. Zwei Stichproben gegen genau das.""" def test_footer_knopf_ist_englisch(self): resp = self.client.get("/", headers={"accept-language": "en"}) self.assertContains(resp, "Settings") self.assertNotContains(resp, "Einstellungen") def test_live_seite_ist_englisch(self): make_camera(live_url="http://10.0.0.9:8131/live", live_enabled=True) resp = self.client.get("/live/", headers={"accept-language": "en"}) self.assertContains(resp, "camera") # ⚠ Nur den SICHTBAREN Text prüfen. Skript- und Stilblöcke tragen # deutsche Kommentare — die sieht niemand, und ohne diesen Schnitt # schlägt der Test bei jedem neuen Kommentar fehl. self.assertNotIn("Kamera", nur_sichtbares(resp)) class StoerungsmeldungTests(TestCase): """Die Störungsmeldung muss SYNCHRON rausgehen. 🔑 Am 24.09.2026 kam sie nie an: Sie lief in einem Hintergrundfaden, und der einzige Aufrufer ist ein Einmal-Befehl. Der war fertig, bevor die Meldung draußen war, und hat den Daemon-Faden beim Beenden mitgenommen. Die Wache erkannte die Störung korrekt — und schwieg. ⚠️ Dieser Test prüft nicht, dass *irgendwann* gesendet wird, sondern dass es beim Rücksprung aus ``melde_stoerung`` **bereits passiert ist**. Nur das unterscheidet die beiden Fälle. """ def setUp(self): from dex.models import Benachrichtigung e = Benachrichtigung.laden() e.aktiv = True e.stoerungen = True e.server = "https://ntfy.example" e.thema = "probe-thema" e.save() def test_stoerung_ist_beim_ruecksprung_schon_gesendet(self): from dex import benachrichtigung as bn with mock.patch.object(bn.urllib.request, "urlopen") as urlopen: bn.melde_stoerung("Titel", "Text") # KEIN join(), kein sleep: Wäre es ein Hintergrundfaden, # stünde hier noch nichts. self.assertTrue( urlopen.called, "Die Meldung war beim Rücksprung noch nicht gesendet — " "läuft sie wieder in einem Hintergrundfaden?", ) ziel = urlopen.call_args[0][0] self.assertEqual(ziel.full_url, "https://ntfy.example/probe-thema") def test_abgeschaltete_stoerungen_senden_nicht(self): from dex import benachrichtigung as bn from dex.models import Benachrichtigung e = Benachrichtigung.laden() e.stoerungen = False e.save() with mock.patch.object(bn.urllib.request, "urlopen") as urlopen: bn.melde_stoerung("Titel", "Text") self.assertFalse(urlopen.called) def test_sichtung_blockiert_nicht(self): """Gegenprobe: Sichtungen dürfen den Aufrufer NICHT warten lassen. Sie laufen in einer Web-Anfrage, die weiterlebt — dort ist der Hintergrundfaden richtig und soll bleiben. """ from dex import benachrichtigung as bn from dex.models import Benachrichtigung e = Benachrichtigung.laden() e.sichtungen = Benachrichtigung.Sichtungen.ALLE e.save() with mock.patch.object(bn, "_senden") as senden: with mock.patch.object(bn.threading, "Thread") as thread: art = make_species() sichtung = make_sighting(art, confidence=0.9) bn.melde_sichtung(sichtung, ist_neue_art=False) self.assertTrue(thread.called, "Sichtung soll im Hintergrund laufen") self.assertFalse(senden.called, "und nicht im Vordergrund") class SichtungEntfernenTests(TestCase): """Eine Fehlerkennung löschen heißt mehr als eine Zeile löschen. 🔑 An einer Sichtung hängt der Dex-Eintrag, der Zeitpunkt an der Kamera und die Punktzahl. Wer nur die Zeile entfernt, lässt einen Dex-Eintrag zurück, der eine Sichtung zu viel zählt. """ def setUp(self): self.art = make_species() self.kamera = make_camera() frueher = timezone.now() - timedelta(days=1) self.echte = make_sighting(self.art, confidence=0.9, when=frueher) self.falsche = make_sighting(self.art, confidence=0.6) for s in (self.echte, self.falsche): s.camera = self.kamera s.save() scoring.register(self.echte) scoring.register(self.falsche) # `last_seen` setzt sonst die Melde-Schnittstelle, nicht die # Punktevergabe — hier von Hand, damit der Ausgangszustand stimmt. Camera.objects.filter(pk=self.kamera.pk).update( last_seen=self.falsche.ended_at) self.echte.refresh_from_db() self.falsche.refresh_from_db() self.kamera.refresh_from_db() def _entfernen(self, pk, **optionen): call_command("sichtung_entfernen", pk, stdout=StringIO(), **optionen) def test_dex_eintrag_wird_neu_gezaehlt(self): eintrag = DexEntry.objects.get(species=self.art) self.assertEqual(eintrag.count, 2) self._entfernen(self.falsche.pk) eintrag.refresh_from_db() self.assertEqual(eintrag.count, 1) self.assertEqual(eintrag.last_seen, self.echte.started_at) self.assertEqual(eintrag.best_sighting_id, self.echte.pk) def test_punkte_verschwinden_mit_der_sichtung(self): vorher = scoring.total_xp() punkte = self.falsche.xp_awarded self.assertGreater(punkte, 0) self._entfernen(self.falsche.pk) self.assertEqual(scoring.total_xp(), vorher - punkte) def test_kamera_verliert_den_zeitpunkt_der_letzten_sichtung(self): self._entfernen(self.falsche.pk) self.kamera.refresh_from_db() self.assertEqual(self.kamera.last_seen, self.echte.ended_at) def test_letzte_sichtung_raeumt_den_dex_eintrag_ab(self): self._entfernen(self.falsche.pk) self._entfernen(self.echte.pk) self.assertFalse(DexEntry.objects.filter(species=self.art).exists()) self.kamera.refresh_from_db() self.assertIsNone(self.kamera.last_seen) def test_trocken_aendert_nichts(self): self._entfernen(self.falsche.pk, trocken=True) self.assertTrue(Sighting.objects.filter(pk=self.falsche.pk).exists()) self.assertEqual(DexEntry.objects.get(species=self.art).count, 2) def test_unbekannte_nummer_bricht_ab(self): with self.assertRaises(CommandError): self._entfernen(999999) # -- Abzeichen ---------------------------------------------------- # # 🔴 Der Fall vom 11.10.2026: Eine Fehlerkennung („Straßentaube", 1 von # 27 Bildern mit Vogel) brachte das Abzeichen „Seltener Gast". Die # Sichtung ließ sich entfernen — die 200 Punkte des Abzeichens blieben # und trugen eine Stufe, die es nicht gab. Die Beschreibung des Befehls # behauptete dabei, die Punkte verschwänden von selbst. def _mit_abzeichen(self): """Hängt ein Abzeichen an die falsche Sichtung, wie scoring es tut.""" abzeichen = Badge.objects.create( code="seltener_gast_test", name="Seltener Gast", description="Eine seltene Art", emoji="💎", xp=200, ) EarnedBadge.objects.create(badge=abzeichen, sighting=self.falsche) return abzeichen def test_abzeichen_bleibt_ohne_schalter(self): """Vorgabe bleibt Vorgabe — verdient ist verdient.""" self._mit_abzeichen() self._entfernen(self.falsche.pk) self.assertTrue( EarnedBadge.objects.filter(badge__code="seltener_gast_test").exists()) def test_abzeichen_wird_genannt_statt_stillschweigend_zu_bleiben(self): """Wer nichts davon erfährt, sucht die Punkte an der falschen Stelle.""" self._mit_abzeichen() ausgabe = StringIO() call_command("sichtung_entfernen", self.falsche.pk, trocken=True, stdout=ausgabe) text = ausgabe.getvalue() self.assertIn("Seltener Gast", text) self.assertIn("200", text) def test_schalter_nimmt_abzeichen_und_punkte_mit(self): self._mit_abzeichen() vorher = scoring.total_xp() punkte = self.falsche.xp_awarded self._entfernen(self.falsche.pk, abzeichen=True) self.assertFalse( EarnedBadge.objects.filter(badge__code="seltener_gast_test").exists()) self.assertEqual(scoring.total_xp(), vorher - punkte - 200) def test_der_katalogeintrag_bleibt(self): """Nur das Verdienst fällt weg, nicht das Abzeichen als solches. ⚠️ Sonst wäre es für immer unerreichbar — und `seed_dex` müsste es beim nächsten Start wieder anlegen. """ self._mit_abzeichen() self._entfernen(self.falsche.pk, abzeichen=True) self.assertTrue(Badge.objects.filter(code="seltener_gast_test").exists()) def test_schalter_ohne_abzeichen_stoert_nicht(self): self._entfernen(self.falsche.pk, abzeichen=True) self.assertFalse(Sighting.objects.filter(pk=self.falsche.pk).exists()) class StaticVersionTests(TestCase): """Das Stylesheet braucht einen Stempel in der URL. Ohne ihn behält ein Browser die alte Datei, obwohl der Server längst die neue ausliefert — eine Fehlersuche an etwas, das nicht kaputt ist.""" def test_stylesheet_traegt_einen_stempel(self): for pfad in ("/", "/widget/"): with self.subTest(pfad=pfad): self.assertRegex( self.client.get(pfad).content.decode(), r'href="[^"]*vogelalbum\.css\?v=\d+"', ) def test_stempel_folgt_der_datei(self): """Gleiche Datei, gleicher Stempel — sonst wäre der Cache wertlos.""" from dex.templatetags.dex_extras import static_v self.assertEqual(static_v("css/vogelalbum.css"), static_v("css/vogelalbum.css")) def test_unbekannte_datei_bleibt_ohne_stempel(self): """Kein Absturz, wenn eine Datei fehlt — dann eben die nackte URL.""" from dex.templatetags.dex_extras import static_v self.assertNotIn("?v=", static_v("css/gibtsnicht.css")) class ConfigApiTests(TestCase): """`/api/config/` — die Web-App gibt dem Detector vor, was gilt. Vorher stand das auf der Kommandozeile des Rechners, auf dem der Detector läuft; wer eine Kamera umstellen wollte, brauchte eine Shell. """ def setUp(self): self.token = "kamera-token-1" self.cam = make_camera( token=self.token, stream_url="http://10.0.0.9:8080/stream", habitats="garten", detect_threshold=0.35, min_bird_score=0.07, ) self.garten = make_species(lebensraum_typ="garten", regions=["de"]) self.wasser = make_species( slug="hoeckerschwan", german="Höckerschwan", scientific="Cygnus olor", lebensraum_typ="wasser", regions=["de"], ) self.amerikaner = make_species( slug="carolina-chickadee", german="Carolinameise", scientific="Poecile carolinensis", lebensraum_typ="garten", regions=["na"], ) def hole(self, token=None): return self.client.get( "/api/config/", headers={"authorization": f"Bearer {token or self.token}"}, ) def test_ohne_token_abgelehnt(self): self.assertEqual(self.client.get("/api/config/").status_code, 401) def test_falscher_token_abgelehnt(self): self.assertEqual(self.hole("quatsch").status_code, 401) def test_liefert_schwellen_und_quelle(self): d = self.hole().json() self.assertEqual(d["kamera"], "vogelhaus") self.assertEqual(d["stream_url"], "http://10.0.0.9:8080/stream") self.assertEqual(d["detect_threshold"], 0.35) self.assertEqual(d["min_bird_score"], 0.07) self.assertEqual(d["habitats"], ["garten"]) def test_lebensraum_schraenkt_die_arten_ein(self): """Eine Kamera am Futterhaus soll nicht erwägen, ob da ein Schwan sitzt.""" namen = [a["scientific"] for a in self.hole().json()["arten"]] self.assertIn(self.garten.scientific, namen) self.assertNotIn(self.wasser.scientific, namen) def test_region_schraenkt_die_arten_ein(self): """Der eigentliche Zweck des Regionsfelds: das Modell ist auf Nordamerika trainiert, hier soll es nur deutsche Arten erwägen.""" namen = [a["scientific"] for a in self.hole().json()["arten"]] self.assertNotIn(self.amerikaner.scientific, namen) with self.settings(REGION="na"): namen = [a["scientific"] for a in self.hole().json()["arten"]] self.assertEqual(namen, [self.amerikaner.scientific]) def test_leere_lebensraeume_lassen_alles_zu(self): self.cam.habitats = "" self.cam.save() namen = [a["scientific"] for a in self.hole().json()["arten"]] self.assertIn(self.wasser.scientific, namen) def test_arten_tragen_die_beschriftung(self): art = self.hole().json()["arten"][0] for feld in ("scientific", "german", "english", "rarity", "lebensraum"): self.assertIn(feld, art) def test_einzelkamera_token_bekommt_vorgaben(self): """Ohne registrierte Kamera soll man nicht erst eine anlegen müssen.""" with self.settings(INGEST_TOKEN="global-token"): d = self.hole("global-token").json() self.assertEqual(d["kamera"], "") self.assertEqual(d["habitats"], ["garten"]) self.assertTrue(d["arten"]) def test_post_ist_nicht_erlaubt(self): resp = self.client.post( "/api/config/", headers={"authorization": f"Bearer {self.token}"} ) self.assertEqual(resp.status_code, 405) class BildgueteTests(TestCase): """Das „beste Foto" ist nicht das mit der höchsten Erkennungssicherheit. 🔑 Am 30.09.2026 wurde eine verwischte Aufnahme zum besten Foto der Kohlmeise: Sicherheit 0,945, aber nur 5 von 31 Bildern mit Vogel und der Kopf nicht erkennbar. Die scharfe Aufnahme (0,936 · 23 von 67) landete dahinter. ⚠️ Die Sicherheit sagt, wie sicher sich das Modell bei der ART ist — nicht, ob das Bild etwas taugt. """ def setUp(self): self.art = make_species() def _sichtung(self, confidence, mit_vogel, gesamt, wert): s = make_sighting(self.art, confidence=confidence) s.frames_with_bird = mit_vogel s.frames_total = gesamt s.mean_bird_score = wert s.save() return s def test_scharfes_bild_schlaegt_hoehere_sicherheit(self): verwischt = self._sichtung(0.945, 5, 31, 0.523) scharf = self._sichtung(0.936, 23, 67, 0.600) self.assertGreater(scoring.bildguete(scharf), scoring.bildguete(verwischt)) self.assertEqual(scoring.beste_sichtung([verwischt, scharf]), scharf) def test_ohne_messwerte_entscheidet_die_sicherheit(self): """Alte Sichtungen haben keine Bildzähler — dann bleibt nur sie. ⚠️ frames_total ist 0 und mean_bird_score 0.0 (so die Vorgaben im Modell, NICHT NULL). Die Güte wird damit null, und der Rückfall auf die Sicherheit muss greifen — sonst wäre die Reihenfolge alter Sichtungen zufällig. """ alt = make_sighting(self.art, confidence=0.4) neu = make_sighting(self.art, confidence=0.8) self.assertEqual(alt.frames_total, 0) self.assertEqual(alt.mean_bird_score, 0.0) self.assertEqual(scoring.beste_sichtung([alt, neu]), neu) def test_ohne_sichtungen_kein_bestes(self): self.assertIsNone(scoring.beste_sichtung([])) def test_register_waehlt_nach_guete(self): """Auch beim Aufnehmen zählt die Güte, nicht die Sicherheit.""" gut = self._sichtung(0.60, 40, 80, 0.7) scoring.register(gut) schwach = self._sichtung(0.99, 2, 80, 0.1) scoring.register(schwach) eintrag = DexEntry.objects.get(species=self.art) self.assertEqual(eintrag.best_sighting_id, gut.pk, "Die schwache Aufnahme hat die gute verdrängt") class ZaehlungTests(TestCase): """Was als Besuch zaehlt. 🔑 Diese Klasse gibt es, weil 98 gruene Tests den Fehler nicht gefangen haben: Am 30.09.2026 meldete die Uebersicht "31 Besuche insgesamt" bei sieben echten Voegeln und "2 Arten heute" bei einer. Getestet wurden bis dahin nur die Formeln, nie die Frage, WORUEBER gezaehlt wird. """ def setUp(self): self.kohlmeise = make_species() self.blaumeise = make_species( slug="blaumeise", german="Blaumeise", scientific="Cyanistes caeruleus") def test_unbestimmte_sichtung_ist_kein_besuch(self): """Spiegelungen auf der Vogeltraenke duerfen nicht mitzaehlen.""" make_sighting(species=self.kohlmeise) for _ in range(24): make_sighting(species=None) self.assertEqual(Sighting.objects.count(), 25) # Zeilen self.assertEqual(Sighting.objects.bestimmt().count(), 1) # Besuche self.assertEqual(scoring.progress().sightings, 1) def test_unbestimmte_sichtung_bleibt_in_der_liste(self): """📌 Nicht zaehlen heisst nicht verstecken -- sie zeigt, dass die Kamera arbeitet, und laesst sich nachbestimmen.""" make_sighting(species=None) self.assertEqual(Sighting.objects.count(), 1) def test_korrigierte_art_zaehlt_bei_der_neuen_art(self): """⚠️ Der Fehler vom 30.09.: gezaehlt wurde die ROHE Erkennung.""" s = make_sighting(species=self.kohlmeise) s.confirmed_species = self.kohlmeise s.save() falsch = make_sighting(species=self.blaumeise) falsch.confirmed_species = self.kohlmeise # war in Wahrheit auch eine falsch.save() self.assertEqual(Sighting.objects.arten_anzahl(), 1) def test_arten_heute_folgt_der_korrektur(self): """Die Kachel 'Arten heute' auf der Startseite.""" make_sighting(species=self.kohlmeise) falsch = make_sighting(species=self.blaumeise) falsch.confirmed_species = self.kohlmeise falsch.save() make_sighting(species=None) # Spiegelung, zaehlt nicht antwort = self.client.get("/") self.assertEqual(antwort.context["today_species"], 1) self.assertEqual(antwort.context["today_count"], 2) def test_bestaetigung_ohne_erkennung_zaehlt_trotzdem(self): """Von Hand bestimmt, obwohl das Modell nichts erkannt hat.""" s = make_sighting(species=None) s.confirmed_species = self.blaumeise s.save() self.assertEqual(Sighting.objects.bestimmt().count(), 1) self.assertEqual(Sighting.objects.arten_anzahl(), 1) def test_abzeichen_balken_zaehlt_keine_spiegelungen(self): make_sighting(species=self.kohlmeise) for _ in range(24): make_sighting(species=None) ist, ziel = scoring.fortschritt_je_abzeichen()["besucher_100"] self.assertEqual((ist, ziel), (1, 100)) def test_straehne_zaehlt_nur_tage_mit_echtem_besuch(self): """Ein Tag mit nur einer Spiegelung unterbricht die Straehne.""" jetzt = timezone.now() make_sighting(species=self.kohlmeise, when=jetzt) make_sighting(species=None, when=jetzt - timedelta(days=1)) make_sighting(species=self.kohlmeise, when=jetzt - timedelta(days=2)) self.assertEqual(scoring._laengste_straehne(), 1) def mini_jpeg(name="vogel.jpg") -> SimpleUploadedFile: """Ein echtes, winziges JPEG. ⚠️ Kein erfundener Bytestring: ``ImageField`` prüft beim Speichern mit Pillow, ob die Datei ein Bild ist, und lehnt alles andere ab. """ from PIL import Image puffer = BytesIO() Image.new("RGB", (8, 8), (90, 140, 60)).save(puffer, format="JPEG") return SimpleUploadedFile(name, puffer.getvalue(), content_type="image/jpeg") # ⚠️ Eigener Medienordner für die Tests. Ohne das schreiben sie echte Bilder # nach MEDIA_ROOT -- beim ersten Lauf am 01.10.2026 lagen prompt sechs # Testdateien zwischen den Vogelfotos und wären beim nächsten Deploy mit # aufs Volume gewandert. @override_settings(MEDIA_ROOT=tempfile.mkdtemp(prefix="vogelalbum-tests-")) class WarteschlangeTests(TestCase): """Fotos von unterwegs: hochladen, abholen, Ergebnis nachtragen.""" @classmethod def tearDownClass(cls): shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) super().tearDownClass() def setUp(self): scoring.sync_badges() self.species = make_species() self.camera = make_camera(slug="handy", token="handy-token", mobil=True) def post_upload(self, token="handy-token", **felder): daten = {"bilder": felder.pop("bilder", [mini_jpeg()]), **felder} return self.client.post( "/api/hochladen/", daten, headers={"Authorization": f"Bearer {token}"}, ) def test_upload_legt_wartende_sichtung_an(self): resp = self.post_upload() self.assertEqual(resp.status_code, 201) s = Sighting.objects.get() self.assertTrue(s.wartet_auf_bestimmung) self.assertIsNone(s.species) self.assertEqual(s.camera, self.camera) def test_upload_ohne_token_wird_abgewiesen(self): self.assertEqual(self.post_upload(token="falsch").status_code, 401) self.assertEqual(Sighting.objects.count(), 0) def test_upload_ohne_bild_wird_abgewiesen(self): resp = self.client.post( "/api/hochladen/", {}, headers={"Authorization": "Bearer handy-token"}, ) self.assertEqual(resp.status_code, 400) def test_mehrere_bilder_werden_einzelne_sichtungen(self): """Zehn Fotos vom Spaziergang sind zehn Sichtungen, nicht eine.""" resp = self.post_upload( bilder=[mini_jpeg("a.jpg"), mini_jpeg("b.jpg"), mini_jpeg("c.jpg")] ) self.assertEqual(resp.status_code, 201) self.assertEqual(Sighting.objects.count(), 3) self.assertEqual(Sighting.objects.wartend().count(), 3) def test_wartende_zaehlen_nicht_als_besuch(self): """Sonst springt der Zähler hoch und nach der Bestimmung zurück.""" self.post_upload() self.assertEqual(Sighting.objects.bestimmt().count(), 0) self.assertEqual(scoring.progress().sightings, 0) def test_upload_uebernimmt_ort_gerundet(self): self.post_upload(latitude="49.48753", longitude="8.46601") s = Sighting.objects.get() self.assertEqual(s.latitude, 49.49) self.assertEqual(s.longitude, 8.47) def test_offen_listet_nur_wartende(self): """Fertige Sichtungen gehören nicht in die Warteschlange.""" self.post_upload() make_sighting(self.species) with self.settings(LESE_TOKEN="lese-token"): resp = self.client.get( "/api/offen/", headers={"Authorization": "Bearer lese-token"}) self.assertEqual(resp.status_code, 200) fotos = resp.json()["fotos"] self.assertEqual(len(fotos), 1) self.assertEqual(fotos[0]["id"], Sighting.objects.wartend().get().id) self.assertTrue(fotos[0]["bild"].endswith(".jpg")) def test_offen_ohne_token_wird_abgewiesen(self): self.post_upload() self.assertEqual(self.client.get("/api/offen/").status_code, 401) def test_ergebnis_macht_aus_wartend_eine_sichtung(self): self.post_upload() s = Sighting.objects.get() resp = self.client.post( f"/api/offen/{s.id}/", {"data": json.dumps({ "species": "kohlmeise", "confidence": 0.9, "mean_bird_score": 0.44, "votes": {"kohlmeise": 0.9}, })}, headers={"Authorization": "Bearer handy-token"}, ) self.assertEqual(resp.status_code, 200) s.refresh_from_db() self.assertFalse(s.wartet_auf_bestimmung) self.assertEqual(s.species, self.species) self.assertEqual(Sighting.objects.bestimmt().count(), 1) self.assertTrue(resp.json()["erstfang"]) def test_ergebnis_ohne_vogel_beendet_das_warten_trotzdem(self): """Sonst bearbeitet der Detector dasselbe Bild endlos erneut.""" self.post_upload() s = Sighting.objects.get() resp = self.client.post( f"/api/offen/{s.id}/", {"data": json.dumps({"species": None, "mean_bird_score": 0.0})}, headers={"Authorization": "Bearer handy-token"}, ) self.assertEqual(resp.status_code, 200) s.refresh_from_db() self.assertFalse(s.wartet_auf_bestimmung) self.assertIsNone(s.species) self.assertEqual(Sighting.objects.wartend().count(), 0) def test_ergebnis_zweimal_melden_geht_nicht(self): self.post_upload() s = Sighting.objects.get() daten = {"data": json.dumps({"species": "kohlmeise", "confidence": 0.9})} kopf = {"Authorization": "Bearer handy-token"} self.assertEqual( self.client.post(f"/api/offen/{s.id}/", daten, headers=kopf).status_code, 200) self.assertEqual( self.client.post(f"/api/offen/{s.id}/", daten, headers=kopf).status_code, 404) class MobilGeraetTests(TestCase): """Ein Handy darf nicht nach den Regeln eines Vogelhauses überwacht werden.""" def test_stille_misst_das_lebenszeichen_nicht_den_letzten_vogel(self): """🔑 Der Fehler vom 01.10.2026 auf dem Dev-Server. Beide Kameras trugen das Warnzeichen, obwohl beide liefen: Das Lebenszeichen war eine Minute alt, der letzte Vogel aber 1810 bzw. 5435 Minuten. An ``last_seen`` zu messen warnt, wenn nichts los ist — nicht, wenn etwas kaputt ist. """ laeuft = make_camera( slug="laeuft", token="t-l", last_seen=timezone.now() - timedelta(hours=30), # lange kein Vogel letzter_kontakt=timezone.now() - timedelta(minutes=1), ) self.assertFalse( laeuft.is_stale, "Eine Kamera ohne Vogel, aber mit frischem Lebenszeichen, laeuft") haengt = make_camera( slug="haengt", token="t-h", last_seen=timezone.now() - timedelta(minutes=5), # gerade noch Vogel letzter_kontakt=timezone.now() - timedelta(hours=3), ) self.assertTrue( haengt.is_stale, "Ohne Lebenszeichen seit drei Stunden haengt sie, trotz Vogel") def test_mobiles_geraet_bekommt_kein_warnzeichen(self): """is_stale gilt fuer Daueraufnahme, nicht fuer ein Handy. ⚠️ Das Warnzeichen im Kamerafilter haengt daran. Ein Zeichen, das dauerhaft steht, liest nach einer Woche niemand mehr. """ alt = timezone.now() - timedelta(days=3) handy = make_camera(slug="h", token="th", mobil=True, letzter_kontakt=alt) haus = make_camera(slug="v", token="tv", mobil=False, letzter_kontakt=alt) self.assertFalse(handy.is_stale) self.assertTrue(haus.is_stale) def test_mobiles_geraet_ohne_lebenszeichen_ist_nicht_still(self): """Ein frisch angelegtes Handy hat noch nie geliefert — trotzdem ok.""" self.assertFalse(make_camera(slug="neu", token="tn", mobil=True).is_stale) def test_mobiles_geraet_wird_nicht_als_still_gemeldet(self): """Sonst kommt nach dem ersten ruhigen Wochenende ein Fehlalarm.""" alt = timezone.now() - timedelta(days=3) make_camera(slug="handy", token="t-handy", mobil=True, letzter_kontakt=alt) make_camera(slug="haus", token="t-haus", mobil=False, letzter_kontakt=alt) ausgabe = StringIO() call_command("stoerungen_pruefen", "--trocken", stdout=ausgabe) text = ausgabe.getvalue() self.assertIn("Haus", text) self.assertNotIn("Handy", text) @override_settings(MEDIA_ROOT=tempfile.mkdtemp(prefix="vogelalbum-form-")) class HochladenSeiteTests(TestCase): """Das Formular in der Web-App — der Weg ohne App.""" @classmethod def tearDownClass(cls): shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) super().tearDownClass() def setUp(self): scoring.sync_badges() make_species() from django.contrib.auth.models import User self.user = User.objects.create_user( "testnutzer", password="geheim", is_staff=True) def test_ohne_anmeldung_kein_zugang(self): """⚠️ Die Instanz darf öffentlich stehen — dann wäre ein offenes Formular eine Einladung, fremde Bilder in eine fremde Sammlung zu legen.""" resp = self.client.get("/hochladen/") self.assertIn(resp.status_code, (302, 403)) self.assertEqual(Sighting.objects.count(), 0) def test_ohne_anmeldung_auch_kein_post(self): resp = self.client.post("/hochladen/", {"bilder": [mini_jpeg()]}) self.assertIn(resp.status_code, (302, 403)) self.assertEqual(Sighting.objects.count(), 0) def test_angemeldet_sichtbar(self): self.client.force_login(self.user) resp = self.client.get("/hochladen/") self.assertEqual(resp.status_code, 200) def test_hochladen_legt_wartende_sichtung_an(self): self.client.force_login(self.user) resp = self.client.post( "/hochladen/", {"bilder": [mini_jpeg("a.jpg"), mini_jpeg("b.jpg")], "latitude": "49.48753", "longitude": "8.46601"}, ) # Umleitung nach dem Hochladen, damit Neuladen nicht doppelt einliefert self.assertEqual(resp.status_code, 302) self.assertEqual(Sighting.objects.wartend().count(), 2) s = Sighting.objects.wartend().first() self.assertEqual(s.latitude, 49.49) self.assertTrue(s.wartet_auf_bestimmung) def test_ohne_datei_meldet_fehler_statt_leerer_sichtung(self): self.client.force_login(self.user) resp = self.client.post("/hochladen/", {}) self.assertEqual(resp.status_code, 200) self.assertEqual(Sighting.objects.count(), 0) def test_knopf_nur_fuer_angemeldete(self): """⚠️ Ein öffentlicher Besucher soll keinen Knopf sehen, der ihn bloß ins Login schickt — einliefern darf er ohnehin nicht.""" ohne = nur_sichtbares(self.client.get("/sichtungen/")) self.assertNotIn("/hochladen/", ohne) self.client.force_login(self.user) mit = nur_sichtbares(self.client.get("/sichtungen/")) self.assertIn("/hochladen/", mit) def test_keine_kommentare_stehen_sichtbar_auf_der_seite(self): """⚠️ ``{# … #}`` ist in Django EINZEILIG. Mehrzeilig geschrieben steht der Kommentartext sichtbar auf der Seite. Das ist im Projekt schon zweimal passiert, zuletzt auf der Abzeichen-Seite (Commit 5699f5f). Hier werden deshalb ``{% comment %}``-Blöcke benutzt — dieser Test hält fest, dass das so bleibt. """ self.client.force_login(self.user) seite = nur_sichtbares(self.client.get("/hochladen/")) for satz in ("multiple ist Absicht", "Von einem Spaziergang kommen selten", "hielte man den Upload für fehlgeschlagen", "sollen zeigen, dass"): self.assertNotIn(satz, seite, f"Kommentartext steht auf der Seite: {satz}") def test_formular_und_schnittstelle_nehmen_denselben_weg(self): """🔑 Der eigentliche Zweck der Seite: dieselbe Warteschlange. Läuft das Formular, ist die Kette bewiesen, bevor eine Zeile App existiert. """ self.client.force_login(self.user) self.client.post("/hochladen/", {"bilder": [mini_jpeg()]}) ueber_formular = Sighting.objects.wartend().count() kamera = make_camera(slug="handy2", token="t2", mobil=True) self.client.post( "/api/hochladen/", {"bilder": [mini_jpeg()]}, headers={"Authorization": "Bearer t2"}, ) self.assertEqual(Sighting.objects.wartend().count(), ueber_formular + 1) # Beide landen in derselben Warteschlange, die der Detector abfragt. self.assertTrue( all(s.wartet_auf_bestimmung for s in Sighting.objects.all())) class UebersetzungsabdeckungTests(SimpleTestCase): """Jeder übersetzbare Text braucht eine englische Fassung — strukturell. 🔑 Warum als Test und nicht als Hinweis im README: Am 03.10.2026 wurde die Startseite umgebaut und ``makemessages`` danach nicht ausgeführt. Die Oberfläche blieb auf Englisch deutsch, und *nichts* schlug an — die alte .po-Datei war ja in sich vollständig, sie kannte die neuen Texte nur nicht. Eine Stichprobe wie ``EnglishTests`` fängt das nicht: sie prüft die Texte, die es schon gab. Dieser Test geht deshalb vom Quelltext aus, nicht von der .po-Datei. Er benutzt dafür ``templatize`` — dieselbe Funktion, mit der ``makemessages`` aus einem Template die übersetzbaren Zeichenketten zieht — und braucht damit kein installiertes gettext. """ LITERAL = re.compile( r"""\bn?gettext(?:_lazy)?\(\s*u?('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")""" ) @classmethod def setUpClass(cls): super().setUpClass() import importlib.util pfad = settings.BASE_DIR.parent / "scripts" / "update_translations.py" spec = importlib.util.spec_from_file_location("update_translations", pfad) modul = importlib.util.module_from_spec(spec) spec.loader.exec_module(modul) cls.bekannt = set(modul.TRANSLATIONS) def _literale(self, quelltext: str) -> set[str]: import ast gefunden = set() for treffer in self.LITERAL.findall(quelltext): try: gefunden.add(ast.literal_eval(treffer)) except (ValueError, SyntaxError): continue return gefunden def _texte_der_oberflaeche(self) -> set[str]: from django.utils.translation.template import templatize texte: set[str] = set() wurzel = settings.BASE_DIR for vorlage in (wurzel / "templates").rglob("*.html"): texte |= self._literale(templatize(vorlage.read_text("utf-8"))) # Texte aus dem Python-Teil: Ränge, Abzeichen, Admin-Hilfe, Meldungen. for quelle in (wurzel / "dex").rglob("*.py"): if "migrations" in quelle.parts or quelle.name == "tests.py": continue texte |= self._literale(quelle.read_text("utf-8")) return texte def test_jeder_text_hat_eine_englische_fassung(self): fehlend = sorted(self._texte_der_oberflaeche() - self.bekannt) self.assertEqual( fehlend, [], "Diese Texte haben keine englische Fassung. Eintragen in " "scripts/update_translations.py, dann:\n" " cd web && ../venv/bin/python manage.py makemessages -l en --ignore=venv\n" " ../venv/bin/python scripts/update_translations.py\n" " cd web && ../venv/bin/python manage.py compilemessages -l en\n" + "\n".join(f" {t!r}: ...," for t in fehlend), ) def test_po_datei_hat_keine_luecken(self): """Die ausgelieferte .po darf keinen leeren und keinen fuzzy-Eintrag haben. ⚠ Beides ist im Betrieb dasselbe Symptom: ``msgfmt`` wirft fuzzy weg, der deutsche Text bleibt stehen. Als Fehler sieht man es nie. """ po = settings.BASE_DIR / "locale/en/LC_MESSAGES/django.po" zeilen = po.read_text("utf-8").split("\n") fuzzy, leer = [], [] letzter_msgid = "" for nr, zeile in enumerate(zeilen): if zeile.startswith("#,") and "fuzzy" in zeile: # Der Kopf der Datei trägt gelegentlich selbst eine Marke. if nr > 6: fuzzy.append(nr + 1) if zeile.startswith("msgid "): letzter_msgid = zeile leere_form = zeile == 'msgstr ""' or re.fullmatch( r'msgstr\[\d+\] ""', zeile) if leere_form and letzter_msgid not in ("", 'msgid ""'): # Mehrzeilige msgstr beginnen ebenfalls so — nur zählen, wenn # keine Fortsetzungszeile folgt. folgt = zeilen[nr + 1] if nr + 1 < len(zeilen) else "" if not folgt.startswith('"'): leer.append(nr + 1) self.assertEqual(fuzzy, [], f"fuzzy-Einträge in {po.name}, Zeilen {fuzzy}") self.assertEqual(leer, [], f"leere Übersetzungen in {po.name}, Zeilen {leer}") def test_kompilierte_fassung_ist_nicht_veraltet(self): """Eine vorhandene .mo muss mindestens so neu sein wie die .po. Sonst läuft hier eine ältere Übersetzung als die, die im Repo steht — und man sucht den Fehler in einer Datei, die längst richtig ist. ⚠ Fehlt die .mo ganz, ist das **kein** Fehler: sie steht absichtlich nicht im Repo (`.gitignore`), das Docker-Image baut sie beim Bauen. Nach einem frischen Klon wäre ein harter Test hier rot, ohne dass etwas kaputt ist — und ein Test, der nach dem Klonen rot ist, erzieht nur dazu, ihn zu überspringen. """ ordner = settings.BASE_DIR / "locale/en/LC_MESSAGES" po, mo = ordner / "django.po", ordner / "django.mo" if not mo.exists(): self.skipTest( "django.mo noch nicht kompiliert (gehört nicht ins Repo) — " "'manage.py compilemessages -l en' baut sie" ) self.assertGreaterEqual( mo.stat().st_mtime, po.stat().st_mtime, "django.mo ist älter als django.po — compilemessages laufen lassen", ) class BlickfangTitelTests(TestCase): """Die Überschrift der Startseite muss stimmen, nicht nur hübsch sein. 🔴 Vorher stand dort fest „Wer war heute im Futterhaus?". Am 09.10.2026 war der letzte Gast fünf Tage her — die Seite fragte trotzdem nach heute, und direkt daneben stand „Sonntag, 04.10.". Gemeldet als: *„Er ist immer auf Deutsch und macht keinen Sinn."* """ def setUp(self): self.art = make_species() def _titel_bei(self, tage_zurueck, sprache="de"): """⚠ Sprache ausdrücklich setzen: die aktive Sprache überlebt einen Testlauf, und ein vorangegangener englischer Request färbt sonst das Ergebnis — der Test misst dann die falsche Fassung.""" from django.utils import translation from .views import blickfang_titel wann = timezone.now() - timedelta(days=tage_zurueck) sichtung = make_sighting(species=self.art, when=wann) with translation.override(sprache): return blickfang_titel(sichtung, timezone.localdate()) def test_ohne_sichtung_wartet_die_kamera(self): from django.utils import translation from .views import blickfang_titel with translation.override("de"): self.assertEqual( blickfang_titel(None, timezone.localdate()), "Noch wartet die Kamera") def test_heute_fragt_nach_heute(self): self.assertEqual(self._titel_bei(0), "Wer war heute zu Gast?") def test_gestern_sagt_gestern(self): self.assertEqual(self._titel_bei(1), "Zuletzt gestern zu Gast") def test_diese_woche_nennt_den_wochentag(self): titel = self._titel_bei(3) self.assertNotIn("heute", titel) wochentag = (timezone.localdate() - timedelta(days=3)).strftime("%A") deutsch = { "Monday": "Montag", "Tuesday": "Dienstag", "Wednesday": "Mittwoch", "Thursday": "Donnerstag", "Friday": "Freitag", "Saturday": "Samstag", "Sunday": "Sonntag", }[wochentag] self.assertEqual(titel, f"Zuletzt am {deutsch} zu Gast") def test_aelter_als_eine_woche_nennt_das_datum(self): titel = self._titel_bei(9) self.assertNotIn("heute", titel) self.assertIn(str((timezone.localdate() - timedelta(days=9)).year), titel) def test_startseite_behauptet_kein_heute_bei_altem_besuch(self): """Der gemeldete Fall — einmal durch die echte Seite.""" make_sighting(species=self.art, when=timezone.now() - timedelta(days=5)) seite = nur_sichtbares(self.client.get("/")) self.assertNotIn("Wer war heute", seite) def test_englische_startseite_bleibt_englisch(self): make_sighting(species=self.art, when=timezone.now()) resp = self.client.get("/", headers={"accept-language": "en"}) self.assertContains(resp, "Who stopped by today?") self.assertNotIn("Wer war heute", nur_sichtbares(resp)) class ZeitpunktFilterTests(TestCase): """``|zeitpunkt`` statt ``|date:"d.m."`` — das Format war deutsch verdrahtet und stand auf der englischen Fassung falsch herum.""" def _gerendert(self, wann, sprache="de"): from django.template import Context, Template from django.utils import translation with translation.override(sprache): return Template( '{% load dex_extras %}{{ w|zeitpunkt }}' ).render(Context({"w": wann})) def test_heute(self): self.assertTrue(self._gerendert(timezone.now()).startswith("heute, ")) def test_gestern(self): wert = self._gerendert(timezone.now() - timedelta(days=1)) self.assertTrue(wert.startswith("gestern, "), wert) def test_englisch_sagt_today(self): self.assertTrue(self._gerendert(timezone.now(), "en").startswith("today, ")) def test_leerer_wert_bricht_nicht(self): self.assertEqual(self._gerendert(None), "") def test_altes_datum_nennt_das_jahr(self): alt = timezone.now() - timedelta(days=400) self.assertIn(str(timezone.localtime(alt).year), self._gerendert(alt))