Files
property-match/src/services/ai/__tests__/mockAIService.test.ts
T
Benjamin Sutter 58a0a05eff test: integration coverage for stores, services, MockAIService, and hooks
- Stores (29 tests): compareStore max-4 enforcement, toastStore unique IDs,
  shortlistStore dialog state, assistantStore context merge + clearConversation
- Services (37 tests): needService CRUD, propertyService dashboard summary
  active-status filter, matchService computeMatchesForNeed + getStrongMatches,
  futureSignalService summary math (distribution totals, highConfidence count)
- MockAIService (18 tests): follow-up question priority ordering, area ambiguity
  detection (ratio >8 and zero values), max-3 cap, trade-off risk logic
  (LOW/MEDIUM/HIGH thresholds), match explanation headline tiers, provenance shape
- Hooks (20 tests): useNeeds/useProperties/useMatches envelope unwrap, disabled-
  when-empty guards, useCreateNeed/useCreateProperty mutations, useNeed/useMatchDetail

All 257 tests pass.

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

180 lines
7.7 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, vi, beforeEach, afterEach } from 'vitest'
import { MockAIService } from '../mock/MockAIService'
import type { ParsedNeedCriteria } from '../../../domain/needBuilder'
import type { TradeOffInput } from '../IAIService'
// Fast-forward simulated delays so tests don't take seconds
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
async function run<T>(promise: Promise<T>): Promise<T> {
// Advance timers while waiting — handles setTimeout-based delays
const p = promise
vi.runAllTimers()
return p
}
// ── generateFollowUpQuestions ─────────────────────────────────────────────────
describe('MockAIService.generateFollowUpQuestions', () => {
it('asks about assetType when missing', async () => {
const criteria: ParsedNeedCriteria = {}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'assetType')).toBe(true)
})
it('asks about areaRange when missing', async () => {
const criteria: ParsedNeedCriteria = { assetType: 'OFFICE' }
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'areaRange')).toBe(true)
})
it('asks area ambiguity question when max/min > 8', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 100, max: 1000 }, // ratio = 10 > 8
preferredLocations: ['Zürich'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
const areaQ = result.data.find(q => q.targetField === 'areaRange')
expect(areaQ).toBeDefined()
expect(areaQ?.importance).toBe('required')
expect(areaQ?.questionText).toContain('weit gefasst')
})
it('treats zero min or max as ambiguous', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 0, max: 500 },
preferredLocations: ['Zürich'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'areaRange')).toBe(true)
})
it('does not ask about areaRange when ratio <= 8', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 200, max: 600 }, // ratio = 3
preferredLocations: ['Zürich'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.some(q => q.targetField === 'areaRange')).toBe(false)
})
it('returns at most 3 questions', async () => {
const criteria: ParsedNeedCriteria = {} // everything missing
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data.length).toBeLessThanOrEqual(3)
})
it('returns 0 questions when all fields are present', async () => {
const criteria: ParsedNeedCriteria = {
assetType: 'OFFICE',
areaRange: { min: 300, max: 600 },
preferredLocations: ['Zürich'],
budgetRange: { maxPerSqm: 400 },
timing: { earliestMoveIn: '2025-09-01' },
mustHaveCriteria: ['ÖV-Anbindung'],
}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
expect(result.data).toHaveLength(0)
})
it('priorities required questions first', async () => {
const criteria: ParsedNeedCriteria = { preferredLocations: [] } // missing assetType (required), budgetRange (recommended)
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
if (result.data.length >= 2) {
expect(result.data[0].importance).toBe('required')
}
})
it('question ids are unique', async () => {
const criteria: ParsedNeedCriteria = {}
const result = await run(MockAIService.generateFollowUpQuestions(criteria))
const ids = result.data.map(q => q.id)
expect(new Set(ids).size).toBe(ids.length)
})
})
// ── provenance ────────────────────────────────────────────────────────────────
describe('MockAIService provenance', () => {
it('returns mock provenance on generateFollowUpQuestions', async () => {
const result = await run(MockAIService.generateFollowUpQuestions({}))
expect(result.provenance.provider).toBe('mock')
expect(result.provenance.source).toBe('mock')
expect(result.provenance.fallbackUsed).toBe(false)
expect(result.provenance.validationPassed).toBe(true)
expect(typeof result.provenance.traceId).toBe('string')
expect(typeof result.provenance.generatedAt).toBe('string')
})
})
// ── summarizeTradeOffs ────────────────────────────────────────────────────────
describe('MockAIService.summarizeTradeOffs', () => {
it('returns LOW risk when no HIGH severity trade-offs', async () => {
const tradeoffs: TradeOffInput[] = [
{ concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' },
]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('LOW')
})
it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => {
const tradeoffs: TradeOffInput[] = [
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('MEDIUM')
})
it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => {
const tradeoffs: TradeOffInput[] = [
{ concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' },
{ concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' },
]
const result = await run(MockAIService.summarizeTradeOffs(tradeoffs))
expect(result.data.overallRisk).toBe('HIGH')
})
it('returns zero-trade-off headline when list is empty', async () => {
const result = await run(MockAIService.summarizeTradeOffs([]))
expect(result.data.headline).toContain('Keine')
expect(result.data.items).toHaveLength(0)
})
})
// ── generateMatchExplanation ──────────────────────────────────────────────────
describe('MockAIService.generateMatchExplanation', () => {
const base = {
propertyId: 'prop-1',
propertyTitle: 'Bürofläche Zollstrasse',
propertyCity: 'Zürich',
positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }],
negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }],
}
it('generates STARK headline for score >= 78', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 85 }))
expect(result.data.headline.toLowerCase()).toContain('stark')
})
it('generates MEDIUM headline for score 5277', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 65 }))
expect(result.data.headline.toLowerCase()).toContain('gut')
})
it('generates WEAK headline for score < 52', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 40 }))
expect(result.data.headline.toLowerCase()).toContain('schwach')
})
it('includes property title in summary', async () => {
const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 80 }))
expect(result.data.summary).toContain('Bürofläche Zollstrasse')
})
})