iPhones fotografieren HEIC; ohne pillow-heif wies die Bildpruefung jedes Foto ab. Dazu: ein erkannter Vogel ohne bestimmbare Art wird nicht mehr als "kein Vogel" gemeldet, und der Ausschnitt fuer die Sammlung ist quadratisch.
645 lines
25 KiB
Python
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 Christian hat daraufhin 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)
|