TeX 59%
Python 40.8%
1"""Unit tests — run with `.venv/bin/python -m pytest -q`. No network, no API."""2import json3import sys4from pathlib import Path56import numpy as np7import pandas as pd8import pytest910ROOT = Path(__file__).resolve().parents[1]11sys.path.insert(0, str(ROOT))1213from src.extraction.textprep import build_text, mask_money # noqa: E40214from src.feature_schema.schema import IMAGE_SCHEMA, TEXT_SCHEMA # noqa: E40215from src.extraction.aggregate import _wvote, _wmedian_ord, aggregate_images, GRADE_ORD # noqa: E40216from src.evaluation.metrics import cohen_kappa, agreement_table, krippendorff_alpha_nominal # noqa: E40217from src.cost_model import cost # noqa: E402181920# ---------------------------------------------------------------- masking21def test_mask_money_removes_amounts_and_assessment_sentences():22 s = "Belle maison. Évaluation municipale : 429 000 $. Prix demandé 575 000$. Terrain de 11 500 pi²."23 m = mask_money(s)24 assert "429" not in m and "575" not in m25 assert "11 500 pi²" in m # non-monetary numbers survive262728def test_build_text_masks_features_and_details():29 row = {"description": "Maison rénovée, toiture 2019.",30 "features": json.dumps(["Style : Bungalow", "Évaluation municipale : 300 000 $", "Taxes municipales : 2 500 $"]),31 "details": json.dumps({"Revêtement": "Brique", "Évaluation municipale (terrain)": "100 000 $", "Taxes scolaires": "300 $",32 "pieces": [{"nom": "Cuisine", "niveau": "RDC", "dimensions": "10x12", "revetement": "Céramique"}]})}33 text, st = build_text(row)34 assert "300 000" not in text and "100 000" not in text and "2 500" not in text35 assert "Brique" in text and "Céramique" in text and "toiture 2019" in text36 assert st["n_detail_masked_keys"] == 2373839# ---------------------------------------------------------------- schema compliance (API constraints)40@pytest.mark.parametrize("schema", [IMAGE_SCHEMA, TEXT_SCHEMA])41def test_schema_has_no_unsupported_keywords_and_all_required(schema):42 txt = json.dumps(schema)43 for kw in ("minimum", "maximum", "maxItems", "maxLength", "minItems"):44 assert f'"{kw}"' not in txt45 assert set(schema["required"]) == set(schema["properties"].keys())46 assert schema["additionalProperties"] is False474849# ---------------------------------------------------------------- aggregation rules50def test_weighted_vote_ignores_noninformative():51 v, s = _wvote(["brick", "not_visible", "vinyl", "brick"], [0.9, 0.9, 0.5, 0.6])52 assert v == "brick" and 0.7 < s < 0.853 assert _wvote(["not_visible", "not_applicable"], [1, 1]) == (None, 0.0)545556def test_weighted_median_ordinal():57 assert _wmedian_ord(["basic", "luxury", "standard"], [1, 1, 1], GRADE_ORD) == 258 assert _wmedian_ord(["not_visible"], [1], GRADE_ORD) is None596061def test_aggregate_images_basic_rules():62 rows = []63 def img(i, scene, **kw):64 base = {"tier": "haiku", "uid": "x:1", "image_index": i, "ok": True, "scene": scene, "photo_quality": 4,65 "staging_or_rendering": "none_apparent", "building_type_visual": "not_applicable", "storeys_visible": "not_applicable",66 "cladding": ["not_applicable"], "roof_form": "not_applicable", "roof_covering": "not_applicable",67 "garage_visible": "not_applicable", "exterior_condition": 0, "kitchen_grade": "not_applicable",68 "kitchen_counters": "not_applicable", "kitchen_cabinets": "not_applicable", "bathroom_grade": "not_applicable",69 "flooring_main": "not_applicable", "basement_finish": "not_applicable", "interior_condition": 0,70 "interior_finish_quality": 0, "renovation_evidence": "none_apparent", "visible_defects": ["none_visible"],71 "confidence": 0.8, "evidence": ""}72 base.update(kw); return base73 rows.append(img(0, "exterior_front", building_type_visual="detached", storeys_visible="2", cladding=["brick", "vinyl"],74 garage_visible="attached", exterior_condition=4))75 rows.append(img(1, "exterior_rear_or_side", building_type_visual="detached", storeys_visible="2", cladding=["vinyl"],76 garage_visible="none_visible", exterior_condition=3, visible_defects=["peeling_paint_or_finish"]))77 rows.append(img(2, "kitchen", kitchen_grade="upgraded", kitchen_counters="quartz_granite_or_stone", interior_condition=5,78 interior_finish_quality=4, renovation_evidence="extensive", flooring_main="hardwood"))79 rows.append(img(3, "bathroom", bathroom_grade="standard", interior_condition=4, interior_finish_quality=3))80 df = pd.DataFrame(rows)81 a = aggregate_images(df).iloc[0]82 assert a["building_type_visual"] == "detached" and a["storeys_visible"] == "2"83 assert a["cladding_primary"] == "vinyl" # 2 votes (0.8+0.8) vs brick (0.8)84 assert a["garage_visible"] == "attached" # positive vote beats none_visible85 assert a["kitchen_grade"] == "upgraded" and a["bathroom_grade"] == "standard"86 assert a["renovation_evidence"] == "extensive" and a["defects"] == ["peeling_paint_or_finish"]87 assert abs(a["exterior_condition"] - 3.5) < 1e-9 and abs(a["interior_condition"] - 4.5) < 1e-988 assert a["grade_composite"] in ("standard", "upgraded")89 # truncation to the first 2 images removes all interior evidence90 a2 = aggregate_images(df, K=2).iloc[0]91 assert a2["kitchen_grade"] == "not_visible" and a2["grade_composite"] is None929394# ---------------------------------------------------------------- metrics95def test_kappa_perfect_and_chance():96 a = pd.Series(["x", "y", "x", "y"]); b = pd.Series(["x", "y", "x", "y"])97 assert abs(cohen_kappa(a, b) - 1.0) < 1e-998 c = pd.Series(["x", "x", "y", "y"]); d = pd.Series(["x", "y", "x", "y"])99 assert abs(cohen_kappa(c, d)) < 1e-9100 t = agreement_table(pd.Series(["x", "not_visible", "y"]), pd.Series(["x", "x", "x"]))101 assert t["n_compared"] == 2 and abs(t["coverage"] - 2 / 3) < 1e-9 and abs(t["accuracy"] - 0.5) < 1e-9102103104def test_krippendorff_alpha_perfect():105 lab = np.array([["a", "b", "a"], ["a", "b", "a"]], dtype=object)106 assert abs(krippendorff_alpha_nominal(lab) - 1.0) < 1e-9107108109# ---------------------------------------------------------------- cost identity110def test_cost_identity_and_monotonicity():111 df = pd.DataFrame({"floor_area_m2": [120.0, 120.0], "storeys": ["1", "1"], "attached": ["detached", "detached"],112 "grade": ["standard", "luxury"], "condition": [3.0, 3.0], "renovation": ["none_apparent", "none_apparent"],113 "basement": ["unfinished", "unfinished"], "garage": ["none", "none"], "pool": ["none", "none"],114 "age": [20, 20], "cma": ["montreal", "montreal"], "roll_land_value": [100000.0, 100000.0],115 "roll_market_date": ["2024-07-01", "2024-07-01"], "roll_building_value": [250000.0, 250000.0]})116 out = cost.cost_indication(df)117 assert np.allclose(out["V_cost"], out["L"] + out["improvements_depreciated"])118 assert np.allclose(out["improvements_depreciated"], (out["RCN"] + out["S"]) * (1 - out["D"]))119 assert out["RCN"].iloc[1] > out["RCN"].iloc[0] # luxury > standard120 assert 0 < out["D"].iloc[0] < 0.85121 # depreciation: better condition → lower D; longer life → lower D122 df2 = df.copy(); df2["condition"] = 5.0123 assert cost.depreciation(df2).iloc[0] < out["D"].iloc[0]124 assert cost.depreciation(df, {"economic_life": 75}).iloc[0] < out["D"].iloc[0]125