build_fallback_queries cherchait le code pays comme SOUS-CHAÎNE du lieu : country_code.upper() in location_raw.upper() Or "DE" est contenu dans "DRESDEN", "FR" dans "FRESNES", "NO" dans "NOTODDEN", "ES" dans "TORRES". Le pays était donc considéré comme déjà présent, et la variante « ville, pays » se retrouvait reléguée APRÈS la ville nue. Le worker s'arrête au PREMIER résultat fiable : il interrogeait donc Geoapify sans aucun contexte pays, précisément sur les noms ambigus — il existe un Dresden dans l'Ohio. Sur un échantillon de 25 villes européennes réelles, 11 partaient sans désambiguïsation. Le code est désormais cherché comme mot entier. Le test existant n'attrapait rien parce qu'il vérifiait la PRÉSENCE du pays quelque part dans la liste (`any(...)`), jamais sa priorité : les nouveaux cas verrouillent l'ordre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
6.7 KiB
Python
165 lines
6.7 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)
|
|
|
|
@pytest.mark.parametrize(
|
|
("ville", "code", "pays"),
|
|
[
|
|
("Dresden", "DE", "Germany"), # "DE" est une sous-chaîne de "DRESDEN"
|
|
("Dessau", "DE", "Germany"),
|
|
("Fresnes", "FR", "France"),
|
|
("Notodden", "NO", "Norway"),
|
|
("Torres", "ES", "Spain"),
|
|
],
|
|
)
|
|
def test_le_contexte_pays_passe_en_premier(self, ville, code, pays):
|
|
# Le worker s'arrête au PREMIER résultat fiable : l'ordre est la seule
|
|
# chose qui compte. Un code pays contenu par hasard dans le nom de la
|
|
# ville faisait passer la requête nue devant, supprimant toute
|
|
# désambiguïsation là où elle était le plus nécessaire.
|
|
assert build_fallback_queries(ville, code)[0] == f"{ville}, {pays}"
|
|
|
|
def test_le_code_pays_reellement_present_reste_prioritaire(self):
|
|
# Contrepartie : quand le code EST un mot du texte, le texte d'origine
|
|
# passe en premier — on ne le double pas d'un contexte redondant. La
|
|
# forme « ville, pays » reste en dernier recours, ce qui est voulu.
|
|
queries = build_fallback_queries("Berlin, DE", "DE")
|
|
assert queries[0] == "Berlin, DE"
|
|
|
|
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
|