Files
property-match/src/features/matching/__tests__/softFactorEnrichment.test.ts
T
Benjamin Sutter e62391af66 feat: Zod AI validation, AIProvenance governance, fix tests (154 green)
- Add AIProvenance + AIResponse<T> to IAIService — all 11 methods now
  return structured provenance (provider, model, source, fallbackUsed,
  validationPassed) instead of bare ItemResponse<T>
- Add schemas.ts with Zod schemas for all 8 AI response types;
  validateAIResponse() utility returns null on failure, never throws
- Rewrite OpenRouterAIService: every method validates AI JSON against
  its Zod schema; failed validation triggers MockAIService fallback
  with fallbackUsed:true — no invalid data can reach the UI
- Fix MockAIService.generateFollowUpQuestions: replace broken
  mockParseNeed(JSON.stringify(criteria)) with direct ParsedNeedCriteria
  field inspection; returns max 3 prioritised FollowUpQuestion objects
- Add provenance: mockProvenance() to all MockAIService responses
- Improve decisionBriefPrompt: structured JSON schema example,
  confidence vocabulary, availability disclaimer
- Improve matchExplanationPrompt: score-tier vocabulary, isFutureSignal
  flag forbids confirmed-availability language for future signals
- Add 102 new tests: mustHaveScorer (16), softFactorEnrichment (38),
  aiSchemas (52) — 154 total, all passing; 0 TypeScript errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:44:46 +02:00

