spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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