Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — tests de la couche de normalisation commune (rentka/normalize.py)3# -----------------------------------------------------------------------------4from datetime import date56from rentka.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 rentka.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 # Québec "n½" input converts to the English bedroom enum (n½ = n-2 bed)44 assert normalize_unit_type("4 1/2") == "2 bedrooms"45 assert normalize_unit_type("4½") == "2 bedrooms"46 assert normalize_unit_type("4,5") == "2 bedrooms"47 assert normalize_unit_type("5 1/5") == "3 bedrooms" # frequent typo48 assert normalize_unit_type("3½") == "1 bedroom"49 assert normalize_unit_type("2 chambres") == "2 bedrooms"50 assert normalize_unit_type("3 bedrooms") == "3 bedrooms"51 assert normalize_unit_type("1 bedroom") == "1 bedroom"52 assert normalize_unit_type("6 1/2") == "4 bedrooms"53 assert normalize_unit_type("7½") == "5+ bedrooms"54 assert normalize_unit_type("Studio") == "Studio"55 assert normalize_unit_type("bachelor") == "Studio"56 assert normalize_unit_type("MDV") == "Townhouse"57 assert normalize_unit_type("maison de ville") == "Townhouse"58 assert normalize_unit_type("townhouse") == "Townhouse"59 assert normalize_unit_type("Chambre") == "Room"60 assert normalize_unit_type("room") == "Room"61 assert normalize_unit_type("condo") == "Condo"62 assert normalize_unit_type("") == ""636465# -- disponibilité ---------------------------------------------------------------6667def test_parse_availability_now():68 for raw in ("Libre immédiatement", "Maintenant", "Disponible",69 "Available now", "Occupation immédiate"):70 assert parse_availability_date(raw, TODAY) == "now", raw717273def test_parse_availability_dates():74 assert parse_availability_date("1er juillet 2027", TODAY) == "2027-07-01"75 assert parse_availability_date("Disponibilité : 01 décembre 2026", TODAY) == "2026-12-01"76 assert parse_availability_date("octobre 2026", TODAY) == "2026-10-01"77 assert parse_availability_date("2026-09-15", TODAY) == "2026-09-15"78 assert parse_availability_date("01/11/2026", TODAY) == "2026-11-01"79 assert parse_availability_date("December 1, 2026", TODAY) == "2026-12-01"808182def test_parse_availability_year_inference():83 # sans année : prochaine occurrence, ou « now » si déjà passée84 assert parse_availability_date("1er septembre", TODAY) == "2026-09-01"85 assert parse_availability_date("1er juillet", TODAY) == "now"86 # date explicite passée -> le logement est libre87 assert parse_availability_date("1er juillet 2026", TODAY) == "now"888990def test_parse_availability_unknown():91 assert parse_availability_date("", TODAY) is None92 assert parse_availability_date("Sur demande", TODAY) is None93 # frontières de mots : « rénové » ≠ novembre, « maison » ≠ mai94 assert parse_availability_date("Logement rénové", TODAY) is None95 assert parse_availability_date("Grande maison", TODAY) is None969798# -- superficie ---------------------------------------------------------------99100def test_parse_area():101 assert parse_area_sqft("850 pi²") == 850102 assert parse_area_sqft("850 pi2") == 850103 assert parse_area_sqft("1 050 sq. ft.") == 1050104 assert parse_area_sqft("78 m²") == round(78 * 10.7639)105 assert parse_area_sqft("aucune mention") is None106 assert parse_area_sqft("2 pi²") is None # hors plage107108109# -- adresse ---------------------------------------------------------------110111def test_clean_address():112 assert clean_address(" 555, rue Exemple ,Québec ") == "555, rue Exemple, Québec"113 assert clean_address("") == ""114115116# -- détails structurés ---------------------------------------------------------117118def test_extract_details_inclusions():119 d = extract_details(["Chauffage inclus", "Eau chaude", "Lave-vaisselle"],120 "Stationnement intérieur disponible. Animaux acceptés.")121 assert d["inclusions"]["heating"] is True122 assert d["inclusions"]["hot_water"] is True123 assert d["appliances"]["dishwasher"] is True124 assert d["parking"]["available"] is True125 assert d["parking"]["type"] == "intérieur"126 assert d["pets"] == "yes"127128129def test_extract_details_negations():130 d = extract_details([], "Pas d'animaux. Logement non-fumeur, non meublé. "131 "Entrées laveuse-sécheuse.")132 assert d["pets"] == "no"133 assert d["smoking"] is False134 assert d["furnished"] is False135 assert d["appliances"]["washer_dryer_hookup"] is True136 assert "washer_dryer" not in d["appliances"]137138139def test_extract_details_edge_cases_connecteurs():140 # « Non chauffé et éclairé » : chauffage NON inclus, électricité incluse141 d = extract_details(["Non chauffé et éclairé"], "")142 assert d["inclusions"]["heating"] is False143 assert d["inclusions"]["electricity"] is True144 # « Chats autorisés (*sous conditions) » -> conditions145 d = extract_details(["Chats autorisés (*sous conditions)"], "")146 assert d["pets"] == "conditions"147 # électroménagers en option payante : ne rien affirmer148 d = extract_details(["Électroménagers sur demande ($)"], "")149 assert "appliances" not in d150 # anglais : refrigerator / Cooling: Central Air reconnus,151 # « Heating: Forced Air » = type de chauffage, pas une inclusion152 d = extract_details(["Refrigerator", "Cooling: Central Air", "Heating: Forced Air"], "")153 assert d["appliances"]["fridge"] is True154 assert d["ac"] is True155 assert "inclusions" not in d156157158def test_extract_details_edge_cases_lot2():159 # « Wifi (optionnel) » = option payante, pas une inclusion160 d = extract_details(["Wifi (optionnel)"], "")161 assert "inclusions" not in d162 # « Les chiens ne sont pas permis » -> sous conditions (chats tolérés)163 d = extract_details([], "Les chiens ne sont pas permis dans nos propriétés.")164 assert d["pets"] == "conditions"165 # stationnement mixte166 d = extract_details(["Stationnement extérieur et intérieur"], "")167 assert d["parking"]["type"] == "intérieur et extérieur"168 # étage ignoré dans une description multi-unités, retenu en commodité169 d = extract_details([], "Grand 4½ au 3e étage d'un immeuble de 12 logements.")170 assert "floor" not in d171 d = extract_details(["3e étage"], "")172 assert d["floor"] == 3173174175def test_extract_details_pets_both_species():176 # « Chiens et chats acceptés » = oui (pas « conditions »)177 assert extract_details(["Chiens et chats acceptés"], "")["pets"] == "yes"178 assert extract_details(["Chats et chiens admis"], "")["pets"] == "yes"179 # « Chats acceptés » seul reste « conditions »180 assert extract_details(["Chats acceptés"], "")["pets"] == "conditions"181182183def test_parse_availability_english_month_first():184 assert parse_availability_date("Available September 17", TODAY) == "2026-09-17"185 assert parse_availability_date("April 1st, 2027", TODAY) == "2027-04-01"186 assert parse_availability_date("juillet 2027", TODAY) == "2027-07-01" # pas « jour 20 »187188189def test_parse_area_context_and_thousands():190 # la superficie du balcon n'est pas celle du logement191 assert parse_area_sqft("1134 pi², balcon 277 pi²") == 1134192 assert parse_area_sqft("1,607 sq. ft.") == 1607193194195def test_extract_details_edge_cases_lots_9_a_13():196 # « washer » ne doit pas matcher « dishwasher »197 d = extract_details(["Dishwasher"], "")198 assert d["appliances"] == {"dishwasher": True}199 # entrée lave-vaisselle = branchement, pas l'appareil200 d = extract_details(["Entrée lave-vaisselle"], "")201 assert d["appliances"].get("dishwasher") is not True202 assert d["appliances"]["dishwasher_hookup"] is True203 # coquille fréquente « ascenceur »204 assert extract_details(["Ascenceur"], "")["elevator"] is True205 # pets : formulations variées206 assert extract_details(["Chien interdit"], "")["pets"] == "conditions"207 assert extract_details(["Chiens permis"], "")["pets"] == "yes"208 assert extract_details(["Animaux de compagnie acceptés"], "")["pets"] == "yes"209 assert extract_details([], "Animaux permis : Non")["pets"] == "no"210 assert extract_details([], "Animaux acceptés (20lb et moins)")["pets"] == "conditions"211 # anglais : heat/hydro/terrace/laundry facilities ; heat pump ≠ inclusion212 d = extract_details(["Heat", "Hydro", "Rooftop terrace", "Laundry facilities"], "")213 assert d["inclusions"]["heating"] is True214 assert d["inclusions"]["electricity"] is True215 assert d["balcony"] is True and d["laundry"] is True216 # semi-meublé : nuance conservée, pas un booléen217 assert extract_details(["Semi-meublé"], "")["furnished"] == "semi"218 # vélo ≠ stationnement auto ; « ($) » = payant219 assert "parking" not in extract_details(["Stationnement à vélo intérieur"], "")220 assert "inclusions" not in extract_details(["Wi-Fi ($)"], "")221222223def test_extract_details_edge_cases_lots_4_et_7():224 # « chauffé » qualifiant autre chose que le logement225 d = extract_details(["Stationnement intérieur non chauffé"], "")226 assert "inclusions" not in d227 assert d["parking"]["available"] is True228 d = extract_details(["Piscine extérieure chauffée"], "")229 assert "inclusions" not in d and d["pool"] is True230 # aires communes ≠ unité231 d = extract_details(["Terrasse commune avec BBQ", "WI-FI dans les aires communes"], "")232 assert "balcony" not in d and "inclusions" not in d233 # étage d'une installation ≠ étage de l'unité234 assert "floor" not in extract_details(["Salon lounge au 25e étage"], "")235 # négations et « rien d'inclus »236 assert extract_details(["Pas de balcon"], "")["balcony"] is False237 d = extract_details([], "Rien d'inclus.")238 assert d["inclusions"]["heating"] is False239 assert extract_details([], "Les animaux sont interdits.")["pets"] == "no"240 # option payante « moyennant des frais mensuels »241 d = extract_details(["Électroménagers (moyennant des frais mensuels)"], "")242 assert "appliances" not in d243244245def test_normalize_unit_type_word_numbers():246 assert normalize_unit_type("Une chambre") == "1 bedroom"247 assert normalize_unit_type("Two bedrooms") == "2 bedrooms"248249250def test_extract_details_edge_cases_lot6():251 # animal au singulier252 assert extract_details([], "Votre animal est le bienvenu !")["pets"] == "yes"253 # nom de promotion ≠ inclusion254 d = extract_details([], "Promotion : Crédit de 1 000 $ Éclaire à la signature.")255 assert "inclusions" not in d256 # prix de stationnement complet, formats FR et EN257 d = extract_details([], "Stationnement disponible : 2 espaces pour seulement 150 $.")258 assert d["parking"]["price"] == 150259 d = extract_details([], "Parking available for $85 per month.")260 assert d["parking"]["price"] == 85261262263def test_extract_details_edge_cases_lot5():264 # « à la charge des locataires » = NON inclus265 d = extract_details([], "Chauffage et électricité à la charge des locataires.")266 assert d["inclusions"]["heating"] is False267 assert d["inclusions"]["electricity"] is False268 # « Actuellement vacant » = libre maintenant269 assert parse_availability_date("Actuellement vacant", TODAY) == "now"270271272def test_extract_details_edge_cases_lot8():273 # « non-chauffé, non-éclairé » avec traits d'union274 d = extract_details(["Non-chauffé, non-éclairé"], "")275 assert d["inclusions"]["heating"] is False276 assert d["inclusions"]["electricity"] is False277 # branchement combiné : ni laveuse ni lave-vaisselle fournis278 d = extract_details(["Entrées lave-vaisselle et laveuse/sécheuse"], "")279 assert d["appliances"].get("washer_dryer") is not True280 assert d["appliances"].get("dishwasher") is not True281 assert d["appliances"]["washer_dryer_hookup"] is True282 # « refusés sauf un chat » = conditions283 assert extract_details([], "Animaux refusés, à l'exception d'un chat.")["pets"] == "conditions"284 # restriction entre parenthèses285 assert "ac" not in extract_details(["Appareil de climatisation (Sans utilisation)"], "")286287288def test_price_from_range():289 assert price_is_from("de 1 650 $ à 2 293 $")290291292def test_extract_details_unknown_stays_absent():293 d = extract_details(["Balcon"], "")294 assert d.get("balcony") is True295 assert "pets" not in d296 assert "smoking" not in d297 assert "inclusions" not in d298299300def test_extract_details_contact():301 d = extract_details([], "Pour info : 418 555-1234 ou louer@exemple.ca")302 assert d["contact"]["phone"] == "418-555-1234"303 assert d["contact"]["email"] == "louer@exemple.ca"304305306# -- finalize (enrichissement de bout en bout) -----------------------------------307308def test_listing_finalize():309 l = Listing(310 source="test", external_id="1", url="https://x/1",311 title="Le Test — 4 1/2",312 address="555,rue Exemple, Québec",313 unit_type="2 chambres",314 price_label="À partir de 1 195 $/mois",315 availability="1er décembre 2026",316 description="Superbe 4½ de 850 pi². Chauffage et eau chaude inclus. "317 "Animaux refusés. Info : (418) 555-9999.",318 amenities=["Balcon", "Ascenseur", "Piscine intérieure"],319 ).finalize()320 assert l.unit_type == "2 bedrooms"321 assert l.price == 1195322 assert l.availability_date == "2026-12-01"323 assert l.area_sqft == 850324 assert l.pets == "no"325 assert l.address == "555, rue Exemple, Québec"326 assert l.details["price_from"] is True327 assert l.details["balcony"] is True328 assert l.details["elevator"] is True329 assert l.details["pool"] is True330 assert l.details["inclusions"]["heating"] is True331 assert l.details["contact"]["phone"] == "418-555-9999"332333334def test_listing_finalize_respects_connector_values():335 l = Listing(source="t", external_id="2", url="u",336 price=999.0, price_label="1 500 $",337 pets="oui", furnished=True,338 availability_date="2027-01-01", availability="maintenant",339 details={"parking": {"available": True, "price": 60.0}})340 l.finalize()341 assert l.price == 999.0 # valeur connecteur conservée342 assert l.availability_date == "2027-01-01"343 # finalize() normalizes the legacy French value to the English canon344 assert l.pets == "yes"345 assert l.furnished is True346 assert l.details["parking"]["price"] == 60.0347