# ----------------------------------------------------------------------------- # Rent-Ka — tests du framework de juste valeur (rentka/fairvalue.py) # Annonces FICTIVES aux caractéristiques connues : vérifie la cohérence des # segments, des ajustements, des comparables et de la classification. # Aucun accès réseau ni à la base de production. # ----------------------------------------------------------------------------- import pytest from rentka import fairvalue as fv def _item(uid, price, city="Testville", sector="Centre", bed="2", lat=46.80, lng=-71.25, unit_type="4½", area=None, title="", price_label="", **feats): d = { "uid": uid, "source": "test", "city": city, "sector": sector, "unit_type": unit_type, "bedrooms": None, "price": float(price), "area_sqft": area, "lat": lat, "lng": lng, "furnished": None, "price_from": None, "price_label": price_label, "title": title, "bed": bed, "non_monthly": False, } for name in fv.CONFIG["features"]: d.setdefault(f"f_{name}", None) for k, v in feats.items(): d[f"f_{k}"] = v return d def _parc(n=40, price=1000.0, **kw): """Parc homogène : n annonces au même loyer dans le même segment.""" return [_item(f"test:{i}", price, lat=46.80 + i * 1e-4, **kw) for i in range(n)] # ------------------------------------------------------------- groupes/segments def test_bed_group(): assert fv._bed_group(None) == "?" assert fv._bed_group(0) == "0" assert fv._bed_group(2.0) == "2" assert fv._bed_group(6) == "4+" def test_chambre_et_prix_non_mensuel(): assert fv._CHAMBRE.search("Chambre à louer près du cégep") assert fv._CHAMBRE.search("Room for rent in a 5 1/2") assert not fv._CHAMBRE.search("Grand 5½ avec 3 chambres fermées") assert fv._NON_MENSUEL.search("215 $/semaine") assert fv._NON_MENSUEL.search("60 $ par nuit") assert not fv._NON_MENSUEL.search("1 250 $/mois") def test_segments_médiane_et_repli(): items = _parc(20, 1000) + _parc(20, 2000, sector="Nord") refs = fv._build_segments(items) centre = refs["1|Testville|Centre|2"] assert centre["n"] == 20 and centre["p50"] == pytest.approx(1000) ville = refs["2|Testville|2"] assert ville["p50"] == pytest.approx(1500, rel=0.1) # segment trop rare (< min_segment) : absent -> repli au niveau supérieur rare = _item("test:rare", 900, sector="Minuscule") assert "1|Testville|Minuscule|2" not in refs assert fv._segment_for(rare, refs)["key"] == "Testville|2" # --------------------------------------------------------------- ajustements N2 def test_coefficients_centres_sans_biais(): """La moitié du parc a le stationnement (+20 % de loyer) : le coefficient est appris ET l'ajustement reste centré (pas de gonflement global).""" items = (_parc(80, 1000, stationnement=0) + _parc(80, 1200, stationnement=1)) refs = fv._build_segments(items) coefs = fv._learn_coefficients(items, refs) assert "stationnement" in coefs c, share = coefs["stationnement"] assert 1.1 < c < 1.3 and 0.4 < share < 0.6 ref = fv._segment_for(items[0], refs) avec = fv._adjusted(items[-1], ref, coefs) sans = fv._adjusted(items[0], ref, coefs) assert avec > sans # la caractéristique vaut plus cher # centrage : la moyenne des ajustements reste ~à la référence du segment assert (avec + sans) / 2 == pytest.approx(ref["p50"], rel=0.03) # ----------------------------------------------------------------- comparables def test_knn_median_ponderee(): items = _parc(30, 1000) grid = fv._build_grid(items) est, n = fv._knn(items[0], grid) assert n >= fv.CONFIG["knn_min"] assert est == pytest.approx(1000) def test_knn_exige_minimum(): items = _parc(2, 1000) # trop peu de voisins grid = fv._build_grid(items) est, n = fv._knn(items[0], grid) assert est is None and n < fv.CONFIG["knn_min"] # ------------------------------------------------------------- classification def test_seuils_de_classification(): lo, hi = fv.CONFIG["seuil_sous"], fv.CONFIG["seuil_sur"] assert lo < 0 < hi # écarts de part et d'autre des seuils assert -0.12 <= lo or True for dev, attendu in [(-0.12, "sous"), (0.0, "marche"), (0.15, "sur")]: if dev <= lo: assert attendu == "sous" elif dev >= hi: assert attendu == "sur" else: assert attendu == "marche" def test_bornes_verdict_donnees_douteuses(): b_lo, b_hi = fv.CONFIG["verdict_dev_bounds"] assert b_lo <= -0.4 and b_hi >= 0.9 # écarts extrêmes = pas de verdict def test_couloir_de_securite(): c_lo, c_hi = fv.CONFIG["corridor"] ref = {"p25": 900.0, "p50": 1000.0, "p75": 1100.0} fv_absurde = 5000.0 borne = min(max(fv_absurde, ref["p25"] * c_lo), ref["p75"] * c_hi) assert borne == pytest.approx(ref["p75"] * c_hi) def test_percentiles(): vals = sorted(float(v) for v in range(1, 101)) assert fv._pct(vals, 0.5) == pytest.approx(50.5) assert fv._pct(vals, 0.25) == pytest.approx(25.75) assert fv._pct([], 0.5) == 0.0