SPB Git

Repository Public

qs.test.ts 9.8 KB 107 lines typescript

1// UQO Éval — Métré : tests des gabarits générés (cohérence géométrique de chacun) et du relevé des quantités (QS) — purs, puis service sur base isolée.2import { afterAll, beforeAll, describe, expect, it } from "vitest";3import fs from "fs";4import os from "os";5import path from "path";6import { buildTemplate, defaultSpec, listTemplates, materializeLevel } from "./templates";7import { quantitySurvey, type LevelInput } from "./quantity-survey";8import { planQuantities, projectOnPolyline } from "./plan-geometry";9import { polygonAreaPx } from "./geometry";1011describe("gabarits générés", () => {12  const all = listTemplates();13  it("au moins 30 gabarits, toutes familles présentes", () => {14    expect(all.length).toBeGreaterThanOrEqual(30);15    for (const kind of ["bungalow", "cottage", "plex", "row", "chalet", "mobile", "condo", "garage"]) expect(all.some((t) => t.kind === kind), kind).toBe(true);16    expect(all.filter((t) => t.kind === "plex").length).toBeGreaterThanOrEqual(8);17  });18  for (const t of all) it(`${t.key} : niveaux cohérents, ouvertures sur les murs, pièces dans l'enveloppe`, () => {19    const full = buildTemplate(t.key)!;20    expect(full.levels.length).toBe(t.storeys + (t.spec.basement && t.spec.basement !== "none" && t.kind !== "garage" ? 1 : 0));21    for (const lv of full.levels) {22      const objs = materializeLevel(lv, 50);23      const walls = objs.filter((o) => o.kind === "wall"), rooms = objs.filter((o) => o.kind === "room"), openings = objs.filter((o) => o.kind === "opening"), fp = objs.find((o) => o.kind === "footprint")!;24      expect(walls.length).toBeGreaterThan(0); expect(fp).toBeTruthy();25      const q = planQuantities(objs, 50);26      // chaque ouverture est à moins de 1 cm de son mur27      for (const o of openings) { const w = objs.find((x) => x.id === o.props.deductFrom)!; expect(w, `${t.key}/${lv.level}/${o.name}`).toBeTruthy(); const pr = projectOnPolyline(o.points[0], w.points, w.closed)!; expect(pr.dist).toBeLessThan(0.5); }28      // chaque pièce est dans l'empreinte (centroïde) et les aires sont plausibles29      const fpArea = polygonAreaPx(fp.points) / 2500;30      for (const r of q.rooms) { expect(r.areaM2, `${t.key}/${lv.level}/${r.name}`).toBeGreaterThan(1); expect(r.areaM2).toBeLessThan(fpArea + 1); }31      if (!lv.level.includes("Sous-sol") && t.kind !== "garage") { expect(rooms.length, `${t.key}/${lv.level}`).toBeGreaterThanOrEqual(2); expect(q.totals.windows + q.totals.doors).toBeGreaterThanOrEqual(3); }32      expect(q.totals.wallLengthM).toBeGreaterThan(10);33    }34  });35});3637describe("relevé des quantités (pur)", () => {38  const t = buildTemplate("bungalow-c")!;39  const levels: LevelInput[] = t.levels.map((lv, i) => ({ pageId: `p${i}`, level: lv.level, objects: materializeLevel(lv, 50).map((o, j) => ({ ...o, id: `${i}-${j}`, props: { ...o.props, deductFrom: o.props.deductFrom ? `${i}-${Number(o.props.deductFrom.slice(4)) - 1}` : o.props.deductFrom } })), pxPerMeter: 50 }));40  const spec = defaultSpec(t.spec);41  it("produit un relevé complet et cohérent pour un bungalow 12,2 × 9,1 m avec sous-sol", () => {42    const r = quantitySurvey(levels, spec);43    const codes = new Set(r.lines.map((l) => l.assemblyCode));44    for (const c of ["SITE-EXCAV-BASEMENT", "FND-FOOTING", "FND-WALL-POURED-8IN", "FND-SLAB-BASEMENT-4IN", "STR-FLOOR-IJOIST", "STR-WALL-2X6-EXT", "STR-WALL-2X4-INT", "STR-ROOF-TRUSS-OSB", "ENV-INSUL-WALL-R24", "ENV-INSUL-ATTIC-R50", "ENV-SIDING-VINYL", "ENV-SIDING-BRICK", "ROOF-ASPHALT-ARCH", "OPN-WINDOW-PVC-STD", "OPN-DOOR-EXT-STEEL", "OPN-DOOR-PATIO-6FT", "OPN-DOOR-CLOSET-BIFOLD", "INT-DRYWALL-1/2-FINISHED", "INT-DRYWALL-5/8-CEILING", "INT-PAINT-2-COATS", "INT-FLOOR-CERAMIC", "INT-FLOOR-HARDWOOD", "INT-TRIM-BASEBOARD", "KIT-STANDARD", "BATH-STANDARD", "MEC-PLUMBING-ROUGH-IN", "MEC-WATER-HEATER-60", "MEC-BASEBOARD-ELECTRIC", "MEC-HRV", "ELE-SERVICE-200A", "ELE-ROUGH-WIRING", "ELE-LIGHTING-STD", "EXT-STEPS-PRECAST"]) expect(codes.has(c), c).toBe(true);45    expect(r.summary.empreinte_m2).toBeCloseTo(12.5 * 9.4, 0); // empreinte hors tout (murs 0,3 m)46    expect(r.summary.perimetre_m).toBeCloseTo((12.2 + 9.1) * 2, 1);47    expect(r.summary.chambres).toBe(3); expect(r.summary.salles_de_bain).toBe(1); expect(r.summary.cuisines).toBe(1);48    const win = r.lines.find((l) => l.assemblyCode === "OPN-WINDOW-PVC-STD")!; expect(win.origin).toBe("plan"); expect(win.quantity).toBeGreaterThanOrEqual(5);49    const ext = r.lines.find((l) => l.assemblyCode === "STR-WALL-2X6-EXT")!; expect(ext.quantity).toBeGreaterThan((12.2 + 9.1) * 2 * 2.44);50    const fnd = r.lines.find((l) => l.assemblyCode === "FND-WALL-POURED-8IN")!; expect(fnd.quantity).toBeCloseTo((12.2 + 9.1) * 2 * 2.44, 0);51    expect(r.lines.every((l) => l.quantity > 0 && l.formula && l.unit)).toBe(true);52    expect(r.warnings).toHaveLength(0);53  });54  it("respecte la fiche : dalle sans sous-sol, thermopompe, toit plat membrane, catalogue filtré", () => {55    const r = quantitySurvey(levels.filter((l) => !l.level.includes("Sous-sol")), defaultSpec({ ...t.spec, basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, heating: "heat_pump", hasAirConditioning: true, roofGeometry: "flat", roofType: "membrane", roofPitch: 0 }), new Set(["FND-SLAB-ON-GRADE-INSULATED", "MEC-HEATPUMP-CENTRAL", "ROOF-MEMBRANE-ELASTO", "STR-ROOF-TRUSS-OSB"]));56    const codes = r.lines.map((l) => l.assemblyCode);57    expect(codes).toContain("FND-SLAB-ON-GRADE-INSULATED"); expect(codes).toContain("MEC-HEATPUMP-CENTRAL"); expect(codes).toContain("ROOF-MEMBRANE-ELASTO");58    expect(codes).not.toContain("FND-SLAB-BASEMENT-4IN"); expect(codes).not.toContain("SITE-EXCAV-BASEMENT");59    expect(r.warnings.some((w) => w.includes("absent du catalogue"))).toBe(true); // les autres codes sont filtrés et signalés60    const roof = r.lines.find((l) => l.assemblyCode === "STR-ROOF-TRUSS-OSB")!; expect(roof.inputs.facteur_pente).toBe(1);61  });62  it("garage seulement : ni cuisine, ni salle de bain, ni chauffe-eau, ni fenêtres de sous-sol ; porte de garage présente", () => {63    const g = buildTemplate("garage-2")!;64    const lv: LevelInput[] = g.levels.map((l, i) => ({ pageId: `g${i}`, level: l.level, objects: materializeLevel(l, 50).map((o, j) => ({ ...o, id: `${i}-${j}`, props: { ...o.props, deductFrom: o.props.deductFrom ? `${i}-${Number(o.props.deductFrom.slice(4)) - 1}` : o.props.deductFrom } })), pxPerMeter: 50 }));65    const r = quantitySurvey(lv, defaultSpec(g.spec));66    const codes = r.lines.map((l) => l.assemblyCode);67    expect(codes.some((c) => c.startsWith("KIT-") || c.startsWith("BATH-"))).toBe(false);68    expect(codes).not.toContain("MEC-WATER-HEATER-60"); expect(codes).not.toContain("OPN-WINDOW-BASEMENT"); expect(codes).not.toContain("MEC-PLUMBING-ROUGH-IN");69    expect(codes.some((c) => c.startsWith("OPN-DOOR-GARAGE"))).toBe(true);70  });71  it("sans niveau calibré : relevé impossible, avertissement explicite", () => {72    const r = quantitySurvey(levels.map((l) => ({ ...l, pxPerMeter: null })), spec);73    expect(r.lines).toHaveLength(0); expect(r.warnings.join(" ")).toMatch(/non calibrée|impossible/);74  });75});7677describe("service : gabarit multi-niveaux → relevé → estimation", () => {78  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "metre-qs-"));79  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");80  afterAll(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } });81  let ctx: import("./auth").Ctx;82  beforeAll(async () => { const db = await import("./db"); const auth = await import("./auth"); const d = db.getMetreDb(); ctx = { db: d, user: auth.anonymousLogin(d, { ip: null, userAgent: null }).user, ip: null }; });83  it("crée les niveaux du cottage, pose la fiche, génère le relevé et chiffre l'estimation", async () => {84    const projects = await import("./projects"); const plans = await import("./plans"); const estimates = await import("./estimates");85    const p = projects.createProject(ctx, { name: "QS cottage", regionCode: "QC-MTL", valuationDate: "2026-10-28" });86    const r = await plans.createTemplateProject(ctx, p.id, { template: "cottage-c" });87    expect(r.pages.map((x) => x.level)).toEqual(["Sous-sol", "Rez-de-chaussée", "Étage"]);88    expect(r.objects).toBeGreaterThan(30);89    const prj = projects.getProject(ctx.db, p.id)!; expect(prj.spec?.basement).toBe("unfinished"); expect(prj.stories).toBe(2);90    const qs = await estimates.generateQuantitySurvey(ctx, p.id, null);91    expect(qs.qs.lines.length).toBeGreaterThan(35);92    expect(qs.estimate.lines.filter((l) => l.origin === "qs").length).toBe(qs.qs.lines.length);93    expect(qs.result.coverage.linesPriced).toBeGreaterThan(30); // chiffrées avec les prix de référence du catalogue seedé94    expect(qs.result.totals.total).toBeGreaterThan(100000);95    expect(plans.listMeasurements(ctx.db, p.id).filter((m) => m.name.startsWith("QS · ")).length).toBe(qs.qs.lines.length);96    // regénération : les lignes qs sont remplacées, pas dupliquées ; une ligne manuelle est conservée97    estimates.updateEstimate(ctx, qs.estimate.id, { lines: [...qs.estimate.lines, { wbs: "99", description: "manuelle", basis: "assumption", unitPrice: 1, quantity: 1, unit: "unit", installed: true }] });98    const again = await estimates.generateQuantitySurvey(ctx, p.id, qs.estimate.id);99    expect(again.estimate.lines.filter((l) => l.origin === "qs").length).toBe(again.qs.lines.length);100    expect(again.estimate.lines.some((l) => l.description === "manuelle")).toBe(true);101    expect(plans.listMeasurements(ctx.db, p.id).filter((m) => m.name.startsWith("QS · ")).length).toBe(again.qs.lines.length);102    // duplication d'un projet : les plans suivent103    const copy = projects.duplicateProject(ctx, p.id, "copie");104    expect(plans.listPlanFiles(ctx.db, copy.id).flatMap((f) => f.pages).length).toBe(3);105  });106});107
Files main
Menu

Theme