Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Tests hors réseau de l'analyse multimodale : schéma/réparation, empreinte, fusion, mapping, vecteur, images.3import { describe, expect, it } from "vitest";4import { buildModelSchema, isValid, MAX_MISSING_SECTIONS, missingTopLevelSections, schemaForApi, validateAndRepair, type ModelOutput } from "./schema";5import { sampleOutput } from "./fixture";6import { inputHash } from "./context";7import { applyOverrides, listingFactsFromDetails, mergeFacts, type ListingFacts, type MamhFacts } from "./merge";8import { mapToCostInput } from "./mapping";9import { EMBEDDING_DIMENSION, canonicalText, cosine, technicalVector } from "./vector";10import { readDimensions, selectImages, sizeAgnosticKey, sniffMediaType } from "./images";11import { deriveQuantities } from "../geometry";12import { ASSEMBLY_SEEDS } from "../seed/assemblies";1314const CODES = ASSEMBLY_SEEDS.map((a) => a.code);15const SCHEMA = buildModelSchema(CODES);1617const listingFacts = (over: Partial<ListingFacts> = {}): ListingFacts => ({18 yearBuilt: 1998, areaSqft: 2470, bedrooms: 4, bathrooms: 2, powderRooms: 1, propertyType: "Maison", lotSqft: 7200, buildingType: "detached", stories: null, foundation: null, siding: null, roof: null, roofGeometry: null, windows: null,19 heating: null, hasAirConditioning: null, hasAirExchanger: null, basement: null, garage: null, pool: null, units: null, waterSupply: null, wasteSystem: null, ...over,20});21const mamh: MamhFacts = { yearBuilt: 1997, grossFloorAreaSqft: 2300, stories: 2, buildingType: "detached", units: 1, landValue: 182000, buildingValue: 400000, totalValue: 582000, unitId: "123" };2223describe("schema", () => {24 it("la fixture est strictement valide et le schéma API ne contient plus de bornes", () => {25 expect(isValid(sampleOutput(), SCHEMA)).toBe(true);26 expect(JSON.stringify(schemaForApi(SCHEMA))).not.toMatch(/"minimum"|"maximum"/);27 expect(JSON.stringify(SCHEMA)).toContain('"KIT-SUPERIOR"');28 });29 it("répare de façon déterministe : chaîne numérique, énumération inconnue, champ manquant, clé inconnue, confiance bornée", () => {30 const bad = JSON.parse(JSON.stringify(sampleOutput())) as Record<string, unknown>;31 const prop = bad.property as Record<string, Record<string, unknown>>;32 prop.stories.value = "2";33 prop.building_type.value = "castle";34 delete prop.bedrooms;35 (bad.quality as Record<string, unknown>).confidence = 1.7;36 bad.extra_key = 1;37 const { value, issues } = validateAndRepair(bad, SCHEMA);38 const v = value as ModelOutput;39 expect(v.property.stories.value).toBe(2);40 expect(v.property.building_type.value).toBeNull();41 expect(v.property.bedrooms.value).toBeNull();42 expect(v.quality.confidence).toBe(1);43 expect("extra_key" in (value as object)).toBe(false);44 expect(issues.length).toBeGreaterThanOrEqual(5);45 expect(issues.every((i) => i.repaired)).toBe(true);46 expect(isValid(value, SCHEMA)).toBe(true);47 // idempotence48 expect(JSON.stringify(validateAndRepair(value, SCHEMA).value)).toBe(JSON.stringify(value));49 });5051 it("un appel d'outil placeholder est détecté comme sortie vide (sections de premier niveau absentes)", () => {52 // observé avec Sonnet 5 en tool_choice forcé (2026-09-08) : le réparateur remplirait tout de nulls53 const placeholder = { property_analysis: { placeholder: true } };54 const { issues } = validateAndRepair(placeholder, SCHEMA);55 const missing = missingTopLevelSections(issues);56 expect(missing.length).toBeGreaterThan(MAX_MISSING_SECTIONS);57 expect(missing).toContain("property");58 expect(missing).toContain("exterior");59 // une vraie sortie complète : aucune section manquante60 expect(missingTopLevelSections(validateAndRepair(sampleOutput(), SCHEMA).issues)).toEqual([]);61 // une sortie à laquelle il ne manque qu'une section reste acceptable (réparée)62 const partial = { ...(sampleOutput() as unknown as Record<string, unknown>) };63 delete partial.renovations;64 expect(missingTopLevelSections(validateAndRepair(partial, SCHEMA).issues)).toEqual(["renovations"]);65 });66});6768describe("input hash", () => {69 it("est stable pour la même entrée et change avec les photos", () => {70 const a = inputHash("base", ["h1", "h2"]);71 expect(a).toBe(inputHash("base", ["h2", "h1"]));72 expect(a).not.toBe(inputHash("base", ["h1", "h3"]));73 expect(a).not.toBe(inputHash("base2", ["h1", "h2"]));74 expect(a).toMatch(/^[0-9a-f]{64}$/);75 });76});7778describe("merge", () => {79 it("hiérarchie : MAMH > annonce > IA, avec conflits tracés", () => {80 const ai = sampleOutput();81 const m = mergeFacts(ai, listingFacts({ roof: "metal" }), mamh);82 expect(m.grossFloorAreaSqft.value).toBe(2300);83 expect(m.grossFloorAreaSqft.source).toBe("MAMH");84 expect(m.yearBuilt.value).toBe(1997);85 expect(m.roof.value).toBe("metal"); // annonce > photo86 expect(m.roof.source).toBe("listing");87 expect(m.conflicts.some((c) => c.field === "roof" && c.valueB.includes("asphalt"))).toBe(true);88 expect(m.siding.value.brick).toBeCloseTo(0.6, 5);89 expect(m.siding.value.vinyl).toBeCloseTo(0.4, 5);90 expect(m.heating.value).toBe("heat_pump");91 expect(m.conditions.roof).toBe("average");92 expect(m.effectiveAge?.effective).toBe(17);93 });94 it("sans MAMH ni champ d'annonce, l'IA fournit la valeur (source AI)", () => {95 const m = mergeFacts(sampleOutput(), listingFacts({ yearBuilt: null, areaSqft: null, buildingType: null }), null);96 expect(m.grossFloorAreaSqft.value).toBe(2470);97 expect(m.grossFloorAreaSqft.source).toBe("listing"); // l'IA cite la source « listing »98 expect(m.foundation.source).toBe("AI");99 expect(m.basement.value).toBe("finished");100 });101 it("les overrides remplacent la valeur, gardent l'originale et passent en source user", () => {102 const m = mergeFacts(sampleOutput(), listingFacts(), null);103 const o = applyOverrides(m, [{ fieldPath: "roof", value: "metal" }, { fieldPath: "conditions.roof", value: "new" }]);104 expect(o.roof.value).toBe("metal");105 expect(o.roof.source).toBe("user");106 expect(o.roof.alternatives[0].value).toBe("asphalt_shingle");107 expect(o.conditions.roof).toBe("new");108 expect(m.roof.value).toBe("asphalt_shingle"); // l'original n'est pas muté109 });110 it("normalise les détails français d'une annonce", () => {111 const f = listingFactsFromDetails({ year_built: null, area_sqft: null, bedrooms: 3, bathrooms: 1, powder_rooms: 1, property_type: "Maison", lot_sqft: null }, { "Type de bâtiment": "Détaché (isolé)", "Revêtement": "Brique, Vinyle", "Toiture": "Bardeaux d'asphalte", "Chauffage": "Plinthes électriques", "Fondation": "Béton coulé", "Sous-sol": "6 pieds et plus, Totalement aménagé", "Stationnement (total)": "Allée (2), Garage (1)", "Piscine": "Creusée", "Année de construction": "1998" });112 expect(f.buildingType).toBe("detached");113 expect(f.roof).toBe("asphalt_shingle");114 expect(f.foundation).toBe("poured_concrete");115 expect(f.heating).toBe("electric_baseboard");116 expect(f.basement).toBe("finished");117 expect(f.garage).toEqual({ type: "attached", spaces: 1 });118 expect(f.pool).toBe("inground");119 expect(f.yearBuilt).toBe(1998);120 expect(f.siding?.brick).toBeCloseTo(0.6, 5);121 });122});123124describe("mapping", () => {125 const ctx = { listingUid: "t:1", address: "1 rue Test", municipality: "Gatineau", lat: 45.47, lng: -75.7, unitId: "123", landValue: 182000, roll: { landValue: 182000, buildingValue: 400000, totalValue: 582000, year: 2026 }, assemblyCodes: new Set(CODES) };126 it("façade hybride 60/40 → deux assemblages de revêtement pondérés ; quantités IA fiables retenues, faibles ignorées", () => {127 const ai = sampleOutput();128 const m = mergeFacts(ai, listingFacts(), mamh);129 const r = mapToCostInput(m, ai, ctx);130 expect(r.input.mode).toBe("listing");131 expect(r.input.building.siding).toEqual({ brick: 0.6, vinyl: 0.4 });132 const { lines } = deriveQuantities(r.input.building, r.input.quantityOverrides, "AI");133 const brick = lines.find((l) => l.assemblyCode === "ENV-SIDING-BRICK")!;134 const vinyl = lines.find((l) => l.assemblyCode === "ENV-SIDING-VINYL")!;135 expect(brick.quantity / (brick.quantity + vinyl.quantity)).toBeCloseTo(0.6, 2);136 expect(r.aiQuantities["KIT-SUPERIOR"].quantity).toBe(34);137 expect(lines.find((l) => l.assemblyCode === "KIT-SUPERIOR")!.source).toBe("AI");138 expect("OPN-DOOR-INT-SOLID" in r.aiQuantities).toBe(false); // confiance 0,55 < 0,6139 expect("ROOF-ASPHALT-ARCH" in r.aiQuantities).toBe(false); // géométrie dérivée par le code140 expect(r.input.depreciation.method).toBe("components");141 expect(r.input.depreciation.componentConditions.roof).toBe("average");142 expect(r.input.depreciation.effectiveAge).toBe(17);143 expect(r.input.land.value).toBe(182000);144 expect(r.input.attributeSources.grossFloorAreaSqft).toBe("MAMH");145 expect(r.input.attributeSources.foundation).toBe("AI");146 });147});148149describe("vector", () => {150 it("déterministe, de dimension fixe, similarité 1 avec lui-même et < 1 avec un bâtiment différent", () => {151 const ai = sampleOutput();152 const m = mergeFacts(ai, listingFacts(), mamh);153 const v1 = technicalVector(m);154 const v2 = technicalVector(mergeFacts(sampleOutput(), listingFacts(), mamh));155 expect(v1.length).toBe(EMBEDDING_DIMENSION);156 expect(v1).toEqual(v2);157 expect(cosine(v1, v2)).toBeCloseTo(1, 6);158 const ai2 = sampleOutput();159 ai2.quality.overall_quality = "economy";160 ai2.exterior.siding_shares = { brick_pct: 0, vinyl_pct: 100, fiber_cement_pct: 0, wood_pct: 0, stone_pct: 0, stucco_pct: 0, metal_pct: 0, other_pct: 0 };161 const v3 = technicalVector(mergeFacts(ai2, listingFacts({ buildingType: "row" }), null));162 expect(cosine(v1, v3)).toBeLessThan(0.95);163 expect(canonicalText(m)).toContain("Detached.");164 expect(canonicalText(m)).toContain("brick 60%");165 });166});167168describe("images", () => {169 it("détecte le type et lit les dimensions PNG/JPEG", () => {170 const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from("0000000d49484452", "hex"), Buffer.alloc(8)]);171 png.writeUInt32BE(1024, 16); png.writeUInt32BE(768, 20);172 expect(sniffMediaType(png)).toBe("image/png");173 expect(readDimensions(png, "image/png")).toEqual({ width: 1024, height: 768 });174 const jpg = Buffer.alloc(30, 0);175 jpg[0] = 0xff; jpg[1] = 0xd8; jpg[2] = 0xff; jpg[3] = 0xc0; jpg.writeUInt16BE(17, 4); jpg[6] = 8; jpg.writeUInt16BE(600, 7); jpg.writeUInt16BE(800, 9);176 expect(sniffMediaType(jpg)).toBe("image/jpeg");177 expect(readDimensions(jpg, "image/jpeg")).toEqual({ width: 800, height: 600 });178 expect(sniffMediaType(Buffer.from("hello"))).toBeNull();179 });180 it("regroupe les tailles d'une même photo et présélectionne en gardant les extérieurs", () => {181 expect(sizeAgnosticKey("https://x/pic/abc-lg.jpg?x=1")).toBe(sizeAgnosticKey("https://x/pic/abc-sm.jpg"));182 const imgs = Array.from({ length: 50 }, (_, i) => ({ position: i + 1, roomHint: i < 2 ? "exterior" : "unknown", qualityScore: 1, hash: `h${i}` }));183 const sel = selectImages(imgs, 40);184 expect(sel.length).toBe(40);185 expect(sel.filter((i) => i.roomHint === "exterior").length).toBe(2);186 expect(new Set(sel.map((i) => i.hash)).size).toBe(40);187 });188});189