1// UQO Éval — Métré : trois exemples résidentiels québécois (QA humaine) — quantités vérifiées à la main, prix d'hypothèse transparents.2// Les prix unitaires ci-dessous sont des HYPOTHÈSES PÉDAGOGIQUES explicites (basis = assumption), pas des observations ;3// ils servent à vérifier l'arithmétique du moteur sur des métrés calculés à la main (docs/construction/VALIDATION_PLAN.md §4).4import { describe, expect, it } from "vitest";5import { computeEstimate, defaultParams, type EngineContext } from "./estimate-engine";6import { computeMeasure } from "./measure";7import { makeCalibration } from "./geometry";8import { computeValuation } from "./valuation";9import type { EstimateLineInput, PlanObject } from "./types";1011const ctx: EngineContext = { quote: () => null, location: { code: "QC-GAT", nameFr: "Gatineau", nameEn: "Gatineau", materialFactor: 1, labourFactor: 1, equipmentFactor: 1, confidence: 75, method: "test" }, catalogueVersion: "exemples", now: "2026-10-28T00:00:00Z" };12const A = (id: string, wbs: string, description: string, quantity: number, unit: string, unitPrice: number, note: string): EstimateLineInput => ({ id, wbs, description, assemblyCode: null, measurementId: null, quantity, unit, adjustmentFactor: 1, basis: "assumption", unitPrice, installed: true, assumptionNote: note, excluded: false, sortOrder: 0 });13const noMarkup = { ...defaultParams("2026-10-28", "QC-GAT"), generalConditionsPct: 0, overheadPct: 0, profitPct: 0, contingencyPct: 0 };1415describe("Exemple 1 — bungalow 1 étage, Gatineau (plan 12,2 m × 9,1 m)", () => {16 // Calibration : 1 220 px = 12,2 m → 100 px/m. Empreinte rectangulaire 1220 × 910 px.17 const cal = makeCalibration({ id: "c", pageId: "p", revision: 1, p1: { x: 0, y: 0 }, p2: { x: 1220, y: 0 }, realLength: 12.2, unit: "m", createdBy: "qa", createdAt: "2026-10-28" });18 const rect = [{ x: 0, y: 0 }, { x: 1220, y: 0 }, { x: 1220, y: 910 }, { x: 0, y: 910 }];19 const obj = (p: Partial<PlanObject>): PlanObject => ({ id: "o", pageId: "p", kind: "slab", layer: "l", name: "n", points: rect, closed: true, props: {}, createdAt: "", updatedAt: "", ...p });20 it("quantités vérifiées à la main", () => {21 const slab = computeMeasure(obj({ kind: "slab", props: { thickness: 0.1 } }), "slab_volume", cal);22 expect(slab.value).toBeCloseTo(12.2 * 9.1 * 0.1, 3); // 11,102 m³23 const floor = computeMeasure(obj({ kind: "room" }), "polygon_area", cal);24 expect(floor.value).toBeCloseTo(111.02, 3); // m²25 const roof = computeMeasure(obj({ kind: "roof", props: { pitch: 4 } }), "roof_surface_area", cal);26 expect(roof.value).toBeCloseTo(111.02 * Math.sqrt(1 + (4 / 12) ** 2), 3); // 117,05 m²27 const wall = computeMeasure(obj({ id: "w", kind: "wall", closed: false, points: [{ x: 0, y: 0 }, { x: 1220, y: 0 }, { x: 1220, y: 910 }, { x: 0, y: 910 }, { x: 0, y: 0 }], props: { height: 2.44 } }), "wall_gross_area", cal);28 expect(wall.value).toBeCloseTo((12.2 + 9.1) * 2 * 2.44, 2); // 103,94 m² (périmètre 42,6 m)29 });30 it("estimation : lignes à prix d'hypothèse, totaux à la main", () => {31 const lines = [32 A("1", "03", "Dalle sur sol 100 mm (hyp. 285 $/m³ béton livré + finition)", 11.102, "m3", 285, "hypothèse pédagogique"),33 A("2", "06", "Murs extérieurs 2×6 + revêtement (hyp. 165 $/m² installé)", 103.94, "m2", 165, "hypothèse pédagogique"),34 A("3", "07", "Toiture bardeau (hyp. 48 $/m² installé)", 117.05, "m2", 48, "hypothèse pédagogique"),35 A("4", "09", "Finitions intérieures (hyp. 310 $/m² plancher)", 111.02, "m2", 310, "hypothèse pédagogique"),36 ];37 const r = computeEstimate(lines, { ...noMarkup, generalConditionsPct: 8, overheadPct: 7, profitPct: 8, contingencyPct: 5 }, ctx);38 // 11,102 × 285 + 103,94 × 165 + 117,05 × 48 + 111,02 × 310 = 3 164,07 + 17 150,10 + 5 618,40 + 34 416,20 = 60 348,7739 expect(r.totals.direct).toBeCloseTo(60348.77, 2);40 const gc = 60348.77 * 0.08, oh = (60348.77 + gc) * 0.07, pr = (60348.77 + gc + oh) * 0.08, ct = 60348.77 * 0.05;41 expect(r.totals.generalConditions).toBeCloseTo(gc, 1); expect(r.totals.overhead).toBeCloseTo(oh, 1); expect(r.totals.profit).toBeCloseTo(pr, 1); expect(r.totals.contingency).toBeCloseTo(ct, 1);42 expect(r.totals.total).toBeCloseTo(60348.77 + gc + oh + pr + ct, 0); // ≈ 78 350 $43 expect(r.coverage.assumptionShare).toBe(1); // 100 % hypothèses : le rapport doit l'afficher44 });45});4647describe("Exemple 2 — cottage 2 étages avec garage, Montréal (unités impériales)", () => {48 // Plan calibré en pieds : 400 px = 40 pi → 10 px/pi = 32,81 px/m49 const cal = makeCalibration({ id: "c", pageId: "p", revision: 1, p1: { x: 0, y: 0 }, p2: { x: 400, y: 0 }, realLength: 40, unit: "ft", createdBy: "qa", createdAt: "2026-10-28" });50 const main = [{ x: 0, y: 0 }, { x: 400, y: 0 }, { x: 400, y: 280 }, { x: 0, y: 280 }]; // 40 × 28 pi = 1 120 pi² par étage51 const garage = [{ x: 400, y: 0 }, { x: 620, y: 0 }, { x: 620, y: 220 }, { x: 400, y: 220 }]; // 22 × 22 pi = 484 pi²52 const o = (points: typeof main, kind: PlanObject["kind"], props = {}): PlanObject => ({ id: "o", pageId: "p", kind, layer: "l", name: "n", points, closed: true, props, createdAt: "", updatedAt: "" });53 it("aires en m² puis conversion vers pi² dans l'estimation", () => {54 const floor = computeMeasure(o(main, "room"), "polygon_area", cal);55 expect(floor.value * 10.7639).toBeCloseTo(1120, 1);56 const g = computeMeasure(o(garage, "slab", { thickness: 0.125 }), "slab_volume", cal);57 expect(g.value).toBeCloseTo(484 * 0.09290304 * 0.125, 3); // 5,621 m³58 const lines: EstimateLineInput[] = [59 { ...A("1", "09", "Plancher 2 étages (hyp. 28 $/pi²)", floor.value * 2, "m2", 28, "hyp."), adjustmentFactor: 1 }, // quantité en m², prix par pi² → conversion attendue60 ];61 // ligne hypothèse : l'unité de la ligne fait foi (prix saisi en $/unité de ligne) — ici on convertit explicitement62 const r = computeEstimate([{ ...lines[0], quantity: 2240, unit: "ft2" }], noMarkup, ctx);63 expect(r.totals.direct).toBe(62720);64 const withFactor = computeEstimate([{ ...lines[0], quantity: 2240, unit: "ft2", adjustmentFactor: 1.05 }], noMarkup, ctx);65 expect(withFactor.totals.direct).toBeCloseTo(62720 * 1.05, 2); // +5 % pertes explicites66 expect(withFactor.lines[0].warnings[0]).toMatch(/facteur/);67 });68 it("taxes non composées (TPS 5 % + TVQ 9,975 % = 14,975 %)", () => {69 const r = computeEstimate([A("1", "01", "Forfait", 1, "lump", 100000, "hyp.")], { ...noMarkup, applyGst: true, applyQst: true }, ctx);70 expect(r.totals.gst).toBe(5000); expect(r.totals.qst).toBe(9975); expect(r.totals.total).toBe(114975);71 });72});7374describe("Exemple 3 — triplex 1965, Québec : méthode du coût avec obsolescence", () => {75 it("valeur indiquée = terrain + (coût à neuf − dépréciations), sans double déduction", () => {76 const r = computeEstimate([A("1", "00", "Coût à neuf du triplex (hyp. 2 150 $/m² × 320 m²)", 320, "m2", 2150, "hyp. — benchmark de classe")], { ...noMarkup, generalConditionsPct: 6, overheadPct: 6, profitPct: 6, contingencyPct: 0, escalateTo: "2027-01-01", escalationFactor: 1.03, escalationSource: "hyp. 3 %" }, ctx);77 const direct = 320 * 2150; // 688 00078 const gc = direct * 0.06, oh = (direct + gc) * 0.06, pr = (direct + gc + oh) * 0.06;79 const sub1 = direct + gc + oh + pr;80 expect(r.totals.subtotalBeforeEscalation).toBeCloseTo(sub1, 0);81 expect(r.totals.escalation).toBeCloseTo(sub1 * 0.03, 0);82 const costNew = r.totals.subtotalBeforeTax;83 const v = computeValuation({ basis: "replacement", costNew, basisAdjustmentPct: -5, siteImprovements: 15000, physical: { method: "age_life", effectiveAge: 35, economicLife: 70, percent: null }, functional: [{ id: "a", label: "cuisines désuètes", curable: true, amount: 45000, eliminatedByReplacement: true }, { id: "b", label: "absence de stationnement", curable: false, amount: 20000, eliminatedByReplacement: false }], external: { amount: 10000, note: "bruit autoroute" }, land: { value: 210000, source: "role", note: null } });84 const used = Math.round(costNew * 0.95 * 100) / 100;85 expect(v.costNewUsed).toBeCloseTo(used, 2);86 expect(v.totalCostNew).toBeCloseTo(used + 15000, 2);87 expect(v.physicalPct).toBe(50);88 expect(v.functionalCurable).toBe(0); expect(v.functionalIncurable).toBe(20000); expect(v.functionalSkipped[0].amount).toBe(45000);89 const dep = Math.round((used + 15000) * 0.5 * 100) / 100 + 20000 + 10000;90 expect(v.totalDepreciation).toBeCloseTo(dep, 1);91 expect(v.indicatedValue).toBeCloseTo(210000 + (used + 15000) - dep, 1);92 });93});94