166 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest'
import { softFactorEnrichmentService } from '../../../services/softFactorEnrichmentService'
import { ResultType } from '../../../domain/enums'
import { makeProperty } from './fixtures'
import type { Property } from '../../../domain/property'
// Helper: create a property with only location context — no address that could
// accidentally trigger another location's keyword rule (e.g. 'Bahnhofstrasse'
// in the default fixture triggers the Zürich CBD rule for any city).
function locProp(city: string, district?: string): Property {
return makeProperty({
location: { city, district: district ?? '', country: 'CH' },
address: { street: 'Teststrasse', houseNumber: '1', postalCode: '0000', city, country: 'CH' },
})
}
// ── Exact keyword match — Zürich CBD ──────────────────────────────────────────
describe('softFactorEnrichmentService — exact keyword match (Zürich CBD)', () => {
it('returns prestige 0.92 for Zürich CBD (Bahnhofstrasse address keyword)', () => {
const prop = makeProperty() // default fixture has address.street: 'Bahnhofstrasse' → CBD match
const result = softFactorEnrichmentService.estimate('prestige', prop)
expect(result).not.toBeNull()
expect(result!.score).toBeCloseTo(0.92, 1)
})
it('returns very high accessibility (≥ 0.95) for Zürich CBD', () => {
const prop = makeProperty()
const result = softFactorEnrichmentService.estimate('accessibility', prop)
expect(result!.score).toBeGreaterThanOrEqual(0.95)
})
it('returns a non-empty label string', () => {
const result = softFactorEnrichmentService.estimate('prestige', makeProperty())
expect(result!.label).toBeTruthy()
expect(typeof result!.label).toBe('string')
})
})
// ── Zürich-West tech cluster ──────────────────────────────────────────────────
describe('softFactorEnrichmentService — Zürich-West tech cluster (Kreis 5)', () => {
it('returns talentAccess ≥ 0.90 for Zürich-West Technopark', () => {
// Use 'kreis 5' as district keyword — address.city = 'Zürich' is neutral
const prop = locProp('Zürich', 'Kreis 5')
const result = softFactorEnrichmentService.estimate('talentAccess', prop)
expect(result!.score).toBeGreaterThanOrEqual(0.90)
})
it('returns higher ESG score for Zürich-West (0.75) than Zürich CBD (0.52)', () => {
const cbd = makeProperty() // CBD via Bahnhofstrasse address
const west = locProp('Zürich', 'Kreis 5')
const cbdEsg = softFactorEnrichmentService.estimate('esg', cbd)
const westEsg = softFactorEnrichmentService.estimate('esg', west)
expect(westEsg!.score).toBeGreaterThan(cbdEsg!.score)
})
it('returns higher flexibility score for Zürich-West than Zürich CBD', () => {
const cbd = makeProperty()
const west = locProp('Zürich', 'Kreis 5')
expect(softFactorEnrichmentService.estimate('flexibility', west)!.score)
.toBeGreaterThan(softFactorEnrichmentService.estimate('flexibility', cbd)!.score)
})
})
// ── City-level fuzzy match ────────────────────────────────────────────────────
describe('softFactorEnrichmentService — city-level fallback', () => {
it('returns a result for "Zürich" city without a district', () => {
const result = softFactorEnrichmentService.estimate('accessibility', locProp('Zürich'))
expect(result).not.toBeNull()
expect(result!.score).toBeGreaterThan(0)
expect(result!.score).toBeLessThanOrEqual(1)
})
it('returns a result for Basel city', () => {
const result = softFactorEnrichmentService.estimate('talentAccess', locProp('Basel'))
expect(result).not.toBeNull()
expect(result!.score).toBeGreaterThan(0)
})
it('returns taxEnvironment > 0.90 for Zug (lowest taxes in CH)', () => {
const result = softFactorEnrichmentService.estimate('taxEnvironment', locProp('Zug'))
expect(result!.score).toBeGreaterThan(0.90)
})
it('Zug prestige is higher than Winterthur prestige', () => {
const zugResult = softFactorEnrichmentService.estimate('prestige', locProp('Zug'))
const wintResult = softFactorEnrichmentService.estimate('prestige', locProp('Winterthur'))
expect(zugResult!.score).toBeGreaterThan(wintResult!.score)
})
})
// ── Swiss generic fallback ────────────────────────────────────────────────────
describe('softFactorEnrichmentService — Swiss generic fallback', () => {
it('returns a non-null estimate for an unknown Swiss city', () => {
const result = softFactorEnrichmentService.estimate('prestige', locProp('Münsingen'))
expect(result).not.toBeNull()
expect(result!.score).toBeGreaterThan(0)
expect(result!.score).toBeLessThanOrEqual(1)
})
it('label includes "Schweiz" for generic Swiss fallback', () => {
// 'Kleindorf' matches no rule → Swiss generic fallback
const result = softFactorEnrichmentService.estimate('accessibility', locProp('Kleindorf'))
expect(result!.label).toMatch(/Schweiz/i)
})
})
// ── Score range invariant ─────────────────────────────────────────────────────
describe('softFactorEnrichmentService — score range invariant (all keys, 01)', () => {
const locations = ['Zürich', 'Basel', 'Zug', 'Bern'] as const
const keys = ['prestige', 'accessibility', 'talentAccess', 'esg', 'taxEnvironment', 'footfall'] as const
for (const city of locations) {
for (const key of keys) {
it(`score is 01 for ${key} in ${city}`, () => {
const result = softFactorEnrichmentService.estimate(key, locProp(city))
if (result !== null) {
expect(result.score).toBeGreaterThanOrEqual(0)
expect(result.score).toBeLessThanOrEqual(1)
}
})
}
}
})
// ── Pre-Market vs Market Signal enrichment ────────────────────────────────────
describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => {
it('returns identical estimate for same location regardless of resultType', () => {
// The enrichment service is location-only — resultType is irrelevant.
// FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score.
const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' }
const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' }
const futureSignal = makeProperty({
location: sharedLocation,
address: sharedAddress,
resultType: ResultType.FUTURE_AVAILABILITY,
})
const marketResult = makeProperty({
location: sharedLocation,
address: sharedAddress,
resultType: ResultType.EXTERNAL_MARKET,
})
const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal)
const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult)
expect(futureEst?.score).toBe(marketEst?.score)
expect(futureEst?.label).toBe(marketEst?.label)
})
it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => {
const prop = makeProperty({
location: { city: 'Zürich', district: 'Oerlikon', country: 'CH' },
address: { street: 'Thurgauerstrasse', houseNumber: '40', postalCode: '8050', city: 'Zürich', country: 'CH' },
resultType: ResultType.FUTURE_AVAILABILITY,
})
const result = softFactorEnrichmentService.estimate('accessibility', prop)
expect(result!.score).toBeGreaterThan(0.70)
})
})