metalfrom.eu/apps/geocoder/tests/test_parser.py
Nicolas Fryder 83ea8030e7
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
test: propriétés généralisées, scraper HTML et couche réseau couverts
BUG trouvé par un test de propriété
parse_location_text("0/0") produisait deux lignes identiques. band_locations
impose UNIQUE (ma_id, step_order, location_raw) : le doublon était absorbé par
le ON CONFLICT DO NOTHING de l'enqueue, mais il faussait les compteurs et n'a
aucun sens métier — une étape ne se déroule pas deux fois au même endroit.
Déduplication par dict.fromkeys (ordre préservé) + tests de régression.
Contre-exemple minimal trouvé en 150 tirages, aucun test par l'exemple ne
l'aurait deviné.

SCRAPER HTML (42 tests) — module le plus exposé du crawler, aucun test
jusqu'ici. C'est lui qui extrait genre, statut, thèmes, line-up et dates. Si
Metal Archives change son HTML, il renvoie des champs vides et le crawler
enregistre des fiches creuses SANS lever d'erreur : la panne invisible, donc
la plus coûteuse.
Couvre les trois orthographes de « Lyrical themes » observées chez MA, les
variantes de « label » et « formed in », le regroupement du line-up par
section, l'audit trail, et surtout la dégradation : une page vide ou
malformée ne doit jamais lever et doit rendre une structure complète.

COUCHE RÉSEAU (57 tests) — ma_http et flaresolverr, sans une seule requête
réelle. Ce qui est testé, c'est la logique AUTOUR du réseau : quand réessayer,
quand abandonner, comment extraire les données d'une réponse enveloppée par
FlareSolverr.
Enjeu concret : ces bornes conditionnent le nombre de requêtes envoyées à
Metal Archives — une boucle de retry mal bornée nous ferait bannir. Vérifié
que retries=2 donne exactement 3 tentatives, qu'un 500 n'est PAS réessayé
(contrairement à 403/429/503, qui justifient une session Chrome neuve), et
que les appels de préchauffage Cloudflare sont comptés à part.

PROPRIÉTÉS GÉNÉRALISÉES
- Python (Hypothesis) : parseur de localisations, requêtes de repli,
  centroïdes. Invariants — jamais d'exception, structure toujours complète,
  aucun lieu vide, aucun doublon, nombre de requêtes facturées borné.
- Frontend (fast-check) : échappement HTML (une faille XSS, pas un défaut
  cosmétique — tout le rendu passe par innerHTML), filtrage tri-état, tri
  sans effet de bord ni perte d'éléments, clés de coordonnées.

max_examples fixé à 150 côté Python : ces tests étaient devenus le chemin
critique de `check` (11 s). 150 tirages suffisaient à trouver « 0/0 ».

Tests : 621 JS + 61 intégration, 162 Python, 89 Playwright. check à 9 s.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 22:09:37 +02:00

141 lines
5.5 KiB
Python

