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_phase4.py4# Desc: Phase 4 (régions manquantes + billetteries) — tests des nouveaux5# connecteurs sur fixtures réelles figées. Aucun appel réseau6# (CLAUDE.md §18).7# ==============================================================================8from __future__ import annotations910import json11from pathlib import Path1213from sortika.connectors import CONNECTORS1415FIXTURES = Path(__file__).parent / "fixtures"161718def _no_cache(monkeypatch, connector):19 """Cache disque neutralisé : ni lecture ni écriture pendant les tests."""20 monkeypatch.setattr(connector, "load_cache", lambda: {})21 monkeypatch.setattr(connector, "save_cache", lambda cache: None)222324class _Resp:25 def __init__(self, text):26 self.text = text272829def test_phase4_registry_autodiscovers():30 for sid in ("monteregie", "centreduquebec", "chaudiereappalaches",31 "gaspesie"):32 assert sid in CONNECTORS, sid333435def test_monteregie_parses_fixture(monkeypatch):36 from sortika.connectors.monteregie import MonteregieConnector37 c = MonteregieConnector()38 _no_cache(monkeypatch, c)39 rows = json.loads(40 (FIXTURES / "monteregie_liste.json").read_text(encoding="utf-8"))41 fiche = (FIXTURES / "monteregie_fiche.html").read_text(encoding="utf-8")42 monkeypatch.setattr(c, "_list_events", lambda: rows)43 monkeypatch.setattr(c, "get", lambda url, **kw: _Resp(fiche))44 events = [e.finalize() for e in c.fetch()]45 assert len(events) == 346 ev = next(e for e in events if "PLAN D" in e.title)47 # plage FR de l'ACF → ISO ; GPS de l'ACF ; région via l'ATR48 assert (ev.start_date, ev.end_date) == ("2026-09-18", "2026-09-20")49 assert ev.lat and ev.lng and ev.region == "Montérégie"50 # fiche JSON-LD Event → adresse civique + ville (Châteauguay)51 assert ev.city == "Châteauguay" and ev.address == "15 Boulevard Maple"52 assert ev.postal_code == "J6J 3P7"53 assert ev.website.startswith("http") and ev.image.startswith("http")54 assert ev.description # content.rendered nettoyé555657def test_centreduquebec_parses_fixture(monkeypatch):58 from sortika.connectors.centreduquebec import CentreDuQuebecConnector59 c = CentreDuQuebecConnector()60 _no_cache(monkeypatch, c)61 liste = (FIXTURES / "centreduquebec_liste.html").read_text(encoding="utf-8")62 fiche = (FIXTURES / "centreduquebec_fiche.html").read_text(encoding="utf-8")63 monkeypatch.setattr(64 c, "get",65 lambda url, **kw: _Resp(liste if url.rstrip("/").endswith("evenements")66 else fiche))67 events = [e.finalize() for e in c.fetch()]68 assert len(events) >= 8 # ~10 fiches sur la liste réelle69 ev = next(e for e in events if "Inverness" in e.title)70 assert (ev.start_date, ev.end_date) == ("2026-09-01", "2026-09-06")71 assert ev.region == "Centre-du-Québec" # dérivée de l'ATR72 assert ev.url.startswith("https://tourismecentreduquebec.com/evenements/")73 # fiche : bloc « Adresse » + og:description/og:image74 assert any(e.address for e in events)75 assert any(e.image for e in events)767778def test_chaudiereappalaches_parses_fixture(monkeypatch):79 from sortika.connectors.chaudiereappalaches import (80 ChaudiereAppalachesConnector)81 c = ChaudiereAppalachesConnector()82 _no_cache(monkeypatch, c)83 liste = (FIXTURES / "chaudiereappalaches_liste.html").read_text(84 encoding="utf-8")85 fiche = (FIXTURES / "chaudiereappalaches_fiche.html").read_text(86 encoding="utf-8")87 monkeypatch.setattr(88 c, "get",89 lambda url, **kw: _Resp(fiche if "-fr-" in url else liste))90 events = [e.finalize() for e in c.fetch()]91 # 12 fiches listées → le budget FICHE_MAX en visite 12, toutes parsées92 # sur la même fixture de fiche (dates ISO du JSON-LD Event)93 assert events94 ev = events[0]95 assert "Accordéon" in ev.title96 assert (ev.start_date, ev.end_date) == ("2026-09-04", "2026-09-06")97 assert ev.city == "Montmagny" and ev.region == "Chaudière-Appalaches"98 assert ev.address == "301, Boulevard Taché Est"99 assert ev.lat and ev.lng # GeoCoordinates du JSON-LD100 assert ev.venue == "" # « Adresse » = libellé CMS101102103def test_gaspesie_parses_fixture(monkeypatch):104 from sortika.connectors.gaspesie import GaspesieConnector105 c = GaspesieConnector()106 _no_cache(monkeypatch, c)107 liste = (FIXTURES / "gaspesie_liste.html").read_text(encoding="utf-8")108 fiche = (FIXTURES / "gaspesie_fiche.html").read_text(encoding="utf-8")109 monkeypatch.setattr(110 c, "_get_waf",111 lambda url: liste if url.rstrip("/").endswith("festival-et-evenements")112 else fiche)113 events = [e.finalize() for e in c.fetch()]114 assert events # fiches sans date écartées115 ev = events[0]116 assert ev.title == "Festival BleuBleu"117 # « 18 au 21 juin 2026 » (sans « du ») → plage complète, jamais inventée118 assert (ev.start_date, ev.end_date) == ("2026-06-18", "2026-06-21")119 assert ev.city == "Carleton-sur-Mer"120 assert ev.region == "Gaspésie–Îles-de-la-Madeleine"121 assert ev.description and ev.image122123124def test_cantonsdelest_paginates_and_parses_fixtures(monkeypatch):125 """Pagination AJAX Yii (POST /event/more) + cartes + fiche JSON-LD.126127 La page 1 réelle ne rend AUCUNE carte /evenements/ (que des articles) :128 tout le calendrier (~596 occurrences) vient de /event/more — le connecteur129 doit poster offset=2, 3, … avec le jeton CSRF et s'arrêter dès qu'une page130 ne donne plus de nouvelle carte.131 """132 from sortika.connectors.cantonsdelest import CantonsDeLEstConnector133 c = CantonsDeLEstConnector()134 _no_cache(monkeypatch, c)135 liste = (FIXTURES / "cantonsdelest_liste.html").read_text(encoding="utf-8")136 more = (FIXTURES / "cantonsdelest_more.html").read_text(encoding="utf-8")137 fiche = (FIXTURES / "cantonsdelest_fiche.html").read_text(encoding="utf-8")138 monkeypatch.setattr(c, "get", lambda url, **kw: _Resp(liste))139 monkeypatch.setattr( # fiches : aucun appel réseau140 c, "fetch_many",141 lambda paths, worker, max_workers=4:142 {p: c._parse_fiche(p, fiche) for p in paths})143 posts: list[dict] = []144145 def fake_post(data):146 posts.append(data)147 return more # même page → offset 3 répète148 monkeypatch.setattr(c, "_post_more", fake_post)149 events = [e.finalize() for e in c.fetch()]150 # offset=2 apporte 6 cartes, offset=3 ne donne rien de neuf → arrêt151 assert [d["offset"] for d in posts] == [2, 3]152 assert all(d["_csrf"] and d["ids"].startswith("EventScheduleView-")153 for d in posts) # jeton CSRF + ids de la page 1154 assert len(events) == 6155 ev = next(e for e in events if "Boulevard des Arts 6e" in e.title)156 assert ev.external_id == "10114-4491" # id événement + id occurrence157 assert ev.region == "Estrie" # ATR Cantons-de-l'Est → Estrie158 assert ev.city == "Frelighsburg" # ville de la carte conservée159 assert "Arts visuels" in " ".join(ev.raw_categories)160 assert ev.image.startswith("https://lvc-medias.lavitrine.com/")161 # fiche JSON-LD (même fixture pour toutes) : dates précises, adresse, GPS162 assert (ev.start_date, ev.end_date) == ("2026-08-28", "2026-09-07")163 assert ev.address == "57, rue Principale Nord"164 assert ev.postal_code == "J0E 2K0"165 assert ev.lat and ev.lng # data-lat/data-lng de la carte166167168def test_cantonsdelest_abbreviated_months():169 """« 21 janv. 2027 » / « 5 févr. 2027 » (45/596 cartes live 2026-08-25) :170 abréviations du gabarit déployées avant le parseur central de dates."""171 from sortika.connectors.cantonsdelest import _dates_fr172 assert _dates_fr("21 janv. 2027") == ("2027-01-21", "2027-01-21")173 assert _dates_fr("5 févr. 2027") == ("2027-02-05", "2027-02-05")174 assert _dates_fr("29 août 2026 ~ 13 sept. 2026") == \175 ("2026-08-29", "2026-09-13")176177178# --- billetteries (Phase 4) --------------------------------------------------179180def test_billetteries_registry_autodiscovers():181 for sid in ("ticketacces", "ovation", "tuxedobillet", "ticketpro"):182 assert sid in CONNECTORS, sid183184185def test_ticketacces_parses_fixtures(monkeypatch):186 from sortika.connectors.ticketacces import TicketAccesConnector187 c = TicketAccesConnector()188 _no_cache(monkeypatch, c)189 portail = (FIXTURES / "ticketacces_portail.html").read_text(encoding="utf-8")190 cal = (FIXTURES / "ticketacces_calendrier.html").read_text(encoding="utf-8")191 fiche = (FIXTURES / "ticketacces_fiche.html").read_text(encoding="utf-8")192193 def fake_get(url):194 if "portail/index" in url:195 return portail196 if "calendrier" in url:197 return cal198 return fiche199 monkeypatch.setattr(c, "_get_cp1252", fake_get)200 events = [e.finalize() for e in c.fetch()]201 # le portail-fixture contient 3 orgs QC + 1 hors-Québec (exclue) ; le202 # calendrier-fixture liste 2 représentations de La Chapelle (Québec)203 assert events204 ev = next(e for e in events if "Parking" in e.title)205 assert ev.start_date == "2026-08-22" and ev.start_time == "12:30"206 assert ev.venue == "La Chapelle" and ev.city == "Québec"207 assert ev.region == "Capitale-Nationale"208 assert ev.organizer # nom de l'organisation du portail209 # fiche : adresse + prix plancher (45,00 $ / 25,00 $ → 25.0)210 assert ev.address == "620, avenue Plante"211 assert ev.price_min == 25.0212 assert ev.description # og:description de la fiche213214215def test_ticketacces_address_ignores_leading_numeric_traps():216 """L'adresse de la salle est extraite même quand la page commence par217 d'autres segments numériques (« 5 BALLES… », durée « 1h40 ») et la ville218 finale est retirée — extrait réel de la fiche bordee/EvenementID=158."""219 from sortika.connectors.ticketacces import TicketAccesConnector220 html = (221 "<p>1h40 Récit biographique</p>"222 "<h3>5 BALLES DANS LA TÊTE</h3>"223 "<p>Théâtre de la Bordée<br />"224 "315, rue Saint-Joseph Est, Québec</p>"225 "<div><strong>Régulier</strong><br />49,00 $</div>"226 "<div><strong>Étudiant</strong><br />25,00 $</div>")227 d = TicketAccesConnector()._parse_detail(html)228 assert d["address"] == "315, rue Saint-Joseph Est" # ville retirée229 assert d["price_min"] == 25.0230231232def test_ticketacces_zero_dollar_row_is_not_a_price():233 """Une ligne tarifaire « 0,00 $ » (constatée live, fiche lsq-10314) n'est234 jamais un prix plancher : sans autre montant → aucun prix émis."""235 from sortika.connectors.ticketacces import TicketAccesConnector236 d = TicketAccesConnector()._parse_detail(237 "<div><strong>Billet</strong><br />0,00 $</div>")238 assert d["price_min"] is None239 d2 = TicketAccesConnector()._parse_detail(240 "<div>0,00 $</div><div>18,50 $</div>")241 assert d2["price_min"] == 18.5242243244def test_ticketacces_excludes_non_quebec(monkeypatch):245 from sortika.connectors.ticketacces import TicketAccesConnector246 c = TicketAccesConnector()247 portail = (FIXTURES / "ticketacces_portail.html").read_text(encoding="utf-8")248 monkeypatch.setattr(c, "_get_cp1252", lambda url: portail)249 orgs = c._orgs_quebec()250 assert "lachapellespectacles" in orgs and "agora" in orgs251 # l'annuaire réseau étiquette le NB par comtés — jamais « Québec »252 assert all("edmundston" not in d and d != "scrn" for d in orgs)253254255def test_ovation_parses_fixtures(monkeypatch):256 from sortika.connectors.ovation import OvationConnector257 c = OvationConnector()258 _no_cache(monkeypatch, c)259 allev = (FIXTURES / "ovation_allevents.html").read_text(encoding="utf-8")260 fiche = (FIXTURES / "ovation_fiche.html").read_text(encoding="utf-8")261 monkeypatch.setattr(c, "_load_orgs", lambda: [262 {"code": "00001J", "nom": "Théâtre Petit Champlain", "ville": "Québec"}])263 monkeypatch.setattr(264 c, "_get_utf8",265 lambda url: allev if "AllEvents" in url else fiche)266 events = [e.finalize() for e in c.fetch()]267 assert events268 ev = events[0]269 # fragment EventDescription : date pleine FR + heure + salle + adresse270 assert ev.title.startswith("Marjo")271 assert ev.start_date == "2026-08-26" and ev.start_time == "20:00"272 assert ev.venue == "Théâtre Petit Champlain"273 assert ev.address == "68 Rue du Petit Champlain"274 assert ev.city == "Québec" and ev.postal_code == "G1K 4H4"275 assert ev.region == "Capitale-Nationale"276 assert ev.artists == ["Marjo"] # lien EventsByPerformer, pas inventé277 assert "musique" in ev.categories278 assert ev.description.startswith("Rockeuse")279280281def test_tuxedobillet_parses_actor_items(monkeypatch):282 import json as _json283 from sortika.connectors.tuxedobillet import TuxedoBilletConnector284 c = TuxedoBilletConnector()285 items = _json.loads(286 (FIXTURES / "tuxedobillet_items.json").read_text(encoding="utf-8"))287 fiche = (FIXTURES / "tuxedobillet_fiche.html").read_text(encoding="utf-8")288 monkeypatch.setattr(c, "load_cache", lambda: {"ts": 10, "items": items})289 monkeypatch.setattr(c, "save_cache", lambda cache: None)290 monkeypatch.setattr(c, "_run_actor", lambda tenants: items)291 monkeypatch.setenv("SCRAPFLY_API_KEY", "cle-de-test") # porte d'enrichissement292293 def fake_render(url):294 if "jeunesse-ad-vision" in url:295 return fiche # fiche rendue réelle (Scrapfly)296 raise RuntimeError("fiche indisponible") # liste seule (fallback)297 monkeypatch.setattr(c, "_render_fiche", fake_render)298 events = [e.finalize() for e in c.fetch()]299 # 3 items : 2 du tenant configuré (espacetheatre), 1 hors config (écarté)300 assert len(events) == 2301 # item AVEC fiche : 1 Event par séance de la fiche (date + HEURE + statut)302 ev = next(e for e in events if "Calamine" in e.title)303 assert ev.start_date == "2026-09-25" and ev.start_time == "20:00"304 assert ev.status == "soldout" # étiquette « COMPLET » normalisée305 assert ev.description.startswith("Est-ce que quelqu")306 assert ev.city == "Mont-Laurier" and ev.region == "Laurentides"307 assert ev.venue == "Espace Théâtre"308 # item SANS fiche : comportement liste (date de la tuile, mois EN traduit)309 perusse = next(e for e in events if "Pérusse" in e.title)310 assert perusse.start_date == "2027-06-17" # « 17 juin 2027 » (FR)311 assert perusse.start_time is None # jamais inventée sans fiche312 assert perusse.artists == [] # sous-titre ≠ artiste garanti313314315def test_tuxedobillet_parse_fiche_seances():316 """Fiche Tuxedo rendue (réelle) → catégorie, description et séances317 datées avec heure « 20H00 » et étiquette COMPLET → soldout."""318 from sortika.connectors.tuxedobillet import TuxedoBilletConnector319 html = (FIXTURES / "tuxedobillet_fiche.html").read_text(encoding="utf-8")320 row = TuxedoBilletConnector()._parse_fiche(html)321 assert row["category"] == "Chanson"322 assert "spectacle intime" in row["description"]323 assert row["seances"] == [324 {"date": "2026-09-25", "time": "20:00", "status": "soldout"}]325326327def test_tuxedobillet_parse_fiche_mois_janvier():328 """Fiche réelle rendue (Scrapfly, figée 2026-08-25) datée « 15 janv.329 2027 » : normalize.parse_date_fr ignore les abréviations FR « janv./330 févr. » → avant le correctif, les séances de janvier-février (et les331 tuiles homologues) étaient silencieusement écartées."""332 from sortika.connectors.tuxedobillet import TuxedoBilletConnector333 html = (FIXTURES / "tuxedobillet_fiche_janvier.html").read_text(334 encoding="utf-8")335 row = TuxedoBilletConnector()._parse_fiche(html)336 assert row["seances"] == [337 {"date": "2027-01-15", "time": "20:00", "status": "soldout"}]338 assert TuxedoBilletConnector._parse_date("3 févr. 2027") == "2027-02-03"339340341def test_ticketpro_seance_status_date_only_fallback():342 """~10 % des séances du détail sont datées SANS heure (constaté live343 2026-08-25) : leur saleState vaut pour la journée — un SOLDOUT du jour344 doit atteindre la séance horodatée de la liste."""345 from sortika.connectors.ticketpro import TicketproConnector346 page = {"id": 1, "title": "Expo", "slug": "expo",347 "dates": ["2099-09-10T19:30:00"],348 "locations": [{"name": "Salle", "city": "Québec",349 "provinceState": "QC"}]}350 enrich = {"price_min": 12.5, "states": {"2099-09-10": "SOLDOUT"}}351 events = TicketproConnector()._events_from_page("t", page, [], enrich)352 assert len(events) == 1353 assert events[0].status == "soldout"354 assert events[0].price_min == 12.5355356357def test_ticketpro_parses_fixtures_with_prices(monkeypatch):358 """Phase 5 enrichie : liste /api/v1/pages + tarifs réels par page359 (détail itemsDates → offre ?options=prices, cents → $) et saleState."""360 import json as _json361 from sortika.connectors.ticketpro import TicketproConnector362 c = TicketproConnector()363 _no_cache(monkeypatch, c)364 pages = _json.loads(365 (FIXTURES / "ticketpro_pages.json").read_text(encoding="utf-8"))366 detail = _json.loads(367 (FIXTURES / "ticketpro_detail.json").read_text(encoding="utf-8"))368 offer = _json.loads(369 (FIXTURES / "ticketpro_offer.json").read_text(encoding="utf-8"))370 monkeypatch.setattr(c, "_load_config", lambda: {371 "tenants": [{"domaine": "tprotsd", "nom": "Théâtre St-Denis"}],372 "exclure_lieux": []})373374 def fake_get_json(url, **kw):375 if "options=prices" in url:376 return offer377 if url.endswith("/pages/1663625040"):378 return detail379 return pages380 monkeypatch.setattr(c, "get_json", fake_get_json)381 events = [e.finalize() for e in c.fetch()]382 # fixture : 3 séances à venir (29 août 14 h/20 h, 30 août 14 h)383 assert len(events) == 3384 ev = next(e for e in events if e.start_time == "14:00"385 and e.start_date == "2026-08-29")386 assert ev.title.startswith("Serge Fiori")387 assert ev.venue == "Théâtre St-Denis" and ev.city == "Montréal"388 assert ev.lat and 45 < ev.lat < 46 # lat/lng de la LISTE (location)389 # prix plancher réel : min(priceLevels.price_min)=7150 cents → 71.50 $390 assert all(e.price_min == 71.5 for e in events)391 assert all(e.is_free is False for e in events)392 assert all(e.status == "" for e in events) # saleState ONSALE = prévu393 assert ev.external_id == "1663625040-2026-08-29T1400"394