# ----------------------------------------------------------------------------- # Lou-Ka — tests de la couche de normalisation commune (louka/normalize.py) # ----------------------------------------------------------------------------- from datetime import date from louka.normalize import ( clean_address, extract_details, normalize_unit_type, parse_area_sqft, parse_availability_date, parse_price, price_is_from, ) from louka.schema import Listing TODAY = date(2026, 8, 8) # -- prix --------------------------------------------------------------------- def test_parse_price_formats(): assert parse_price("1 195 $/mois") == 1195 assert parse_price("À partir de 1 250,00$") == 1250 assert parse_price("$1,195") == 1195 assert parse_price("995$ - 1 200$") == 995 # le plus bas assert parse_price("1195,50 $") == 1195.5 assert parse_price("Contactez-nous") is None assert parse_price("") is None assert parse_price("50 $") is None # hors plage plausible assert parse_price("4 1/2 au 2e étage") is None # pas de $ def test_price_is_from(): assert price_is_from("À partir de 995$") assert price_is_from("From $1,200") assert not price_is_from("1 195 $/mois") # -- type d'unité --------------------------------------------------------------- def test_normalize_unit_type(): assert normalize_unit_type("4 1/2") == "4½" assert normalize_unit_type("4½") == "4½" assert normalize_unit_type("4,5") == "4½" assert normalize_unit_type("5 1/5") == "5½" # coquille fréquente assert normalize_unit_type("2 chambres") == "4½" assert normalize_unit_type("3 bedrooms") == "5½" assert normalize_unit_type("6 1/2") == "6½+" assert normalize_unit_type("7½") == "6½+" assert normalize_unit_type("Studio") == "Studio" assert normalize_unit_type("bachelor") == "Studio" assert normalize_unit_type("MDV") == "Maison" assert normalize_unit_type("maison de ville") == "Maison" assert normalize_unit_type("Chambre") == "Chambre" assert normalize_unit_type("condo") == "Condo" assert normalize_unit_type("") == "" # -- disponibilité --------------------------------------------------------------- def test_parse_availability_now(): for raw in ("Libre immédiatement", "Maintenant", "Disponible", "Available now", "Occupation immédiate"): assert parse_availability_date(raw, TODAY) == "now", raw def test_parse_availability_dates(): assert parse_availability_date("1er juillet 2027", TODAY) == "2027-07-01" assert parse_availability_date("Disponibilité : 01 décembre 2026", TODAY) == "2026-12-01" assert parse_availability_date("octobre 2026", TODAY) == "2026-10-01" assert parse_availability_date("2026-09-15", TODAY) == "2026-09-15" assert parse_availability_date("01/11/2026", TODAY) == "2026-11-01" assert parse_availability_date("December 1, 2026", TODAY) == "2026-12-01" def test_parse_availability_year_inference(): # sans année : prochaine occurrence, ou « now » si déjà passée assert parse_availability_date("1er septembre", TODAY) == "2026-09-01" assert parse_availability_date("1er juillet", TODAY) == "now" # date explicite passée -> le logement est libre assert parse_availability_date("1er juillet 2026", TODAY) == "now" def test_parse_availability_unknown(): assert parse_availability_date("", TODAY) is None assert parse_availability_date("Sur demande", TODAY) is None # frontières de mots : « rénové » ≠ novembre, « maison » ≠ mai assert parse_availability_date("Logement rénové", TODAY) is None assert parse_availability_date("Grande maison", TODAY) is None # -- superficie --------------------------------------------------------------- def test_parse_area(): assert parse_area_sqft("850 pi²") == 850 assert parse_area_sqft("850 pi2") == 850 assert parse_area_sqft("1 050 sq. ft.") == 1050 assert parse_area_sqft("78 m²") == round(78 * 10.7639) assert parse_area_sqft("aucune mention") is None assert parse_area_sqft("2 pi²") is None # hors plage # -- adresse --------------------------------------------------------------- def test_clean_address(): assert clean_address(" 555, rue Exemple ,Québec ") == "555, rue Exemple, Québec" assert clean_address("") == "" # -- détails structurés --------------------------------------------------------- def test_extract_details_inclusions(): d = extract_details(["Chauffage inclus", "Eau chaude", "Lave-vaisselle"], "Stationnement intérieur disponible. Animaux acceptés.") assert d["inclusions"]["heating"] is True assert d["inclusions"]["hot_water"] is True assert d["appliances"]["dishwasher"] is True assert d["parking"]["available"] is True assert d["parking"]["type"] == "intérieur" assert d["pets"] == "oui" def test_extract_details_negations(): d = extract_details([], "Pas d'animaux. Logement non-fumeur, non meublé. " "Entrées laveuse-sécheuse.") assert d["pets"] == "non" assert d["smoking"] is False assert d["furnished"] is False assert d["appliances"]["washer_dryer_hookup"] is True assert "washer_dryer" not in d["appliances"] def test_extract_details_edge_cases_connecteurs(): # « Non chauffé et éclairé » : chauffage NON inclus, électricité incluse d = extract_details(["Non chauffé et éclairé"], "") assert d["inclusions"]["heating"] is False assert d["inclusions"]["electricity"] is True # « Chats autorisés (*sous conditions) » -> conditions d = extract_details(["Chats autorisés (*sous conditions)"], "") assert d["pets"] == "conditions" # électroménagers en option payante : ne rien affirmer d = extract_details(["Électroménagers sur demande ($)"], "") assert "appliances" not in d # anglais : refrigerator / Cooling: Central Air reconnus, # « Heating: Forced Air » = type de chauffage, pas une inclusion d = extract_details(["Refrigerator", "Cooling: Central Air", "Heating: Forced Air"], "") assert d["appliances"]["fridge"] is True assert d["ac"] is True assert "inclusions" not in d def test_extract_details_edge_cases_lot2(): # « Wifi (optionnel) » = option payante, pas une inclusion d = extract_details(["Wifi (optionnel)"], "") assert "inclusions" not in d # « Les chiens ne sont pas permis » -> sous conditions (chats tolérés) d = extract_details([], "Les chiens ne sont pas permis dans nos propriétés.") assert d["pets"] == "conditions" # stationnement mixte d = extract_details(["Stationnement extérieur et intérieur"], "") assert d["parking"]["type"] == "intérieur et extérieur" # étage ignoré dans une description multi-unités, retenu en commodité d = extract_details([], "Grand 4½ au 3e étage d'un immeuble de 12 logements.") assert "floor" not in d d = extract_details(["3e étage"], "") assert d["floor"] == 3 def test_extract_details_pets_both_species(): # « Chiens et chats acceptés » = oui (pas « conditions ») assert extract_details(["Chiens et chats acceptés"], "")["pets"] == "oui" assert extract_details(["Chats et chiens admis"], "")["pets"] == "oui" # « Chats acceptés » seul reste « conditions » assert extract_details(["Chats acceptés"], "")["pets"] == "conditions" def test_parse_availability_english_month_first(): assert parse_availability_date("Available September 17", TODAY) == "2026-09-17" assert parse_availability_date("April 1st, 2027", TODAY) == "2027-04-01" assert parse_availability_date("juillet 2027", TODAY) == "2027-07-01" # pas « jour 20 » def test_parse_area_context_and_thousands(): # la superficie du balcon n'est pas celle du logement assert parse_area_sqft("1134 pi², balcon 277 pi²") == 1134 assert parse_area_sqft("1,607 sq. ft.") == 1607 def test_extract_details_edge_cases_lots_9_a_13(): # « washer » ne doit pas matcher « dishwasher » d = extract_details(["Dishwasher"], "") assert d["appliances"] == {"dishwasher": True} # entrée lave-vaisselle = branchement, pas l'appareil d = extract_details(["Entrée lave-vaisselle"], "") assert d["appliances"].get("dishwasher") is not True assert d["appliances"]["dishwasher_hookup"] is True # coquille fréquente « ascenceur » assert extract_details(["Ascenceur"], "")["elevator"] is True # pets : formulations variées assert extract_details(["Chien interdit"], "")["pets"] == "conditions" assert extract_details(["Chiens permis"], "")["pets"] == "oui" assert extract_details(["Animaux de compagnie acceptés"], "")["pets"] == "oui" assert extract_details([], "Animaux permis : Non")["pets"] == "non" assert extract_details([], "Animaux acceptés (20lb et moins)")["pets"] == "conditions" # anglais : heat/hydro/terrace/laundry facilities ; heat pump ≠ inclusion d = extract_details(["Heat", "Hydro", "Rooftop terrace", "Laundry facilities"], "") assert d["inclusions"]["heating"] is True assert d["inclusions"]["electricity"] is True assert d["balcony"] is True and d["laundry"] is True # semi-meublé : nuance conservée, pas un booléen assert extract_details(["Semi-meublé"], "")["furnished"] == "semi" # vélo ≠ stationnement auto ; « ($) » = payant assert "parking" not in extract_details(["Stationnement à vélo intérieur"], "") assert "inclusions" not in extract_details(["Wi-Fi ($)"], "") def test_extract_details_edge_cases_lots_4_et_7(): # « chauffé » qualifiant autre chose que le logement d = extract_details(["Stationnement intérieur non chauffé"], "") assert "inclusions" not in d assert d["parking"]["available"] is True d = extract_details(["Piscine extérieure chauffée"], "") assert "inclusions" not in d and d["pool"] is True # aires communes ≠ unité d = extract_details(["Terrasse commune avec BBQ", "WI-FI dans les aires communes"], "") assert "balcony" not in d and "inclusions" not in d # étage d'une installation ≠ étage de l'unité assert "floor" not in extract_details(["Salon lounge au 25e étage"], "") # négations et « rien d'inclus » assert extract_details(["Pas de balcon"], "")["balcony"] is False d = extract_details([], "Rien d'inclus.") assert d["inclusions"]["heating"] is False assert extract_details([], "Les animaux sont interdits.")["pets"] == "non" # option payante « moyennant des frais mensuels » d = extract_details(["Électroménagers (moyennant des frais mensuels)"], "") assert "appliances" not in d def test_normalize_unit_type_word_numbers(): assert normalize_unit_type("Une chambre") == "3½" assert normalize_unit_type("Two bedrooms") == "4½" def test_extract_details_edge_cases_lot6(): # animal au singulier assert extract_details([], "Votre animal est le bienvenu !")["pets"] == "oui" # nom de promotion ≠ inclusion d = extract_details([], "Promotion : Crédit de 1 000 $ Éclaire à la signature.") assert "inclusions" not in d # prix de stationnement complet, formats FR et EN d = extract_details([], "Stationnement disponible : 2 espaces pour seulement 150 $.") assert d["parking"]["price"] == 150 d = extract_details([], "Parking available for $85 per month.") assert d["parking"]["price"] == 85 def test_extract_details_edge_cases_lot5(): # « à la charge des locataires » = NON inclus d = extract_details([], "Chauffage et électricité à la charge des locataires.") assert d["inclusions"]["heating"] is False assert d["inclusions"]["electricity"] is False # « Actuellement vacant » = libre maintenant assert parse_availability_date("Actuellement vacant", TODAY) == "now" def test_extract_details_edge_cases_lot8(): # « non-chauffé, non-éclairé » avec traits d'union d = extract_details(["Non-chauffé, non-éclairé"], "") assert d["inclusions"]["heating"] is False assert d["inclusions"]["electricity"] is False # branchement combiné : ni laveuse ni lave-vaisselle fournis d = extract_details(["Entrées lave-vaisselle et laveuse/sécheuse"], "") assert d["appliances"].get("washer_dryer") is not True assert d["appliances"].get("dishwasher") is not True assert d["appliances"]["washer_dryer_hookup"] is True # « refusés sauf un chat » = conditions assert extract_details([], "Animaux refusés, à l'exception d'un chat.")["pets"] == "conditions" # restriction entre parenthèses assert "ac" not in extract_details(["Appareil de climatisation (Sans utilisation)"], "") def test_price_from_range(): assert price_is_from("de 1 650 $ à 2 293 $") def test_extract_details_unknown_stays_absent(): d = extract_details(["Balcon"], "") assert d.get("balcony") is True assert "pets" not in d assert "smoking" not in d assert "inclusions" not in d def test_extract_details_contact(): d = extract_details([], "Pour info : 418 555-1234 ou louer@exemple.ca") assert d["contact"]["phone"] == "418-555-1234" assert d["contact"]["email"] == "louer@exemple.ca" # -- finalize (enrichissement de bout en bout) ----------------------------------- def test_listing_finalize(): l = Listing( source="test", external_id="1", url="https://x/1", title="Le Test — 4 1/2", address="555,rue Exemple, Québec", unit_type="2 chambres", price_label="À partir de 1 195 $/mois", availability="1er décembre 2026", description="Superbe 4½ de 850 pi². Chauffage et eau chaude inclus. " "Animaux refusés. Info : (418) 555-9999.", amenities=["Balcon", "Ascenseur", "Piscine intérieure"], ).finalize() assert l.unit_type == "4½" assert l.price == 1195 assert l.availability_date == "2026-12-01" assert l.area_sqft == 850 assert l.pets == "non" assert l.address == "555, rue Exemple, Québec" assert l.details["price_from"] is True assert l.details["balcony"] is True assert l.details["elevator"] is True assert l.details["pool"] is True assert l.details["inclusions"]["heating"] is True assert l.details["contact"]["phone"] == "418-555-9999" def test_listing_finalize_respects_connector_values(): l = Listing(source="t", external_id="2", url="u", price=999.0, price_label="1 500 $", pets="oui", furnished=True, availability_date="2027-01-01", availability="maintenant", details={"parking": {"available": True, "price": 60.0}}) l.finalize() assert l.price == 999.0 # valeur connecteur conservée assert l.availability_date == "2027-01-01" assert l.pets == "oui" assert l.furnished is True assert l.details["parking"]["price"] == 60.0