# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : tests/test_normalize.py # Rôle : Tests unitaires de la couche de normalisation (salaires, dates, # mode/type d'emploi, lieux, catégories) # Créé : 2026-08-17 Modifié : 2026-09-14 # ============================================================================= import datetime as dt from jobka.normalize import ( categorize, clean_html, is_quebec_location, parse_date, parse_employment_type, parse_location, parse_salary, parse_work_mode, salary_to_hourly, salary_to_yearly, ) TODAY = dt.date(2026, 8, 17) # --- salaires ---------------------------------------------------------------- def test_salary_hourly_range(): assert parse_salary("25,00 $ à 30,00 $ de l'heure") == (25.0, 30.0, "hour") def test_salary_hourly_english(): assert parse_salary("$22.50 - $27.75 per hour") == (22.5, 27.75, "hour") def test_salary_yearly_thousands(): assert parse_salary("70 000 $ à 90 000 $ par année") == (70000.0, 90000.0, "year") def test_salary_yearly_english_commas(): assert parse_salary("$85,000 - $105,000 annually") == (85000.0, 105000.0, "year") def test_salary_k_notation(): assert parse_salary("80k$ - 100k$ /an") == (80000.0, 100000.0, "year") def test_salary_single_value(): lo, hi, unit = parse_salary("Salaire : 28,50 $/h") assert (lo, hi, unit) == (28.5, 28.5, "hour") def test_salary_infers_unit_from_magnitude(): assert parse_salary("55 000 $")[2] == "year" assert parse_salary("26,75 $")[2] == "hour" def test_salary_rejects_hours_of_work(): # « 35 heures/semaine » n'est PAS un salaire assert parse_salary("35 heures par semaine") == (None, None, None) def test_salary_rejects_empty_and_garbage(): assert parse_salary("") == (None, None, None) assert parse_salary("Salaire concurrentiel") == (None, None, None) def test_salary_conversions(): assert salary_to_yearly(25.0, "hour") == 52000.0 assert salary_to_hourly(52000.0, "year") == 25.0 assert salary_to_yearly(5000.0, "month") == 60000.0 assert salary_to_yearly(None, "hour") is None # --- dates --------------------------------------------------------------------- def test_date_iso_passthrough(): assert parse_date("2026-09-01") == "2026-09-01" assert parse_date("2026-06-16T05:06:55-04:00") == "2026-06-16" def test_date_epoch_ms(): # Lever : createdAt en millisecondes assert parse_date(1755000000000) == dt.date.fromtimestamp(1755000000).isoformat() def test_date_french(): assert parse_date("1er juillet 2026", TODAY) == "2026-07-01" assert parse_date("déc. 2026", TODAY) == "2026-12-01" def test_date_english(): assert parse_date("July 1st, 2026", TODAY) == "2026-07-01" def test_date_relative(): assert parse_date("Posted 3 Days Ago", TODAY) == "2026-08-14" assert parse_date("il y a 2 semaines", TODAY) == "2026-08-03" assert parse_date("Posted Today", TODAY) == "2026-08-17" assert parse_date("Posted Yesterday", TODAY) == "2026-08-16" def test_date_never_invented(): assert parse_date("bientôt") is None assert parse_date("") is None assert parse_date(None) is None # --- mode / type --------------------------------------------------------------- def test_work_mode(): assert parse_work_mode("Télétravail") == "teletravail" assert parse_work_mode("Remote - QC") == "teletravail" assert parse_work_mode("Mode hybride (3 jours au bureau)") == "hybride" assert parse_work_mode("Sur place") == "presentiel" assert parse_work_mode("Montréal") is None def test_work_mode_hybrid_wins(): assert parse_work_mode("hybride avec télétravail partiel") == "hybride" def test_employment_type(): assert parse_employment_type("Temps plein") == "temps_plein" assert parse_employment_type("Full time") == "temps_plein" assert parse_employment_type("Permanent") == "temps_plein" assert parse_employment_type("Temps partiel") == "temps_partiel" assert parse_employment_type("Contrat 12 mois") == "contractuel" assert parse_employment_type("Stage été 2027") == "stage" assert parse_employment_type("Emploi saisonnier") == "saisonnier" assert parse_employment_type("") is None # --- lieux ----------------------------------------------------------------------- def test_location_montreal(): loc = parse_location("Montréal, QC, Canada") assert loc["city"] == "Montréal" assert loc["region"] == "Québec" def test_location_quebec_city_english(): assert parse_location("Quebec City, Quebec")["city"] == "Québec" def test_location_postal_code(): loc = parse_location("1500 rue Exemple, Lévis G6V 4Z2") assert loc["postal_code"] == "G6V 4Z2" assert loc["city"] == "Lévis" def test_is_quebec_location(): assert is_quebec_location("Montréal, QC") assert is_quebec_location("Saint-Laurent, Quebec, Canada") assert is_quebec_location("Sherbrooke") # « Sitec Fermont » : site minier de Cambria à Fermont (Côte-Nord) — # libellé Workday sans province, la ville seule doit suffire (bug cambria # 2026-09-12 : Fermont absent de _QC_CITIES rendait l'offre invisible) assert is_quebec_location("Sitec Fermont") assert not is_quebec_location("Toronto, ON") assert not is_quebec_location("Richmond, BC") assert not is_quebec_location("Paris, IDF, France") assert not is_quebec_location("") # « Quebec St./Street/Ave » = nom de RUE hors Québec, pas la province (bug # solmentalhealth 2026-09-14 : bureau de Denver sur Quebec Street publié # comme Québec/Capitale-Nationale — le marqueur \bquebec\b gagnait avant # le rejet « , CO ») assert not is_quebec_location("3401 Quebec St. Suite 8000, Denver, CO, 80207") assert not is_quebec_location("3401 Quebec St. Suite 8000") assert not is_quebec_location("2600 Quebec Street NW, Washington, DC") assert not is_quebec_location("Quebec Ave, Golden Valley, MN") # …mais les abréviations de saints des libellés QC légitimes survivent assert is_quebec_location("CAN Quebec St-Hubert") assert is_quebec_location("CAN Quebec Ste-Thérèse") assert is_quebec_location("CAN Quebec Plessisville") assert not parse_location("3401 Quebec St. Suite 8000, Denver, CO, 80207")["region"] # --- catégories / HTML ------------------------------------------------------------ def test_categorize(): assert categorize("Développeur logiciel senior") == "TI" assert categorize("Infirmière clinicienne") == "Santé" assert categorize("Conseiller en ressources humaines") == "RH" assert categorize("Poste mystère") == "" def test_clean_html(): html = "
Bonjour !