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-09-129# =============================================================================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 # « Sitec Fermont » : site minier de Cambria à Fermont (Côte-Nord) —159 # libellé Workday sans province, la ville seule doit suffire (bug cambria160 # 2026-09-12 : Fermont absent de _QC_CITIES rendait l'offre invisible)161 assert is_quebec_location("Sitec Fermont")162 assert not is_quebec_location("Toronto, ON")163 assert not is_quebec_location("Richmond, BC")164 assert not is_quebec_location("Paris, IDF, France")165 assert not is_quebec_location("")166167168# --- catégories / HTML ------------------------------------------------------------169170def test_categorize():171 assert categorize("Développeur logiciel senior") == "TI"172 assert categorize("Infirmière clinicienne") == "Santé"173 assert categorize("Conseiller en ressources humaines") == "RH"174 assert categorize("Poste mystère") == ""175176177def test_clean_html():178 html = "<p>Bonjour !</p><ul><li>Point 1</li><li>Point 2</li></ul>"179 txt = clean_html(html)180 assert "Bonjour !" in txt181 assert "Point 1" in txt and "Point 2" in txt182 assert "<" not in txt183184185# --- Phase 2 (2026-08-19) : enrichissement ------------------------------------186187def test_clean_html_double_escaped():188 # contenu sur-échappé (Greenhouse/Lever) : entités dans les entités189 html = "<p>Bonjour&nbsp;!</p><li>Point</li>"190 txt = clean_html(html)191 assert "<" not in txt and " " not in txt192 assert "Bonjour" in txt and "Point" in txt193194195def test_sanitize_salary_month_is_actually_year():196 from jobka.normalize import sanitize_salary197 # « 75 000 $/mois » : hors bornes mensuelles -> réinterprété en annuel198 assert sanitize_salary(75000, 82500, "month") == (75000, 82500, "year")199200201def test_sanitize_salary_hour_is_actually_year():202 from jobka.normalize import sanitize_salary203 assert sanitize_salary(57200, None, "hour") == (57200, None, "year")204205206def test_sanitize_salary_plausible_untouched():207 from jobka.normalize import sanitize_salary208 assert sanitize_salary(25.0, 30.0, "hour") == (25.0, 30.0, "hour")209210211def test_sanitize_salary_absurd_dropped():212 from jobka.normalize import sanitize_salary213 assert sanitize_salary(2.0, 3.0, "hour") == (None, None, None)214215216def test_salary_from_text_skips_funding():217 from jobka.normalize import salary_from_text218 lo, _, _, _ = salary_from_text(219 "Nous offrons 600 000 $ en financement de pré-amorçage aux fondateurs.")220 assert lo is None221222223def test_salary_from_text_decimal_amounts():224 from jobka.normalize import salary_from_text225 lo, hi, unit, _ = salary_from_text(226 "The annual salary range is $82,500.00 to $137,500.00 per year.")227 assert (lo, hi, unit) == (82500.0, 137500.0, "year")228229230def test_salary_from_text_skips_expense():231 from jobka.normalize import salary_from_text232 lo, _, _, _ = salary_from_text(233 "You get a 250$ BEHappy expense reimbursement to buy what you want.")234 assert lo is None235236237def test_detect_language_fr():238 from jobka.normalize import detect_language239 assert detect_language(240 "Développeur logiciel",241 "Nous cherchons une personne pour rejoindre notre équipe dans nos "242 "bureaux de Montréal. Le poste est pour vous si vous aimez le défi "243 "et les tâches variées avec des exigences claires.") == "fr"244245246def test_detect_language_en():247 from jobka.normalize import detect_language248 assert detect_language(249 "Software Developer",250 "We are looking for someone to join our team in Montreal. You will "251 "work with the best and your skills will grow as we build this "252 "product for the world.") == "en"253254255def test_detect_language_too_short():256 from jobka.normalize import detect_language257 assert detect_language("Poste", "") == ""258259260def test_extract_benefits_block():261 from jobka.normalize import extract_benefits262 desc = ("Description du poste.\n\nAvantages\n"263 "• Assurances collectives\n• REER avec cotisation de l'employeur\n"264 "• 4 semaines de vacances\n\nComment postuler\nEnvoyez votre CV.")265 ben = extract_benefits(desc)266 assert "Assurances collectives" in ben267 assert "4 semaines de vacances" in ben268 assert len(ben) == 3269270271def test_extract_benefits_none_without_block():272 from jobka.normalize import extract_benefits273 assert extract_benefits("Un poste intéressant sans liste structurée.") == []274275276def test_clean_title_requisition_code():277 from jobka.normalize import clean_title278 assert clean_title("Analyste financier - R-12345") == "Analyste financier"279 assert clean_title("[REQ-9876] Technicien réseau") == "Technicien réseau"280281282def test_clean_title_city_suffix():283 from jobka.normalize import clean_title284 assert clean_title("Développeur Python - Montréal", "Montréal") == \285 "Développeur Python"286 # ville différente : suffixe conservé287 assert clean_title("Développeur Python - Montréal", "Laval") == \288 "Développeur Python - Montréal"289290291def test_clean_title_all_caps():292 from jobka.normalize import clean_title293 t = clean_title("CONSEILLER RH ET ACQUISITION DE TALENTS")294 assert t != t.upper()295 assert "RH" in t296297298def test_region_for_city():299 from jobka.regions import region_for_city300 assert region_for_city("Montréal") == "Montréal"301 assert region_for_city("Québec") == "Capitale-Nationale"302 assert region_for_city("Lévis") == "Chaudière-Appalaches"303 assert region_for_city("Dorval") == "Montréal"304 assert region_for_city("Ville inconnue XYZ") == ""305306307def test_quality_issues():308 from jobka.quality import issues309 ok = issues(title="Développeur", description="x" * 100, city="Montréal",310 date_posted="2026-08-01", date_deadline="2026-09-01",311 salary_hour_min=25.0, salary_hour_max=30.0,312 salary_year_min=None, salary_year_max=None,313 salary_unit="hour")314 assert ok == []315 bad = issues(title="", description="court", city="Toronto",316 date_posted="2026-08-01", date_deadline="2026-07-01",317 salary_hour_min=300.0, salary_hour_max=None,318 salary_year_min=None, salary_year_max=None,319 salary_unit="hour")320 assert set(bad) == {"titre_vide", "description_courte", "ville_hors_quebec",321 "dates_incoherentes", "salaire_hors_bornes"}322323324def test_quality_high_yearly_not_hourly_capped():325 from jobka.quality import issues326 # 457 500 $/an (~ 220 $/h equiv.) : legitime, sous la borne annuelle 500 k$327 assert issues(title="Directeur", description="x" * 100, city="Montréal",328 date_posted=None, date_deadline=None,329 salary_hour_min=131.97, salary_hour_max=219.95,330 salary_year_min=274500.0, salary_year_max=457500.0,331 salary_unit="year") == []332