Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: tests/test_expansion.py4# Desc: Phase 3 (expansion des sources) — tests des nouveaux connecteurs sur5# fixtures réelles + annuaire de salles + plages de dates françaises.6# Aucun appel réseau (CLAUDE.md §18).7# ==============================================================================8from __future__ import annotations910import json11from pathlib import Path1213import pytest1415from sortika.connectors import CONNECTORS16from sortika.normalize import parse_date_range_fr1718FIXTURES = Path(__file__).parent / "fixtures"192021def _no_cache(monkeypatch, connector):22 """Cache disque neutralisé : ni lecture ni écriture pendant les tests."""23 monkeypatch.setattr(connector, "load_cache", lambda: {})24 monkeypatch.setattr(connector, "save_cache", lambda cache: None)252627def test_expansion_registry_autodiscovers():28 for sid in ("lavalweb", "outaouais", "lanaudiere", "saguenaysorties",29 "bassaintlaurent", "quebecanimee", "placedesarts",30 "lhjmq", "sallesjsonld"):31 assert sid in CONNECTORS, sid323334def test_parse_date_range_fr():35 assert parse_date_range_fr("Du 11 au 13 septembre 2026") == \36 ("2026-09-11", "2026-09-13")37 assert parse_date_range_fr("Du 4 juin au 22 août 2026") == \38 ("2026-06-04", "2026-08-22")39 assert parse_date_range_fr("Du 27 août 2026 au 3 janvier 2027") == \40 ("2026-08-27", "2027-01-03")41 assert parse_date_range_fr("17 août 2026") == ("2026-08-17", "2026-08-17")42 assert parse_date_range_fr("n'importe quoi") == (None, None)43 assert parse_date_range_fr(None) == (None, None)444546def test_lavalweb_parses_fixture(monkeypatch):47 from sortika.connectors.lavalweb import LavalWebConnector48 c = LavalWebConnector()49 _no_cache(monkeypatch, c)50 html = (FIXTURES / "lavalweb_page.html").read_text(encoding="utf-8")51 pages = [html, "<html></html>"] # page 2 vide → arrêt pagination52 monkeypatch.setattr(c, "get",53 lambda url: type("R", (), {"text": pages.pop(0)})())54 monkeypatch.setattr(c, "_enrich_fiches", lambda rows: None)55 events = [e.finalize() for e in c.fetch()]56 assert len(events) == 357 ev = events[0]58 assert ev.source == "lavalweb"59 assert ev.city == "Laval" and ev.region == "Laval"60 assert ev.start_date and ev.start_date.startswith("20")61 assert ev.url.startswith("https://www.laval.ca/calendrier-activites/")62 # une carte-séance porte la date ET l'heure (« 18 août 2026 | 18 h »)63 seance = [e for e in events if e.start_time]64 assert seance and seance[0].venue656667def test_outaouais_parses_fixture(monkeypatch):68 from sortika.connectors.outaouais import OutaouaisConnector69 c = OutaouaisConnector()70 html = (FIXTURES / "outaouais_page.html").read_text(encoding="utf-8")71 monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())72 events = [e.finalize() for e in c.fetch()]73 assert len(events) == 374 ev = events[0]75 assert ev.source == "outaouais"76 assert ev.region == "Outaouais" # via tourist_region77 assert ev.start_date and ev.end_date78 assert ev.image.startswith("https://admin.tourismeoutaouais.com/")798081def test_lanaudiere_parses_fixture(monkeypatch):82 from sortika.connectors.lanaudiere import LanaudiereConnector83 c = LanaudiereConnector()84 _no_cache(monkeypatch, c)85 sitemap = (FIXTURES / "lanaudiere_sitemap.xml").read_text(encoding="utf-8")86 fiche = (FIXTURES / "lanaudiere_fiche.html").read_text(encoding="utf-8")87 responses = {True: sitemap}88 monkeypatch.setattr(89 c, "get", lambda url: type("R", (), {90 "text": sitemap if url.endswith(".xml") else fiche})())91 events = [e.finalize() for e in c.fetch()]92 # 2 URLs françaises dans le sitemap (l'anglaise est exclue), même fiche93 # → dédupliquées par external_id distinct des slugs94 assert 1 <= len(events) <= 295 ev = events[0]96 assert ev.source == "lanaudiere"97 assert ev.title == "Festival Mémoire et Racines"98 assert ev.city == "Saint-Charles-Borromée"99 assert ev.region == "Lanaudière"100 assert (ev.start_date, ev.end_date) == ("2026-07-17", "2026-07-26")101 assert "tourinsoft" in ev.image102103104def test_saguenaysorties_parses_fixture(monkeypatch):105 from sortika.connectors.saguenaysorties import SaguenaySortiesConnector106 c = SaguenaySortiesConnector()107 _no_cache(monkeypatch, c)108 liste = (FIXTURES / "saguenaysorties_liste.html").read_text(encoding="utf-8")109 detail = (FIXTURES / "saguenaysorties_detail.html").read_text(encoding="utf-8")110 monkeypatch.setattr(111 c, "_post", lambda url, data: detail if url.endswith("details")112 else (liste if data["offset"] == "0" else ""))113 events = [e.finalize() for e in c.fetch()]114 assert len(events) == 2115 ev = events[0]116 assert ev.source == "saguenaysorties"117 assert ev.city and ev.venue118 assert ev.region == "Saguenay–Lac-Saint-Jean"119 assert ev.start_date == "2026-08-20" and ev.start_time == "08:30"120 assert ev.url.startswith("https://sorties.saguenay.ca/festivals?id=")121 assert "archéologues" in ev.description # détail ajax branché122123124def test_bassaintlaurent_parses_fixture(monkeypatch):125 from sortika.connectors.bassaintlaurent import BasSaintLaurentConnector126 c = BasSaintLaurentConnector()127 _no_cache(monkeypatch, c)128 liste = (FIXTURES / "bassaintlaurent_liste.html").read_text(encoding="utf-8")129 fiche = (FIXTURES / "bassaintlaurent_fiche.html").read_text(encoding="utf-8")130 monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})())131 monkeypatch.setattr(c, "fetch_many",132 lambda urls, worker, max_workers=3:133 {u: c._parse_fiche(fiche) for u in urls})134 events = [e.finalize() for e in c.fetch()]135 assert len(events) == 2136 ev = events[0]137 assert ev.source == "bassaintlaurent"138 assert ev.region == "Bas-Saint-Laurent"139 assert ev.start_date and ev.end_date and ev.city140 assert ev.lat and 44.0 < ev.lat < 63.0 # GPS exact de la fiche141142143def test_quebecanimee_parses_fixture(monkeypatch):144 from sortika.connectors.quebecanimee import QuebecAnimeeConnector145 c = QuebecAnimeeConnector()146 _no_cache(monkeypatch, c)147 html = (FIXTURES / "quebecanimee_liste.html").read_text(encoding="utf-8")148 monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": html})())149 monkeypatch.setattr(c, "_enrich_fiches", lambda rows: None)150 events = [e.finalize() for e in c.fetch()]151 assert len(events) == 3152 ev = events[0]153 assert ev.source == "quebecanimee"154 assert ev.city == "Québec" and ev.region == "Capitale-Nationale"155 assert ev.start_date and ev.start_date.startswith("2026")156 # data-prix « Gratuit » → gratuité réelle, jamais inventée157 assert any(e.is_free for e in events)158159160def test_placedesarts_parses_fixture(monkeypatch):161 from sortika.connectors.placedesarts import PlaceDesArtsConnector162 c = PlaceDesArtsConnector()163 _no_cache(monkeypatch, c)164 fiche = (FIXTURES / "placedesarts_fiche.html").read_text(encoding="utf-8")165 liste = '<a href="/evenement/24-poses-portraits">x</a>'166 monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})())167 monkeypatch.setattr(c, "fetch_many",168 lambda paths, worker, max_workers=4:169 {p: c._parse_fiche(fiche) for p in paths})170 events = [e.finalize() for e in c.fetch()]171 assert len(events) >= 3 # une fiche = N séances172 ev = events[0]173 assert ev.source == "placedesarts"174 assert ev.venue == "Théâtre Jean-Duceppe"175 assert ev.city == "Montréal" and ev.organizer == "Duceppe"176 assert ev.start_time and len(ev.start_time) == 5177 # prix réels de la barre latérale (« 33 $ à 97,50 $ »)178 assert ev.price_min == 33.0 and ev.is_free is False179 # deux séances distinctes = deux dedup_key distincts (heure incluse)180 assert events[0].dedup_key() != events[1].dedup_key()181182183def test_lhjmq_parses_fixture(monkeypatch):184 from sortika.connectors.lhjmq import LhjmqConnector185 c = LhjmqConnector()186 seasons = json.loads((FIXTURES / "lhjmq_seasons.json").read_text(encoding="utf-8"))187 sched = json.loads((FIXTURES / "lhjmq_schedule.json").read_text(encoding="utf-8"))188 monkeypatch.setattr(c, "get_json",189 lambda url: seasons if "view=seasons" in url else sched)190 monkeypatch.setattr(c, "_current_seasons", lambda: ["214"])191 events = [e.finalize() for e in c.fetch()]192 # seuls les matchs à domicile AU QUÉBEC sont publiés (Terre-Neuve exclue)193 assert events and all(e.city != "Terre-Neuve" for e in events)194 ev = events[0]195 assert ev.source == "lhjmq"196 assert ev.title.startswith("LHJMQ : ")197 assert ev.venue and ev.start_date and ev.start_time198 assert "sport" in ev.categories199 assert ev.url.startswith("https://theqmjhl.ca/gamecentre/")200201202def test_sallesjsonld_parses_fixture(monkeypatch):203 from sortika.connectors.sallesjsonld import SallesJsonLdConnector204 c = SallesJsonLdConnector()205 _no_cache(monkeypatch, c)206 liste = (FIXTURES / "sallesjsonld_liste.html").read_text(encoding="utf-8")207 fiche = (FIXTURES / "sallesjsonld_fiche.html").read_text(encoding="utf-8")208 salle = {"id": "grandtheatre", "nom": "Grand Théâtre de Québec",209 "listes": ["https://grandtheatre.qc.ca/programmation"],210 "motif": r"grandtheatre\.qc\.ca/programmation/[a-z0-9-]{3,}",211 "salle": "Grand Théâtre de Québec", "ville": "Québec"}212 monkeypatch.setattr(c, "_load_config", lambda: [salle])213 monkeypatch.setattr(c, "get", lambda url: type("R", (), {"text": liste})())214 monkeypatch.setattr(c, "fetch_many",215 lambda urls, worker, max_workers=3:216 {u: c._fiche_events(salle, u, fiche) for u in urls})217 events = [e.finalize() for e in c.fetch()]218 assert len(events) == 1219 ev = events[0]220 assert ev.source == "sallesjsonld"221 assert ev.venue and ev.city == "Québec"222 # le Grand Théâtre publie ses horodatages en UTC (+00:00) → convertis223 # en heure locale de Montréal (18:00 UTC = 14:00 EDT)224 assert ev.start_date == "2027-04-18"225 assert ev.start_time == "14:00"226227228def test_venues_directory(tmp_path):229 """Annuaire de salles : apprentissage, matching prudent, application."""230 from sortika import db, venues231 con = db.connect(tmp_path / "t.db")232 venues.ensure_schema(con)233 now = 0234 # 2 événements géocodés par la source (même salle) + 2 sans GPS235 rows = [236 ("a:1", "a", "1", "MTelus", "Montréal", 45.51, -73.56),237 ("a:2", "a", "2", "MTelus", "Montréal", 45.51, -73.56),238 ("b:1", "b", "1", "MTelus", "Montréal", None, None),239 # nom composé : le segment le plus long doit matcher l'annuaire240 ("b:2", "b", "2", "Théâtre Desjardins | MTelus", "Montréal", None, None),241 # ville en conflit : ne doit JAMAIS matcher242 ("b:3", "b", "3", "MTelus", "Québec", None, None),243 ]244 for uid, src, ext, venue, city, lat, lng in rows:245 con.execute(246 "INSERT INTO events (uid, source, external_id, title, venue, city,"247 " lat, lng, active) VALUES (?,?,?,?,?,?,?,?,1)",248 (uid, src, ext, "T", venue, city, lat, lng))249 con.commit()250 assert venues.learn(con) >= 1251 stats = venues.apply(con)252 assert stats["events_filled"] == 2253 got = con.execute("SELECT lat FROM events WHERE uid='b:1'").fetchone()254 assert got["lat"] == 45.51255 conflict = con.execute("SELECT lat FROM events WHERE uid='b:3'").fetchone()256 assert conflict["lat"] is None # jamais de GPS inventé257 # alias : nom seul matché uniquement si UNIQUE dans l'annuaire258 con.execute("INSERT INTO venues (name, name_key, city, city_key, lat, lng,"259 " origin) VALUES ('MTelus','mtelus','Trois-Rivières',"260 "'trois rivieres', 46.34, -72.54, 'osm')")261 con.commit()262 assert venues._match_row(con, "MTelus", "Laval") is None # ambigu → rien263264265def test_venues_aliases():266 from sortika.venues import name_aliases267 assert name_aliases("Salle Wilfrid-Pelletier, Place des Arts") == [268 "salle wilfrid pelletier place des arts",269 "salle wilfrid pelletier", "place des arts"]270 assert name_aliases("") == []271