# ============================================================================= # 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 !

" txt = clean_html(html) assert "Bonjour !" in txt assert "Point 1" in txt and "Point 2" in txt assert "<" not in txt # --- Phase 2 (2026-08-19) : enrichissement ------------------------------------ def test_clean_html_double_escaped(): # contenu sur-échappé (Greenhouse/Lever) : entités dans les entités html = "<p>Bonjour&nbsp;!</p><li>Point</li>" txt = clean_html(html) assert "<" not in txt and " " not in txt assert "Bonjour" in txt and "Point" in txt def test_sanitize_salary_month_is_actually_year(): from jobka.normalize import sanitize_salary # « 75 000 $/mois » : hors bornes mensuelles -> réinterprété en annuel assert sanitize_salary(75000, 82500, "month") == (75000, 82500, "year") def test_sanitize_salary_hour_is_actually_year(): from jobka.normalize import sanitize_salary assert sanitize_salary(57200, None, "hour") == (57200, None, "year") def test_sanitize_salary_plausible_untouched(): from jobka.normalize import sanitize_salary assert sanitize_salary(25.0, 30.0, "hour") == (25.0, 30.0, "hour") def test_sanitize_salary_absurd_dropped(): from jobka.normalize import sanitize_salary assert sanitize_salary(2.0, 3.0, "hour") == (None, None, None) def test_salary_from_text_skips_funding(): from jobka.normalize import salary_from_text lo, _, _, _ = salary_from_text( "Nous offrons 600 000 $ en financement de pré-amorçage aux fondateurs.") assert lo is None def test_salary_from_text_decimal_amounts(): from jobka.normalize import salary_from_text lo, hi, unit, _ = salary_from_text( "The annual salary range is $82,500.00 to $137,500.00 per year.") assert (lo, hi, unit) == (82500.0, 137500.0, "year") def test_salary_from_text_skips_expense(): from jobka.normalize import salary_from_text lo, _, _, _ = salary_from_text( "You get a 250$ BEHappy expense reimbursement to buy what you want.") assert lo is None def test_detect_language_fr(): from jobka.normalize import detect_language assert detect_language( "Développeur logiciel", "Nous cherchons une personne pour rejoindre notre équipe dans nos " "bureaux de Montréal. Le poste est pour vous si vous aimez le défi " "et les tâches variées avec des exigences claires.") == "fr" def test_detect_language_en(): from jobka.normalize import detect_language assert detect_language( "Software Developer", "We are looking for someone to join our team in Montreal. You will " "work with the best and your skills will grow as we build this " "product for the world.") == "en" def test_detect_language_too_short(): from jobka.normalize import detect_language assert detect_language("Poste", "") == "" def test_extract_benefits_block(): from jobka.normalize import extract_benefits desc = ("Description du poste.\n\nAvantages\n" "• Assurances collectives\n• REER avec cotisation de l'employeur\n" "• 4 semaines de vacances\n\nComment postuler\nEnvoyez votre CV.") ben = extract_benefits(desc) assert "Assurances collectives" in ben assert "4 semaines de vacances" in ben assert len(ben) == 3 def test_extract_benefits_none_without_block(): from jobka.normalize import extract_benefits assert extract_benefits("Un poste intéressant sans liste structurée.") == [] def test_clean_title_requisition_code(): from jobka.normalize import clean_title assert clean_title("Analyste financier - R-12345") == "Analyste financier" assert clean_title("[REQ-9876] Technicien réseau") == "Technicien réseau" def test_clean_title_city_suffix(): from jobka.normalize import clean_title assert clean_title("Développeur Python - Montréal", "Montréal") == \ "Développeur Python" # ville différente : suffixe conservé assert clean_title("Développeur Python - Montréal", "Laval") == \ "Développeur Python - Montréal" def test_clean_title_all_caps(): from jobka.normalize import clean_title t = clean_title("CONSEILLER RH ET ACQUISITION DE TALENTS") assert t != t.upper() assert "RH" in t def test_region_for_city(): from jobka.regions import region_for_city assert region_for_city("Montréal") == "Montréal" assert region_for_city("Québec") == "Capitale-Nationale" assert region_for_city("Lévis") == "Chaudière-Appalaches" assert region_for_city("Dorval") == "Montréal" assert region_for_city("Ville inconnue XYZ") == "" def test_quality_issues(): from jobka.quality import issues ok = issues(title="Développeur", description="x" * 100, city="Montréal", date_posted="2026-08-01", date_deadline="2026-09-01", salary_hour_min=25.0, salary_hour_max=30.0, salary_year_min=None, salary_year_max=None, salary_unit="hour") assert ok == [] bad = issues(title="", description="court", city="Toronto", date_posted="2026-08-01", date_deadline="2026-07-01", salary_hour_min=300.0, salary_hour_max=None, salary_year_min=None, salary_year_max=None, salary_unit="hour") assert set(bad) == {"titre_vide", "description_courte", "ville_hors_quebec", "dates_incoherentes", "salaire_hors_bornes"} def test_quality_high_yearly_not_hourly_capped(): from jobka.quality import issues # 457 500 $/an (~ 220 $/h equiv.) : legitime, sous la borne annuelle 500 k$ assert issues(title="Directeur", description="x" * 100, city="Montréal", date_posted=None, date_deadline=None, salary_hour_min=131.97, salary_hour_max=219.95, salary_year_min=274500.0, salary_year_max=457500.0, salary_unit="year") == []