1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : tests/test_normalize.py6# Rôle : Tests unitaires de la couche de normalisation (salaires, dates,7# mode/type d'emploi, lieux, catégories)8# Créé : 2026-08-17 Modifié : 2026-08-179# =============================================================================10import datetime as dt1112from jobka.normalize import (13 categorize,14 clean_html,15 is_quebec_location,16 parse_date,17 parse_employment_type,18 parse_location,19 parse_salary,20 parse_work_mode,21 salary_to_hourly,22 salary_to_yearly,23)2425TODAY = dt.date(2026, 8, 17)262728# --- salaires ----------------------------------------------------------------2930def test_salary_hourly_range():31 assert parse_salary("25,00 $ à 30,00 $ de l'heure") == (25.0, 30.0, "hour")323334def test_salary_hourly_english():35 assert parse_salary("$22.50 - $27.75 per hour") == (22.5, 27.75, "hour")363738def test_salary_yearly_thousands():39 assert parse_salary("70 000 $ à 90 000 $ par année") == (70000.0, 90000.0, "year")404142def test_salary_yearly_english_commas():43 assert parse_salary("$85,000 - $105,000 annually") == (85000.0, 105000.0, "year")444546def test_salary_k_notation():47 assert parse_salary("80k$ - 100k$ /an") == (80000.0, 100000.0, "year")484950def test_salary_single_value():51 lo, hi, unit = parse_salary("Salaire : 28,50 $/h")52 assert (lo, hi, unit) == (28.5, 28.5, "hour")535455def test_salary_infers_unit_from_magnitude():56 assert parse_salary("55 000 $")[2] == "year"57 assert parse_salary("26,75 $")[2] == "hour"585960def test_salary_rejects_hours_of_work():61 # « 35 heures/semaine » n'est PAS un salaire62 assert parse_salary("35 heures par semaine") == (None, None, None)636465def test_salary_rejects_empty_and_garbage():66 assert parse_salary("") == (None, None, None)67 assert parse_salary("Salaire concurrentiel") == (None, None, None)686970def test_salary_conversions():71 assert salary_to_yearly(25.0, "hour") == 52000.072 assert salary_to_hourly(52000.0, "year") == 25.073 assert salary_to_yearly(5000.0, "month") == 60000.074 assert salary_to_yearly(None, "hour") is None757677# --- dates ---------------------------------------------------------------------7879def test_date_iso_passthrough():80 assert parse_date("2026-09-01") == "2026-09-01"81 assert parse_date("2026-06-16T05:06:55-04:00") == "2026-06-16"828384def test_date_epoch_ms():85 # Lever : createdAt en millisecondes86 assert parse_date(1755000000000) == dt.date.fromtimestamp(1755000000).isoformat()878889def test_date_french():90 assert parse_date("1er juillet 2026", TODAY) == "2026-07-01"91 assert parse_date("déc. 2026", TODAY) == "2026-12-01"929394def test_date_english():95 assert parse_date("July 1st, 2026", TODAY) == "2026-07-01"969798def test_date_relative():99 assert parse_date("Posted 3 Days Ago", TODAY) == "2026-08-14"100 assert parse_date("il y a 2 semaines", TODAY) == "2026-08-03"101 assert parse_date("Posted Today", TODAY) == "2026-08-17"102 assert parse_date("Posted Yesterday", TODAY) == "2026-08-16"103104105def test_date_never_invented():106 assert parse_date("bientôt") is None107 assert parse_date("") is None108 assert parse_date(None) is None109110111# --- mode / type ---------------------------------------------------------------112113def test_work_mode():114 assert parse_work_mode("Télétravail") == "teletravail"115 assert parse_work_mode("Remote - QC") == "teletravail"116 assert parse_work_mode("Mode hybride (3 jours au bureau)") == "hybride"117 assert parse_work_mode("Sur place") == "presentiel"118 assert parse_work_mode("Montréal") is None119120121def test_work_mode_hybrid_wins():122 assert parse_work_mode("hybride avec télétravail partiel") == "hybride"123124125def test_employment_type():126 assert parse_employment_type("Temps plein") == "temps_plein"127 assert parse_employment_type("Full time") == "temps_plein"128 assert parse_employment_type("Permanent") == "temps_plein"129 assert parse_employment_type("Temps partiel") == "temps_partiel"130 assert parse_employment_type("Contrat 12 mois") == "contractuel"131 assert parse_employment_type("Stage été 2027") == "stage"132 assert parse_employment_type("Emploi saisonnier") == "saisonnier"133 assert parse_employment_type("") is None134135136# --- lieux -----------------------------------------------------------------------137138def test_location_montreal():139 loc = parse_location("Montréal, QC, Canada")140 assert loc["city"] == "Montréal"141 assert loc["region"] == "Québec"142143144def test_location_quebec_city_english():145 assert parse_location("Quebec City, Quebec")["city"] == "Québec"146147148def test_location_postal_code():149 loc = parse_location("1500 rue Exemple, Lévis G6V 4Z2")150 assert loc["postal_code"] == "G6V 4Z2"151 assert loc["city"] == "Lévis"152153154def test_is_quebec_location():155 assert is_quebec_location("Montréal, QC")156 assert is_quebec_location("Saint-Laurent, Quebec, Canada")157 assert is_quebec_location("Sherbrooke")158 assert not is_quebec_location("Toronto, ON")159 assert not is_quebec_location("Richmond, BC")160 assert not is_quebec_location("Paris, IDF, France")161 assert not is_quebec_location("")162163164# --- catégories / HTML ------------------------------------------------------------165166def test_categorize():167 assert categorize("Développeur logiciel senior") == "TI"168 assert categorize("Infirmière clinicienne") == "Santé"169 assert categorize("Conseiller en ressources humaines") == "RH"170 assert categorize("Poste mystère") == ""171172173def test_clean_html():174 html = "<p>Bonjour !</p><ul><li>Point 1</li><li>Point 2</li></ul>"175 txt = clean_html(html)176 assert "Bonjour !" in txt177 assert "Point 1" in txt and "Point 2" in txt178 assert "<" not in txt179