""" Propriétés du parseur de localisations. Ce module décide combien de lignes `band_locations` sont créées, donc combien d'appels Geoapify et Groq — facturés — seront déclenchés. Les tests par l'exemple ne couvrent que les formats qu'on a vus ; Hypothesis explore les autres, en particulier ce que Metal Archives contient réellement : parenthèses déséquilibrées, points-virgules en série, unicode, chaînes très longues. L'invariant qui compte : quelle que soit l'entrée, la fonction rend une liste de lignes bien formées ou une liste vide — jamais d'exception, jamais une clé manquante. Une exception ici interromprait l'enqueue de toute la file. max_examples est fixé à 150 : `npm run check` tourne des dizaines de fois par jour et ces tests en constituaient le chemin critique. 150 tirages ont suffi à trouver le contre-exemple « 0/0 » (villes dupliquées dans une même étape) ; au-delà, on paie sans rien apprendre de plus. Augmenter ponctuellement pour une chasse ciblée. """ from hypothesis import given, settings from hypothesis import strategies as st from src.parser import build_fallback_queries, country_centroid, parse_location_text # Caractères que MA utilise réellement comme séparateurs ou décorations. LOCATION_TEXT = st.text( alphabet=st.sampled_from( list("abcdefghijklmnopqrstuvwxyzÀÉÖøå ,;/()-.'0123456789") + ["\t", "\n"] ), max_size=120, ) COUNTRY = st.one_of( st.sampled_from(["FR", "NO", "DE", "SE", "GB", "ZZ", "fr", ""]), st.none(), st.text(max_size=5), ) class TestParseLocationTextTotalite: @settings(max_examples=150, deadline=None) @given(LOCATION_TEXT) def test_ne_leve_jamais_et_rend_toujours_une_liste(self, texte): assert isinstance(parse_location_text(texte), list) @settings(max_examples=150, deadline=None) @given(LOCATION_TEXT) def test_chaque_ligne_a_exactement_les_cles_attendues(self, texte): # db.py insère ces clés sans les vérifier : une clé manquante ferait # échouer l'enqueue au milieu du lot. for row in parse_location_text(texte): assert set(row) == {"step_order", "step_label", "location_raw", "is_country_only"} @settings(max_examples=150, deadline=None) @given(LOCATION_TEXT) def test_les_types_de_chaque_champ_sont_stables(self, texte): for row in parse_location_text(texte): assert isinstance(row["step_order"], int) assert isinstance(row["is_country_only"], bool) assert isinstance(row["location_raw"], str) assert row["step_label"] is None or isinstance(row["step_label"], str) @settings(max_examples=150, deadline=None) @given(LOCATION_TEXT) def test_aucun_lieu_vide_nest_produit(self, texte): # Un lieu vide partirait en géocodage et consommerait un appel facturé # pour rien. for row in parse_location_text(texte): assert row["location_raw"].strip() != "" @settings(max_examples=150, deadline=None) @given(LOCATION_TEXT) def test_les_step_order_sont_positifs_et_croissants(self, texte): orders = [r["step_order"] for r in parse_location_text(texte)] assert all(o >= 0 for o in orders) assert orders == sorted(orders) @settings(max_examples=150, deadline=None) @given(LOCATION_TEXT) def test_la_contrainte_dunicite_de_la_base_est_respectee(self, texte): # band_locations a UNIQUE (ma_id, step_order, location_raw) : deux lignes # identiques dans un même lot feraient échouer l'insertion. rows = parse_location_text(texte) cles = [(r["step_order"], r["location_raw"]) for r in rows] assert len(cles) == len(set(cles)) @settings(max_examples=150, deadline=None) @given(st.text(max_size=400)) def test_supporte_du_texte_totalement_arbitraire(self, texte): assert isinstance(parse_location_text(texte), list) @settings(max_examples=150, deadline=None) @given(LOCATION_TEXT, COUNTRY) def test_le_pays_du_groupe_ne_casse_rien(self, texte, pays): assert isinstance(parse_location_text(texte, pays), list) class TestBuildFallbackQueriesProprietes: @settings(max_examples=150, deadline=None) @given(st.text(min_size=1, max_size=100), COUNTRY) def test_ne_leve_jamais_et_rend_une_liste_de_chaines(self, brut, pays): out = build_fallback_queries(brut, pays) assert isinstance(out, list) assert all(isinstance(q, str) for q in out) @settings(max_examples=150, deadline=None) @given(st.text(min_size=1, max_size=100), COUNTRY) def test_aucun_doublon(self, brut, pays): # Chaque requête est un appel Geoapify facturé : un doublon, c'est de # l'argent jeté. out = build_fallback_queries(brut, pays) assert len(out) == len(set(out)) @settings(max_examples=150, deadline=None) @given(st.text(min_size=1, max_size=100), COUNTRY) def test_aucune_requete_vide(self, brut, pays): assert all(q.strip() != "" for q in build_fallback_queries(brut, pays)) @settings(max_examples=150, deadline=None) @given(st.text(min_size=1, max_size=60), COUNTRY) def test_le_nombre_de_requetes_reste_borne(self, brut, pays): # Le repli découpe sur les virgules : sans borne, une entrée pathologique # générerait des dizaines d'appels facturés pour un seul lieu. n_virgules = brut.count(",") assert len(build_fallback_queries(brut, pays)) <= 2 * (n_virgules + 2) + 2 @settings(max_examples=150, deadline=None) @given(st.text(alphabet=st.characters(blacklist_categories=("Cs",)), min_size=1, max_size=50)) def test_supporte_lunicode_arbitraire(self, brut): assert isinstance(build_fallback_queries(brut, "FR"), list) class TestCountryCentroidProprietes: @settings(max_examples=150, deadline=None) @given(st.text(max_size=10)) def test_rend_none_ou_des_coordonnees_valides(self, code): r = country_centroid(code) if r is not None: lat, lon = r assert -90 <= lat <= 90 assert -180 <= lon <= 180 @settings(max_examples=150, deadline=None) @given(st.sampled_from(["FR", "NO", "DE", "SE", "IT", "ES", "PL"])) def test_insensible_a_la_casse_et_aux_espaces(self, code): assert country_centroid(code) == country_centroid(f" {code.lower()} ")