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>
146 lines
6.3 KiB
Python
146 lines
6.3 KiB
Python
"""
|
|
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()} ")
|