58a0a05eff
- 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>
180 lines
7.7 KiB
TypeScript
180 lines
7.7 KiB
TypeScript
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 52–77', 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')
|
||
})
|
||
})
|