// Auteur : Simon-Pierre Boucher — contact@spboucher.ai import { describe, expect, it } from "vitest"; import { adjustComps, ageAdjustment, areaAdjustment, doorsAdjustment, estimate, haversineM, timeFactor, weightedMedian, type CompInput, type MarketIndexPoint, type Subject, } from "./engine"; const NOW = "2026-08-09"; const INDEX: MarketIndexPoint[] = [ { month: "2024-01", idx: 0.8 }, { month: "2025-01", idx: 0.9 }, { month: "2026-01", idx: 1.0 }, ]; function comp(over: Partial): CompInput { return { id: "c1", date: "2026-02-15", amount: 900000, lat: 45.56, lng: -73.58, propertyType: "plex", doors: 3, yearBuilt: 1955, floorArea: 240, street: "4562 rue Chabot", city: "Montréal", ...over, }; } const SUBJECT: Subject = { lat: 45.55, lng: -73.58, typeProp: "plex", doors: 3, floorArea: 240, yearBuilt: 1955, modelEstimate: 920000, modelP10: 760000, modelP90: 1100000, }; describe("haversineM", () => { it("est nul à distance nulle et ~111 km par degré de latitude", () => { expect(haversineM(45.55, -73.58, 45.55, -73.58)).toBe(0); expect(haversineM(45.0, -73.58, 46.0, -73.58)).toBeGreaterThan(110000); expect(haversineM(45.0, -73.58, 46.0, -73.58)).toBeLessThan(112000); }); }); describe("timeFactor", () => { it("majore une vente ancienne selon l'indice", () => { expect(timeFactor("2024-01", INDEX)).toBeCloseTo(1.0 / 0.8, 5); expect(timeFactor("2026-03", INDEX)).toBe(1); }); }); describe("areaAdjustment", () => { it("ajuste à 50 % du $/m² du comparable", () => { expect(areaAdjustment(260, { floorArea: 240, amount: 900000 })).toBe( 20 * 0.5 * (900000 / 240) ); }); }); describe("ageAdjustment", () => { it("0,5 % par année d'écart, borné à ±10 %", () => { expect(ageAdjustment(1965, { yearBuilt: 1955, amount: 900000 })).toBeCloseTo(45000); expect(ageAdjustment(1900, { yearBuilt: 1990, amount: 900000 })).toBeCloseTo(-90000); }); }); describe("doorsAdjustment (plex)", () => { it("ajuste à 50 % du prix par porte du comparable", () => { // comp triplex 900 000 $ => 300 000 $/porte ; sujet quadruplex => +150 000 $ expect(doorsAdjustment(4, { doors: 3, amount: 900000 })).toBe(150000); expect(doorsAdjustment(2, { doors: 3, amount: 900000 })).toBe(-150000); }); it("borne à ±30 % du prix du comparable", () => { expect(doorsAdjustment(12, { doors: 2, amount: 600000 })).toBe(180000); }); it("retourne 0 si portes manquantes", () => { expect(doorsAdjustment(null, { doors: 3, amount: 900000 })).toBe(0); expect(doorsAdjustment(3, { doors: null, amount: 900000 })).toBe(0); }); }); describe("weightedMedian", () => { it("respecte les poids", () => { expect(weightedMedian([100, 200, 300], [1, 1, 10])).toBe(300); }); }); describe("adjustComps", () => { it("filtre par similarité de portes et pondère la proximité", () => { const candidates = [ comp({ id: "proche", lat: 45.551 }), comp({ id: "loin", lat: 45.65 }), comp({ id: "tour-12portes", doors: 12, amount: 2500000 }), comp({ id: "a", doors: 2 }), comp({ id: "b", doors: 4 }), comp({ id: "c" }), comp({ id: "d", doors: 3 }), ]; const res = adjustComps(SUBJECT, candidates, INDEX, NOW); const ids = res.map((c) => c.id); expect(ids).not.toContain("tour-12portes"); // ±1 porte suffit ici expect(res[0].id).toBe("proche"); }); it("intègre l'ajustement portes dans le prix ajusté", () => { const candidates = Array.from({ length: 6 }, (_, i) => comp({ id: `c${i}`, doors: 2, amount: 600000 }) ); const res = adjustComps({ ...SUBJECT, doors: 3 }, candidates, INDEX, NOW); expect(res[0].adjDoors).toBeGreaterThan(0); // sujet a plus de portes }); }); describe("estimate", () => { const candidates = Array.from({ length: 8 }, (_, i) => comp({ id: `c${i}`, lat: 45.55 + i * 0.001, amount: 850000 + i * 20000, date: "2026-01-10" }) ); it("combine modèle (65 %) et comparables (35 %)", () => { const r = estimate(SUBJECT, candidates, INDEX, NOW); expect(r.modelWeight).toBe(0.65); expect(r.compsEstimate).not.toBeNull(); const expected = 0.65 * 920000 + 0.35 * r.compsEstimate!; expect(Math.abs(r.estimate - expected)).toBeLessThanOrEqual(100); }); it("retombe sur le modèle seul sans comparables", () => { const r = estimate(SUBJECT, [], INDEX, NOW); expect(r.modelWeight).toBe(1); expect(r.estimate).toBe(920000); }); it("jamais d'estimation sans niveau de confiance", () => { const r = estimate(SUBJECT, candidates, INDEX, NOW); expect(r.confidencePct).toBeGreaterThan(0); expect(["A", "B", "C", "D"]).toContain(r.confidenceLevel); }); });