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%
12.2 KB · 271 lines python
Raw Blame History
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