Vogelalbum 1.0.0
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.
This commit is contained in:
commit
c11fe9ab04
189 files changed
+29806
No files matched your search
@@ -0,0 +1,644 @@
|
||||
"""Tests für die Lese-Schnittstelle der Handy-App.
|
||||
|
||||
Konzentriert auf das, was leise falsch sein kann: der Zugang (ein zu weit
|
||||
geöffneter Endpunkt fällt niemandem auf), die Handbestimmung (die App würde
|
||||
sonst dauerhaft die korrigierte Art verschweigen), die Warteschleife nach
|
||||
einem Upload (ein falsches ``wartet`` dreht endlos) und die Bildadressen
|
||||
(relativ ausgeliefert ist in einer App schlicht kein Bild).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from . import scoring
|
||||
from .models import Badge, Camera, DexEntry, EarnedBadge, Sighting, Species
|
||||
from .tests import make_camera, make_species, make_sighting
|
||||
|
||||
# Ein 1x1-PNG. Reicht, weil wir die Adresse prüfen, nicht das Bild.
|
||||
PNG = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00"
|
||||
b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
LESEN = "lese-token-fuer-tests"
|
||||
|
||||
|
||||
def bild(name="v.png") -> SimpleUploadedFile:
|
||||
return SimpleUploadedFile(name, PNG, content_type="image/png")
|
||||
|
||||
|
||||
@override_settings(LESE_TOKEN=LESEN)
|
||||
class ZugangTests(TestCase):
|
||||
"""Wer darf lesen — und vor allem: wer nicht."""
|
||||
|
||||
def setUp(self):
|
||||
make_camera(token="kamera-geheim")
|
||||
|
||||
def hole(self, token=None):
|
||||
kopf = {}
|
||||
if token is not None:
|
||||
kopf["HTTP_AUTHORIZATION"] = f"Bearer {token}"
|
||||
return self.client.get("/api/app/start/", **kopf)
|
||||
|
||||
def test_ohne_header_abgelehnt(self):
|
||||
self.assertEqual(self.hole().status_code, 401)
|
||||
|
||||
def test_falscher_token_abgelehnt(self):
|
||||
self.assertEqual(self.hole("nein").status_code, 401)
|
||||
|
||||
def test_lese_token_darf(self):
|
||||
self.assertEqual(self.hole(LESEN).status_code, 200)
|
||||
|
||||
def test_kamera_token_darf_auch(self):
|
||||
"""Die App soll mit EINEM Geheimnis auskommen — dem ihrer Kamera."""
|
||||
self.assertEqual(self.hole("kamera-geheim").status_code, 200)
|
||||
|
||||
def test_header_ohne_bearer_abgelehnt(self):
|
||||
resp = self.client.get("/api/app/start/", HTTP_AUTHORIZATION=LESEN)
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
@override_settings(LESE_TOKEN="")
|
||||
def test_leerer_lese_token_heisst_zu_nicht_offen(self):
|
||||
"""⚠️ Die Regel aus api.py gilt hier genauso.
|
||||
|
||||
Ein leerer Token ist ein vergessenes Geheimnis, kein Freibrief. Ohne
|
||||
diese Prüfung stünde die Sammlung im Netz, sobald jemand die
|
||||
Umgebungsvariable nicht setzt — und ein leerer Bearer-Header käme
|
||||
durch.
|
||||
"""
|
||||
self.assertEqual(self.hole("").status_code, 401)
|
||||
self.assertEqual(self.hole("irgendwas").status_code, 401)
|
||||
# Die Kamera bleibt der Weg hinein, auch ohne Lese-Token.
|
||||
self.assertEqual(self.hole("kamera-geheim").status_code, 200)
|
||||
|
||||
def test_alle_endpunkte_sind_geschuetzt(self):
|
||||
"""Ein neuer Endpunkt ohne Prüfung ist der Fehler, der nie auffällt."""
|
||||
art = make_species()
|
||||
s = make_sighting(art)
|
||||
for pfad in (
|
||||
"/api/app/start/",
|
||||
"/api/app/album/",
|
||||
f"/api/app/album/{art.slug}/",
|
||||
"/api/app/sichtungen/",
|
||||
f"/api/app/sichtungen/{s.id}/",
|
||||
"/api/app/abzeichen/",
|
||||
):
|
||||
with self.subTest(pfad=pfad):
|
||||
self.assertEqual(self.client.get(pfad).status_code, 401)
|
||||
|
||||
|
||||
@override_settings(LESE_TOKEN=LESEN)
|
||||
class AppTestBasis(TestCase):
|
||||
def setUp(self):
|
||||
scoring.sync_badges()
|
||||
|
||||
def json(self, pfad, **params):
|
||||
resp = self.client.get(
|
||||
pfad, params, HTTP_AUTHORIZATION=f"Bearer {LESEN}"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200, resp.content[:400])
|
||||
return json.loads(resp.content)
|
||||
|
||||
|
||||
class AlbumTests(AppTestBasis):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.kohlmeise = make_species(
|
||||
slug="kohlmeise", german="Kohlmeise", number=3,
|
||||
lebensraum_typ="garten", typ="meise",
|
||||
)
|
||||
self.blaumeise = make_species(
|
||||
slug="blaumeise", german="Blaumeise", scientific="Cyanistes caeruleus",
|
||||
number=1, lebensraum_typ="garten", typ="meise",
|
||||
)
|
||||
self.reiher = make_species(
|
||||
slug="graureiher", german="Graureiher", scientific="Ardea cinerea",
|
||||
number=2, lebensraum_typ="wasser", typ="schreitvogel",
|
||||
)
|
||||
|
||||
def test_vorgabe_ist_garten(self):
|
||||
"""Ohne Angabe nur der Garten — 148 Arten sind eine Wand, kein Ziel."""
|
||||
daten = self.json("/api/app/album/")
|
||||
self.assertEqual(daten["lebensraum"], "garten")
|
||||
self.assertEqual(
|
||||
{a["slug"] for a in daten["arten"]}, {"kohlmeise", "blaumeise"}
|
||||
)
|
||||
|
||||
def test_alle_holt_jeden_lebensraum(self):
|
||||
daten = self.json("/api/app/album/", lebensraum="alle")
|
||||
self.assertEqual(len(daten["arten"]), 3)
|
||||
self.assertEqual(daten["lebensraum"], "")
|
||||
|
||||
def test_sortierung_nach_albumnummer_nicht_nach_fund(self):
|
||||
"""Die Nummer ist die Seitenzahl und bleibt, wo sie ist."""
|
||||
scoring.register(make_sighting(self.kohlmeise)) # Nummer 3, gefunden
|
||||
daten = self.json("/api/app/album/", lebensraum="alle")
|
||||
self.assertEqual(
|
||||
[a["nummer"] for a in daten["arten"]], [1, 2, 3]
|
||||
)
|
||||
|
||||
def test_ungefundene_art_ist_schatten_ohne_foto(self):
|
||||
daten = self.json("/api/app/album/")
|
||||
seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise")
|
||||
self.assertFalse(seite["gefunden"])
|
||||
self.assertNotIn("foto", seite)
|
||||
self.assertNotIn("anzahl", seite)
|
||||
|
||||
def test_gefundene_art_bringt_zaehler_und_foto(self):
|
||||
s = make_sighting(self.kohlmeise)
|
||||
s.image = bild()
|
||||
s.save()
|
||||
scoring.register(s)
|
||||
|
||||
daten = self.json("/api/app/album/")
|
||||
seite = next(a for a in daten["arten"] if a["slug"] == "kohlmeise")
|
||||
self.assertTrue(seite["gefunden"])
|
||||
self.assertEqual(seite["anzahl"], 1)
|
||||
self.assertTrue(seite["neu"])
|
||||
self.assertTrue(seite["foto"].startswith("http://"))
|
||||
|
||||
def test_zaehler_je_lebensraum(self):
|
||||
scoring.register(make_sighting(self.blaumeise))
|
||||
daten = self.json("/api/app/album/")
|
||||
self.assertEqual(daten["gefunden"], 1)
|
||||
self.assertEqual(daten["gesamt"], 2)
|
||||
|
||||
garten = next(
|
||||
h for h in daten["lebensraeume"] if h["code"] == "garten"
|
||||
)
|
||||
wasser = next(
|
||||
h for h in daten["lebensraeume"] if h["code"] == "wasser"
|
||||
)
|
||||
self.assertEqual((garten["gefunden"], garten["gesamt"]), (1, 2))
|
||||
self.assertEqual((wasser["gefunden"], wasser["gesamt"]), (0, 1))
|
||||
|
||||
def test_leere_lebensraeume_fehlen_in_den_reitern(self):
|
||||
codes = {h["code"] for h in self.json("/api/app/album/")["lebensraeume"]}
|
||||
self.assertEqual(codes, {"garten", "wasser"})
|
||||
|
||||
def test_farben_kommen_mit(self):
|
||||
"""Die App soll die Palette nicht nachbauen müssen."""
|
||||
seite = next(
|
||||
a for a in self.json("/api/app/album/")["arten"]
|
||||
if a["slug"] == "kohlmeise"
|
||||
)
|
||||
self.assertEqual(seite["typ_farbe"], "#f2c744")
|
||||
self.assertEqual(seite["seltenheit_farbe"], "#7f9b6a")
|
||||
|
||||
|
||||
class ArtTests(AppTestBasis):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.art = make_species(
|
||||
number=5, gewicht_g=18.0, spannweite_cm=24.0, size_cm="14 cm",
|
||||
verwechslung=["blaumeise"],
|
||||
)
|
||||
make_species(slug="blaumeise", german="Blaumeise", number=6)
|
||||
|
||||
def test_unbekannte_art_ist_404(self):
|
||||
resp = self.client.get(
|
||||
"/api/app/album/gibtsnicht/", HTTP_AUTHORIZATION=f"Bearer {LESEN}"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_steckbrief_und_balken(self):
|
||||
daten = self.json(f"/api/app/album/{self.art.slug}/")
|
||||
self.assertEqual(daten["art"]["slug"], "kohlmeise")
|
||||
self.assertTrue(daten["art"]["balken"])
|
||||
labels = {b["label"] for b in daten["art"]["balken"]}
|
||||
self.assertIn("Gewicht", labels)
|
||||
|
||||
def test_verwechslung_in_katalogreihenfolge(self):
|
||||
daten = self.json(f"/api/app/album/{self.art.slug}/")
|
||||
self.assertEqual(
|
||||
[a["slug"] for a in daten["verwechslung"]], ["blaumeise"]
|
||||
)
|
||||
|
||||
def test_blaettern_zeigt_die_nachbarn(self):
|
||||
daten = self.json(f"/api/app/album/{self.art.slug}/")
|
||||
self.assertIsNone(daten["blaettern"]["vorher"])
|
||||
self.assertEqual(daten["blaettern"]["nachher"], "blaumeise")
|
||||
|
||||
def test_tagesverlauf_hat_24_stunden_in_ortszeit(self):
|
||||
wann = timezone.localtime(timezone.now()).replace(hour=6, minute=31)
|
||||
make_sighting(self.art, when=wann)
|
||||
|
||||
daten = self.json(f"/api/app/album/{self.art.slug}/")
|
||||
self.assertEqual(len(daten["tagesverlauf"]), 24)
|
||||
self.assertEqual(daten["tagesverlauf"][6]["anzahl"], 1)
|
||||
self.assertEqual(daten["tagesverlauf"][6]["anteil"], 1.0)
|
||||
|
||||
def test_handbestimmung_zaehlt_als_besuch_dieser_art(self):
|
||||
"""Eine Korrektur im Admin muss in der App ankommen."""
|
||||
andere = make_species(slug="amsel", german="Amsel", number=9)
|
||||
s = make_sighting(andere)
|
||||
s.confirmed_species = self.art
|
||||
s.save()
|
||||
|
||||
daten = self.json(f"/api/app/album/{self.art.slug}/")
|
||||
self.assertEqual(len(daten["besuche"]), 1)
|
||||
self.assertEqual(daten["besuche"][0]["art"]["slug"], "kohlmeise")
|
||||
self.assertTrue(daten["besuche"][0]["handbestimmt"])
|
||||
|
||||
|
||||
class SichtungenTests(AppTestBasis):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.art = make_species()
|
||||
self.kamera = make_camera(token="kamera-geheim")
|
||||
|
||||
def test_juengste_zuerst(self):
|
||||
jetzt = timezone.now()
|
||||
alt = make_sighting(self.art, when=jetzt - timedelta(hours=2))
|
||||
neu = make_sighting(self.art, when=jetzt)
|
||||
|
||||
daten = self.json("/api/app/sichtungen/")
|
||||
self.assertEqual([s["id"] for s in daten["sichtungen"]], [neu.id, alt.id])
|
||||
|
||||
def test_nur_bestimmt_blendet_artlose_aus(self):
|
||||
make_sighting(self.art)
|
||||
make_sighting(None)
|
||||
|
||||
self.assertEqual(self.json("/api/app/sichtungen/")["gesamt"], 2)
|
||||
self.assertEqual(
|
||||
self.json("/api/app/sichtungen/", nur_bestimmt="1")["gesamt"], 1
|
||||
)
|
||||
|
||||
def test_filter_nach_art(self):
|
||||
andere = make_species(slug="amsel", german="Amsel")
|
||||
make_sighting(self.art)
|
||||
make_sighting(andere)
|
||||
daten = self.json("/api/app/sichtungen/", art="amsel")
|
||||
self.assertEqual(daten["gesamt"], 1)
|
||||
self.assertEqual(daten["sichtungen"][0]["art"]["slug"], "amsel")
|
||||
|
||||
def test_filter_nach_kamera(self):
|
||||
s = make_sighting(self.art)
|
||||
s.camera = self.kamera
|
||||
s.save()
|
||||
make_sighting(self.art)
|
||||
|
||||
self.assertEqual(
|
||||
self.json("/api/app/sichtungen/", kamera=self.kamera.slug)["gesamt"], 1
|
||||
)
|
||||
|
||||
def test_seiten(self):
|
||||
for _ in range(5):
|
||||
make_sighting(self.art)
|
||||
erste = self.json("/api/app/sichtungen/", anzahl=2)
|
||||
self.assertEqual(len(erste["sichtungen"]), 2)
|
||||
self.assertTrue(erste["weiter"])
|
||||
|
||||
letzte = self.json("/api/app/sichtungen/", anzahl=2, seite=3)
|
||||
self.assertEqual(len(letzte["sichtungen"]), 1)
|
||||
self.assertFalse(letzte["weiter"])
|
||||
|
||||
def test_anzahl_ist_gedeckelt(self):
|
||||
"""Ein Feed über Mobilfunk darf nicht alles auf einmal ziehen."""
|
||||
daten = self.json("/api/app/sichtungen/", anzahl=5000)
|
||||
self.assertEqual(daten["pro_seite"], 60)
|
||||
|
||||
def test_unsinnige_seite_fuehrt_nicht_zum_fehler(self):
|
||||
make_sighting(self.art)
|
||||
daten = self.json("/api/app/sichtungen/", seite="haha", anzahl="auch")
|
||||
self.assertEqual(daten["seite"], 1)
|
||||
self.assertEqual(daten["pro_seite"], 24)
|
||||
|
||||
def test_art_bringt_ihr_referenzbild_mit(self):
|
||||
"""Damit eine Sichtung ohne eigenes Foto nicht als Fragezeichen endet.
|
||||
|
||||
Gefunden am gerenderten Feed: Die Demo-Sichtungen hatten keine Bilder,
|
||||
und die App zeigte neben "Haussperling" ein Fragezeichen — obwohl sie
|
||||
den Vogel abbilden könnte.
|
||||
"""
|
||||
self.art.reference_image.name = "referenz/kohlmeise.jpg"
|
||||
self.art.save(update_fields=["reference_image"])
|
||||
make_sighting(self.art)
|
||||
|
||||
zeile = self.json("/api/app/sichtungen/")["sichtungen"][0]
|
||||
self.assertTrue(zeile["art"]["referenzbild"].startswith("http://"))
|
||||
|
||||
def test_bildadressen_sind_absolut(self):
|
||||
s = make_sighting(self.art)
|
||||
s.image = bild("ganz.png")
|
||||
s.crop = bild("aus.png")
|
||||
s.save()
|
||||
|
||||
zeile = self.json("/api/app/sichtungen/")["sichtungen"][0]
|
||||
self.assertTrue(zeile["foto"].startswith("http://"))
|
||||
self.assertTrue(zeile["ausschnitt"].startswith("http://"))
|
||||
|
||||
def test_bildadressen_folgen_dem_proxy_auf_https(self):
|
||||
"""🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst
|
||||
jede Bildadresse mit ``http://``.
|
||||
|
||||
Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist
|
||||
es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu
|
||||
folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das
|
||||
es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht
|
||||
im Test.
|
||||
"""
|
||||
s = make_sighting(self.art)
|
||||
s.image = bild("ganz.png")
|
||||
s.save()
|
||||
self.art.reference_image.name = "referenz/kohlmeise.jpg"
|
||||
self.art.save(update_fields=["reference_image"])
|
||||
|
||||
resp = self.client.get(
|
||||
"/api/app/sichtungen/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {LESEN}",
|
||||
# Genau der Header, den Traefik setzt.
|
||||
HTTP_X_FORWARDED_PROTO="https",
|
||||
secure=False,
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
zeile = json.loads(resp.content)["sichtungen"][0]
|
||||
self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"])
|
||||
self.assertTrue(
|
||||
zeile["art"]["referenzbild"].startswith("https://"),
|
||||
zeile["art"]["referenzbild"],
|
||||
)
|
||||
|
||||
def test_bildadressen_sind_absolut(self):
|
||||
s = make_sighting(self.art)
|
||||
s.image = bild("ganz.png")
|
||||
s.crop = bild("aus.png")
|
||||
s.save()
|
||||
|
||||
zeile = self.json("/api/app/sichtungen/")["sichtungen"][0]
|
||||
self.assertTrue(zeile["foto"].startswith("http://"))
|
||||
self.assertTrue(zeile["ausschnitt"].startswith("http://"))
|
||||
|
||||
def test_bildadressen_folgen_dem_proxy_auf_https(self):
|
||||
"""🔴 Hinter Traefik spricht niemand HTTPS mit uns — Django baut sonst
|
||||
jede Bildadresse mit ``http://``.
|
||||
|
||||
Im Browser fällt das nicht auf (Traefik leitet um). Auf dem Telefon ist
|
||||
es ein Ausfall: iOS blockt einfaches HTTP, ohne der Umleitung zu
|
||||
folgen, und das Album bleibt bilderlos — genau auf dem Gerät, für das
|
||||
es gebaut ist. Gefunden an den echten Antworten der Live-Instanz, nicht
|
||||
im Test.
|
||||
"""
|
||||
s = make_sighting(self.art)
|
||||
s.image = bild("ganz.png")
|
||||
s.save()
|
||||
self.art.reference_image.name = "referenz/kohlmeise.jpg"
|
||||
self.art.save(update_fields=["reference_image"])
|
||||
|
||||
resp = self.client.get(
|
||||
"/api/app/sichtungen/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {LESEN}",
|
||||
# Genau der Header, den Traefik setzt.
|
||||
HTTP_X_FORWARDED_PROTO="https",
|
||||
secure=False,
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
zeile = json.loads(resp.content)["sichtungen"][0]
|
||||
self.assertTrue(zeile["foto"].startswith("https://"), zeile["foto"])
|
||||
self.assertTrue(
|
||||
zeile["art"]["referenzbild"].startswith("https://"),
|
||||
zeile["art"]["referenzbild"],
|
||||
)
|
||||
|
||||
def test_abzeichenbilder_folgen_dem_proxy_auf_https(self):
|
||||
resp = self.client.get(
|
||||
"/api/app/abzeichen/",
|
||||
HTTP_AUTHORIZATION=f"Bearer {LESEN}",
|
||||
HTTP_X_FORWARDED_PROTO="https",
|
||||
)
|
||||
pins = [z["bild"] for z in json.loads(resp.content)["abzeichen"] if z["bild"]]
|
||||
self.assertTrue(pins)
|
||||
self.assertTrue(all(p.startswith("https://") for p in pins), pins[:2])
|
||||
|
||||
|
||||
class EinzelneSichtungTests(AppTestBasis):
|
||||
"""Der Endpunkt, mit dem die App nach einem Upload nachfragt."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.art = make_species()
|
||||
|
||||
def test_unbekannte_kennung_ist_404(self):
|
||||
resp = self.client.get(
|
||||
"/api/app/sichtungen/9999/", HTTP_AUTHORIZATION=f"Bearer {LESEN}"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_wartendes_foto_meldet_wartet(self):
|
||||
jetzt = timezone.now()
|
||||
s = Sighting.objects.create(
|
||||
started_at=jetzt, ended_at=jetzt, frames_total=1,
|
||||
wartet_auf_bestimmung=True,
|
||||
)
|
||||
daten = self.json(f"/api/app/sichtungen/{s.id}/")
|
||||
self.assertTrue(daten["wartet"])
|
||||
self.assertIsNone(daten["art"])
|
||||
|
||||
def test_foto_ohne_vogel_endet_die_warteschleife(self):
|
||||
"""⚠️ ``wartet: false`` mit ``art: null`` ist die Antwort, kein Fehler.
|
||||
|
||||
Ohne diesen Fall dreht die App für ein Foto vom eigenen Daumen endlos.
|
||||
"""
|
||||
jetzt = timezone.now()
|
||||
s = Sighting.objects.create(
|
||||
started_at=jetzt, ended_at=jetzt, frames_total=1,
|
||||
wartet_auf_bestimmung=False,
|
||||
)
|
||||
daten = self.json(f"/api/app/sichtungen/{s.id}/")
|
||||
self.assertFalse(daten["wartet"])
|
||||
self.assertIsNone(daten["art"])
|
||||
self.assertNotIn("album", daten)
|
||||
|
||||
def test_bestimmtes_foto_bringt_album_und_fortschritt(self):
|
||||
s = make_sighting(self.art)
|
||||
scoring.register(s)
|
||||
|
||||
daten = self.json(f"/api/app/sichtungen/{s.id}/")
|
||||
self.assertFalse(daten["wartet"])
|
||||
self.assertEqual(daten["art"]["slug"], "kohlmeise")
|
||||
self.assertTrue(daten["neue_art"])
|
||||
self.assertEqual(daten["punkte"], 30) # 10 XP x 3 beim Erstfang
|
||||
self.assertTrue(daten["album"]["gefunden"])
|
||||
self.assertEqual(daten["fortschritt"]["arten_gesammelt"], 1)
|
||||
|
||||
|
||||
class StartTests(AppTestBasis):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.art = make_species()
|
||||
self.kamera = make_camera(token="kamera-geheim", name="Futterhaus")
|
||||
|
||||
def test_leerer_stand_ist_kein_fehler(self):
|
||||
"""Bens Album ist am ersten Tag leer — und die App muss starten."""
|
||||
daten = self.json("/api/app/start/")
|
||||
self.assertIsNone(daten["letzte_sichtung"])
|
||||
self.assertEqual(daten["neueste_arten"], [])
|
||||
self.assertEqual(daten["fortschritt"]["punkte"], 0)
|
||||
self.assertEqual(daten["fortschritt"]["stufe"], 1)
|
||||
|
||||
def test_letzte_sichtung_ueberspringt_unbestimmtes(self):
|
||||
"""Eine Mondspiegelung ist kein Besuch und gehört nicht auf die Startseite."""
|
||||
jetzt = timezone.now()
|
||||
echt = make_sighting(self.art, when=jetzt - timedelta(hours=1))
|
||||
make_sighting(None, when=jetzt)
|
||||
|
||||
daten = self.json("/api/app/start/")
|
||||
self.assertEqual(daten["letzte_sichtung"]["id"], echt.id)
|
||||
|
||||
def test_wartende_fotos_werden_gezaehlt(self):
|
||||
jetzt = timezone.now()
|
||||
Sighting.objects.create(
|
||||
started_at=jetzt, ended_at=jetzt, frames_total=1,
|
||||
wartet_auf_bestimmung=True,
|
||||
)
|
||||
self.assertEqual(self.json("/api/app/start/")["wartende_fotos"], 1)
|
||||
|
||||
def test_kameras_ohne_geheimnisse(self):
|
||||
daten = self.json("/api/app/start/")
|
||||
kamera = daten["kameras"][0]
|
||||
self.assertEqual(kamera["name"], "Futterhaus")
|
||||
# ⚠️ Kein Token, kein Live-Bild, keine Geräteadresse — die App ist
|
||||
# kein Wartungswerkzeug.
|
||||
for verboten in ("token", "token_hash", "bild_url", "geraet_url",
|
||||
"stream_url", "live_url"):
|
||||
self.assertNotIn(verboten, kamera)
|
||||
|
||||
def test_neueste_arten_sind_die_letzten_erstfaenge(self):
|
||||
zweite = make_species(slug="amsel", german="Amsel", number=2)
|
||||
scoring.register(make_sighting(self.art))
|
||||
scoring.register(make_sighting(zweite))
|
||||
|
||||
daten = self.json("/api/app/start/")
|
||||
self.assertEqual(
|
||||
[a["slug"] for a in daten["neueste_arten"]], ["amsel", "kohlmeise"]
|
||||
)
|
||||
|
||||
|
||||
class AbzeichenTests(AppTestBasis):
|
||||
def test_verdient_und_offen(self):
|
||||
art = make_species()
|
||||
scoring.register(make_sighting(art))
|
||||
|
||||
daten = self.json("/api/app/abzeichen/")
|
||||
self.assertEqual(daten["gesamt"], Badge.objects.count())
|
||||
self.assertGreaterEqual(daten["verdient"], 1)
|
||||
|
||||
verdiente = [z for z in daten["abzeichen"] if z["verdient"]]
|
||||
self.assertTrue(all(z["am"] for z in verdiente))
|
||||
# 🔑 Verdiente Abzeichen haben keinen Balken — der stuende dauerhaft
|
||||
# auf 100 % und sagt nichts.
|
||||
self.assertTrue(all("anteil" not in z for z in verdiente))
|
||||
|
||||
def test_abzeichen_liefern_den_pin_nicht_nur_das_emoji(self):
|
||||
"""🔑 Die Website zeigt gezeichnete Emaille-Pins, die App muss dieselben
|
||||
zeigen.
|
||||
|
||||
Der Anlass ist derselbe, aus dem die Pins überhaupt entstanden sind:
|
||||
Auf einem System ohne Emoji-Schrift steht statt des Abzeichens ein
|
||||
leeres Rechteck. Beim Ansehen der gerenderten App stand genau das da,
|
||||
fünfzehnmal untereinander.
|
||||
"""
|
||||
daten = self.json("/api/app/abzeichen/")
|
||||
mit_pin = [z for z in daten["abzeichen"] if z["bild"]]
|
||||
self.assertTrue(mit_pin, "kein einziges Abzeichen mit Pin")
|
||||
self.assertTrue(mit_pin[0]["bild"].startswith("http://"))
|
||||
self.assertIn("/img/pins/", mit_pin[0]["bild"])
|
||||
# ⚠️ Das Emoji bleibt als Rückfall erhalten — ein später
|
||||
# hinzugefügtes Abzeichen hat noch keinen Pin.
|
||||
self.assertTrue(all(z["symbol"] for z in daten["abzeichen"]))
|
||||
|
||||
def test_abzeichen_ohne_pin_liefert_bild_none_statt_kaputter_adresse(self):
|
||||
from .models import Badge
|
||||
|
||||
Badge.objects.create(
|
||||
code="ganz-neu", name="Ganz neu", description="Kommt später.", emoji="🆕"
|
||||
)
|
||||
daten = self.json("/api/app/abzeichen/")
|
||||
neu = next(z for z in daten["abzeichen"] if z["code"] == "ganz-neu")
|
||||
self.assertIsNone(neu["bild"])
|
||||
self.assertEqual(neu["symbol"], "🆕")
|
||||
|
||||
def test_offene_zaehlbare_abzeichen_haben_einen_anteil(self):
|
||||
art = make_species()
|
||||
scoring.register(make_sighting(art))
|
||||
|
||||
daten = self.json("/api/app/abzeichen/")
|
||||
mit_balken = [
|
||||
z for z in daten["abzeichen"] if not z["verdient"] and "anteil" in z
|
||||
]
|
||||
self.assertTrue(mit_balken, "kein offenes Abzeichen mit Fortschritt")
|
||||
for z in mit_balken:
|
||||
self.assertLessEqual(z["anteil"], 1.0)
|
||||
self.assertLessEqual(z["ist"], z["ziel"])
|
||||
|
||||
|
||||
|
||||
|
||||
class VogelOhneArtTests(AppTestBasis):
|
||||
"""🔴 „Vogel ja, Art nein" darf nicht als „kein Vogel" ankommen.
|
||||
|
||||
Am 14.10.2026 erkannte der Detektor auf einem Handyfoto mit 93 % einen
|
||||
Vogel, konnte die Art aber nicht bestimmen (Gegenlicht, Silhouette). In
|
||||
der App stand „Kein Vogel erkannt. Auf dem Foto war nichts zu finden" —
|
||||
und daraufhin wurde zugeschnitten, gegen ein Problem, das nie der
|
||||
Zuschnitt war. Eine falsche Auskunft ist teurer als gar keine.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.kamera = make_camera(token="kamera-geheim", mobil=True)
|
||||
|
||||
def _wartendes_foto(self):
|
||||
jetzt = timezone.now()
|
||||
s = Sighting.objects.create(
|
||||
camera=self.kamera, started_at=jetzt, ended_at=jetzt,
|
||||
frames_total=1, wartet_auf_bestimmung=True,
|
||||
)
|
||||
return s
|
||||
|
||||
def melde(self, s, nutzlast):
|
||||
return self.client.post(
|
||||
f"/api/offen/{s.id}/",
|
||||
{"data": json.dumps(nutzlast)},
|
||||
HTTP_AUTHORIZATION="Bearer kamera-geheim",
|
||||
)
|
||||
|
||||
def test_vogel_ohne_art_behaelt_seinen_vogelwert(self):
|
||||
s = self._wartendes_foto()
|
||||
resp = self.melde(s, {"species": None, "confidence": 0.0,
|
||||
"mean_bird_score": 0.93})
|
||||
self.assertEqual(resp.status_code, 200, resp.content)
|
||||
|
||||
daten = self.json(f"/api/app/sichtungen/{s.id}/")
|
||||
self.assertFalse(daten["wartet"])
|
||||
self.assertIsNone(daten["art"])
|
||||
# Das ist der Unterschied, an dem die App ihren Text waehlt.
|
||||
self.assertAlmostEqual(daten["vogelwert"], 0.93, places=3)
|
||||
|
||||
def test_wirklich_kein_vogel_bleibt_bei_null(self):
|
||||
s = self._wartendes_foto()
|
||||
self.melde(s, {"species": None, "confidence": 0.0, "mean_bird_score": 0.0})
|
||||
|
||||
daten = self.json(f"/api/app/sichtungen/{s.id}/")
|
||||
self.assertIsNone(daten["art"])
|
||||
self.assertEqual(daten["vogelwert"], 0.0)
|
||||
|
||||
def test_vogel_ohne_art_zaehlt_als_bild_mit_vogel(self):
|
||||
"""``frames_with_bird`` hing an der ART statt am Vogel."""
|
||||
s = self._wartendes_foto()
|
||||
self.melde(s, {"species": None, "mean_bird_score": 0.93})
|
||||
s.refresh_from_db()
|
||||
self.assertEqual(s.frames_with_bird, 1)
|
||||
|
||||
def test_ohne_vogel_kein_bild_mit_vogel(self):
|
||||
s = self._wartendes_foto()
|
||||
self.melde(s, {"species": None, "mean_bird_score": 0.0})
|
||||
s.refresh_from_db()
|
||||
self.assertEqual(s.frames_with_bird, 0)
|
||||
Reference in new issue
Block a user