SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
14.8 KB · 341 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — tests de la couche de normalisation commune (louka/normalize.py)3# -----------------------------------------------------------------------------4from datetime import date56from louka.normalize import (7    clean_address,8    extract_details,9    normalize_unit_type,10    parse_area_sqft,11    parse_availability_date,12    parse_price,13    price_is_from,14)15from louka.schema import Listing1617TODAY = date(2026, 8, 8)181920# -- prix ---------------------------------------------------------------------2122def test_parse_price_formats():23    assert parse_price("1 195 $/mois") == 119524    assert parse_price("À partir de 1 250,00$") == 125025    assert parse_price("$1,195") == 119526    assert parse_price("995$ - 1 200$") == 995          # le plus bas27    assert parse_price("1195,50 $") == 1195.528    assert parse_price("Contactez-nous") is None29    assert parse_price("") is None30    assert parse_price("50 $") is None                  # hors plage plausible31    assert parse_price("4 1/2 au 2e étage") is None     # pas de $323334def test_price_is_from():35    assert price_is_from("À partir de 995$")36    assert price_is_from("From $1,200")37    assert not price_is_from("1 195 $/mois")383940# -- type d'unité ---------------------------------------------------------------4142def test_normalize_unit_type():43    assert normalize_unit_type("4 1/2") == "4½"44    assert normalize_unit_type("4½") == "4½"45    assert normalize_unit_type("4,5") == "4½"46    assert normalize_unit_type("5 1/5") == "5½"          # coquille fréquente47    assert normalize_unit_type("2 chambres") == "4½"48    assert normalize_unit_type("3 bedrooms") == "5½"49    assert normalize_unit_type("6 1/2") == "6½+"50    assert normalize_unit_type("7½") == "6½+"51    assert normalize_unit_type("Studio") == "Studio"52    assert normalize_unit_type("bachelor") == "Studio"53    assert normalize_unit_type("MDV") == "Maison"54    assert normalize_unit_type("maison de ville") == "Maison"55    assert normalize_unit_type("Chambre") == "Chambre"56    assert normalize_unit_type("condo") == "Condo"57    assert normalize_unit_type("") == ""585960# -- disponibilité ---------------------------------------------------------------6162def test_parse_availability_now():63    for raw in ("Libre immédiatement", "Maintenant", "Disponible",64                "Available now", "Occupation immédiate"):65        assert parse_availability_date(raw, TODAY) == "now", raw666768def test_parse_availability_dates():69    assert parse_availability_date("1er juillet 2027", TODAY) == "2027-07-01"70    assert parse_availability_date("Disponibilité : 01 décembre 2026", TODAY) == "2026-12-01"71    assert parse_availability_date("octobre 2026", TODAY) == "2026-10-01"72    assert parse_availability_date("2026-09-15", TODAY) == "2026-09-15"73    assert parse_availability_date("01/11/2026", TODAY) == "2026-11-01"74    assert parse_availability_date("December 1, 2026", TODAY) == "2026-12-01"757677def test_parse_availability_year_inference():78    # sans année : prochaine occurrence, ou « now » si déjà passée79    assert parse_availability_date("1er septembre", TODAY) == "2026-09-01"80    assert parse_availability_date("1er juillet", TODAY) == "now"81    # date explicite passée -> le logement est libre82    assert parse_availability_date("1er juillet 2026", TODAY) == "now"838485def test_parse_availability_unknown():86    assert parse_availability_date("", TODAY) is None87    assert parse_availability_date("Sur demande", TODAY) is None88    # frontières de mots : « rénové » ≠ novembre, « maison » ≠ mai89    assert parse_availability_date("Logement rénové", TODAY) is None90    assert parse_availability_date("Grande maison", TODAY) is None919293# -- superficie ---------------------------------------------------------------9495def test_parse_area():96    assert parse_area_sqft("850 pi²") == 85097    assert parse_area_sqft("850 pi2") == 85098    assert parse_area_sqft("1 050 sq. ft.") == 105099    assert parse_area_sqft("78 m²") == round(78 * 10.7639)100    assert parse_area_sqft("aucune mention") is None101    assert parse_area_sqft("2 pi²") is None              # hors plage102103104# -- adresse ---------------------------------------------------------------105106def test_clean_address():107    assert clean_address("  555,   rue Exemple ,Québec ") == "555, rue Exemple, Québec"108    assert clean_address("") == ""109110111# -- détails structurés ---------------------------------------------------------112113def test_extract_details_inclusions():114    d = extract_details(["Chauffage inclus", "Eau chaude", "Lave-vaisselle"],115                        "Stationnement intérieur disponible. Animaux acceptés.")116    assert d["inclusions"]["heating"] is True117    assert d["inclusions"]["hot_water"] is True118    assert d["appliances"]["dishwasher"] is True119    assert d["parking"]["available"] is True120    assert d["parking"]["type"] == "intérieur"121    assert d["pets"] == "oui"122123124def test_extract_details_negations():125    d = extract_details([], "Pas d'animaux. Logement non-fumeur, non meublé. "126                            "Entrées laveuse-sécheuse.")127    assert d["pets"] == "non"128    assert d["smoking"] is False129    assert d["furnished"] is False130    assert d["appliances"]["washer_dryer_hookup"] is True131    assert "washer_dryer" not in d["appliances"]132133134def test_extract_details_edge_cases_connecteurs():135    # « Non chauffé et éclairé » : chauffage NON inclus, électricité incluse136    d = extract_details(["Non chauffé et éclairé"], "")137    assert d["inclusions"]["heating"] is False138    assert d["inclusions"]["electricity"] is True139    # « Chats autorisés (*sous conditions) » -> conditions140    d = extract_details(["Chats autorisés (*sous conditions)"], "")141    assert d["pets"] == "conditions"142    # électroménagers en option payante : ne rien affirmer143    d = extract_details(["Électroménagers sur demande ($)"], "")144    assert "appliances" not in d145    # anglais : refrigerator / Cooling: Central Air reconnus,146    # « Heating: Forced Air » = type de chauffage, pas une inclusion147    d = extract_details(["Refrigerator", "Cooling: Central Air", "Heating: Forced Air"], "")148    assert d["appliances"]["fridge"] is True149    assert d["ac"] is True150    assert "inclusions" not in d151152153def test_extract_details_edge_cases_lot2():154    # « Wifi (optionnel) » = option payante, pas une inclusion155    d = extract_details(["Wifi (optionnel)"], "")156    assert "inclusions" not in d157    # « Les chiens ne sont pas permis » -> sous conditions (chats tolérés)158    d = extract_details([], "Les chiens ne sont pas permis dans nos propriétés.")159    assert d["pets"] == "conditions"160    # stationnement mixte161    d = extract_details(["Stationnement extérieur et intérieur"], "")162    assert d["parking"]["type"] == "intérieur et extérieur"163    # étage ignoré dans une description multi-unités, retenu en commodité164    d = extract_details([], "Grand 4½ au 3e étage d'un immeuble de 12 logements.")165    assert "floor" not in d166    d = extract_details(["3e étage"], "")167    assert d["floor"] == 3168169170def test_extract_details_pets_both_species():171    # « Chiens et chats acceptés » = oui (pas « conditions »)172    assert extract_details(["Chiens et chats acceptés"], "")["pets"] == "oui"173    assert extract_details(["Chats et chiens admis"], "")["pets"] == "oui"174    # « Chats acceptés » seul reste « conditions »175    assert extract_details(["Chats acceptés"], "")["pets"] == "conditions"176177178def test_parse_availability_english_month_first():179    assert parse_availability_date("Available September 17", TODAY) == "2026-09-17"180    assert parse_availability_date("April 1st, 2027", TODAY) == "2027-04-01"181    assert parse_availability_date("juillet 2027", TODAY) == "2027-07-01"  # pas « jour 20 »182183184def test_parse_area_context_and_thousands():185    # la superficie du balcon n'est pas celle du logement186    assert parse_area_sqft("1134 pi², balcon 277 pi²") == 1134187    assert parse_area_sqft("1,607 sq. ft.") == 1607188189190def test_extract_details_edge_cases_lots_9_a_13():191    # « washer » ne doit pas matcher « dishwasher »192    d = extract_details(["Dishwasher"], "")193    assert d["appliances"] == {"dishwasher": True}194    # entrée lave-vaisselle = branchement, pas l'appareil195    d = extract_details(["Entrée lave-vaisselle"], "")196    assert d["appliances"].get("dishwasher") is not True197    assert d["appliances"]["dishwasher_hookup"] is True198    # coquille fréquente « ascenceur »199    assert extract_details(["Ascenceur"], "")["elevator"] is True200    # pets : formulations variées201    assert extract_details(["Chien interdit"], "")["pets"] == "conditions"202    assert extract_details(["Chiens permis"], "")["pets"] == "oui"203    assert extract_details(["Animaux de compagnie acceptés"], "")["pets"] == "oui"204    assert extract_details([], "Animaux permis : Non")["pets"] == "non"205    assert extract_details([], "Animaux acceptés (20lb et moins)")["pets"] == "conditions"206    # anglais : heat/hydro/terrace/laundry facilities ; heat pump ≠ inclusion207    d = extract_details(["Heat", "Hydro", "Rooftop terrace", "Laundry facilities"], "")208    assert d["inclusions"]["heating"] is True209    assert d["inclusions"]["electricity"] is True210    assert d["balcony"] is True and d["laundry"] is True211    # semi-meublé : nuance conservée, pas un booléen212    assert extract_details(["Semi-meublé"], "")["furnished"] == "semi"213    # vélo ≠ stationnement auto ; « ($) » = payant214    assert "parking" not in extract_details(["Stationnement à vélo intérieur"], "")215    assert "inclusions" not in extract_details(["Wi-Fi ($)"], "")216217218def test_extract_details_edge_cases_lots_4_et_7():219    # « chauffé » qualifiant autre chose que le logement220    d = extract_details(["Stationnement intérieur non chauffé"], "")221    assert "inclusions" not in d222    assert d["parking"]["available"] is True223    d = extract_details(["Piscine extérieure chauffée"], "")224    assert "inclusions" not in d and d["pool"] is True225    # aires communes ≠ unité226    d = extract_details(["Terrasse commune avec BBQ", "WI-FI dans les aires communes"], "")227    assert "balcony" not in d and "inclusions" not in d228    # étage d'une installation ≠ étage de l'unité229    assert "floor" not in extract_details(["Salon lounge au 25e étage"], "")230    # négations et « rien d'inclus »231    assert extract_details(["Pas de balcon"], "")["balcony"] is False232    d = extract_details([], "Rien d'inclus.")233    assert d["inclusions"]["heating"] is False234    assert extract_details([], "Les animaux sont interdits.")["pets"] == "non"235    # option payante « moyennant des frais mensuels »236    d = extract_details(["Électroménagers (moyennant des frais mensuels)"], "")237    assert "appliances" not in d238239240def test_normalize_unit_type_word_numbers():241    assert normalize_unit_type("Une chambre") == "3½"242    assert normalize_unit_type("Two bedrooms") == "4½"243244245def test_extract_details_edge_cases_lot6():246    # animal au singulier247    assert extract_details([], "Votre animal est le bienvenu !")["pets"] == "oui"248    # nom de promotion ≠ inclusion249    d = extract_details([], "Promotion : Crédit de 1 000 $ Éclaire à la signature.")250    assert "inclusions" not in d251    # prix de stationnement complet, formats FR et EN252    d = extract_details([], "Stationnement disponible : 2 espaces pour seulement 150 $.")253    assert d["parking"]["price"] == 150254    d = extract_details([], "Parking available for $85 per month.")255    assert d["parking"]["price"] == 85256257258def test_extract_details_edge_cases_lot5():259    # « à la charge des locataires » = NON inclus260    d = extract_details([], "Chauffage et électricité à la charge des locataires.")261    assert d["inclusions"]["heating"] is False262    assert d["inclusions"]["electricity"] is False263    # « Actuellement vacant » = libre maintenant264    assert parse_availability_date("Actuellement vacant", TODAY) == "now"265266267def test_extract_details_edge_cases_lot8():268    # « non-chauffé, non-éclairé » avec traits d'union269    d = extract_details(["Non-chauffé, non-éclairé"], "")270    assert d["inclusions"]["heating"] is False271    assert d["inclusions"]["electricity"] is False272    # branchement combiné : ni laveuse ni lave-vaisselle fournis273    d = extract_details(["Entrées lave-vaisselle et laveuse/sécheuse"], "")274    assert d["appliances"].get("washer_dryer") is not True275    assert d["appliances"].get("dishwasher") is not True276    assert d["appliances"]["washer_dryer_hookup"] is True277    # « refusés sauf un chat » = conditions278    assert extract_details([], "Animaux refusés, à l'exception d'un chat.")["pets"] == "conditions"279    # restriction entre parenthèses280    assert "ac" not in extract_details(["Appareil de climatisation (Sans utilisation)"], "")281282283def test_price_from_range():284    assert price_is_from("de 1 650 $ à 2 293 $")285286287def test_extract_details_unknown_stays_absent():288    d = extract_details(["Balcon"], "")289    assert d.get("balcony") is True290    assert "pets" not in d291    assert "smoking" not in d292    assert "inclusions" not in d293294295def test_extract_details_contact():296    d = extract_details([], "Pour info : 418 555-1234 ou louer@exemple.ca")297    assert d["contact"]["phone"] == "418-555-1234"298    assert d["contact"]["email"] == "louer@exemple.ca"299300301# -- finalize (enrichissement de bout en bout) -----------------------------------302303def test_listing_finalize():304    l = Listing(305        source="test", external_id="1", url="https://x/1",306        title="Le Test — 4 1/2",307        address="555,rue Exemple,  Québec",308        unit_type="2 chambres",309        price_label="À partir de 1 195 $/mois",310        availability="1er décembre 2026",311        description="Superbe 4½ de 850 pi². Chauffage et eau chaude inclus. "312                    "Animaux refusés. Info : (418) 555-9999.",313        amenities=["Balcon", "Ascenseur", "Piscine intérieure"],314    ).finalize()315    assert l.unit_type == "4½"316    assert l.price == 1195317    assert l.availability_date == "2026-12-01"318    assert l.area_sqft == 850319    assert l.pets == "non"320    assert l.address == "555, rue Exemple, Québec"321    assert l.details["price_from"] is True322    assert l.details["balcony"] is True323    assert l.details["elevator"] is True324    assert l.details["pool"] is True325    assert l.details["inclusions"]["heating"] is True326    assert l.details["contact"]["phone"] == "418-555-9999"327328329def test_listing_finalize_respects_connector_values():330    l = Listing(source="t", external_id="2", url="u",331                price=999.0, price_label="1 500 $",332                pets="oui", furnished=True,333                availability_date="2027-01-01", availability="maintenant",334                details={"parking": {"available": True, "price": 60.0}})335    l.finalize()336    assert l.price == 999.0                       # valeur connecteur conservée337    assert l.availability_date == "2027-01-01"338    assert l.pets == "oui"339    assert l.furnished is True340    assert l.details["parking"]["price"] == 60.0341