# ============================================================================== # Author: Simon-Pierre Boucher # File: tests/test_phase4.py # Desc: Phase 4 (régions manquantes + billetteries) — tests des nouveaux # connecteurs sur fixtures réelles figées. Aucun appel réseau # (CLAUDE.md §18). # ============================================================================== from __future__ import annotations import json from pathlib import Path from sortika.connectors import CONNECTORS FIXTURES = Path(__file__).parent / "fixtures" def _no_cache(monkeypatch, connector): """Cache disque neutralisé : ni lecture ni écriture pendant les tests.""" monkeypatch.setattr(connector, "load_cache", lambda: {}) monkeypatch.setattr(connector, "save_cache", lambda cache: None) class _Resp: def __init__(self, text): self.text = text def test_phase4_registry_autodiscovers(): for sid in ("monteregie", "centreduquebec", "chaudiereappalaches", "gaspesie"): assert sid in CONNECTORS, sid def test_monteregie_parses_fixture(monkeypatch): from sortika.connectors.monteregie import MonteregieConnector c = MonteregieConnector() _no_cache(monkeypatch, c) rows = json.loads( (FIXTURES / "monteregie_liste.json").read_text(encoding="utf-8")) fiche = (FIXTURES / "monteregie_fiche.html").read_text(encoding="utf-8") monkeypatch.setattr(c, "_list_events", lambda: rows) monkeypatch.setattr(c, "get", lambda url, **kw: _Resp(fiche)) events = [e.finalize() for e in c.fetch()] assert len(events) == 3 ev = next(e for e in events if "PLAN D" in e.title) # plage FR de l'ACF → ISO ; GPS de l'ACF ; région via l'ATR assert (ev.start_date, ev.end_date) == ("2026-09-18", "2026-09-20") assert ev.lat and ev.lng and ev.region == "Montérégie" # fiche JSON-LD Event → adresse civique + ville (Châteauguay) assert ev.city == "Châteauguay" and ev.address == "15 Boulevard Maple" assert ev.postal_code == "J6J 3P7" assert ev.website.startswith("http") and ev.image.startswith("http") assert ev.description # content.rendered nettoyé def test_centreduquebec_parses_fixture(monkeypatch): from sortika.connectors.centreduquebec import CentreDuQuebecConnector c = CentreDuQuebecConnector() _no_cache(monkeypatch, c) liste = (FIXTURES / "centreduquebec_liste.html").read_text(encoding="utf-8") fiche = (FIXTURES / "centreduquebec_fiche.html").read_text(encoding="utf-8") monkeypatch.setattr( c, "get", lambda url, **kw: _Resp(liste if url.rstrip("/").endswith("evenements") else fiche)) events = [e.finalize() for e in c.fetch()] assert len(events) >= 8 # ~10 fiches sur la liste réelle ev = next(e for e in events if "Inverness" in e.title) assert (ev.start_date, ev.end_date) == ("2026-09-01", "2026-09-06") assert ev.region == "Centre-du-Québec" # dérivée de l'ATR assert ev.url.startswith("https://tourismecentreduquebec.com/evenements/") # fiche : bloc « Adresse » + og:description/og:image assert any(e.address for e in events) assert any(e.image for e in events) def test_chaudiereappalaches_parses_fixture(monkeypatch): from sortika.connectors.chaudiereappalaches import ( ChaudiereAppalachesConnector) c = ChaudiereAppalachesConnector() _no_cache(monkeypatch, c) liste = (FIXTURES / "chaudiereappalaches_liste.html").read_text( encoding="utf-8") fiche = (FIXTURES / "chaudiereappalaches_fiche.html").read_text( encoding="utf-8") monkeypatch.setattr( c, "get", lambda url, **kw: _Resp(fiche if "-fr-" in url else liste)) events = [e.finalize() for e in c.fetch()] # 12 fiches listées → le budget FICHE_MAX en visite 12, toutes parsées # sur la même fixture de fiche (dates ISO du JSON-LD Event) assert events ev = events[0] assert "Accordéon" in ev.title assert (ev.start_date, ev.end_date) == ("2026-09-04", "2026-09-06") assert ev.city == "Montmagny" and ev.region == "Chaudière-Appalaches" assert ev.address == "301, Boulevard Taché Est" assert ev.lat and ev.lng # GeoCoordinates du JSON-LD assert ev.venue == "" # « Adresse » = libellé CMS def test_gaspesie_parses_fixture(monkeypatch): from sortika.connectors.gaspesie import GaspesieConnector c = GaspesieConnector() _no_cache(monkeypatch, c) liste = (FIXTURES / "gaspesie_liste.html").read_text(encoding="utf-8") fiche = (FIXTURES / "gaspesie_fiche.html").read_text(encoding="utf-8") monkeypatch.setattr( c, "_get_waf", lambda url: liste if url.rstrip("/").endswith("festival-et-evenements") else fiche) events = [e.finalize() for e in c.fetch()] assert events # fiches sans date écartées ev = events[0] assert ev.title == "Festival BleuBleu" # « 18 au 21 juin 2026 » (sans « du ») → plage complète, jamais inventée assert (ev.start_date, ev.end_date) == ("2026-06-18", "2026-06-21") assert ev.city == "Carleton-sur-Mer" assert ev.region == "Gaspésie–Îles-de-la-Madeleine" assert ev.description and ev.image def test_cantonsdelest_paginates_and_parses_fixtures(monkeypatch): """Pagination AJAX Yii (POST /event/more) + cartes + fiche JSON-LD. La page 1 réelle ne rend AUCUNE carte /evenements/ (que des articles) : tout le calendrier (~596 occurrences) vient de /event/more — le connecteur doit poster offset=2, 3, … avec le jeton CSRF et s'arrêter dès qu'une page ne donne plus de nouvelle carte. """ from sortika.connectors.cantonsdelest import CantonsDeLEstConnector c = CantonsDeLEstConnector() _no_cache(monkeypatch, c) liste = (FIXTURES / "cantonsdelest_liste.html").read_text(encoding="utf-8") more = (FIXTURES / "cantonsdelest_more.html").read_text(encoding="utf-8") fiche = (FIXTURES / "cantonsdelest_fiche.html").read_text(encoding="utf-8") monkeypatch.setattr(c, "get", lambda url, **kw: _Resp(liste)) monkeypatch.setattr( # fiches : aucun appel réseau c, "fetch_many", lambda paths, worker, max_workers=4: {p: c._parse_fiche(p, fiche) for p in paths}) posts: list[dict] = [] def fake_post(data): posts.append(data) return more # même page → offset 3 répète monkeypatch.setattr(c, "_post_more", fake_post) events = [e.finalize() for e in c.fetch()] # offset=2 apporte 6 cartes, offset=3 ne donne rien de neuf → arrêt assert [d["offset"] for d in posts] == [2, 3] assert all(d["_csrf"] and d["ids"].startswith("EventScheduleView-") for d in posts) # jeton CSRF + ids de la page 1 assert len(events) == 6 ev = next(e for e in events if "Boulevard des Arts 6e" in e.title) assert ev.external_id == "10114-4491" # id événement + id occurrence assert ev.region == "Estrie" # ATR Cantons-de-l'Est → Estrie assert ev.city == "Frelighsburg" # ville de la carte conservée assert "Arts visuels" in " ".join(ev.raw_categories) assert ev.image.startswith("https://lvc-medias.lavitrine.com/") # fiche JSON-LD (même fixture pour toutes) : dates précises, adresse, GPS assert (ev.start_date, ev.end_date) == ("2026-08-28", "2026-09-07") assert ev.address == "57, rue Principale Nord" assert ev.postal_code == "J0E 2K0" assert ev.lat and ev.lng # data-lat/data-lng de la carte def test_cantonsdelest_abbreviated_months(): """« 21 janv. 2027 » / « 5 févr. 2027 » (45/596 cartes live 2026-08-25) : abréviations du gabarit déployées avant le parseur central de dates.""" from sortika.connectors.cantonsdelest import _dates_fr assert _dates_fr("21 janv. 2027") == ("2027-01-21", "2027-01-21") assert _dates_fr("5 févr. 2027") == ("2027-02-05", "2027-02-05") assert _dates_fr("29 août 2026 ~ 13 sept. 2026") == \ ("2026-08-29", "2026-09-13") # --- billetteries (Phase 4) -------------------------------------------------- def test_billetteries_registry_autodiscovers(): for sid in ("ticketacces", "ovation", "tuxedobillet", "ticketpro"): assert sid in CONNECTORS, sid def test_ticketacces_parses_fixtures(monkeypatch): from sortika.connectors.ticketacces import TicketAccesConnector c = TicketAccesConnector() _no_cache(monkeypatch, c) portail = (FIXTURES / "ticketacces_portail.html").read_text(encoding="utf-8") cal = (FIXTURES / "ticketacces_calendrier.html").read_text(encoding="utf-8") fiche = (FIXTURES / "ticketacces_fiche.html").read_text(encoding="utf-8") def fake_get(url): if "portail/index" in url: return portail if "calendrier" in url: return cal return fiche monkeypatch.setattr(c, "_get_cp1252", fake_get) events = [e.finalize() for e in c.fetch()] # le portail-fixture contient 3 orgs QC + 1 hors-Québec (exclue) ; le # calendrier-fixture liste 2 représentations de La Chapelle (Québec) assert events ev = next(e for e in events if "Parking" in e.title) assert ev.start_date == "2026-08-22" and ev.start_time == "12:30" assert ev.venue == "La Chapelle" and ev.city == "Québec" assert ev.region == "Capitale-Nationale" assert ev.organizer # nom de l'organisation du portail # fiche : adresse + prix plancher (45,00 $ / 25,00 $ → 25.0) assert ev.address == "620, avenue Plante" assert ev.price_min == 25.0 assert ev.description # og:description de la fiche def test_ticketacces_address_ignores_leading_numeric_traps(): """L'adresse de la salle est extraite même quand la page commence par d'autres segments numériques (« 5 BALLES… », durée « 1h40 ») et la ville finale est retirée — extrait réel de la fiche bordee/EvenementID=158.""" from sortika.connectors.ticketacces import TicketAccesConnector html = ( "

