Files
vogelalbum/web/dex/test_app_api.py
T
christian c11fe9ab04 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.
2026-10-15 14:30:02 +00:00

645 lines
25 KiB
Python

"""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)