1// UQO Éval — Quantity Survey : la correspondance relevé → RSMeans est cohérente (unités compatibles) et, quand la base RSMeans est2// installée, chaque ligne existe et possède un prix national.3import { describe, expect, it } from "vitest";4import { QS_RSMEANS_MAP, QS_RSMEANS_UNMAPPED } from "./rsmeans-qs-map";5import { rsmLine, rsmPrice, rsmUnit, rsmeansAvailable } from "./rsmeans";6import { dimensionOf } from "./units";78describe("correspondance relevé des quantités → RSMeans", () => {9 it("toutes les familles du relevé sont couvertes (correspondance ou exclusion explicite)", async () => {10 const src = (await import("fs")).readFileSync(new URL("./quantity-survey.ts", import.meta.url), "utf8");11 const codes = [...new Set([...src.matchAll(/"([A-Z]{2,4}-[A-Z0-9/-]{3,})"/g)].map((m) => m[1]).filter((c) => !c.startsWith("QC-")))];12 expect(codes.length).toBeGreaterThan(40);13 for (const c of codes) expect(c in QS_RSMEANS_MAP || QS_RSMEANS_UNMAPPED.includes(c), c).toBe(true);14 });15 it("numéros à 12 chiffres, facteurs > 0, unités de remplacement valides", () => {16 for (const [code, parts] of Object.entries(QS_RSMEANS_MAP)) {17 expect(parts.length, code).toBeGreaterThan(0);18 for (const p of parts) { expect(p.line, code).toMatch(/^\d{12}$/); expect(p.qtyFactor ?? 1, code).toBeGreaterThan(0); if (p.unit) expect(["m", "m2", "m3", "unit"]).toContain(p.unit); }19 }20 });21 it.skipIf(!rsmeansAvailable())("base installée : chaque ligne existe, a un prix national et une unité compatible avec la ligne du relevé", async () => {22 const src = (await import("fs")).readFileSync(new URL("./quantity-survey.ts", import.meta.url), "utf8");23 const unitOf: Record<string, string> = Object.fromEntries([...src.matchAll(/add\("[^"]*", "([A-Z0-9/-]+)", [^;]*?, "(m|m2|m3|unit|lump)"/g)].map((m) => [m[1], m[2]]));24 for (const k of Object.keys(QS_RSMEANS_MAP)) { if (k.startsWith("KIT-")) unitOf[k] = "m"; if (k.startsWith("BATH-")) unitOf[k] = "unit"; if (k.startsWith("ENV-SIDING-")) unitOf[k] = "m2"; if (k.startsWith("ROOF-")) unitOf[k] = "m2"; if (k.startsWith("INT-FLOOR-")) unitOf[k] = "m2"; if (k.startsWith("OPN-")) unitOf[k] = "unit"; if (k === "FND-WALL-BLOCK-8IN") unitOf[k] = "m2"; if (k.startsWith("EXT-DECK-RAILING")) unitOf[k] = "m"; if (k.startsWith("EXT-DRIVEWAY")) unitOf[k] = "m2"; }25 const problems: string[] = [];26 for (const [code, parts] of Object.entries(QS_RSMEANS_MAP)) for (const p of parts) {27 const l = rsmLine(p.line); if (!l) { problems.push(`${code}: ligne ${p.line} absente`); continue; }28 const um = rsmUnit(l.unit); if (!um) { problems.push(`${code}: ligne ${p.line} sans unité chiffrable (${l.unit})`); continue; }29 const price = rsmPrice(p.line, 431, "STD"); if (!price || price.total == null) problems.push(`${code}: ligne ${p.line} sans prix national`);30 const qsUnit = p.unit ?? unitOf[code]; if (qsUnit && qsUnit !== "lump" && dimensionOf(qsUnit) !== dimensionOf(um.unit)) problems.push(`${code}: ${p.line} ${l.unit} (${dimensionOf(um.unit)}) ≠ ${qsUnit} (${dimensionOf(qsUnit)})`);31 }32 expect(problems).toEqual([]);33 });34});3536describe("relevé des quantités tarifé RSMeans (base installée)", () => {37 it.skipIf(!rsmeansAvailable())("cottage à Gatineau : lignes « rsmeans » chiffrées, couverture élevée, total plausible, regénération sans doublon", async () => {38 const fs = await import("fs"); const os = await import("os"); const path = await import("path");39 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "metre-qsrsm-"));40 process.env.METRE_DB = path.join(tmp, "metre.db"); process.env.METRE_FILES_DIR = path.join(tmp, "files"); process.env.COST_DB = path.join(tmp, "cost.db");41 const db = await import("./db"); const auth = await import("./auth"); const projects = await import("./projects"); const plans = await import("./plans"); const estimates = await import("./estimates");42 const d = db.getMetreDb(); const ctx = { db: d, user: auth.anonymousLogin(d, { ip: null, userAgent: null }).user, ip: null };43 const p = projects.createProject(ctx, { name: "Cas RSMeans", regionCode: "QC-GAT", municipality: "Gatineau", valuationDate: "2026-10-28" });44 await plans.createTemplateProject(ctx, p.id, { template: "cottage-c" });45 const r = await estimates.generateQuantitySurvey(ctx, p.id, null, { pricing: "rsmeans" });46 const rsm = r.estimate.lines.filter((l) => l.basis === "rsmeans");47 expect(rsm.length).toBeGreaterThan(40);48 expect(rsm.every((l) => /^\d{12}$/.test(l.rsmeansLine ?? ""))).toBe(true);49 const unpriced = r.result.lines.filter((l) => !l.excluded && l.basis === "rsmeans" && l.located <= 0);50 expect(unpriced.map((l) => l.description), "lignes RSMeans non chiffrées").toEqual([]);51 expect(r.result.coverage.linesPriced / r.result.coverage.linesTotal).toBeGreaterThan(0.9);52 expect(r.result.totals.total).toBeGreaterThan(150_000); expect(r.result.totals.total).toBeLessThan(900_000);53 expect(r.result.lines.some((l) => l.provenance.source.includes("GATINEAU"))).toBe(true);54 const again = await estimates.generateQuantitySurvey(ctx, p.id, r.estimate.id, { pricing: "rsmeans" });55 expect(again.estimate.lines.filter((l) => l.origin === "qs").length).toBe(r.estimate.lines.filter((l) => l.origin === "qs").length);56 fs.rmSync(tmp, { recursive: true, force: true });57 });58});59