SPB Git

Repository Public

plan.test.ts 7.7 KB 99 lines typescript

1// UQO Éval — Métré : tests de la géométrie d'architecte (murs décalés, ouvertures sur mur, cotations, aimant, quantités) et du rendu SVG.2import { describe, expect, it } from "vitest";3import { centroid, closedWallRings, dimensionGeometry, doorPath, fmtArea, fmtLength, placeOpening, planQuantities, projectOnPolyline, snapPoint, wallOutline, windowPath } from "./plan-geometry";4import { renderPlanInner, renderPlanSvg } from "./plan-render";5import { polygonAreaPx } from "./geometry";6import type { PlanObject } from "./types";78const obj = (p: Partial<PlanObject>): PlanObject => ({ id: "o", pageId: "p", kind: "wall", layer: "murs", name: "o", points: [], closed: false, props: {}, createdAt: "", updatedAt: "", ...p });910describe("murs à épaisseur", () => {11  it("contour d'un mur droit : rectangle de longueur × épaisseur", () => {12    const o = wallOutline([{ x: 0, y: 0 }, { x: 100, y: 0 }], 10, false);13    expect(o).toHaveLength(4);14    expect(polygonAreaPx(o)).toBeCloseTo(1000, 6);15  });16  it("angle droit : onglet propre, aire = somme des deux murs − recouvrement", () => {17    const o = wallOutline([{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }], 10, false);18    expect(o).toHaveLength(6);19    expect(polygonAreaPx(o)).toBeCloseTo(1000 + 1000 - 25 + 25, 1); // le coin extérieur compense le coin intérieur20  });21  it("boucle fermée : anneaux extérieur/intérieur, aire = périmètre × épaisseur", () => {22    const r = closedWallRings([{ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 200, y: 100 }, { x: 0, y: 100 }], 10);23    expect(polygonAreaPx(r.outer)).toBeCloseTo(210 * 110, 3);24    expect(polygonAreaPx(r.inner)).toBeCloseTo(190 * 90, 3);25    expect(polygonAreaPx(r.outer) - polygonAreaPx(r.inner)).toBeCloseTo(600 * 10, 3); // périmètre 600 × 1026  });27});2829describe("ouvertures et cotations", () => {30  const wall = obj({ id: "w", points: [{ x: 0, y: 0 }, { x: 400, y: 0 }], props: { thickness: 0.3 } });31  it("une porte se projette sur le mur, prend son angle et son épaisseur", () => {32    const door = obj({ id: "d", kind: "opening", points: [{ x: 100, y: 12 }], props: { openingKind: "door", openingWidth: 0.9, deductFrom: "w" } });33    const pl = placeOpening(door, wall, 100);34    expect(pl.valid).toBe(true); expect(pl.center).toEqual({ x: 100, y: 0 }); expect(pl.angle).toBe(0); expect(pl.halfWidthPx).toBe(45); expect(pl.thicknessPx).toBe(30);35    const dp = doorPath(pl); expect(dp.leaf).toMatch(/^M -45 0 L -45 -90$/); expect(dp.arc).toMatch(/A 90 90/);36    const w = windowPath({ ...pl, kind: "window" }); expect(w.glass.split("M").length - 1).toBe(3);37    expect(placeOpening(door, null, 100).valid).toBe(false);38  });39  it("projection sur une polyligne et cotation", () => {40    const pr = projectOnPolyline({ x: 50, y: 30 }, [{ x: 0, y: 0 }, { x: 100, y: 0 }, { x: 100, y: 100 }], false)!;41    expect(pr.segIndex).toBe(0); expect(pr.point).toEqual({ x: 50, y: 0 }); expect(pr.dist).toBe(30);42    const g = dimensionGeometry({ x: 0, y: 0 }, { x: 100, y: 0 }, -20);43    expect(g.line.a).toEqual({ x: 0, y: -20 }); expect(g.lengthPx).toBe(100); expect(g.angleDeg).toBe(0);44    const g2 = dimensionGeometry({ x: 100, y: 0 }, { x: 0, y: 0 }, 20); expect(g2.angleDeg).toBe(0); // texte toujours lisible45    expect(centroid([{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }])).toEqual({ x: 5, y: 5 });46  });47});4849describe("aimant", () => {50  const w = obj({ id: "w", points: [{ x: 0, y: 0 }, { x: 100, y: 0 }] });51  it("sommet, segment, ortho, grille, libre", () => {52    expect(snapPoint({ x: 3, y: 2 }, { objects: [w], draft: [], anchor: null, ortho: false, grid: null, tolPx: 5 })).toEqual({ p: { x: 0, y: 0 }, kind: "vertex" });53    expect(snapPoint({ x: 50, y: 3 }, { objects: [w], draft: [], anchor: null, ortho: false, grid: null, tolPx: 5 })).toEqual({ p: { x: 50, y: 0 }, kind: "segment" });54    const o = snapPoint({ x: 80, y: 40 }, { objects: [], draft: [], anchor: { x: 0, y: 40 }, ortho: true, grid: 10, tolPx: 5 });55    expect(o.kind).toBe("ortho"); expect(o.p.y).toBeCloseTo(40, 6); expect(o.p.x).toBeCloseTo(80, 6);56    expect(snapPoint({ x: 23, y: 37 }, { objects: [], draft: [], anchor: null, ortho: false, grid: 10, tolPx: 5 })).toEqual({ p: { x: 20, y: 40 }, kind: "grid" });57    expect(snapPoint({ x: 23, y: 37 }, { objects: [], draft: [], anchor: null, ortho: false, grid: null, tolPx: 5 }).kind).toBe("free");58  });59});6061describe("quantités et rendu", () => {62  const k = 100; // px/m63  const objs: PlanObject[] = [64    obj({ id: "w1", name: "Murs ext.", points: [{ x: 0, y: 0 }, { x: 1000, y: 0 }, { x: 1000, y: 800 }, { x: 0, y: 800 }], closed: true, props: { thickness: 0.3, height: 2.44, exterior: true } }),65    obj({ id: "w2", name: "Cloison", points: [{ x: 500, y: 0 }, { x: 500, y: 800 }], props: { thickness: 0.12, height: 2.44 } }),66    obj({ id: "d1", kind: "opening", name: "Porte", points: [{ x: 200, y: 0 }], props: { openingKind: "door", openingWidth: 0.9, openingHeight: 2.1, deductFrom: "w1" } }),67    obj({ id: "n1", kind: "opening", name: "Fenêtre", points: [{ x: 700, y: 0 }], props: { openingKind: "window", openingWidth: 1.2, openingHeight: 1.2, count: 2, deductFrom: "w1" } }),68    obj({ id: "r1", kind: "room", name: "Salon", points: [{ x: 15, y: 15 }, { x: 485, y: 15 }, { x: 485, y: 785 }, { x: 15, y: 785 }], closed: true, props: {} }),69    obj({ id: "f1", kind: "footprint", name: "Empreinte", points: [{ x: -15, y: -15 }, { x: 1015, y: -15 }, { x: 1015, y: 815 }, { x: -15, y: 815 }], closed: true, props: {} }),70    obj({ id: "m1", kind: "dimension", name: "cote", points: [{ x: 0, y: 0 }, { x: 1000, y: 0 }], props: { offset: -40 } }),71    obj({ id: "t1", kind: "text", name: "note", points: [{ x: 100, y: 100 }], props: { text: "Rez-de-chaussée" } }),72  ];73  it("quantités : longueurs, surfaces brutes/nettes, ouvertures, empreinte", () => {74    const q = planQuantities(objs, k);75    expect(q.walls[0]).toMatchObject({ lengthM: 36, grossAreaM2: 87.84, openingsAreaM2: 1.89 + 2.88, exterior: true });76    expect(q.walls[0].netAreaM2).toBeCloseTo(87.84 - 4.77, 3);77    expect(q.walls[1]).toMatchObject({ lengthM: 8, grossAreaM2: 19.52, netAreaM2: 19.52 });78    expect(q.rooms[0].areaM2).toBeCloseTo(4.7 * 7.7, 3); expect(q.rooms[0].perimeterM).toBeCloseTo(24.8, 3);79    expect(q.footprintM2).toBeCloseTo(10.3 * 8.3, 3);80    expect(q.totals).toMatchObject({ wallLengthM: 44, doors: 1, windows: 1 });81    expect(planQuantities(objs, null).totals.wallLengthM).toBe(0);82  });83  it("rendu SVG : murs pleins, porte avec arc, fenêtre, étiquettes, cote, cartouche", () => {84    const inner = renderPlanInner(objs, { widthPx: 1200, heightPx: 1000, pxPerMeter: k, imperial: false, showLabels: true, showDims: true, selectedId: "w2" });85    expect(inner).toMatch(/fill-rule="evenodd"/); // boucle fermée86    expect((inner.match(/<polygon data-id="w2"/g) ?? []).length).toBe(1);87    expect(inner).toMatch(/data-id="d1"/); expect(inner).toMatch(/A 90 90/); // arc de porte (0,9 m × 100 px)88    expect(inner).toMatch(/Salon/); expect(inner).toMatch(/36,19 m²/); // 4,7 × 7,789    expect(inner).toMatch(/Rez-de-chaussée/); expect(inner).toMatch(/10,00 m/); // cote du mur90    const svg = renderPlanSvg(objs, { widthPx: 1200, heightPx: 1000, pxPerMeter: k, imperial: false, title: { project: "Test", level: "RDC", scale: "1:100", date: "2026-10-28", author: "QA" } });91    expect(svg.startsWith("<svg")).toBe(true); expect(svg).toMatch(/PLAN PÉDAGOGIQUE/); expect(svg).toMatch(/>N</);92    const hidden = renderPlanInner(objs, { widthPx: 1200, heightPx: 1000, pxPerMeter: k, imperial: false, hiddenLayers: new Set(["murs"]) });93    expect(hidden).not.toMatch(/data-id="w1"/);94  });95  it("formats métrique / impérial", () => {96    expect(fmtLength(3.048, true)).toBe("10'-0\""); expect(fmtLength(1.5, false)).toBe("1,50 m"); expect(fmtArea(10, true)).toMatch(/108 pi²/);97  });98});99
Files main
Menu

Theme