"""
Tests du parseur de localisations Metal Archives.
Aucun accès réseau ni base : `parser.py` n'a que des fonctions pures, ce qui en
fait le module le plus rentable à couvrir de tout le pipeline de géocodage —
c'est lui qui décide combien de lignes `band_locations` sont créées, et donc
combien d'appels Geoapify/Groq (payants) seront déclenchés.
"""
import pytest
from src.parser import (
_classify,
build_fallback_queries,
country_centroid,
parse_location_text,
)
class TestClassify:
@pytest.mark.parametrize("value", ["N/A", "n/a", "", " "])
def test_valeurs_vides_ignorees(self, value):
assert _classify(value) == "skip"
@pytest.mark.parametrize("value", ["FR", "DE", "NO"])
def test_codes_iso2(self, value):
assert _classify(value) == "country_code"
def test_ville_normale(self):
assert _classify("Oslo") == "parseable"
class TestParseLocationText:
def test_texte_vide_ne_produit_aucune_ligne(self):
assert parse_location_text("") == []
assert parse_location_text("N/A") == []
def test_ville_simple(self):
rows = parse_location_text("Oslo")
assert len(rows) == 1
assert rows[0]["location_raw"] == "Oslo"
assert rows[0]["is_country_only"] is False
assert rows[0]["step_order"] == 0
def test_ville_avec_region(self):
rows = parse_location_text("Bergen, Hordaland")
assert [r["location_raw"] for r in rows] == ["Bergen, Hordaland"]
def test_code_pays_seul_marque_country_only(self):
rows = parse_location_text("FR")
assert len(rows) == 1
assert rows[0]["is_country_only"] is True
assert rows[0]["location_raw"] == "FR"
def test_code_pays_minuscule_non_reconnu(self):
"""Comportement actuel documenté, pas une validation.
`_ISO2_RE` vaut `^[A-Z]{2}$` : un code pays en minuscules n'est PAS
reconnu comme pays et repart en géocodage comme s'il s'agissait d'une
ville, ce qui consomme un appel Geoapify pour un résultat au mieux
douteux. Metal Archives renvoie les codes en majuscules, donc le cas ne
se produit pas en pratique aujourd'hui — mais rien ne le garantit.
Si `_ISO2_RE` devient insensible à la casse, ce test doit être inversé.
"""
rows = parse_location_text("fr")
assert rows[0]["is_country_only"] is False
# Régression : "Oslo/Oslo" produisait deux lignes identiques, que la
# contrainte UNIQUE de band_locations aurait rejetées. Contre-exemple
# trouvé par test_parser_property.py.
def test_une_ville_repetee_dans_une_etape_ne_donne_quune_ligne(self):
rows = parse_location_text("Oslo/Oslo")
assert len(rows) == 1
assert rows[0]["location_raw"] == "Oslo"
def test_lordre_dapparition_est_preserve_apres_deduplication(self):
rows = parse_location_text("Bergen/Oslo/Bergen")
assert [r["location_raw"] for r in rows] == ["Bergen", "Oslo"]
def test_plusieurs_villes_separees(self):
"""Une même étape peut lister plusieurs villes : chacune devient une ligne."""
rows = parse_location_text("Oslo/Bergen")
assert len(rows) >= 2
assert {r["location_raw"] for r in rows} >= {"Oslo", "Bergen"}
# Même étape → même step_order
assert len({r["step_order"] for r in rows}) == 1
def test_etapes_successives_incrementent_step_order(self):
rows = parse_location_text("Oslo (early), Bergen (later)")
orders = [r["step_order"] for r in rows]
assert orders == sorted(orders)
def test_les_valeurs_ignorables_ne_creent_pas_de_ligne(self):
rows = parse_location_text("N/A")
assert rows == []
def test_resultat_toujours_serialisable(self):
"""Chaque ligne doit exposer exactement les clés attendues par db.py."""
for row in parse_location_text("Trondheim (1991-1993), Oslo"):
assert set(row) == {"step_order", "step_label", "location_raw", "is_country_only"}
assert isinstance(row["step_order"], int)
assert isinstance(row["is_country_only"], bool)
class TestCountryCentroid:
def test_code_iso2_connu(self):
lat, lon = country_centroid("FR")
assert 41 < lat < 52
assert -6 < lon < 10
def test_insensible_a_la_casse_et_aux_espaces(self):
assert country_centroid(" fr ") == country_centroid("FR")
def test_code_inconnu_renvoie_none(self):
assert country_centroid("ZZ") is None
class TestBuildFallbackQueries:
def test_du_plus_specifique_au_plus_vague(self):
queries = build_fallback_queries("Bergen, Hordaland", "NO")
assert queries[0].startswith("Bergen, Hordaland")
assert queries[-1] == "Bergen"
def test_aucun_doublon(self):
queries = build_fallback_queries("Oslo", "NO")
assert len(queries) == len(set(queries))
def test_le_pays_est_injecte_comme_contexte(self):
queries = build_fallback_queries("Bergen", "NO")
assert any("Norway" in q for q in queries)
def test_pays_deja_present_non_duplique(self):
queries = build_fallback_queries("Bergen, Norway", "NO")
assert not any(q.count("Norway") > 1 for q in queries)
def test_pays_inconnu_ne_plante_pas(self):
assert build_fallback_queries("Bergen", "ZZ") == ["Bergen"]
assert build_fallback_queries("Bergen", None) == ["Bergen"]
def test_toujours_au_moins_une_requete(self):
for raw in ["Oslo", "A, B, C, D", "Saint-Étienne"]:
assert len(build_fallback_queries(raw, "FR")) >= 1