Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2import { describe, expect, it } from "vitest";3import { assemblyUnitCost, computeEstimate, computeDepreciation, type PricingContext } from "./engine";4import { computeGeometry, deriveQuantities } from "./geometry";5import { canonicalPrice, detectOutliers, indexFactor, median, type PriceObservation } from "./pricing";6import { convertPrice, isKnownUnit } from "./units";7import { ITEM_SEEDS } from "./seed/items";8import { ASSEMBLY_SEEDS } from "./seed/assemblies";9import { CONDITIONS, CONDITION_GROUPS, DEFAULT_PARAMS } from "./taxonomy";10import type { Assembly, Condition, CostInput, CostItem, ItemPrice, LabourRate } from "./types";11import { defaultInput } from "./estimate-defaults";1213/* ------------------------------------------------------------ contexte test */1415function ctxFromSeeds(opts: { observed?: boolean } = {}): PricingContext {16 const items = new Map<string, CostItem>();17 const prices = new Map<string, ItemPrice>();18 for (const s of ITEM_SEEDS) {19 items.set(s.code, { id: 0, code: s.code, masterformat: s.mf, division: s.mf, category: s.cat, subcategory: null, nameFr: s.fr, nameEn: s.en, descriptionFr: null, descriptionEn: null, unit: s.unit, materialClass: s.cls ?? "material", trade: s.trade ?? null, defaultWastePct: s.waste ?? 0, qualityLevel: s.quality ?? null, referencePrice: s.ref, referenceNote: s.refNote ?? null, active: true });20 prices.set(s.code, { itemCode: s.code, unit: s.unit, price: s.ref, low: s.ref * 0.9, high: s.ref * 1.1, kind: opts.observed ? "observed" : "reference", sourceCount: opts.observed ? 2 : 0, observedAt: opts.observed ? "2026-09-01" : null, sources: [], confidence: 50, indexed: null, note: null });21 }22 const assemblies = new Map<string, Assembly>();23 for (const a of ASSEMBLY_SEEDS) assemblies.set(a.code, { code: a.code, masterformat: a.mf, category: a.cat, nameFr: a.fr, nameEn: a.en, descriptionFr: a.descFr, descriptionEn: a.descEn, unit: a.unit, buildingType: a.buildingType ?? null, quality: a.quality ?? null, version: 1, economicLife: a.life, conditionGroup: a.group, components: a.components, active: true });24 const labour = new Map<string, LabourRate>();25 for (const t of ["charpentier", "briqueteur", "carreleur", "cimentier", "coffreur", "couvreur", "electricien", "plombier", "ferblantier", "frigoriste", "peintre", "platrier", "poseur_systemes", "poseur_revetements", "ferrailleur", "manoeuvre", "operateur", "poseur_bardeaux"]) {26 labour.set(t, { tradeCode: t, tradeNameFr: t, tradeNameEn: t, sector: "residentiel_leger", classification: "compagnon", region: "QC", effectiveFrom: "2026-04-26", effectiveTo: null, baseWage: 60, vacationCost: 7.8, benefitsCost: 7.89, employerContributions: 12, otherContributions: 1.5, totalEmployerCost: 89.14, source: "APCHQ (test)", sourceUrl: null, confidence: 90 });27 }28 const conditionRules = new Map<string, Map<Condition, { effectiveAgeRatio: number; economicLife: number }>>();29 for (const g of CONDITION_GROUPS) conditionRules.set(g.key, new Map(CONDITIONS.map((c) => [c.key, { effectiveAgeRatio: c.effectiveAgeRatio, economicLife: g.economicLife }])));30 return {31 asOf: "2026-09-06", items, prices, labour, assemblies,32 location: { code: "QC-MTL", nameFr: "Montréal", nameEn: "Montréal", regionCode: "06", materialFactor: 1, labourFactor: 1, equipmentFactor: 1, overallFactor: 1, effectiveDate: "2026-09-01", sourceMethod: "test", confidence: 75 },33 conditionRules, benchmarks: [{ source: "test", buildingType: "single_family", market: "Montréal", unit: "$/pi2", low: 200, high: 400, midpoint: 300, year: 2026, notes: null }], fallbackHourlyRate: 85,34 };35}3637const META = { id: "t", createdAt: "2026-09-06T00:00:00Z", costDatabaseVersion: "test", assemblyVersion: "1" };3839function houseInput(): CostInput {40 const i = defaultInput();41 i.building = { ...i.building, type: "detached", quality: "standard", grossFloorAreaSqft: 2000, stories: 2, yearBuilt: 1998, basement: "finished", basementFinishedPct: 1, garage: { type: "attached", spaces: 2, areaSqft: null }, siding: { brick: 0.6, vinyl: 0.4 }, heating: "heat_pump", bathrooms: 2, powderRooms: 1, deckSqft: 200, drivewaySqft: 600, driveway: "asphalt" };42 i.land = { value: 182000, source: "role", rollYear: 2026, method: "rôle" };43 i.attributeSources = { grossFloorAreaSqft: "MAMH", stories: "MAMH", yearBuilt: "MAMH", basement: "user", garage: "user", siding: "user", roof: "user", heating: "user", bathrooms: "user", kitchenQuality: "user" };44 return i;45}4647/* ------------------------------------------------------------------ unités */4849describe("units", () => {50 it("convertit une feuille de gypse 4×8 en $/pi²", () => {51 const r = convertPrice(17.6, "feuille", "pi2", 32);52 expect(r.price).toBeCloseTo(0.55, 4);53 expect(r.conversion.factor).toBeCloseTo(1 / 32, 6);54 expect(r.conversion.sourceUnit).toBe("feuille");55 });56 it("convertit m² → pi² et refuse une unité inconnue sans emballage", () => {57 expect(convertPrice(10.764, "m2", "pi2").price).toBeCloseTo(1, 3);58 expect(() => convertPrice(5, "zorglub", "pi2")).toThrow();59 expect(isKnownUnit("paquet")).toBe(true);60 expect(isKnownUnit("chaque")).toBe(true);61 });62});6364/* ------------------------------------------------------------------- prix */6566describe("pricing", () => {67 const obs = (source: string, price: number, date = "2026-09-01", regular = true): PriceObservation => ({ source, sourceQuality: 0.85, price, observedAt: date, url: null, sourceUnit: "unit", conversionFactor: 1, regular, kind: "observed", locationMatch: true });68 it("médiane robuste multi-source, observations conservées", () => {69 const cp = canonicalPrice([obs("Canac", 8.94), obs("RONA", 9.22), obs("Home Depot", 9.18)], "2026-09-06")!;70 expect(cp.price).toBeCloseTo(9.18, 2);71 expect(cp.sourceCount).toBe(3);72 expect(cp.low).toBeCloseTo(8.94, 2);73 expect(cp.high).toBeCloseTo(9.22, 2);74 });75 it("préfère le prix régulier à la promotion", () => {76 const cp = canonicalPrice([obs("Canac", 5.0, "2026-09-01", false), obs("BMR", 8.0), obs("PM", 8.2)], "2026-09-06")!;77 expect(cp.price).toBeCloseTo(8.1, 2);78 expect(cp.used.every((o) => o.regular)).toBe(true);79 });80 it("marque les aberrations sans les effacer", () => {81 expect(detectOutliers([8, 8.5, 9, 40]).length).toBe(1);82 const cp = canonicalPrice([obs("A", 8), obs("B", 8.5), obs("C", 40)], "2026-09-06")!;83 expect(cp.outliers.length).toBe(1);84 expect(cp.price).toBeCloseTo(8.25, 2);85 });86 it("ignore les observations hors fenêtre et actualise par indice", () => {87 expect(canonicalPrice([obs("A", 8, "2025-01-01")], "2026-09-06")).toBeNull();88 const f = indexFactor([{ period: "2025-01-01", value: 100 }, { period: "2026-01-01", value: 110 }], "2025-03-01", "2026-06-01")!;89 expect(f.factor).toBeCloseTo(1.1, 6);90 expect(median([1, 3, 2])).toBe(2);91 });92});9394/* -------------------------------------------------------------- géométrie */9596describe("geometry", () => {97 it("dérive une géométrie cohérente pour une maison de 2 000 pi² sur 2 étages", () => {98 const g = computeGeometry(houseInput().building);99 expect(g.footprintSqft).toBe(1000);100 expect(g.perimeterFt).toBeGreaterThan(120);101 expect(g.roofSqft).toBeGreaterThan(g.footprintSqft);102 expect(g.netWallSqft).toBeLessThan(g.grossWallSqft + g.gableSqft);103 expect(g.garageSqft).toBe(480);104 expect(g.basementFinishedSqft).toBeGreaterThan(800);105 });106 it("produit des quantités pour ≥ 30 assemblages avec formules, et applique les surcharges", () => {107 const b = houseInput().building;108 const { lines } = deriveQuantities(b);109 expect(lines.length).toBeGreaterThanOrEqual(30);110 expect(lines.every((l) => l.quantity > 0 && l.formula.length > 3)).toBe(true);111 const brick = lines.find((l) => l.assemblyCode === "ENV-SIDING-BRICK")!;112 const vinyl = lines.find((l) => l.assemblyCode === "ENV-SIDING-VINYL")!;113 expect(brick.quantity / (brick.quantity + vinyl.quantity)).toBeCloseTo(0.6, 2);114 const o = deriveQuantities(b, { "ROOF-ASPHALT-ARCH": 1650 });115 expect(o.lines.find((l) => l.assemblyCode === "ROOF-ASPHALT-ARCH")!.quantity).toBe(1650);116 expect(o.lines.find((l) => l.assemblyCode === "ROOF-ASPHALT-ARCH")!.source).toBe("user");117 const ex = deriveQuantities(b, {}, "user", ["EXT-DECK-TREATED"]);118 expect(ex.lines.some((l) => l.assemblyCode === "EXT-DECK-TREATED")).toBe(false);119 });120 it("une dalle sur sol remplace le sous-sol", () => {121 const b = { ...houseInput().building, basement: "none" as const, stories: 1 };122 const { lines } = deriveQuantities(b);123 expect(lines.some((l) => l.assemblyCode === "FND-SLAB-ON-GRADE-INSULATED")).toBe(true);124 expect(lines.some((l) => l.assemblyCode === "FND-SLAB-BASEMENT-4IN")).toBe(false);125 });126});127128/* ------------------------------------------------------------------ moteur */129130describe("engine", () => {131 const ctx = ctxFromSeeds();132 it("chaque assemblage du catalogue a un coût direct positif et une décomposition M/MO/É", () => {133 for (const a of ctx.assemblies.values()) {134 const u = assemblyUnitCost(a, ctx);135 expect(u.direct, a.code).toBeGreaterThan(0);136 expect(u.material + u.labour + u.equipment, a.code).toBeCloseTo(u.direct, 1);137 }138 });139 it("le mur 2×6 coûte un ordre de grandeur plausible et sépare matériaux / main-d'œuvre", () => {140 const u = assemblyUnitCost(ctx.assemblies.get("STR-WALL-2X6-EXT")!, ctx);141 expect(u.direct).toBeGreaterThan(6);142 expect(u.direct).toBeLessThan(20);143 expect(u.labour).toBeCloseTo(0.075 * 89.14, 1);144 });145 it("invariants : RCN ≥ direct, valeur dépréciée ≥ 0, V = terrain + valeur dépréciée, fourchette encadre le RCN", () => {146 const input = houseInput();147 const { lines } = deriveQuantities(input.building);148 const est = computeEstimate(input, lines, ctx, META);149 expect(est.replacementCostNew).toBeGreaterThanOrEqual(est.directCost);150 expect(est.depreciation.depreciatedImprovementValue).toBeGreaterThanOrEqual(0);151 expect(est.costApproachValue).toBe(est.landValue + est.depreciation.depreciatedImprovementValue);152 expect(est.range.p10).toBeLessThan(est.replacementCostNew);153 expect(est.range.p90).toBeGreaterThan(est.replacementCostNew);154 expect(est.directMaterial + est.directLabour + est.directEquipment).toBeCloseTo(est.directCost, -1);155 expect(est.replacementCostNew).toBe(est.directCost + est.indirectCost + est.contractorOverhead + est.contractorProfit + est.contingency);156 expect(est.categories.reduce((s, c) => s + c.sharePct, 0)).toBeCloseTo(100, 0);157 });158 it("le RCN d'une maison standard de 2 000 pi² est dans une plage plausible (150-450 $/pi²)", () => {159 const input = houseInput();160 const { lines } = deriveQuantities(input.building);161 const est = computeEstimate(input, lines, ctx, META);162 expect(est.perSqft).toBeGreaterThan(150);163 expect(est.perSqft).toBeLessThan(450);164 });165 it("frais indirects, généraux, profit et contingence suivent les pourcentages saisis", () => {166 const input = houseInput();167 input.params = { ...DEFAULT_PARAMS, overheadPct: 10, profitPct: 10, contingencyPct: 5 };168 const { lines } = deriveQuantities(input.building);169 const est = computeEstimate(input, lines, ctx, META);170 const ind = Object.values(input.params.indirect).reduce((s, v) => s + v, 0);171 expect(est.indirectCost).toBeCloseTo((est.directCost * ind) / 100, -2);172 expect(est.contractorOverhead).toBeCloseTo((est.directCost + est.indirectCost) * 0.1, -1);173 expect(est.contractorProfit).toBeCloseTo((est.directCost + est.indirectCost + est.contractorOverhead) * 0.1, -1);174 expect(est.contingency).toBeCloseTo((est.directCost + est.indirectCost) * 0.05, -1);175 });176 it("déterminisme : même entrée + même contexte = même résultat", () => {177 const input = houseInput();178 const { lines } = deriveQuantities(input.building);179 const a = computeEstimate(input, lines, ctx, META);180 const b = computeEstimate(input, lines, ctx, META);181 expect(JSON.stringify(a)).toBe(JSON.stringify(b));182 });183 it("facteurs régionaux séparés matériaux / main-d'œuvre / équipement", () => {184 const input = houseInput();185 const { lines } = deriveQuantities(input.building);186 const base = computeEstimate(input, lines, ctx, META);187 const far = computeEstimate(input, lines, { ...ctx, location: { ...ctx.location, code: "QC-CDN", materialFactor: 1.08, labourFactor: 1.03, equipmentFactor: 1.06 } }, META);188 expect(far.directMaterial).toBeCloseTo(base.directMaterial * 1.08, -2);189 expect(far.directLabour).toBeCloseTo(base.directLabour * 1.03, -2);190 expect(far.directCost).toBeGreaterThan(base.directCost);191 });192 it("dépréciation âge-vie : 28 ans / 60 ans = 46,7 %, plafonnée à 90 %", () => {193 const input = houseInput();194 input.depreciation = { ...input.depreciation, method: "age_life", effectiveAge: 28, economicLife: 60 };195 const { lines } = deriveQuantities(input.building);196 const est = computeEstimate(input, lines, ctx, META);197 expect(est.depreciation.physicalPct).toBeCloseTo(46.67, 1);198 expect(est.depreciation.physical).toBeCloseTo(est.replacementCostNew * 0.4667, -2);199 input.depreciation.effectiveAge = 200;200 const capped = computeDepreciation(input, est.lines, est.replacementCostNew, est.directCost, ctx);201 expect(capped.physicalPct).toBe(90);202 });203 it("dépréciation par composante : la toiture « poor » se déprécie plus qu'une toiture « new »", () => {204 const input = houseInput();205 input.depreciation = { ...input.depreciation, method: "components", componentConditions: { roof: "poor", kitchen: "renovated" } };206 const { lines } = deriveQuantities(input.building);207 const est = computeEstimate(input, lines, ctx, META);208 const roof = est.depreciation.components.find((c) => c.conditionGroup === "roof")!;209 const kitchen = est.depreciation.components.find((c) => c.conditionGroup === "kitchen")!;210 expect(roof.depreciationPct).toBeCloseTo(85, 0);211 expect(kitchen.depreciationPct).toBeCloseTo(12, 0);212 expect(est.depreciation.components.reduce((s, c) => s + c.rcn, 0)).toBeCloseTo(est.replacementCostNew, -3);213 });214 it("désuétudes fonctionnelle et externe s'ajoutent, sans dépasser le RCN", () => {215 const input = houseInput();216 input.depreciation = { ...input.depreciation, functional: [{ id: "a", type: "sdb", curable: true, costToCure: 17500, valueLoss: 0, notes: "" }, { id: "b", type: "config", curable: false, costToCure: 0, valueLoss: 12000, notes: "" }], externalValueLoss: 5000 };217 const { lines } = deriveQuantities(input.building);218 const est = computeEstimate(input, lines, ctx, META);219 expect(est.depreciation.functional).toBe(29500);220 expect(est.depreciation.external).toBe(5000);221 expect(est.depreciation.total).toBeLessThanOrEqual(est.replacementCostNew);222 });223 it("confiance : des prix observés valent mieux que des références ; benchmark évalué", () => {224 const input = houseInput();225 const { lines } = deriveQuantities(input.building);226 const ref = computeEstimate(input, lines, ctx, META);227 const obsv = computeEstimate(input, lines, ctxFromSeeds({ observed: true }), META);228 expect(obsv.confidence.total).toBeGreaterThan(ref.confidence.total);229 expect(ref.coverage.materialObservedShare).toBe(0);230 expect(obsv.coverage.materialObservedShare).toBeCloseTo(1, 2);231 expect(["within", "below", "above"]).toContain(ref.benchmarks[0].status);232 expect(["A", "B", "C", "D"]).toContain(ref.confidence.letter);233 });234 it("un terrain manquant est signalé et compté à 0", () => {235 const input = houseInput();236 input.land = { value: null, source: "none", rollYear: null, method: "" };237 const { lines } = deriveQuantities(input.building);238 const est = computeEstimate(input, lines, ctx, META);239 expect(est.landValue).toBe(0);240 expect(est.warnings).toContain("land_missing");241 expect(est.costApproachValue).toBe(est.depreciation.depreciatedImprovementValue);242 });243});244