SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
15.7 KB · 244 lines typescript
Raw Blame History
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