SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
18.5 KB · 394 lines python
Raw Blame History
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