e62391af66
- 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>
166 lines
7.9 KiB
TypeScript
166 lines
7.9 KiB
TypeScript
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, 0–1)', () => {
|
||
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 0–1 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)
|
||
})
|
||
})
|