"""Unit tests — run with `.venv/bin/python -m pytest -q`. No network, no API.""" import json import sys from pathlib import Path import numpy as np import pandas as pd import pytest ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from src.extraction.textprep import build_text, mask_money # noqa: E402 from src.feature_schema.schema import IMAGE_SCHEMA, TEXT_SCHEMA # noqa: E402 from src.extraction.aggregate import _wvote, _wmedian_ord, aggregate_images, GRADE_ORD # noqa: E402 from src.evaluation.metrics import cohen_kappa, agreement_table, krippendorff_alpha_nominal # noqa: E402 from src.cost_model import cost # noqa: E402 # ---------------------------------------------------------------- masking def test_mask_money_removes_amounts_and_assessment_sentences(): s = "Belle maison. Évaluation municipale : 429 000 $. Prix demandé 575 000$. Terrain de 11 500 pi²." m = mask_money(s) assert "429" not in m and "575" not in m assert "11 500 pi²" in m # non-monetary numbers survive def test_build_text_masks_features_and_details(): row = {"description": "Maison rénovée, toiture 2019.", "features": json.dumps(["Style : Bungalow", "Évaluation municipale : 300 000 $", "Taxes municipales : 2 500 $"]), "details": json.dumps({"Revêtement": "Brique", "Évaluation municipale (terrain)": "100 000 $", "Taxes scolaires": "300 $", "pieces": [{"nom": "Cuisine", "niveau": "RDC", "dimensions": "10x12", "revetement": "Céramique"}]})} text, st = build_text(row) assert "300 000" not in text and "100 000" not in text and "2 500" not in text assert "Brique" in text and "Céramique" in text and "toiture 2019" in text assert st["n_detail_masked_keys"] == 2 # ---------------------------------------------------------------- schema compliance (API constraints) @pytest.mark.parametrize("schema", [IMAGE_SCHEMA, TEXT_SCHEMA]) def test_schema_has_no_unsupported_keywords_and_all_required(schema): txt = json.dumps(schema) for kw in ("minimum", "maximum", "maxItems", "maxLength", "minItems"): assert f'"{kw}"' not in txt assert set(schema["required"]) == set(schema["properties"].keys()) assert schema["additionalProperties"] is False # ---------------------------------------------------------------- aggregation rules def test_weighted_vote_ignores_noninformative(): v, s = _wvote(["brick", "not_visible", "vinyl", "brick"], [0.9, 0.9, 0.5, 0.6]) assert v == "brick" and 0.7 < s < 0.8 assert _wvote(["not_visible", "not_applicable"], [1, 1]) == (None, 0.0) def test_weighted_median_ordinal(): assert _wmedian_ord(["basic", "luxury", "standard"], [1, 1, 1], GRADE_ORD) == 2 assert _wmedian_ord(["not_visible"], [1], GRADE_ORD) is None def test_aggregate_images_basic_rules(): rows = [] def img(i, scene, **kw): base = {"tier": "haiku", "uid": "x:1", "image_index": i, "ok": True, "scene": scene, "photo_quality": 4, "staging_or_rendering": "none_apparent", "building_type_visual": "not_applicable", "storeys_visible": "not_applicable", "cladding": ["not_applicable"], "roof_form": "not_applicable", "roof_covering": "not_applicable", "garage_visible": "not_applicable", "exterior_condition": 0, "kitchen_grade": "not_applicable", "kitchen_counters": "not_applicable", "kitchen_cabinets": "not_applicable", "bathroom_grade": "not_applicable", "flooring_main": "not_applicable", "basement_finish": "not_applicable", "interior_condition": 0, "interior_finish_quality": 0, "renovation_evidence": "none_apparent", "visible_defects": ["none_visible"], "confidence": 0.8, "evidence": ""} base.update(kw); return base rows.append(img(0, "exterior_front", building_type_visual="detached", storeys_visible="2", cladding=["brick", "vinyl"], garage_visible="attached", exterior_condition=4)) rows.append(img(1, "exterior_rear_or_side", building_type_visual="detached", storeys_visible="2", cladding=["vinyl"], garage_visible="none_visible", exterior_condition=3, visible_defects=["peeling_paint_or_finish"])) rows.append(img(2, "kitchen", kitchen_grade="upgraded", kitchen_counters="quartz_granite_or_stone", interior_condition=5, interior_finish_quality=4, renovation_evidence="extensive", flooring_main="hardwood")) rows.append(img(3, "bathroom", bathroom_grade="standard", interior_condition=4, interior_finish_quality=3)) df = pd.DataFrame(rows) a = aggregate_images(df).iloc[0] assert a["building_type_visual"] == "detached" and a["storeys_visible"] == "2" assert a["cladding_primary"] == "vinyl" # 2 votes (0.8+0.8) vs brick (0.8) assert a["garage_visible"] == "attached" # positive vote beats none_visible assert a["kitchen_grade"] == "upgraded" and a["bathroom_grade"] == "standard" assert a["renovation_evidence"] == "extensive" and a["defects"] == ["peeling_paint_or_finish"] assert abs(a["exterior_condition"] - 3.5) < 1e-9 and abs(a["interior_condition"] - 4.5) < 1e-9 assert a["grade_composite"] in ("standard", "upgraded") # truncation to the first 2 images removes all interior evidence a2 = aggregate_images(df, K=2).iloc[0] assert a2["kitchen_grade"] == "not_visible" and a2["grade_composite"] is None # ---------------------------------------------------------------- metrics def test_kappa_perfect_and_chance(): a = pd.Series(["x", "y", "x", "y"]); b = pd.Series(["x", "y", "x", "y"]) assert abs(cohen_kappa(a, b) - 1.0) < 1e-9 c = pd.Series(["x", "x", "y", "y"]); d = pd.Series(["x", "y", "x", "y"]) assert abs(cohen_kappa(c, d)) < 1e-9 t = agreement_table(pd.Series(["x", "not_visible", "y"]), pd.Series(["x", "x", "x"])) assert t["n_compared"] == 2 and abs(t["coverage"] - 2 / 3) < 1e-9 and abs(t["accuracy"] - 0.5) < 1e-9 def test_krippendorff_alpha_perfect(): lab = np.array([["a", "b", "a"], ["a", "b", "a"]], dtype=object) assert abs(krippendorff_alpha_nominal(lab) - 1.0) < 1e-9 # ---------------------------------------------------------------- cost identity def test_cost_identity_and_monotonicity(): df = pd.DataFrame({"floor_area_m2": [120.0, 120.0], "storeys": ["1", "1"], "attached": ["detached", "detached"], "grade": ["standard", "luxury"], "condition": [3.0, 3.0], "renovation": ["none_apparent", "none_apparent"], "basement": ["unfinished", "unfinished"], "garage": ["none", "none"], "pool": ["none", "none"], "age": [20, 20], "cma": ["montreal", "montreal"], "roll_land_value": [100000.0, 100000.0], "roll_market_date": ["2024-07-01", "2024-07-01"], "roll_building_value": [250000.0, 250000.0]}) out = cost.cost_indication(df) assert np.allclose(out["V_cost"], out["L"] + out["improvements_depreciated"]) assert np.allclose(out["improvements_depreciated"], (out["RCN"] + out["S"]) * (1 - out["D"])) assert out["RCN"].iloc[1] > out["RCN"].iloc[0] # luxury > standard assert 0 < out["D"].iloc[0] < 0.85 # depreciation: better condition → lower D; longer life → lower D df2 = df.copy(); df2["condition"] = 5.0 assert cost.depreciation(df2).iloc[0] < out["D"].iloc[0] assert cost.depreciation(df, {"economic_life": 75}).iloc[0] < out["D"].iloc[0]