// Auteur : Simon-Pierre Boucher — contact@spboucher.ai import { describe, expect, it } from "vitest"; import { assemblyUnitCost, computeEstimate, computeDepreciation, type PricingContext } from "./engine"; import { computeGeometry, deriveQuantities } from "./geometry"; import { canonicalPrice, detectOutliers, indexFactor, median, type PriceObservation } from "./pricing"; import { convertPrice, isKnownUnit } from "./units"; import { ITEM_SEEDS } from "./seed/items"; import { ASSEMBLY_SEEDS } from "./seed/assemblies"; import { CONDITIONS, CONDITION_GROUPS, DEFAULT_PARAMS } from "./taxonomy"; import type { Assembly, Condition, CostInput, CostItem, ItemPrice, LabourRate } from "./types"; import { defaultInput } from "./estimate-defaults"; /* ------------------------------------------------------------ contexte test */ function ctxFromSeeds(opts: { observed?: boolean } = {}): PricingContext { const items = new Map(); const prices = new Map(); for (const s of ITEM_SEEDS) { 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 }); 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 }); } const assemblies = new Map(); 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 }); const labour = new Map(); 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"]) { 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 }); } const conditionRules = new Map>(); for (const g of CONDITION_GROUPS) conditionRules.set(g.key, new Map(CONDITIONS.map((c) => [c.key, { effectiveAgeRatio: c.effectiveAgeRatio, economicLife: g.economicLife }]))); return { asOf: "2026-09-06", items, prices, labour, assemblies, 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 }, conditionRules, benchmarks: [{ source: "test", buildingType: "single_family", market: "Montréal", unit: "$/pi2", low: 200, high: 400, midpoint: 300, year: 2026, notes: null }], fallbackHourlyRate: 85, }; } const META = { id: "t", createdAt: "2026-09-06T00:00:00Z", costDatabaseVersion: "test", assemblyVersion: "1" }; function houseInput(): CostInput { const i = defaultInput(); 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" }; i.land = { value: 182000, source: "role", rollYear: 2026, method: "rôle" }; i.attributeSources = { grossFloorAreaSqft: "MAMH", stories: "MAMH", yearBuilt: "MAMH", basement: "user", garage: "user", siding: "user", roof: "user", heating: "user", bathrooms: "user", kitchenQuality: "user" }; return i; } /* ------------------------------------------------------------------ unités */ describe("units", () => { it("convertit une feuille de gypse 4×8 en $/pi²", () => { const r = convertPrice(17.6, "feuille", "pi2", 32); expect(r.price).toBeCloseTo(0.55, 4); expect(r.conversion.factor).toBeCloseTo(1 / 32, 6); expect(r.conversion.sourceUnit).toBe("feuille"); }); it("convertit m² → pi² et refuse une unité inconnue sans emballage", () => { expect(convertPrice(10.764, "m2", "pi2").price).toBeCloseTo(1, 3); expect(() => convertPrice(5, "zorglub", "pi2")).toThrow(); expect(isKnownUnit("paquet")).toBe(true); expect(isKnownUnit("chaque")).toBe(true); }); }); /* ------------------------------------------------------------------- prix */ describe("pricing", () => { 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 }); it("médiane robuste multi-source, observations conservées", () => { const cp = canonicalPrice([obs("Canac", 8.94), obs("RONA", 9.22), obs("Home Depot", 9.18)], "2026-09-06")!; expect(cp.price).toBeCloseTo(9.18, 2); expect(cp.sourceCount).toBe(3); expect(cp.low).toBeCloseTo(8.94, 2); expect(cp.high).toBeCloseTo(9.22, 2); }); it("préfère le prix régulier à la promotion", () => { const cp = canonicalPrice([obs("Canac", 5.0, "2026-09-01", false), obs("BMR", 8.0), obs("PM", 8.2)], "2026-09-06")!; expect(cp.price).toBeCloseTo(8.1, 2); expect(cp.used.every((o) => o.regular)).toBe(true); }); it("marque les aberrations sans les effacer", () => { expect(detectOutliers([8, 8.5, 9, 40]).length).toBe(1); const cp = canonicalPrice([obs("A", 8), obs("B", 8.5), obs("C", 40)], "2026-09-06")!; expect(cp.outliers.length).toBe(1); expect(cp.price).toBeCloseTo(8.25, 2); }); it("ignore les observations hors fenêtre et actualise par indice", () => { expect(canonicalPrice([obs("A", 8, "2025-01-01")], "2026-09-06")).toBeNull(); const f = indexFactor([{ period: "2025-01-01", value: 100 }, { period: "2026-01-01", value: 110 }], "2025-03-01", "2026-06-01")!; expect(f.factor).toBeCloseTo(1.1, 6); expect(median([1, 3, 2])).toBe(2); }); }); /* -------------------------------------------------------------- géométrie */ describe("geometry", () => { it("dérive une géométrie cohérente pour une maison de 2 000 pi² sur 2 étages", () => { const g = computeGeometry(houseInput().building); expect(g.footprintSqft).toBe(1000); expect(g.perimeterFt).toBeGreaterThan(120); expect(g.roofSqft).toBeGreaterThan(g.footprintSqft); expect(g.netWallSqft).toBeLessThan(g.grossWallSqft + g.gableSqft); expect(g.garageSqft).toBe(480); expect(g.basementFinishedSqft).toBeGreaterThan(800); }); it("produit des quantités pour ≥ 30 assemblages avec formules, et applique les surcharges", () => { const b = houseInput().building; const { lines } = deriveQuantities(b); expect(lines.length).toBeGreaterThanOrEqual(30); expect(lines.every((l) => l.quantity > 0 && l.formula.length > 3)).toBe(true); const brick = lines.find((l) => l.assemblyCode === "ENV-SIDING-BRICK")!; const vinyl = lines.find((l) => l.assemblyCode === "ENV-SIDING-VINYL")!; expect(brick.quantity / (brick.quantity + vinyl.quantity)).toBeCloseTo(0.6, 2); const o = deriveQuantities(b, { "ROOF-ASPHALT-ARCH": 1650 }); expect(o.lines.find((l) => l.assemblyCode === "ROOF-ASPHALT-ARCH")!.quantity).toBe(1650); expect(o.lines.find((l) => l.assemblyCode === "ROOF-ASPHALT-ARCH")!.source).toBe("user"); const ex = deriveQuantities(b, {}, "user", ["EXT-DECK-TREATED"]); expect(ex.lines.some((l) => l.assemblyCode === "EXT-DECK-TREATED")).toBe(false); }); it("une dalle sur sol remplace le sous-sol", () => { const b = { ...houseInput().building, basement: "none" as const, stories: 1 }; const { lines } = deriveQuantities(b); expect(lines.some((l) => l.assemblyCode === "FND-SLAB-ON-GRADE-INSULATED")).toBe(true); expect(lines.some((l) => l.assemblyCode === "FND-SLAB-BASEMENT-4IN")).toBe(false); }); }); /* ------------------------------------------------------------------ moteur */ describe("engine", () => { const ctx = ctxFromSeeds(); it("chaque assemblage du catalogue a un coût direct positif et une décomposition M/MO/É", () => { for (const a of ctx.assemblies.values()) { const u = assemblyUnitCost(a, ctx); expect(u.direct, a.code).toBeGreaterThan(0); expect(u.material + u.labour + u.equipment, a.code).toBeCloseTo(u.direct, 1); } }); it("le mur 2×6 coûte un ordre de grandeur plausible et sépare matériaux / main-d'œuvre", () => { const u = assemblyUnitCost(ctx.assemblies.get("STR-WALL-2X6-EXT")!, ctx); expect(u.direct).toBeGreaterThan(6); expect(u.direct).toBeLessThan(20); expect(u.labour).toBeCloseTo(0.075 * 89.14, 1); }); it("invariants : RCN ≥ direct, valeur dépréciée ≥ 0, V = terrain + valeur dépréciée, fourchette encadre le RCN", () => { const input = houseInput(); const { lines } = deriveQuantities(input.building); const est = computeEstimate(input, lines, ctx, META); expect(est.replacementCostNew).toBeGreaterThanOrEqual(est.directCost); expect(est.depreciation.depreciatedImprovementValue).toBeGreaterThanOrEqual(0); expect(est.costApproachValue).toBe(est.landValue + est.depreciation.depreciatedImprovementValue); expect(est.range.p10).toBeLessThan(est.replacementCostNew); expect(est.range.p90).toBeGreaterThan(est.replacementCostNew); expect(est.directMaterial + est.directLabour + est.directEquipment).toBeCloseTo(est.directCost, -1); expect(est.replacementCostNew).toBe(est.directCost + est.indirectCost + est.contractorOverhead + est.contractorProfit + est.contingency); expect(est.categories.reduce((s, c) => s + c.sharePct, 0)).toBeCloseTo(100, 0); }); it("le RCN d'une maison standard de 2 000 pi² est dans une plage plausible (150-450 $/pi²)", () => { const input = houseInput(); const { lines } = deriveQuantities(input.building); const est = computeEstimate(input, lines, ctx, META); expect(est.perSqft).toBeGreaterThan(150); expect(est.perSqft).toBeLessThan(450); }); it("frais indirects, généraux, profit et contingence suivent les pourcentages saisis", () => { const input = houseInput(); input.params = { ...DEFAULT_PARAMS, overheadPct: 10, profitPct: 10, contingencyPct: 5 }; const { lines } = deriveQuantities(input.building); const est = computeEstimate(input, lines, ctx, META); const ind = Object.values(input.params.indirect).reduce((s, v) => s + v, 0); expect(est.indirectCost).toBeCloseTo((est.directCost * ind) / 100, -2); expect(est.contractorOverhead).toBeCloseTo((est.directCost + est.indirectCost) * 0.1, -1); expect(est.contractorProfit).toBeCloseTo((est.directCost + est.indirectCost + est.contractorOverhead) * 0.1, -1); expect(est.contingency).toBeCloseTo((est.directCost + est.indirectCost) * 0.05, -1); }); it("déterminisme : même entrée + même contexte = même résultat", () => { const input = houseInput(); const { lines } = deriveQuantities(input.building); const a = computeEstimate(input, lines, ctx, META); const b = computeEstimate(input, lines, ctx, META); expect(JSON.stringify(a)).toBe(JSON.stringify(b)); }); it("facteurs régionaux séparés matériaux / main-d'œuvre / équipement", () => { const input = houseInput(); const { lines } = deriveQuantities(input.building); const base = computeEstimate(input, lines, ctx, META); const far = computeEstimate(input, lines, { ...ctx, location: { ...ctx.location, code: "QC-CDN", materialFactor: 1.08, labourFactor: 1.03, equipmentFactor: 1.06 } }, META); expect(far.directMaterial).toBeCloseTo(base.directMaterial * 1.08, -2); expect(far.directLabour).toBeCloseTo(base.directLabour * 1.03, -2); expect(far.directCost).toBeGreaterThan(base.directCost); }); it("dépréciation âge-vie : 28 ans / 60 ans = 46,7 %, plafonnée à 90 %", () => { const input = houseInput(); input.depreciation = { ...input.depreciation, method: "age_life", effectiveAge: 28, economicLife: 60 }; const { lines } = deriveQuantities(input.building); const est = computeEstimate(input, lines, ctx, META); expect(est.depreciation.physicalPct).toBeCloseTo(46.67, 1); expect(est.depreciation.physical).toBeCloseTo(est.replacementCostNew * 0.4667, -2); input.depreciation.effectiveAge = 200; const capped = computeDepreciation(input, est.lines, est.replacementCostNew, est.directCost, ctx); expect(capped.physicalPct).toBe(90); }); it("dépréciation par composante : la toiture « poor » se déprécie plus qu'une toiture « new »", () => { const input = houseInput(); input.depreciation = { ...input.depreciation, method: "components", componentConditions: { roof: "poor", kitchen: "renovated" } }; const { lines } = deriveQuantities(input.building); const est = computeEstimate(input, lines, ctx, META); const roof = est.depreciation.components.find((c) => c.conditionGroup === "roof")!; const kitchen = est.depreciation.components.find((c) => c.conditionGroup === "kitchen")!; expect(roof.depreciationPct).toBeCloseTo(85, 0); expect(kitchen.depreciationPct).toBeCloseTo(12, 0); expect(est.depreciation.components.reduce((s, c) => s + c.rcn, 0)).toBeCloseTo(est.replacementCostNew, -3); }); it("désuétudes fonctionnelle et externe s'ajoutent, sans dépasser le RCN", () => { const input = houseInput(); 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 }; const { lines } = deriveQuantities(input.building); const est = computeEstimate(input, lines, ctx, META); expect(est.depreciation.functional).toBe(29500); expect(est.depreciation.external).toBe(5000); expect(est.depreciation.total).toBeLessThanOrEqual(est.replacementCostNew); }); it("confiance : des prix observés valent mieux que des références ; benchmark évalué", () => { const input = houseInput(); const { lines } = deriveQuantities(input.building); const ref = computeEstimate(input, lines, ctx, META); const obsv = computeEstimate(input, lines, ctxFromSeeds({ observed: true }), META); expect(obsv.confidence.total).toBeGreaterThan(ref.confidence.total); expect(ref.coverage.materialObservedShare).toBe(0); expect(obsv.coverage.materialObservedShare).toBeCloseTo(1, 2); expect(["within", "below", "above"]).toContain(ref.benchmarks[0].status); expect(["A", "B", "C", "D"]).toContain(ref.confidence.letter); }); it("un terrain manquant est signalé et compté à 0", () => { const input = houseInput(); input.land = { value: null, source: "none", rollYear: null, method: "" }; const { lines } = deriveQuantities(input.building); const est = computeEstimate(input, lines, ctx, META); expect(est.landValue).toBe(0); expect(est.warnings).toContain("land_missing"); expect(est.costApproachValue).toBe(est.depreciation.depreciatedImprovementValue); }); });