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-10-069# =============================================================================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_java_tostring():99 # SuccessFactors CSB : meta itemprop="datePosted" au format100 # java.util.Date.toString() (cas Dentons careers.dentons.com, 2026-10-06)101 assert parse_date("Sun Oct 04 02:00:00 UTC 2026") == "2026-10-04"102 assert parse_date("Mon Dec 01 23:00:00 EST 2026") == "2026-12-01"103104105def test_date_relative():106 assert parse_date("Posted 3 Days Ago", TODAY) == "2026-08-14"107 assert parse_date("il y a 2 semaines", TODAY) == "2026-08-03"108 assert parse_date("Posted Today", TODAY) == "2026-08-17"109 assert parse_date("Posted Yesterday", TODAY) == "2026-08-16"110111112def test_date_never_invented():113 assert parse_date("bientôt") is None114 assert parse_date("") is None115 assert parse_date(None) is None116117118# --- mode / type ---------------------------------------------------------------119120def test_work_mode():121 assert parse_work_mode("Télétravail") == "teletravail"122 assert parse_work_mode("Remote - QC") == "teletravail"123 assert parse_work_mode("Mode hybride (3 jours au bureau)") == "hybride"124 assert parse_work_mode("Sur place") == "presentiel"125 assert parse_work_mode("Montréal") is None126127128def test_work_mode_hybrid_wins():129 assert parse_work_mode("hybride avec télétravail partiel") == "hybride"130131132def test_employment_type():133 assert parse_employment_type("Temps plein") == "temps_plein"134 assert parse_employment_type("Full time") == "temps_plein"135 assert parse_employment_type("Permanent") == "temps_plein"136 assert parse_employment_type("Temps partiel") == "temps_partiel"137 assert parse_employment_type("Contrat 12 mois") == "contractuel"138 assert parse_employment_type("Stage été 2027") == "stage"139 assert parse_employment_type("Emploi saisonnier") == "saisonnier"140 assert parse_employment_type("") is None141142143# --- lieux -----------------------------------------------------------------------144145def test_location_montreal():146 loc = parse_location("Montréal, QC, Canada")147 assert loc["city"] == "Montréal"148 assert loc["region"] == "Québec"149150151def test_location_quebec_city_english():152 assert parse_location("Quebec City, Quebec")["city"] == "Québec"153154155def test_location_postal_code():156 loc = parse_location("1500 rue Exemple, Lévis G6V 4Z2")157 assert loc["postal_code"] == "G6V 4Z2"158 assert loc["city"] == "Lévis"159160161def test_is_quebec_location():162 assert is_quebec_location("Montréal, QC")163 assert is_quebec_location("Saint-Laurent, Quebec, Canada")164 assert is_quebec_location("Sherbrooke")165 # « Sitec Fermont » : site minier de Cambria à Fermont (Côte-Nord) —166 # libellé Workday sans province, la ville seule doit suffire (bug cambria167 # 2026-09-12 : Fermont absent de _QC_CITIES rendait l'offre invisible)168 assert is_quebec_location("Sitec Fermont")169 assert not is_quebec_location("Toronto, ON")170 assert not is_quebec_location("Richmond, BC")171 assert not is_quebec_location("Paris, IDF, France")172 assert not is_quebec_location("")173 # « Quebec St./Street/Ave » = nom de RUE hors Québec, pas la province (bug174 # solmentalhealth 2026-09-14 : bureau de Denver sur Quebec Street publié175 # comme Québec/Capitale-Nationale — le marqueur \bquebec\b gagnait avant176 # le rejet « , CO »)177 assert not is_quebec_location("3401 Quebec St. Suite 8000, Denver, CO, 80207")178 assert not is_quebec_location("3401 Quebec St. Suite 8000")179 assert not is_quebec_location("2600 Quebec Street NW, Washington, DC")180 assert not is_quebec_location("Quebec Ave, Golden Valley, MN")181 # …mais les abréviations de saints des libellés QC légitimes survivent182 assert is_quebec_location("CAN Quebec St-Hubert")183 assert is_quebec_location("CAN Quebec Ste-Thérèse")184 assert is_quebec_location("CAN Quebec Plessisville")185 assert not parse_location("3401 Quebec St. Suite 8000, Denver, CO, 80207")["region"]186187188# --- catégories / HTML ------------------------------------------------------------189190def test_categorize():191 assert categorize("Développeur logiciel senior") == "TI"192 assert categorize("Infirmière clinicienne") == "Santé"193 assert categorize("Conseiller en ressources humaines") == "RH"194 assert categorize("Poste mystère") == ""195196197def test_clean_html():198 html = "<p>Bonjour !</p><ul><li>Point 1</li><li>Point 2</li></ul>"199 txt = clean_html(html)200 assert "Bonjour !" in txt201 assert "Point 1" in txt and "Point 2" in txt202 assert "<" not in txt203204205# --- Phase 2 (2026-08-19) : enrichissement ------------------------------------206207def test_clean_html_double_escaped():208 # contenu sur-échappé (Greenhouse/Lever) : entités dans les entités209 html = "<p>Bonjour&nbsp;!</p><li>Point</li>"210 txt = clean_html(html)211 assert "<" not in txt and " " not in txt212 assert "Bonjour" in txt and "Point" in txt213214215def test_sanitize_salary_month_is_actually_year():216 from jobka.normalize import sanitize_salary217 # « 75 000 $/mois » : hors bornes mensuelles -> réinterprété en annuel218 assert sanitize_salary(75000, 82500, "month") == (75000, 82500, "year")219220221def test_sanitize_salary_hour_is_actually_year():222 from jobka.normalize import sanitize_salary223 assert sanitize_salary(57200, None, "hour") == (57200, None, "year")224225226def test_sanitize_salary_plausible_untouched():227 from jobka.normalize import sanitize_salary228 assert sanitize_salary(25.0, 30.0, "hour") == (25.0, 30.0, "hour")229230231def test_sanitize_salary_absurd_dropped():232 from jobka.normalize import sanitize_salary233 assert sanitize_salary(2.0, 3.0, "hour") == (None, None, None)234235236def test_salary_from_text_skips_funding():237 from jobka.normalize import salary_from_text238 lo, _, _, _ = salary_from_text(239 "Nous offrons 600 000 $ en financement de pré-amorçage aux fondateurs.")240 assert lo is None241242243def test_salary_from_text_decimal_amounts():244 from jobka.normalize import salary_from_text245 lo, hi, unit, _ = salary_from_text(246 "The annual salary range is $82,500.00 to $137,500.00 per year.")247 assert (lo, hi, unit) == (82500.0, 137500.0, "year")248249250def test_salary_from_text_skips_expense():251 from jobka.normalize import salary_from_text252 lo, _, _, _ = salary_from_text(253 "You get a 250$ BEHappy expense reimbursement to buy what you want.")254 assert lo is None255256257def test_detect_language_fr():258 from jobka.normalize import detect_language259 assert detect_language(260 "Développeur logiciel",261 "Nous cherchons une personne pour rejoindre notre équipe dans nos "262 "bureaux de Montréal. Le poste est pour vous si vous aimez le défi "263 "et les tâches variées avec des exigences claires.") == "fr"264265266def test_detect_language_en():267 from jobka.normalize import detect_language268 assert detect_language(269 "Software Developer",270 "We are looking for someone to join our team in Montreal. You will "271 "work with the best and your skills will grow as we build this "272 "product for the world.") == "en"273274275def test_detect_language_too_short():276 from jobka.normalize import detect_language277 assert detect_language("Poste", "") == ""278279280def test_extract_benefits_block():281 from jobka.normalize import extract_benefits282 desc = ("Description du poste.\n\nAvantages\n"283 "• Assurances collectives\n• REER avec cotisation de l'employeur\n"284 "• 4 semaines de vacances\n\nComment postuler\nEnvoyez votre CV.")285 ben = extract_benefits(desc)286 assert "Assurances collectives" in ben287 assert "4 semaines de vacances" in ben288 assert len(ben) == 3289290291def test_extract_benefits_none_without_block():292 from jobka.normalize import extract_benefits293 assert extract_benefits("Un poste intéressant sans liste structurée.") == []294295296def test_clean_title_requisition_code():297 from jobka.normalize import clean_title298 assert clean_title("Analyste financier - R-12345") == "Analyste financier"299 assert clean_title("[REQ-9876] Technicien réseau") == "Technicien réseau"300301302def test_clean_title_city_suffix():303 from jobka.normalize import clean_title304 assert clean_title("Développeur Python - Montréal", "Montréal") == \305 "Développeur Python"306 # ville différente : suffixe conservé307 assert clean_title("Développeur Python - Montréal", "Laval") == \308 "Développeur Python - Montréal"309310311def test_clean_title_all_caps():312 from jobka.normalize import clean_title313 t = clean_title("CONSEILLER RH ET ACQUISITION DE TALENTS")314 assert t != t.upper()315 assert "RH" in t316317318def test_region_for_city():319 from jobka.regions import region_for_city320 assert region_for_city("Montréal") == "Montréal"321 assert region_for_city("Québec") == "Capitale-Nationale"322 assert region_for_city("Lévis") == "Chaudière-Appalaches"323 assert region_for_city("Dorval") == "Montréal"324 assert region_for_city("Ville inconnue XYZ") == ""325326327def test_quality_issues():328 from jobka.quality import issues329 ok = issues(title="Développeur", description="x" * 100, city="Montréal",330 date_posted="2026-08-01", date_deadline="2026-09-01",331 salary_hour_min=25.0, salary_hour_max=30.0,332 salary_year_min=None, salary_year_max=None,333 salary_unit="hour")334 assert ok == []335 bad = issues(title="", description="court", city="Toronto",336 date_posted="2026-08-01", date_deadline="2026-07-01",337 salary_hour_min=300.0, salary_hour_max=None,338 salary_year_min=None, salary_year_max=None,339 salary_unit="hour")340 assert set(bad) == {"titre_vide", "description_courte", "ville_hors_quebec",341 "dates_incoherentes", "salaire_hors_bornes"}342343344def test_quality_high_yearly_not_hourly_capped():345 from jobka.quality import issues346 # 457 500 $/an (~ 220 $/h equiv.) : legitime, sous la borne annuelle 500 k$347 assert issues(title="Directeur", description="x" * 100, city="Montréal",348 date_posted=None, date_deadline=None,349 salary_hour_min=131.97, salary_hour_max=219.95,350 salary_year_min=274500.0, salary_year_max=457500.0,351 salary_unit="year") == []352