1h40 Récit biographique

" "

5 BALLES DANS LA TÊTE

" "

Théâtre de la Bordée
" "315, rue Saint-Joseph Est, Québec

" "
Régulier
49,00 $
" "
Étudiant
25,00 $
") d = TicketAccesConnector()._parse_detail(html) assert d["address"] == "315, rue Saint-Joseph Est" # ville retirée assert d["price_min"] == 25.0 def test_ticketacces_zero_dollar_row_is_not_a_price(): """Une ligne tarifaire « 0,00 $ » (constatée live, fiche lsq-10314) n'est jamais un prix plancher : sans autre montant → aucun prix émis.""" from sortika.connectors.ticketacces import TicketAccesConnector d = TicketAccesConnector()._parse_detail( "
Billet
0,00 $
") assert d["price_min"] is None d2 = TicketAccesConnector()._parse_detail( "
0,00 $
18,50 $
") assert d2["price_min"] == 18.5 def test_ticketacces_excludes_non_quebec(monkeypatch): from sortika.connectors.ticketacces import TicketAccesConnector c = TicketAccesConnector() portail = (FIXTURES / "ticketacces_portail.html").read_text(encoding="utf-8") monkeypatch.setattr(c, "_get_cp1252", lambda url: portail) orgs = c._orgs_quebec() assert "lachapellespectacles" in orgs and "agora" in orgs # l'annuaire réseau étiquette le NB par comtés — jamais « Québec » assert all("edmundston" not in d and d != "scrn" for d in orgs) def test_ovation_parses_fixtures(monkeypatch): from sortika.connectors.ovation import OvationConnector c = OvationConnector() _no_cache(monkeypatch, c) allev = (FIXTURES / "ovation_allevents.html").read_text(encoding="utf-8") fiche = (FIXTURES / "ovation_fiche.html").read_text(encoding="utf-8") monkeypatch.setattr(c, "_load_orgs", lambda: [ {"code": "00001J", "nom": "Théâtre Petit Champlain", "ville": "Québec"}]) monkeypatch.setattr( c, "_get_utf8", lambda url: allev if "AllEvents" in url else fiche) events = [e.finalize() for e in c.fetch()] assert events ev = events[0] # fragment EventDescription : date pleine FR + heure + salle + adresse assert ev.title.startswith("Marjo") assert ev.start_date == "2026-08-26" and ev.start_time == "20:00" assert ev.venue == "Théâtre Petit Champlain" assert ev.address == "68 Rue du Petit Champlain" assert ev.city == "Québec" and ev.postal_code == "G1K 4H4" assert ev.region == "Capitale-Nationale" assert ev.artists == ["Marjo"] # lien EventsByPerformer, pas inventé assert "musique" in ev.categories assert ev.description.startswith("Rockeuse") def test_tuxedobillet_parses_actor_items(monkeypatch): import json as _json from sortika.connectors.tuxedobillet import TuxedoBilletConnector c = TuxedoBilletConnector() items = _json.loads( (FIXTURES / "tuxedobillet_items.json").read_text(encoding="utf-8")) fiche = (FIXTURES / "tuxedobillet_fiche.html").read_text(encoding="utf-8") monkeypatch.setattr(c, "load_cache", lambda: {"ts": 10, "items": items}) monkeypatch.setattr(c, "save_cache", lambda cache: None) monkeypatch.setattr(c, "_run_actor", lambda tenants: items) monkeypatch.setenv("SCRAPFLY_API_KEY", "cle-de-test") # porte d'enrichissement def fake_render(url): if "jeunesse-ad-vision" in url: return fiche # fiche rendue réelle (Scrapfly) raise RuntimeError("fiche indisponible") # liste seule (fallback) monkeypatch.setattr(c, "_render_fiche", fake_render) events = [e.finalize() for e in c.fetch()] # 3 items : 2 du tenant configuré (espacetheatre), 1 hors config (écarté) assert len(events) == 2 # item AVEC fiche : 1 Event par séance de la fiche (date + HEURE + statut) ev = next(e for e in events if "Calamine" in e.title) assert ev.start_date == "2026-09-25" and ev.start_time == "20:00" assert ev.status == "soldout" # étiquette « COMPLET » normalisée assert ev.description.startswith("Est-ce que quelqu") assert ev.city == "Mont-Laurier" and ev.region == "Laurentides" assert ev.venue == "Espace Théâtre" # item SANS fiche : comportement liste (date de la tuile, mois EN traduit) perusse = next(e for e in events if "Pérusse" in e.title) assert perusse.start_date == "2027-06-17" # « 17 juin 2027 » (FR) assert perusse.start_time is None # jamais inventée sans fiche assert perusse.artists == [] # sous-titre ≠ artiste garanti def test_tuxedobillet_parse_fiche_seances(): """Fiche Tuxedo rendue (réelle) → catégorie, description et séances datées avec heure « 20H00 » et étiquette COMPLET → soldout.""" from sortika.connectors.tuxedobillet import TuxedoBilletConnector html = (FIXTURES / "tuxedobillet_fiche.html").read_text(encoding="utf-8") row = TuxedoBilletConnector()._parse_fiche(html) assert row["category"] == "Chanson" assert "spectacle intime" in row["description"] assert row["seances"] == [ {"date": "2026-09-25", "time": "20:00", "status": "soldout"}] def test_tuxedobillet_parse_fiche_mois_janvier(): """Fiche réelle rendue (Scrapfly, figée 2026-08-25) datée « 15 janv. 2027 » : normalize.parse_date_fr ignore les abréviations FR « janv./ févr. » → avant le correctif, les séances de janvier-février (et les tuiles homologues) étaient silencieusement écartées.""" from sortika.connectors.tuxedobillet import TuxedoBilletConnector html = (FIXTURES / "tuxedobillet_fiche_janvier.html").read_text( encoding="utf-8") row = TuxedoBilletConnector()._parse_fiche(html) assert row["seances"] == [ {"date": "2027-01-15", "time": "20:00", "status": "soldout"}] assert TuxedoBilletConnector._parse_date("3 févr. 2027") == "2027-02-03" def test_ticketpro_seance_status_date_only_fallback(): """~10 % des séances du détail sont datées SANS heure (constaté live 2026-08-25) : leur saleState vaut pour la journée — un SOLDOUT du jour doit atteindre la séance horodatée de la liste.""" from sortika.connectors.ticketpro import TicketproConnector page = {"id": 1, "title": "Expo", "slug": "expo", "dates": ["2099-09-10T19:30:00"], "locations": [{"name": "Salle", "city": "Québec", "provinceState": "QC"}]} enrich = {"price_min": 12.5, "states": {"2099-09-10": "SOLDOUT"}} events = TicketproConnector()._events_from_page("t", page, [], enrich) assert len(events) == 1 assert events[0].status == "soldout" assert events[0].price_min == 12.5 def test_ticketpro_parses_fixtures_with_prices(monkeypatch): """Phase 5 enrichie : liste /api/v1/pages + tarifs réels par page (détail itemsDates → offre ?options=prices, cents → $) et saleState.""" import json as _json from sortika.connectors.ticketpro import TicketproConnector c = TicketproConnector() _no_cache(monkeypatch, c) pages = _json.loads( (FIXTURES / "ticketpro_pages.json").read_text(encoding="utf-8")) detail = _json.loads( (FIXTURES / "ticketpro_detail.json").read_text(encoding="utf-8")) offer = _json.loads( (FIXTURES / "ticketpro_offer.json").read_text(encoding="utf-8")) monkeypatch.setattr(c, "_load_config", lambda: { "tenants": [{"domaine": "tprotsd", "nom": "Théâtre St-Denis"}], "exclure_lieux": []}) def fake_get_json(url, **kw): if "options=prices" in url: return offer if url.endswith("/pages/1663625040"): return detail return pages monkeypatch.setattr(c, "get_json", fake_get_json) events = [e.finalize() for e in c.fetch()] # fixture : 3 séances à venir (29 août 14 h/20 h, 30 août 14 h) assert len(events) == 3 ev = next(e for e in events if e.start_time == "14:00" and e.start_date == "2026-08-29") assert ev.title.startswith("Serge Fiori") assert ev.venue == "Théâtre St-Denis" and ev.city == "Montréal" assert ev.lat and 45 < ev.lat < 46 # lat/lng de la LISTE (location) # prix plancher réel : min(priceLevels.price_min)=7150 cents → 71.50 $ assert all(e.price_min == 71.5 for e in events) assert all(e.is_free is False for e in events) assert all(e.status == "" for e in events) # saleState ONSALE = prévu assert ev.external_id == "1663625040-2026-08-29T1400"