// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // Tests hors réseau de l'analyse multimodale : schéma/réparation, empreinte, fusion, mapping, vecteur, images. import { describe, expect, it } from "vitest"; import { buildModelSchema, isValid, MAX_MISSING_SECTIONS, missingTopLevelSections, schemaForApi, validateAndRepair, type ModelOutput } from "./schema"; import { sampleOutput } from "./fixture"; import { inputHash } from "./context"; import { applyOverrides, listingFactsFromDetails, mergeFacts, type ListingFacts, type MamhFacts } from "./merge"; import { mapToCostInput } from "./mapping"; import { EMBEDDING_DIMENSION, canonicalText, cosine, technicalVector } from "./vector"; import { readDimensions, selectImages, sizeAgnosticKey, sniffMediaType } from "./images"; import { deriveQuantities } from "../geometry"; import { ASSEMBLY_SEEDS } from "../seed/assemblies"; const CODES = ASSEMBLY_SEEDS.map((a) => a.code); const SCHEMA = buildModelSchema(CODES); const listingFacts = (over: Partial = {}): ListingFacts => ({ 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, heating: null, hasAirConditioning: null, hasAirExchanger: null, basement: null, garage: null, pool: null, units: null, waterSupply: null, wasteSystem: null, ...over, }); const mamh: MamhFacts = { yearBuilt: 1997, grossFloorAreaSqft: 2300, stories: 2, buildingType: "detached", units: 1, landValue: 182000, buildingValue: 400000, totalValue: 582000, unitId: "123" }; describe("schema", () => { it("la fixture est strictement valide et le schéma API ne contient plus de bornes", () => { expect(isValid(sampleOutput(), SCHEMA)).toBe(true); expect(JSON.stringify(schemaForApi(SCHEMA))).not.toMatch(/"minimum"|"maximum"/); expect(JSON.stringify(SCHEMA)).toContain('"KIT-SUPERIOR"'); }); it("répare de façon déterministe : chaîne numérique, énumération inconnue, champ manquant, clé inconnue, confiance bornée", () => { const bad = JSON.parse(JSON.stringify(sampleOutput())) as Record; const prop = bad.property as Record>; prop.stories.value = "2"; prop.building_type.value = "castle"; delete prop.bedrooms; (bad.quality as Record).confidence = 1.7; bad.extra_key = 1; const { value, issues } = validateAndRepair(bad, SCHEMA); const v = value as ModelOutput; expect(v.property.stories.value).toBe(2); expect(v.property.building_type.value).toBeNull(); expect(v.property.bedrooms.value).toBeNull(); expect(v.quality.confidence).toBe(1); expect("extra_key" in (value as object)).toBe(false); expect(issues.length).toBeGreaterThanOrEqual(5); expect(issues.every((i) => i.repaired)).toBe(true); expect(isValid(value, SCHEMA)).toBe(true); // idempotence expect(JSON.stringify(validateAndRepair(value, SCHEMA).value)).toBe(JSON.stringify(value)); }); it("un appel d'outil placeholder est détecté comme sortie vide (sections de premier niveau absentes)", () => { // observé avec Sonnet 5 en tool_choice forcé (2026-09-08) : le réparateur remplirait tout de nulls const placeholder = { property_analysis: { placeholder: true } }; const { issues } = validateAndRepair(placeholder, SCHEMA); const missing = missingTopLevelSections(issues); expect(missing.length).toBeGreaterThan(MAX_MISSING_SECTIONS); expect(missing).toContain("property"); expect(missing).toContain("exterior"); // une vraie sortie complète : aucune section manquante expect(missingTopLevelSections(validateAndRepair(sampleOutput(), SCHEMA).issues)).toEqual([]); // une sortie à laquelle il ne manque qu'une section reste acceptable (réparée) const partial = { ...(sampleOutput() as unknown as Record) }; delete partial.renovations; expect(missingTopLevelSections(validateAndRepair(partial, SCHEMA).issues)).toEqual(["renovations"]); }); }); describe("input hash", () => { it("est stable pour la même entrée et change avec les photos", () => { const a = inputHash("base", ["h1", "h2"]); expect(a).toBe(inputHash("base", ["h2", "h1"])); expect(a).not.toBe(inputHash("base", ["h1", "h3"])); expect(a).not.toBe(inputHash("base2", ["h1", "h2"])); expect(a).toMatch(/^[0-9a-f]{64}$/); }); }); describe("merge", () => { it("hiérarchie : MAMH > annonce > IA, avec conflits tracés", () => { const ai = sampleOutput(); const m = mergeFacts(ai, listingFacts({ roof: "metal" }), mamh); expect(m.grossFloorAreaSqft.value).toBe(2300); expect(m.grossFloorAreaSqft.source).toBe("MAMH"); expect(m.yearBuilt.value).toBe(1997); expect(m.roof.value).toBe("metal"); // annonce > photo expect(m.roof.source).toBe("listing"); expect(m.conflicts.some((c) => c.field === "roof" && c.valueB.includes("asphalt"))).toBe(true); expect(m.siding.value.brick).toBeCloseTo(0.6, 5); expect(m.siding.value.vinyl).toBeCloseTo(0.4, 5); expect(m.heating.value).toBe("heat_pump"); expect(m.conditions.roof).toBe("average"); expect(m.effectiveAge?.effective).toBe(17); }); it("sans MAMH ni champ d'annonce, l'IA fournit la valeur (source AI)", () => { const m = mergeFacts(sampleOutput(), listingFacts({ yearBuilt: null, areaSqft: null, buildingType: null }), null); expect(m.grossFloorAreaSqft.value).toBe(2470); expect(m.grossFloorAreaSqft.source).toBe("listing"); // l'IA cite la source « listing » expect(m.foundation.source).toBe("AI"); expect(m.basement.value).toBe("finished"); }); it("les overrides remplacent la valeur, gardent l'originale et passent en source user", () => { const m = mergeFacts(sampleOutput(), listingFacts(), null); const o = applyOverrides(m, [{ fieldPath: "roof", value: "metal" }, { fieldPath: "conditions.roof", value: "new" }]); expect(o.roof.value).toBe("metal"); expect(o.roof.source).toBe("user"); expect(o.roof.alternatives[0].value).toBe("asphalt_shingle"); expect(o.conditions.roof).toBe("new"); expect(m.roof.value).toBe("asphalt_shingle"); // l'original n'est pas muté }); it("normalise les détails français d'une annonce", () => { 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" }); expect(f.buildingType).toBe("detached"); expect(f.roof).toBe("asphalt_shingle"); expect(f.foundation).toBe("poured_concrete"); expect(f.heating).toBe("electric_baseboard"); expect(f.basement).toBe("finished"); expect(f.garage).toEqual({ type: "attached", spaces: 1 }); expect(f.pool).toBe("inground"); expect(f.yearBuilt).toBe(1998); expect(f.siding?.brick).toBeCloseTo(0.6, 5); }); }); describe("mapping", () => { 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) }; it("façade hybride 60/40 → deux assemblages de revêtement pondérés ; quantités IA fiables retenues, faibles ignorées", () => { const ai = sampleOutput(); const m = mergeFacts(ai, listingFacts(), mamh); const r = mapToCostInput(m, ai, ctx); expect(r.input.mode).toBe("listing"); expect(r.input.building.siding).toEqual({ brick: 0.6, vinyl: 0.4 }); const { lines } = deriveQuantities(r.input.building, r.input.quantityOverrides, "AI"); const brick = lines.find((l) => l.assemblyCode === "ENV-SIDING-BRICK")!; const vinyl = lines.find((l) => l.assemblyCode === "ENV-SIDING-VINYL")!; expect(brick.quantity / (brick.quantity + vinyl.quantity)).toBeCloseTo(0.6, 2); expect(r.aiQuantities["KIT-SUPERIOR"].quantity).toBe(34); expect(lines.find((l) => l.assemblyCode === "KIT-SUPERIOR")!.source).toBe("AI"); expect("OPN-DOOR-INT-SOLID" in r.aiQuantities).toBe(false); // confiance 0,55 < 0,6 expect("ROOF-ASPHALT-ARCH" in r.aiQuantities).toBe(false); // géométrie dérivée par le code expect(r.input.depreciation.method).toBe("components"); expect(r.input.depreciation.componentConditions.roof).toBe("average"); expect(r.input.depreciation.effectiveAge).toBe(17); expect(r.input.land.value).toBe(182000); expect(r.input.attributeSources.grossFloorAreaSqft).toBe("MAMH"); expect(r.input.attributeSources.foundation).toBe("AI"); }); }); describe("vector", () => { it("déterministe, de dimension fixe, similarité 1 avec lui-même et < 1 avec un bâtiment différent", () => { const ai = sampleOutput(); const m = mergeFacts(ai, listingFacts(), mamh); const v1 = technicalVector(m); const v2 = technicalVector(mergeFacts(sampleOutput(), listingFacts(), mamh)); expect(v1.length).toBe(EMBEDDING_DIMENSION); expect(v1).toEqual(v2); expect(cosine(v1, v2)).toBeCloseTo(1, 6); const ai2 = sampleOutput(); ai2.quality.overall_quality = "economy"; 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 }; const v3 = technicalVector(mergeFacts(ai2, listingFacts({ buildingType: "row" }), null)); expect(cosine(v1, v3)).toBeLessThan(0.95); expect(canonicalText(m)).toContain("Detached."); expect(canonicalText(m)).toContain("brick 60%"); }); }); describe("images", () => { it("détecte le type et lit les dimensions PNG/JPEG", () => { const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from("0000000d49484452", "hex"), Buffer.alloc(8)]); png.writeUInt32BE(1024, 16); png.writeUInt32BE(768, 20); expect(sniffMediaType(png)).toBe("image/png"); expect(readDimensions(png, "image/png")).toEqual({ width: 1024, height: 768 }); const jpg = Buffer.alloc(30, 0); 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); expect(sniffMediaType(jpg)).toBe("image/jpeg"); expect(readDimensions(jpg, "image/jpeg")).toEqual({ width: 800, height: 600 }); expect(sniffMediaType(Buffer.from("hello"))).toBeNull(); }); it("regroupe les tailles d'une même photo et présélectionne en gardant les extérieurs", () => { expect(sizeAgnosticKey("https://x/pic/abc-lg.jpg?x=1")).toBe(sizeAgnosticKey("https://x/pic/abc-sm.jpg")); const imgs = Array.from({ length: 50 }, (_, i) => ({ position: i + 1, roomHint: i < 2 ? "exterior" : "unknown", qualityScore: 1, hash: `h${i}` })); const sel = selectImages(imgs, 40); expect(sel.length).toBe(40); expect(sel.filter((i) => i.roomHint === "exterior").length).toBe(2); expect(new Set(sel.map((i) => i.hash)).size).toBe(40); }); });