SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
10 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
12.6 KB · 345 lines python
Raw Blame History
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-149# =============================================================================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("")166    # « Quebec St./Street/Ave » = nom de RUE hors Québec, pas la province (bug167    # solmentalhealth 2026-09-14 : bureau de Denver sur Quebec Street publié168    # comme Québec/Capitale-Nationale — le marqueur \bquebec\b gagnait avant169    # le rejet « , CO »)170    assert not is_quebec_location("3401 Quebec St. Suite 8000, Denver, CO, 80207")171    assert not is_quebec_location("3401 Quebec St. Suite 8000")172    assert not is_quebec_location("2600 Quebec Street NW, Washington, DC")173    assert not is_quebec_location("Quebec Ave, Golden Valley, MN")174    # …mais les abréviations de saints des libellés QC légitimes survivent175    assert is_quebec_location("CAN Quebec St-Hubert")176    assert is_quebec_location("CAN Quebec Ste-Thérèse")177    assert is_quebec_location("CAN Quebec Plessisville")178    assert not parse_location("3401 Quebec St. Suite 8000, Denver, CO, 80207")["region"]179180181# --- catégories / HTML ------------------------------------------------------------182183def test_categorize():184    assert categorize("Développeur logiciel senior") == "TI"185    assert categorize("Infirmière clinicienne") == "Santé"186    assert categorize("Conseiller en ressources humaines") == "RH"187    assert categorize("Poste mystère") == ""188189190def test_clean_html():191    html = "<p>Bonjour&nbsp;!</p><ul><li>Point 1</li><li>Point 2</li></ul>"192    txt = clean_html(html)193    assert "Bonjour !" in txt194    assert "Point 1" in txt and "Point 2" in txt195    assert "<" not in txt196197198# --- Phase 2 (2026-08-19) : enrichissement ------------------------------------199200def test_clean_html_double_escaped():201    # contenu sur-échappé (Greenhouse/Lever) : entités dans les entités202    html = "&lt;p&gt;Bonjour&amp;nbsp;!&lt;/p&gt;&lt;li&gt;Point&lt;/li&gt;"203    txt = clean_html(html)204    assert "<" not in txt and "&nbsp;" not in txt205    assert "Bonjour" in txt and "Point" in txt206207208def test_sanitize_salary_month_is_actually_year():209    from jobka.normalize import sanitize_salary210    # « 75 000 $/mois » : hors bornes mensuelles -> réinterprété en annuel211    assert sanitize_salary(75000, 82500, "month") == (75000, 82500, "year")212213214def test_sanitize_salary_hour_is_actually_year():215    from jobka.normalize import sanitize_salary216    assert sanitize_salary(57200, None, "hour") == (57200, None, "year")217218219def test_sanitize_salary_plausible_untouched():220    from jobka.normalize import sanitize_salary221    assert sanitize_salary(25.0, 30.0, "hour") == (25.0, 30.0, "hour")222223224def test_sanitize_salary_absurd_dropped():225    from jobka.normalize import sanitize_salary226    assert sanitize_salary(2.0, 3.0, "hour") == (None, None, None)227228229def test_salary_from_text_skips_funding():230    from jobka.normalize import salary_from_text231    lo, _, _, _ = salary_from_text(232        "Nous offrons 600 000 $ en financement de pré-amorçage aux fondateurs.")233    assert lo is None234235236def test_salary_from_text_decimal_amounts():237    from jobka.normalize import salary_from_text238    lo, hi, unit, _ = salary_from_text(239        "The annual salary range is $82,500.00 to $137,500.00 per year.")240    assert (lo, hi, unit) == (82500.0, 137500.0, "year")241242243def test_salary_from_text_skips_expense():244    from jobka.normalize import salary_from_text245    lo, _, _, _ = salary_from_text(246        "You get a 250$ BEHappy expense reimbursement to buy what you want.")247    assert lo is None248249250def test_detect_language_fr():251    from jobka.normalize import detect_language252    assert detect_language(253        "Développeur logiciel",254        "Nous cherchons une personne pour rejoindre notre équipe dans nos "255        "bureaux de Montréal. Le poste est pour vous si vous aimez le défi "256        "et les tâches variées avec des exigences claires.") == "fr"257258259def test_detect_language_en():260    from jobka.normalize import detect_language261    assert detect_language(262        "Software Developer",263        "We are looking for someone to join our team in Montreal. You will "264        "work with the best and your skills will grow as we build this "265        "product for the world.") == "en"266267268def test_detect_language_too_short():269    from jobka.normalize import detect_language270    assert detect_language("Poste", "") == ""271272273def test_extract_benefits_block():274    from jobka.normalize import extract_benefits275    desc = ("Description du poste.\n\nAvantages\n"276            "• Assurances collectives\n• REER avec cotisation de l'employeur\n"277            "• 4 semaines de vacances\n\nComment postuler\nEnvoyez votre CV.")278    ben = extract_benefits(desc)279    assert "Assurances collectives" in ben280    assert "4 semaines de vacances" in ben281    assert len(ben) == 3282283284def test_extract_benefits_none_without_block():285    from jobka.normalize import extract_benefits286    assert extract_benefits("Un poste intéressant sans liste structurée.") == []287288289def test_clean_title_requisition_code():290    from jobka.normalize import clean_title291    assert clean_title("Analyste financier - R-12345") == "Analyste financier"292    assert clean_title("[REQ-9876] Technicien réseau") == "Technicien réseau"293294295def test_clean_title_city_suffix():296    from jobka.normalize import clean_title297    assert clean_title("Développeur Python - Montréal", "Montréal") == \298        "Développeur Python"299    # ville différente : suffixe conservé300    assert clean_title("Développeur Python - Montréal", "Laval") == \301        "Développeur Python - Montréal"302303304def test_clean_title_all_caps():305    from jobka.normalize import clean_title306    t = clean_title("CONSEILLER RH ET ACQUISITION DE TALENTS")307    assert t != t.upper()308    assert "RH" in t309310311def test_region_for_city():312    from jobka.regions import region_for_city313    assert region_for_city("Montréal") == "Montréal"314    assert region_for_city("Québec") == "Capitale-Nationale"315    assert region_for_city("Lévis") == "Chaudière-Appalaches"316    assert region_for_city("Dorval") == "Montréal"317    assert region_for_city("Ville inconnue XYZ") == ""318319320def test_quality_issues():321    from jobka.quality import issues322    ok = issues(title="Développeur", description="x" * 100, city="Montréal",323                date_posted="2026-08-01", date_deadline="2026-09-01",324                salary_hour_min=25.0, salary_hour_max=30.0,325                salary_year_min=None, salary_year_max=None,326                salary_unit="hour")327    assert ok == []328    bad = issues(title="", description="court", city="Toronto",329                 date_posted="2026-08-01", date_deadline="2026-07-01",330                 salary_hour_min=300.0, salary_hour_max=None,331                 salary_year_min=None, salary_year_max=None,332                 salary_unit="hour")333    assert set(bad) == {"titre_vide", "description_courte", "ville_hors_quebec",334                        "dates_incoherentes", "salaire_hors_bornes"}335336337def test_quality_high_yearly_not_hourly_capped():338    from jobka.quality import issues339    # 457 500 $/an (~ 220 $/h equiv.) : legitime, sous la borne annuelle 500 k$340    assert issues(title="Directeur", description="x" * 100, city="Montréal",341                  date_posted=None, date_deadline=None,342                  salary_hour_min=131.97, salary_hour_max=219.95,343                  salary_year_min=274500.0, salary_year_max=457500.0,344                  salary_unit="year") == []345