SPB Git

spb/job-ka

Public
13.3 KB · 358 lines python
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-209# =============================================================================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    # « Baie-D'Urfé » : libellé Workday nu sans province (bug galderma170    # 2026-10-20 : le tenant a relibellé son usine « Montreal » ->171    # « Baie-D'Urfé » avec ids republiés — ville absente de _QC_CITIES,172    # found 16->0 alors que le board portait 49 offres QC vivantes)173    assert is_quebec_location("Baie-D'Urfé")174    assert is_quebec_location("Baie D'Urfe, QC")175    assert not is_quebec_location("Toronto, ON")176    assert not is_quebec_location("Richmond, BC")177    assert not is_quebec_location("Paris, IDF, France")178    assert not is_quebec_location("")179    # « Quebec St./Street/Ave » = nom de RUE hors Québec, pas la province (bug180    # solmentalhealth 2026-09-14 : bureau de Denver sur Quebec Street publié181    # comme Québec/Capitale-Nationale — le marqueur \bquebec\b gagnait avant182    # le rejet « , CO »)183    assert not is_quebec_location("3401 Quebec St. Suite 8000, Denver, CO, 80207")184    assert not is_quebec_location("3401 Quebec St. Suite 8000")185    assert not is_quebec_location("2600 Quebec Street NW, Washington, DC")186    assert not is_quebec_location("Quebec Ave, Golden Valley, MN")187    # …mais les abréviations de saints des libellés QC légitimes survivent188    assert is_quebec_location("CAN Quebec St-Hubert")189    assert is_quebec_location("CAN Quebec Ste-Thérèse")190    assert is_quebec_location("CAN Quebec Plessisville")191    assert not parse_location("3401 Quebec St. Suite 8000, Denver, CO, 80207")["region"]192193194# --- catégories / HTML ------------------------------------------------------------195196def test_categorize():197    assert categorize("Développeur logiciel senior") == "TI"198    assert categorize("Infirmière clinicienne") == "Santé"199    assert categorize("Conseiller en ressources humaines") == "RH"200    assert categorize("Poste mystère") == ""201202203def test_clean_html():204    html = "<p>Bonjour&nbsp;!</p><ul><li>Point 1</li><li>Point 2</li></ul>"205    txt = clean_html(html)206    assert "Bonjour !" in txt207    assert "Point 1" in txt and "Point 2" in txt208    assert "<" not in txt209210211# --- Phase 2 (2026-08-19) : enrichissement ------------------------------------212213def test_clean_html_double_escaped():214    # contenu sur-échappé (Greenhouse/Lever) : entités dans les entités215    html = "&lt;p&gt;Bonjour&amp;nbsp;!&lt;/p&gt;&lt;li&gt;Point&lt;/li&gt;"216    txt = clean_html(html)217    assert "<" not in txt and "&nbsp;" not in txt218    assert "Bonjour" in txt and "Point" in txt219220221def test_sanitize_salary_month_is_actually_year():222    from jobka.normalize import sanitize_salary223    # « 75 000 $/mois » : hors bornes mensuelles -> réinterprété en annuel224    assert sanitize_salary(75000, 82500, "month") == (75000, 82500, "year")225226227def test_sanitize_salary_hour_is_actually_year():228    from jobka.normalize import sanitize_salary229    assert sanitize_salary(57200, None, "hour") == (57200, None, "year")230231232def test_sanitize_salary_plausible_untouched():233    from jobka.normalize import sanitize_salary234    assert sanitize_salary(25.0, 30.0, "hour") == (25.0, 30.0, "hour")235236237def test_sanitize_salary_absurd_dropped():238    from jobka.normalize import sanitize_salary239    assert sanitize_salary(2.0, 3.0, "hour") == (None, None, None)240241242def test_salary_from_text_skips_funding():243    from jobka.normalize import salary_from_text244    lo, _, _, _ = salary_from_text(245        "Nous offrons 600 000 $ en financement de pré-amorçage aux fondateurs.")246    assert lo is None247248249def test_salary_from_text_decimal_amounts():250    from jobka.normalize import salary_from_text251    lo, hi, unit, _ = salary_from_text(252        "The annual salary range is $82,500.00 to $137,500.00 per year.")253    assert (lo, hi, unit) == (82500.0, 137500.0, "year")254255256def test_salary_from_text_skips_expense():257    from jobka.normalize import salary_from_text258    lo, _, _, _ = salary_from_text(259        "You get a 250$ BEHappy expense reimbursement to buy what you want.")260    assert lo is None261262263def test_detect_language_fr():264    from jobka.normalize import detect_language265    assert detect_language(266        "Développeur logiciel",267        "Nous cherchons une personne pour rejoindre notre équipe dans nos "268        "bureaux de Montréal. Le poste est pour vous si vous aimez le défi "269        "et les tâches variées avec des exigences claires.") == "fr"270271272def test_detect_language_en():273    from jobka.normalize import detect_language274    assert detect_language(275        "Software Developer",276        "We are looking for someone to join our team in Montreal. You will "277        "work with the best and your skills will grow as we build this "278        "product for the world.") == "en"279280281def test_detect_language_too_short():282    from jobka.normalize import detect_language283    assert detect_language("Poste", "") == ""284285286def test_extract_benefits_block():287    from jobka.normalize import extract_benefits288    desc = ("Description du poste.\n\nAvantages\n"289            "• Assurances collectives\n• REER avec cotisation de l'employeur\n"290            "• 4 semaines de vacances\n\nComment postuler\nEnvoyez votre CV.")291    ben = extract_benefits(desc)292    assert "Assurances collectives" in ben293    assert "4 semaines de vacances" in ben294    assert len(ben) == 3295296297def test_extract_benefits_none_without_block():298    from jobka.normalize import extract_benefits299    assert extract_benefits("Un poste intéressant sans liste structurée.") == []300301302def test_clean_title_requisition_code():303    from jobka.normalize import clean_title304    assert clean_title("Analyste financier - R-12345") == "Analyste financier"305    assert clean_title("[REQ-9876] Technicien réseau") == "Technicien réseau"306307308def test_clean_title_city_suffix():309    from jobka.normalize import clean_title310    assert clean_title("Développeur Python - Montréal", "Montréal") == \311        "Développeur Python"312    # ville différente : suffixe conservé313    assert clean_title("Développeur Python - Montréal", "Laval") == \314        "Développeur Python - Montréal"315316317def test_clean_title_all_caps():318    from jobka.normalize import clean_title319    t = clean_title("CONSEILLER RH ET ACQUISITION DE TALENTS")320    assert t != t.upper()321    assert "RH" in t322323324def test_region_for_city():325    from jobka.regions import region_for_city326    assert region_for_city("Montréal") == "Montréal"327    assert region_for_city("Québec") == "Capitale-Nationale"328    assert region_for_city("Lévis") == "Chaudière-Appalaches"329    assert region_for_city("Dorval") == "Montréal"330    assert region_for_city("Ville inconnue XYZ") == ""331332333def test_quality_issues():334    from jobka.quality import issues335    ok = issues(title="Développeur", description="x" * 100, city="Montréal",336                date_posted="2026-08-01", date_deadline="2026-09-01",337                salary_hour_min=25.0, salary_hour_max=30.0,338                salary_year_min=None, salary_year_max=None,339                salary_unit="hour")340    assert ok == []341    bad = issues(title="", description="court", city="Toronto",342                 date_posted="2026-08-01", date_deadline="2026-07-01",343                 salary_hour_min=300.0, salary_hour_max=None,344                 salary_year_min=None, salary_year_max=None,345                 salary_unit="hour")346    assert set(bad) == {"titre_vide", "description_courte", "ville_hors_quebec",347                        "dates_incoherentes", "salaire_hors_bornes"}348349350def test_quality_high_yearly_not_hourly_capped():351    from jobka.quality import issues352    # 457 500 $/an (~ 220 $/h equiv.) : legitime, sous la borne annuelle 500 k$353    assert issues(title="Directeur", description="x" * 100, city="Montréal",354                  date_posted=None, date_deadline=None,355                  salary_hour_min=131.97, salary_hour_max=219.95,356                  salary_year_min=274500.0, salary_year_max=457500.0,357                  salary_unit="year") == []358
Files · 7cbdaeb71238ac59822297f2d65ab604be37eee9
Menu

Theme