SPB Git

spb/uqo-eval

Public
16.7 KB · 227 lines typescript
1// UQO Éval — Métré : tests unitaires des modules purs (unités, géométrie, mesures, argent, recettes, estimation, évaluation).2import { describe, expect, it } from "vitest";3import { convertQuantity, dimensionOf, lengthToMeters, preferredUnit } from "./units";4import { isSelfIntersecting, makeCalibration, polygonAreaPx, polylineLengthPx, roofSlopeFactor } from "./geometry";5import { computeMeasure, isStale } from "./measure";6import { fromCents, lineCents, pctOf, roundCentsTo, roundHalfUp, toCents } from "./money";7import { AssemblyCycleError, detectCycle, quoteCustomAssembly, type AssemblyResolver } from "./assemblies";8import { GST_RATE, QST_RATE, computeEstimate, defaultParams, type EngineContext } from "./estimate-engine";9import { computeValuation } from "./valuation";10import type { Calibration, CustomAssembly, EstimateLineInput, PlanObject } from "./types";1112const cal = (): Calibration => makeCalibration({ id: "c1", pageId: "p1", revision: 1, p1: { x: 0, y: 0 }, p2: { x: 1000, y: 0 }, realLength: 10, unit: "m", createdBy: "u", createdAt: "2026-10-28" });13const obj = (p: Partial<PlanObject>): PlanObject => ({ id: "o1", pageId: "p1", kind: "room", layer: "default", name: "o", points: [], closed: true, props: {}, createdAt: "", updatedAt: "", ...p });1415describe("unités", () => {16  it("convertit entre unités de même dimension et refuse les autres", () => {17    expect(convertQuantity(1, "m2", "ft2").value).toBeCloseTo(10.7639, 3);18    expect(convertQuantity(10.7639, "ft2", "m2").value).toBeCloseTo(1, 3);19    expect(convertQuantity(1, "yd3", "m3").value).toBeCloseTo(0.7646, 3);20    expect(convertQuantity(12, "in", "ft").value).toBeCloseTo(1, 9);21    expect(() => convertQuantity(1, "m2", "m")).toThrow();22    expect(() => convertQuantity(1, "m2", "unit")).toThrow();23    expect(dimensionOf("pi2")).toBe("unknown");24    expect(lengthToMeters(1, "ft")).toBeCloseTo(0.3048, 6);25    expect(preferredUnit("area", "imperial")).toBe("ft2");26  });27});2829describe("géométrie", () => {30  it("aire du lacet et longueur de polyligne", () => {31    const sq = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }];32    expect(polygonAreaPx(sq)).toBe(100);33    expect(polygonAreaPx([...sq].reverse())).toBe(100);34    expect(polylineLengthPx(sq)).toBe(30);35    expect(polylineLengthPx(sq, true)).toBe(40);36    expect(isSelfIntersecting(sq)).toBe(false);37    expect(isSelfIntersecting([{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 10, y: 0 }, { x: 0, y: 10 }])).toBe(true);38  });39  it("calibration : px/m et incertitude", () => {40    const c = cal();41    expect(c.pxPerMeter).toBe(100);42    expect(c.uncertaintyPct).toBeCloseTo(0.21, 2);43    const short = makeCalibration({ ...c, p2: { x: 20, y: 0 }, realLength: 0.2 });44    expect(short.uncertaintyPct).toBeGreaterThan(10);45    expect(() => makeCalibration({ ...c, p2: { x: 0, y: 0 } })).toThrow();46    expect(makeCalibration({ ...c, realLength: 32.8084, unit: "ft" }).pxPerMeter).toBeCloseTo(100, 2);47  });48  it("toit : facteur de pente", () => {49    expect(roofSlopeFactor(0)).toBe(1);50    expect(roofSlopeFactor(6)).toBeCloseTo(1.118, 3);51    expect(roofSlopeFactor(12)).toBeCloseTo(Math.SQRT2, 6);52  });53});5455describe("mesures", () => {56  const sq = [{ x: 0, y: 0 }, { x: 500, y: 0 }, { x: 500, y: 400 }, { x: 0, y: 400 }];57  it("aire de pièce, volume de dalle, toit en pente", () => {58    const m = computeMeasure(obj({ points: sq }), "polygon_area", cal());59    expect(m.value).toBeCloseTo(20, 4); expect(m.unit).toBe("m2");60    const v = computeMeasure(obj({ kind: "foundation", points: sq, props: { thickness: 0.2 } }), "slab_volume", cal());61    expect(v.value).toBeCloseTo(4, 4); expect(v.unit).toBe("m3");62    const r = computeMeasure(obj({ kind: "roof", points: sq, props: { pitch: 6 } }), "roof_surface_area", cal());63    expect(r.value).toBeCloseTo(20 * 1.118, 2);64    expect(() => computeMeasure(obj({ points: sq }), "polygon_area", null)).toThrow(/calibr/);65    expect(() => computeMeasure(obj({ kind: "foundation", points: sq }), "slab_volume", cal())).toThrow(/épaisseur/);66  });67  it("mur net : ouvertures vérifiées déduites une seule fois, jamais sous zéro", () => {68    const wall = obj({ id: "w", kind: "wall", points: [{ x: 0, y: 0 }, { x: 1000, y: 0 }], closed: false, props: { height: 2.4 } });69    const win = obj({ id: "win", kind: "opening", props: { openingWidth: 1.2, openingHeight: 1.5, count: 2, deductFrom: "w" } });70    const door = obj({ id: "door", kind: "opening", props: { openingWidth: 0.9, openingHeight: 2.1, deductFrom: "w" } });71    const other = obj({ id: "x", kind: "opening", props: { openingWidth: 5, openingHeight: 5, deductFrom: "autre-mur" } });72    const gross = computeMeasure(wall, "wall_gross_area", cal());73    expect(gross.value).toBeCloseTo(24, 4);74    const net = computeMeasure(wall, "wall_net_area", cal(), [{ obj: win, verified: true }, { obj: win, verified: true }, { obj: door, verified: false }, { obj: other, verified: true }]);75    expect(net.value).toBeCloseTo(24 - 3.6, 4);76    expect(net.lineage).toEqual(["w", "win"]);77    expect(net.warnings[0]).toMatch(/non vérifiée/);78    const huge = obj({ id: "h", kind: "opening", props: { openingWidth: 10, openingHeight: 10, deductFrom: "w" } });79    expect(computeMeasure(wall, "wall_net_area", cal(), [{ obj: huge, verified: true }]).value).toBe(0);80  });81  it("mesure périmée après recalibration", () => {82    expect(isStale(1, 2)).toBe(true); expect(isStale(2, 2)).toBe(false); expect(isStale(null, 3)).toBe(false); expect(isStale(1, null)).toBe(true);83  });84});8586describe("argent (cents entiers)", () => {87  it("arrondis explicites", () => {88    expect(roundHalfUp(2.5)).toBe(3); expect(roundHalfUp(-2.5)).toBe(-3); expect(roundHalfUp(2.4999)).toBe(2);89    expect(toCents(0.1 + 0.2)).toBe(30);90    expect(fromCents(lineCents(3, 0.1))).toBe(0.3);91    expect(pctOf(100000, 9.975)).toBe(9975);92    expect(roundCentsTo(123456, 100)).toBe(123500);93    expect(roundCentsTo(123449, 100)).toBe(123400);94    let s = 0; for (let i = 0; i < 1000; i++) s += toCents(0.01); expect(fromCents(s)).toBe(10);95  });96});9798/* ------------------------------------------------------------ recettes */99100function resolver(custom: CustomAssembly[]): AssemblyResolver {101  const items: Record<string, { unitPrice: number; unit: string; kind: "observed" | "reference"; isEquipment?: boolean; trade?: string }> = {102    GYP: { unitPrice: 0.55, unit: "pi2", kind: "observed", trade: "platrier" }, LUM: { unitPrice: 6, unit: "unit", kind: "reference", trade: "charpentier" }, PELLE: { unitPrice: 120, unit: "h", kind: "reference", isEquipment: true },103  };104  return {105    item: (c) => { const i = items[c]; return i ? { unitPrice: i.unitPrice, unit: i.unit, kind: i.kind, source: "test", sourceUrl: null, observedAt: "2026-10-01", confidence: i.kind === "observed" ? 80 : 35, isEquipment: !!i.isEquipment, defaultTrade: i.trade ?? null } : null; },106    labourRate: (t) => (t === "platrier" ? { rate: 80, source: "APCHQ test", sourceUrl: null, effectiveFrom: "2026-04-26", confidence: 90 } : null),107    catalogueAssembly: (c) => (c === "CAT-WALL" ? { code: c, unit: "pi2", material: 2, labour: 3, equipment: 0, other: 0, direct: 5, observedShare: 1, confidence: 70, source: "catalogue", components: [] } : null),108    customAssembly: (c) => custom.find((a) => a.code === c) ?? null,109    fallbackHourlyRate: 85,110  };111}112const asm = (code: string, comps: Partial<CustomAssembly["components"][number]>[], extra: Partial<CustomAssembly> = {}): CustomAssembly => ({113  id: code, projectId: null, code, nameFr: code, nameEn: code, unit: "m2", outputQuantity: 1, category: "test", version: 1, exclusions: null, createdBy: "u", createdAt: "", updatedAt: "", ...extra,114  components: comps.map((c, i) => ({ id: `${code}-${i}`, refType: "item", refCode: "GYP", quantity: 1, wasteRate: 0, labourHours: 0, trade: null, equipmentHours: 0, equipmentRate: 0, subcontract: 0, transport: 0, installed: false, notes: null, ...c })),115});116117describe("recettes d'assemblages", () => {118  it("direct = matériaux × (1 + pertes) + heures × taux + équipement + sous-traitance + transport, divisé par la sortie", () => {119    const a = asm("A", [{ refCode: "GYP", quantity: 10, wasteRate: 0.1, labourHours: 0.5, trade: "platrier" }, { refCode: "PELLE", quantity: 0.1 }, { refCode: "LUM", quantity: 2, labourHours: 0.25, trade: "charpentier", subcontract: 3, transport: 1 }], { outputQuantity: 2 });120    const q = quoteCustomAssembly(a, resolver([]));121    // matériaux : 10 × 1,1 × 0,55 = 6,05 ; 2 × 6 = 12 → 18,05 ; MO : 0,5 × 80 + 0,25 × 85 = 61,25 ; équipement : 0,1 × 120 = 12 ; autres : 4 → direct 95,30 / 2122    // les coûts par unité de sortie sont arrondis au cent (demi vers le haut) : 9,025 → 9,03 ; 30,625 → 30,63123    expect(q.material).toBe(9.03); expect(q.labour).toBe(30.63); expect(q.equipment).toBe(6); expect(q.other).toBe(2); expect(q.direct).toBe(47.65);124    expect(q.components[2].warnings[0]).toMatch(/repli/);125    expect(q.observedShare).toBeCloseTo(6.05 / 18.05, 3);126  });127  it("prix installé : ni pertes ni main-d'œuvre ajoutées", () => {128    const a = asm("B", [{ refCode: "GYP", quantity: 10, wasteRate: 0.1, labourHours: 2, trade: "platrier", installed: true }]);129    const q = quoteCustomAssembly(a, resolver([]));130    expect(q.material).toBeCloseTo(5.5, 4); expect(q.labour).toBe(0);131    expect(q.components[0].warnings.join(" ")).toMatch(/installé/);132  });133  it("imbrication : pertes et heures non réappliquées, catalogue accessible, cycles refusés", () => {134    const inner = asm("IN", [{ refCode: "GYP", quantity: 4, wasteRate: 0.05, labourHours: 0.1, trade: "platrier" }]);135    const outer = asm("OUT", [{ refType: "assembly", refCode: "IN", quantity: 2, wasteRate: 0.2, labourHours: 5 }, { refType: "assembly", refCode: "CAT-WALL", quantity: 3 }]);136    const q = quoteCustomAssembly(outer, resolver([inner, outer]));137    const innerDirect = 4 * 1.05 * 0.55 + 0.1 * 80; // 2,31 + 8 = 10,31138    expect(q.direct).toBeCloseTo(2 * innerDirect + 3 * 5, 2);139    expect(q.components[0].warnings.length).toBe(2);140    const a = asm("A", [{ refType: "assembly", refCode: "B" }]); const b = asm("B", [{ refType: "assembly", refCode: "A" }]);141    expect(detectCycle("A", resolver([a, b]))).toEqual(["A", "B", "A"]);142    expect(() => quoteCustomAssembly(a, resolver([a, b]))).toThrow(AssemblyCycleError);143    expect(detectCycle("OUT", resolver([inner, outer]))).toBeNull();144  });145});146147/* ------------------------------------------------------------ estimation */148149const line = (p: Partial<EstimateLineInput>): EstimateLineInput => ({ id: "l", wbs: "03", description: "x", assemblyCode: "ASM", measurementId: null, quantity: 100, unit: "m2", adjustmentFactor: 1, basis: "catalogue", unitPrice: null, installed: false, assumptionNote: null, excluded: false, sortOrder: 0, ...p });150function ctx(factors = { m: 1, l: 1, e: 1 }): EngineContext {151  return {152    quote: (l) => (l.assemblyCode === "ASM" ? { unit: "m2", material: 10, labour: 5, equipment: 1, other: 0, installed: false, provenance: { basis: "catalogue", kind: "observed", evidenceClass: "supplier_quote", source: "t", sourceUrl: null, observedAt: null, note: null, confidence: 80, observedShare: 1 } } : l.assemblyCode === "FT" ? { unit: "ft2", material: 1, labour: 0, equipment: 0, other: 0, installed: false, provenance: { basis: "catalogue", kind: "reference", evidenceClass: null, source: "t", sourceUrl: null, observedAt: null, note: null, confidence: 30, observedShare: 0 } } : null),153    location: { code: "QC-MTL", nameFr: "Montréal", nameEn: "Montreal", materialFactor: factors.m, labourFactor: factors.l, equipmentFactor: factors.e, confidence: 75, method: "test" }, catalogueVersion: "test", now: "2026-10-28T00:00:00Z",154  };155}156157describe("moteur d'estimation", () => {158  it("ordre des majorations : CG sur le direct, FG et profit sur le cumul, contingence sur le direct, puis taxes non composées", () => {159    const p = { ...defaultParams("2026-10-28"), generalConditionsPct: 10, overheadPct: 10, profitPct: 10, contingencyPct: 5, applyGst: true, applyQst: true };160    const r = computeEstimate([line({})], p, ctx());161    expect(r.totals.direct).toBe(1600);162    expect(r.totals.generalConditions).toBe(160);163    expect(r.totals.overhead).toBe(176); // (1600 + 160) × 10 %164    expect(r.totals.profit).toBeCloseTo(193.6, 2); // (1600 + 160 + 176) × 10 %165    expect(r.totals.contingency).toBe(80); // 5 % du direct seulement166    expect(r.totals.subtotalBeforeTax).toBeCloseTo(2209.6, 2);167    expect(r.totals.gst).toBeCloseTo(2209.6 * GST_RATE / 100, 1);168    expect(r.totals.qst).toBeCloseTo(2209.6 * QST_RATE / 100, 1);169    expect(r.totals.total).toBeCloseTo(2209.6 * 1.14975, 1);170    expect(r.applicationOrder.length).toBe(9);171  });172  it("facteur régional par composante, actualisation par indice, arrondi au dollar", () => {173    const p = { ...defaultParams("2026-10-28"), generalConditionsPct: 0, overheadPct: 0, profitPct: 0, contingencyPct: 0, escalateTo: "2027-01-01", escalationFactor: 1.05, escalationSource: "StatCan test", roundingCents: 100 };174    const r = computeEstimate([line({})], p, ctx({ m: 1.1, l: 1.2, e: 1 }));175    expect(r.totals.located).toBe(1000 * 1.1 + 500 * 1.2 + 100); // 1800176    expect(r.totals.escalation).toBeCloseTo(90, 2);177    expect(r.totals.total).toBe(1890);178    expect(r.lines[0].located).toBe(1800);179  });180  it("facteur d'ajustement explicite, conversion d'unités, lignes exclues et hypothèses", () => {181    const r = computeEstimate([182      line({ id: "a", quantity: 100, adjustmentFactor: 1.1 }),183      line({ id: "b", assemblyCode: "FT", quantity: 1, unit: "m2" }),184      line({ id: "c", basis: "assumption", assemblyCode: null, unitPrice: 50, quantity: 2, unit: "unit", installed: true, assumptionNote: "soumission verbale" }),185      line({ id: "d", excluded: true, quantity: 1e6 }),186      line({ id: "e", assemblyCode: "ASM", quantity: 3, unit: "m" }),187    ], { ...defaultParams("2026-10-28"), generalConditionsPct: 0, overheadPct: 0, profitPct: 0, contingencyPct: 0 }, ctx());188    const [a, b, c, d, e] = r.lines;189    expect(a.quantity).toBeCloseTo(110, 6); expect(a.direct).toBe(1760);190    expect(b.quantity).toBeCloseTo(10.7639, 3); expect(b.unit).toBe("ft2"); expect(b.direct).toBeCloseTo(10.76, 2);191    expect(c.direct).toBe(100); expect(c.provenance.basis).toBe("assumption"); expect(c.other).toBe(100);192    expect(d.excluded).toBe(true); expect(r.totals.direct).toBeCloseTo(1760 + 10.76 + 100, 2);193    expect(e.direct).toBe(0); expect(e.warnings.join(" ")).toMatch(/incompatibles/);194    expect(r.coverage.linesTotal).toBe(4); expect(r.coverage.linesPriced).toBe(3);195    expect(r.warnings.join(" ")).toMatch(/non chiffrée/);196    expect(r.byWbs[0].wbs).toBe("03");197  });198  it("déterminisme : mêmes entrées → même résultat", () => {199    const ls = [line({ id: "1" }), line({ id: "2", quantity: 33.333 })];200    const p = defaultParams("2026-10-28");201    expect(JSON.stringify(computeEstimate(ls, p, ctx()).totals)).toBe(JSON.stringify(computeEstimate(ls, p, ctx()).totals));202  });203});204205/* ------------------------------------------------------------ évaluation */206207describe("méthode du coût (valeur)", () => {208  const base = { basis: "replacement" as const, costNew: 400000, basisAdjustmentPct: 0, siteImprovements: 20000, physical: { method: "age_life" as const, effectiveAge: 15, economicLife: 60, percent: null }, functional: [], external: { amount: 0, note: null }, land: { value: 150000, source: "role" as const, note: null } };209  it("âge-vie, terrain, valeur indiquée", () => {210    const r = computeValuation(base);211    expect(r.totalCostNew).toBe(420000); expect(r.physicalPct).toBe(25); expect(r.physical).toBe(105000);212    expect(r.depreciatedImprovements).toBe(315000); expect(r.indicatedValue).toBe(465000);213  });214  it("pas de double déduction : déficience éliminée par le remplacement ignorée, mais déduite en reproduction", () => {215    const f = [{ id: "f1", label: "cuisine désuète", curable: true, amount: 25000, eliminatedByReplacement: true }, { id: "f2", label: "plafonds bas", curable: false, amount: 10000, eliminatedByReplacement: false }];216    const rep = computeValuation({ ...base, functional: f });217    expect(rep.functionalCurable).toBe(0); expect(rep.functionalIncurable).toBe(10000); expect(rep.functionalSkipped.length).toBe(1);218    const repro = computeValuation({ ...base, basis: "reproduction", functional: f });219    expect(repro.functionalCurable).toBe(25000);220  });221  it("plafonds : 90 % physique, dépréciation ≤ coût à neuf, pourcentage direct", () => {222    expect(computeValuation({ ...base, physical: { method: "age_life", effectiveAge: 100, economicLife: 50, percent: null } }).physicalPct).toBe(90);223    const r = computeValuation({ ...base, physical: { method: "percent", effectiveAge: null, economicLife: 60, percent: 50 }, external: { amount: 1e6, note: null } });224    expect(r.totalDepreciation).toBe(420000); expect(r.depreciatedImprovements).toBe(0); expect(r.indicatedValue).toBe(150000);225  });226});227
Files · main
Menu

Theme