// Auteur : Simon-Pierre Boucher — contact@spboucher.ai import { describe, expect, it } from "vitest"; import { adjustComps, ageAdjustment, areaAdjustment, estimate, haversineM, timeFactor, weightedMedian, type CompInput, type MarketIndexPoint, type Subject, } from "./engine"; const NOW = "2026-08-08"; 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: 400000, lat: 46.8, lng: -71.2, propertyType: "unifamilial", yearBuilt: 1990, floorArea: 120, street: "1 rue Test", city: "Québec", ...over, }; } const SUBJECT: Subject = { lat: 46.8, lng: -71.2, typeProp: "unifamilial", floorArea: 120, yearBuilt: 1990, modelEstimate: 410000, modelP10: 340000, modelP90: 480000, }; describe("haversineM", () => { it("est nul à distance nulle et ~111 km par degré de latitude", () => { expect(haversineM(46.8, -71.2, 46.8, -71.2)).toBe(0); expect(haversineM(46.0, -71.2, 47.0, -71.2)).toBeGreaterThan(110000); expect(haversineM(46.0, -71.2, 47.0, -71.2)).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); }); it("retourne 1 sans indice", () => { expect(timeFactor("2024-01", [])).toBe(1); }); }); describe("areaAdjustment", () => { it("ajuste à 50 % du $/m² du comparable", () => { // comp 100 m² à 400 000 $ => 4 000 $/m² ; sujet 110 m² => +10 × 2 000 = +20 000 expect(areaAdjustment(110, { floorArea: 100, amount: 400000 })).toBe(20000); expect(areaAdjustment(90, { floorArea: 100, amount: 400000 })).toBe(-20000); }); it("borne à ±25 % du prix", () => { expect(areaAdjustment(300, { floorArea: 100, amount: 400000 })).toBe(100000); }); it("retourne 0 si superficie manquante", () => { expect(areaAdjustment(null, { floorArea: 100, amount: 400000 })).toBe(0); expect(areaAdjustment(110, { floorArea: null, amount: 400000 })).toBe(0); }); }); describe("ageAdjustment", () => { it("0,5 % par année d'écart, borné à ±10 %", () => { expect(ageAdjustment(2000, { yearBuilt: 1990, amount: 400000 })).toBeCloseTo(20000); expect(ageAdjustment(1900, { yearBuilt: 2020, amount: 400000 })).toBeCloseTo(-40000); }); }); describe("weightedMedian", () => { it("respecte les poids", () => { expect(weightedMedian([100, 200, 300], [1, 1, 10])).toBe(300); expect(weightedMedian([100, 200, 300], [1, 1, 1])).toBe(200); }); }); describe("adjustComps", () => { it("filtre par type et superficie, trie par poids", () => { const candidates = [ comp({ id: "proche", lat: 46.801, floorArea: 118 }), comp({ id: "loin", lat: 46.9, floorArea: 118 }), comp({ id: "condo", propertyType: "condo" }), comp({ id: "trop-grand", floorArea: 400 }), comp({ id: "a", floorArea: 125 }), comp({ id: "b", floorArea: 115 }), comp({ id: "c", floorArea: 130 }), comp({ id: "d", floorArea: 110 }), ]; const res = adjustComps(SUBJECT, candidates, INDEX, NOW); const ids = res.map((c) => c.id); expect(ids).not.toContain("condo"); expect(ids).not.toContain("trop-grand"); expect(res[0].id).toBe("proche"); // le plus proche pèse le plus }); it("relâche le filtre de type quand le marché est mince", () => { const candidates = [ comp({ id: "x", propertyType: "indéterminé" }), comp({ id: "y", propertyType: "indéterminé" }), ]; const res = adjustComps(SUBJECT, candidates, INDEX, NOW); expect(res.length).toBe(2); }); }); describe("estimate", () => { const candidates = Array.from({ length: 8 }, (_, i) => comp({ id: `c${i}`, lat: 46.8 + i * 0.001, amount: 380000 + i * 10000, 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.modelEstimate).toBe(410000); expect(r.compsEstimate).not.toBeNull(); const expected = 0.65 * 410000 + 0.35 * r.compsEstimate!; expect(Math.abs(r.estimate - expected)).toBeLessThanOrEqual(100); expect(r.low).toBeLessThan(r.estimate); expect(r.high).toBeGreaterThan(r.estimate); }); it("retombe sur le modèle seul sans comparables", () => { const r = estimate(SUBJECT, [], INDEX, NOW); expect(r.modelWeight).toBe(1); expect(r.estimate).toBe(410000); expect(r.nCompsUsed).toBe(0); }); it("fonctionne aux comparables seuls (sans modèle)", () => { const r = estimate({ ...SUBJECT, modelEstimate: null, modelP10: null, modelP90: null }, candidates, INDEX, NOW); expect(r.modelEstimate).toBeNull(); expect(r.estimate).toBeGreaterThan(300000); expect(r.confidenceLevel).toMatch(/[A-D]/); }); it("la confiance augmente avec le nombre de comparables", () => { const few = estimate({ ...SUBJECT, modelEstimate: null }, candidates.slice(0, 3), INDEX, NOW); const many = estimate({ ...SUBJECT, modelEstimate: null }, candidates, INDEX, NOW); expect(many.confidencePct).toBeGreaterThanOrEqual(few.confidencePct); }); 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); }); });