Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine Sammlung, die sich von allein fuellt. Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert — ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten, gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte, Stufen und 15 Abzeichen. Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto, keine Telemetrie; die Bilder verlassen das eigene Netz nicht. Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und braucht deshalb weder eine erreichbare Adresse noch einen offenen Port — er laeuft hinter einem gewoehnlichen Router. Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs. AGPL-3.0.
1870 lines
77 KiB
Python
1870 lines
77 KiB
Python
"""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.*?</\1>", 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))
|