SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
11.1 KB · 189 lines typescript
Raw Blame History
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