test: Vitest test infrastructure + 52 unit tests for matching engine
Install vitest, @vitest/coverage-v8, jsdom, @testing-library/react/jest-dom. Add test / test:watch / test:coverage scripts to package.json. Three test suites covering the business-critical scoring pipeline: scoreCalculator.test.ts (28 tests) - calcDataQualityModifier: all 5 boundary thresholds (+5 / 0 / -5 / -10 / -15) - calcConfidenceModifier: verified/external/maison-work/future + low-conf stacking - applyHardFilters: pass, wrong asset type, area tolerance, budget exclusion, OCCUPIED penalty, excluded city - calculateScore: strong match ≥85, weak match <50, excluded=0, determinism, formula verification, DQ+confidence direction, occupied 25-point penalty, positive factors, allHardFactors completeness rankingEngine.test.ts (10 tests) - matchStrengthFromScore: STRONG/MODERATE/WEAK boundaries (78/52) - rankMatches: score sort, type tiebreak (VERIFIED > EXTERNAL), confidence tiebreak - buildFullMatch: all explainability fields, SHORTLIST+CONTACT for strong match, SCHEDULE for future signals, uncertainty indicators matchCardAdapter.test.ts (14 tests) - VERIFIED_PORTFOLIO: resultType, title, locationLabel, matchScore, scoreBreakdown, no disclaimer, reasons from positiveFactors, actions passthrough - FUTURE_AVAILABILITY: resultType, disclaimer always present, signalProbability, signalQuality HIGH for probability >= 0.70 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1296
-1
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -7,7 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -30,18 +33,23 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Need } from '../../../domain/need'
|
||||
import type { Property } from '../../../domain/property'
|
||||
import { AssetType, ResultType, AvailabilityStatus, FreshnessStatus } from '../../../domain/enums'
|
||||
import { DEFAULT_SCORING_PROFILES } from '../../../domain/scoring'
|
||||
|
||||
// ── Minimal valid Need ────────────────────────────────────────────────────────
|
||||
// earliestMoveIn is set to a past date so timing calculations are stable
|
||||
// regardless of when tests run.
|
||||
|
||||
export function makeNeed(overrides: Partial<Need> = {}): Need {
|
||||
return {
|
||||
id: 'need-1',
|
||||
companyName: 'TestCo AG',
|
||||
assetType: AssetType.OFFICE,
|
||||
requiredArea: { min: 200, max: 400 },
|
||||
preferredLocations: ['Zürich'],
|
||||
budgetRange: { maxPerSqm: 50, currency: 'CHF' },
|
||||
timing: {
|
||||
earliestMoveIn: '2024-01-01', // always in the past
|
||||
latestMoveIn: '2027-12-31',
|
||||
flexibleTiming: false,
|
||||
},
|
||||
weightingProfile: { ...DEFAULT_SCORING_PROFILES.OFFICE },
|
||||
confidenceInCriteria: 0.9,
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
} as Need
|
||||
}
|
||||
|
||||
// ── Minimal valid Property ────────────────────────────────────────────────────
|
||||
// All softFactor fields are filled so scoreSoftFactor never calls the
|
||||
// enrichment service — keeping tests hermetic and fast.
|
||||
|
||||
export function makeProperty(overrides: Partial<Property> = {}): Property {
|
||||
return {
|
||||
id: 'prop-1',
|
||||
title: 'Testbüro Zürich',
|
||||
assetType: AssetType.OFFICE,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
location: { city: 'Zürich', district: 'Kreis 1', country: 'CH' },
|
||||
address: {
|
||||
street: 'Bahnhofstrasse',
|
||||
houseNumber: '1',
|
||||
postalCode: '8001',
|
||||
city: 'Zürich',
|
||||
country: 'CH',
|
||||
},
|
||||
areaSqm: 300,
|
||||
rentPricePerSqm: 40,
|
||||
availabilityDate: '2025-03-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
|
||||
sourceType: 'ERP_IMPORT',
|
||||
confidenceScore: 0.85,
|
||||
dataQuality: {
|
||||
score: 0.85,
|
||||
freshness: FreshnessStatus.FRESH,
|
||||
missingCriticalFields: [],
|
||||
missingOptionalFields: [],
|
||||
warnings: [],
|
||||
},
|
||||
// All 9 soft factors provided → enrichment service is never called
|
||||
softFactors: {
|
||||
prestige: 75,
|
||||
accessibility: 80,
|
||||
expansionPotentialScore: 60,
|
||||
flexibilityScore: 70,
|
||||
visibilityScore: 65,
|
||||
footfallScore: 50,
|
||||
talentAccess: 80,
|
||||
esgScore: 60,
|
||||
taxEnvironmentScore: 70,
|
||||
},
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
updatedAt: '2025-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
} as Property
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildMatchCardViewModel } from '../matchCardAdapter'
|
||||
import { buildFullMatch } from '../rankingEngine'
|
||||
import { ResultType } from '../../../domain/enums'
|
||||
import type { VerifiedPortfolioResult, FutureAvailabilityResult } from '../../../domain/unifiedResult'
|
||||
import type { FutureSignal } from '../../../domain/futureSignal'
|
||||
import { makeNeed, makeProperty } from './fixtures'
|
||||
|
||||
// ── buildMatchCardViewModel — verified portfolio ──────────────────────────────
|
||||
|
||||
describe('buildMatchCardViewModel — VERIFIED_PORTFOLIO', () => {
|
||||
const need = makeNeed()
|
||||
const property = makeProperty()
|
||||
const match = buildFullMatch(need, property)
|
||||
|
||||
const result: VerifiedPortfolioResult = {
|
||||
matchId: 'vm-test-1',
|
||||
needId: need.id,
|
||||
matchScore: match.matchScore,
|
||||
resultType: 'VERIFIED_PORTFOLIO',
|
||||
property,
|
||||
match,
|
||||
}
|
||||
|
||||
it('sets resultType correctly', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.resultType).toBe('VERIFIED_PORTFOLIO')
|
||||
})
|
||||
|
||||
it('uses property title as the card title', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.title).toBe(property.title)
|
||||
})
|
||||
|
||||
it('builds locationLabel from city (and district when present)', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.locationLabel).toContain('Zürich')
|
||||
})
|
||||
|
||||
it('propagates matchScore from the result', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.matchScore).toBe(match.matchScore)
|
||||
})
|
||||
|
||||
it('scoreBreakdown reflects hard and soft components from the engine', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.scoreBreakdown.hardMatchScore).toBe(match.scoreBreakdown.hardMatchScore)
|
||||
expect(vm.scoreBreakdown.softFactorScore).toBe(match.scoreBreakdown.softFactorScore)
|
||||
expect(vm.scoreBreakdown.totalScore).toBe(match.scoreBreakdown.totalScore)
|
||||
})
|
||||
|
||||
it('does NOT set a disclaimer for verified portfolio results', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
expect(vm.disclaimer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns reasons derived from top positive factors', () => {
|
||||
const vm = buildMatchCardViewModel(result, [])
|
||||
// A high-scoring match should have at least one reason
|
||||
expect(vm.reasons.length).toBeGreaterThan(0)
|
||||
vm.reasons.forEach(r => {
|
||||
expect(r.type).toMatch(/HARD_FACT|SOFT_FACTOR/)
|
||||
expect(r.score).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through supplied actions unchanged', () => {
|
||||
const actions = [{ label: 'Shortlist', actionType: 'SHORTLIST' as const, primary: true }]
|
||||
const vm = buildMatchCardViewModel(result, actions)
|
||||
expect(vm.actions).toBe(actions)
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildMatchCardViewModel — future availability ─────────────────────────────
|
||||
|
||||
describe('buildMatchCardViewModel — FUTURE_AVAILABILITY', () => {
|
||||
const need = makeNeed()
|
||||
const futureProperty = makeProperty({ resultType: ResultType.FUTURE_AVAILABILITY })
|
||||
const match = buildFullMatch(need, futureProperty)
|
||||
|
||||
const minimalSignal = {
|
||||
id: 'sig-1',
|
||||
probability: 0.72,
|
||||
locationHint: 'Zürich Oerlikon',
|
||||
isVerified: false,
|
||||
source: { type: 'AI_SIGNAL', credibility: 0.7 },
|
||||
} as unknown as FutureSignal
|
||||
|
||||
const futureResult: FutureAvailabilityResult = {
|
||||
matchId: 'future-test-1',
|
||||
needId: need.id,
|
||||
matchScore: match.matchScore,
|
||||
resultType: 'FUTURE_AVAILABILITY',
|
||||
signal: minimalSignal,
|
||||
property: futureProperty,
|
||||
match,
|
||||
}
|
||||
|
||||
it('sets resultType to FUTURE_AVAILABILITY', () => {
|
||||
const vm = buildMatchCardViewModel(futureResult, [])
|
||||
expect(vm.resultType).toBe('FUTURE_AVAILABILITY')
|
||||
})
|
||||
|
||||
it('always includes a disclaimer for future signals', () => {
|
||||
const vm = buildMatchCardViewModel(futureResult, [])
|
||||
expect(vm.disclaimer).toBeTruthy()
|
||||
expect(typeof vm.disclaimer).toBe('string')
|
||||
})
|
||||
|
||||
it('exposes signal probability in the view model', () => {
|
||||
const vm = buildMatchCardViewModel(futureResult, [])
|
||||
expect(vm.signalProbability).toBe(0.72)
|
||||
})
|
||||
|
||||
it('classifies signal quality as HIGH when probability >= 0.70', () => {
|
||||
const vm = buildMatchCardViewModel(futureResult, [])
|
||||
expect(vm.signalQuality).toBe('HIGH')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { matchStrengthFromScore, rankMatches, buildFullMatch } from '../rankingEngine'
|
||||
import { MatchStrength, ResultType } from '../../../domain/enums'
|
||||
import { makeNeed, makeProperty } from './fixtures'
|
||||
|
||||
// ── matchStrengthFromScore ────────────────────────────────────────────────────
|
||||
|
||||
describe('matchStrengthFromScore', () => {
|
||||
it('classifies score >= 78 as STRONG', () => {
|
||||
expect(matchStrengthFromScore(78)).toBe(MatchStrength.STRONG)
|
||||
expect(matchStrengthFromScore(100)).toBe(MatchStrength.STRONG)
|
||||
expect(matchStrengthFromScore(90)).toBe(MatchStrength.STRONG)
|
||||
})
|
||||
|
||||
it('classifies score 52–77 as MODERATE', () => {
|
||||
expect(matchStrengthFromScore(77)).toBe(MatchStrength.MODERATE)
|
||||
expect(matchStrengthFromScore(52)).toBe(MatchStrength.MODERATE)
|
||||
expect(matchStrengthFromScore(65)).toBe(MatchStrength.MODERATE)
|
||||
})
|
||||
|
||||
it('classifies score < 52 as WEAK', () => {
|
||||
expect(matchStrengthFromScore(51)).toBe(MatchStrength.WEAK)
|
||||
expect(matchStrengthFromScore(0)).toBe(MatchStrength.WEAK)
|
||||
expect(matchStrengthFromScore(30)).toBe(MatchStrength.WEAK)
|
||||
})
|
||||
})
|
||||
|
||||
// ── rankMatches ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('rankMatches', () => {
|
||||
const need = makeNeed()
|
||||
|
||||
it('sorts matches by matchScore descending', () => {
|
||||
const high = buildFullMatch(need, makeProperty({ id: 'high', rentPricePerSqm: 40 }))
|
||||
const low = buildFullMatch(need, makeProperty({ id: 'low', rentPricePerSqm: 65, location: { city: 'Basel', country: 'CH' } }))
|
||||
const ranked = rankMatches([low, high])
|
||||
expect(ranked[0].matchScore).toBeGreaterThanOrEqual(ranked[1].matchScore)
|
||||
expect(ranked[0].propertyId).toBe('high')
|
||||
})
|
||||
|
||||
it('breaks ties by result type: VERIFIED_PORTFOLIO before EXTERNAL_MARKET', () => {
|
||||
// Build one of each type but force identical score by using same property body
|
||||
const prop = makeProperty()
|
||||
const verified = buildFullMatch(need, { ...prop, id: 'v', resultType: ResultType.VERIFIED_PORTFOLIO })
|
||||
const external = buildFullMatch(need, { ...prop, id: 'e', resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.85 })
|
||||
|
||||
// If scores differ, force them equal for a clean tiebreak test
|
||||
if (verified.matchScore !== external.matchScore) {
|
||||
const lower = Math.min(verified.matchScore, external.matchScore)
|
||||
verified.matchScore = lower
|
||||
external.matchScore = lower
|
||||
}
|
||||
|
||||
const ranked = rankMatches([external, verified])
|
||||
expect(ranked[0].resultType).toBe(ResultType.VERIFIED_PORTFOLIO)
|
||||
})
|
||||
|
||||
it('breaks ties among same type by confidence descending', () => {
|
||||
const highConf = buildFullMatch(need, makeProperty({ id: 'hc', confidenceScore: 0.90 }))
|
||||
const lowConf = buildFullMatch(need, makeProperty({ id: 'lc', confidenceScore: 0.60 }))
|
||||
|
||||
// Force same score and same type
|
||||
highConf.matchScore = 75
|
||||
lowConf.matchScore = 75
|
||||
|
||||
const ranked = rankMatches([lowConf, highConf])
|
||||
expect(ranked[0].confidenceLevel).toBeGreaterThan(ranked[1].confidenceLevel)
|
||||
})
|
||||
})
|
||||
|
||||
// ── buildFullMatch ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFullMatch', () => {
|
||||
it('returns a Match with all required explainability fields', () => {
|
||||
const match = buildFullMatch(makeNeed(), makeProperty())
|
||||
expect(match.id).toContain('match-')
|
||||
expect(match.scoreBreakdown).toBeDefined()
|
||||
expect(match.scoreBreakdown.hardMatchScore).toBeGreaterThan(0)
|
||||
expect(match.scoreBreakdown.softFactorScore).toBeGreaterThan(0)
|
||||
expect(match.scoreBreakdown.totalScore).toBe(match.matchScore)
|
||||
expect(match.matchStrength).toBeDefined()
|
||||
expect(match.explainabilitySummary).toBeTruthy()
|
||||
})
|
||||
|
||||
it('generates shortlist and contact actions for a strong match', () => {
|
||||
const match = buildFullMatch(makeNeed(), makeProperty())
|
||||
// Perfect fixture should score >= 78 (STRONG) and generate SHORTLIST + CONTACT actions
|
||||
if (match.matchScore >= 78) {
|
||||
const types = match.nextBestActions?.map(a => a.actionType) ?? []
|
||||
expect(types).toContain('SHORTLIST')
|
||||
expect(types).toContain('CONTACT')
|
||||
}
|
||||
})
|
||||
|
||||
it('adds a SCHEDULE action for future availability properties', () => {
|
||||
const prop = makeProperty({ resultType: ResultType.FUTURE_AVAILABILITY })
|
||||
const match = buildFullMatch(makeNeed(), prop)
|
||||
const types = match.nextBestActions?.map(a => a.actionType) ?? []
|
||||
expect(types).toContain('SCHEDULE')
|
||||
})
|
||||
|
||||
it('uncertainty indicators include a note for future availability', () => {
|
||||
const prop = makeProperty({ resultType: ResultType.FUTURE_AVAILABILITY })
|
||||
const match = buildFullMatch(makeNeed(), prop)
|
||||
expect(match.uncertaintyIndicators?.some(s => s.includes('Signal'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calcDataQualityModifier,
|
||||
calcConfidenceModifier,
|
||||
applyHardFilters,
|
||||
calculateScore,
|
||||
} from '../scoreCalculator'
|
||||
import { AssetType, ResultType, AvailabilityStatus, FreshnessStatus } from '../../../domain/enums'
|
||||
import { makeNeed, makeProperty } from './fixtures'
|
||||
|
||||
// ── calcDataQualityModifier ───────────────────────────────────────────────────
|
||||
|
||||
describe('calcDataQualityModifier', () => {
|
||||
function dq(score: number) {
|
||||
return makeProperty({
|
||||
dataQuality: { score, freshness: FreshnessStatus.FRESH, missingCriticalFields: [], missingOptionalFields: [], warnings: [] },
|
||||
})
|
||||
}
|
||||
|
||||
it('returns +5 for excellent data quality (score >= 0.85)', () => {
|
||||
expect(calcDataQualityModifier(dq(0.90))).toBe(5)
|
||||
expect(calcDataQualityModifier(dq(0.85))).toBe(5)
|
||||
})
|
||||
|
||||
it('returns 0 for good data quality (0.70 ≤ score < 0.85)', () => {
|
||||
expect(calcDataQualityModifier(dq(0.75))).toBe(0)
|
||||
expect(calcDataQualityModifier(dq(0.70))).toBe(0)
|
||||
})
|
||||
|
||||
it('returns -5 for fair data quality (0.55 ≤ score < 0.70)', () => {
|
||||
expect(calcDataQualityModifier(dq(0.60))).toBe(-5)
|
||||
expect(calcDataQualityModifier(dq(0.55))).toBe(-5)
|
||||
})
|
||||
|
||||
it('returns -10 for poor data quality (0.40 ≤ score < 0.55)', () => {
|
||||
expect(calcDataQualityModifier(dq(0.45))).toBe(-10)
|
||||
expect(calcDataQualityModifier(dq(0.40))).toBe(-10)
|
||||
})
|
||||
|
||||
it('returns -15 for critical data quality (score < 0.40)', () => {
|
||||
expect(calcDataQualityModifier(dq(0.30))).toBe(-15)
|
||||
expect(calcDataQualityModifier(dq(0.00))).toBe(-15)
|
||||
})
|
||||
})
|
||||
|
||||
// ── calcConfidenceModifier ────────────────────────────────────────────────────
|
||||
|
||||
describe('calcConfidenceModifier', () => {
|
||||
it('returns +3 for verified portfolio with high confidence (>= 0.80)', () => {
|
||||
const p = makeProperty({ resultType: ResultType.VERIFIED_PORTFOLIO, confidenceScore: 0.85 })
|
||||
expect(calcConfidenceModifier(p)).toBe(3)
|
||||
})
|
||||
|
||||
it('returns 0 for verified portfolio with medium confidence (< 0.80)', () => {
|
||||
const p = makeProperty({ resultType: ResultType.VERIFIED_PORTFOLIO, confidenceScore: 0.75 })
|
||||
expect(calcConfidenceModifier(p)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns -3 for external market results', () => {
|
||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.80 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-3)
|
||||
})
|
||||
|
||||
it('returns -2 for Maison Work results', () => {
|
||||
const p = makeProperty({ resultType: ResultType.MAISON_WORK, confidenceScore: 0.80 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-2)
|
||||
})
|
||||
|
||||
it('returns -15 for future availability signals', () => {
|
||||
const p = makeProperty({ resultType: ResultType.FUTURE_AVAILABILITY, confidenceScore: 0.70 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-15)
|
||||
})
|
||||
|
||||
it('stacks an additional -10 penalty when confidenceScore < 0.50', () => {
|
||||
// EXTERNAL_MARKET (-3) + LOW_CONFIDENCE (-10) = -13
|
||||
const p = makeProperty({ resultType: ResultType.EXTERNAL_MARKET, confidenceScore: 0.45 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-13)
|
||||
})
|
||||
|
||||
it('stacks -10 on FUTURE_AVAILABILITY when confidence is also low', () => {
|
||||
// FUTURE_AVAILABILITY (-15) + LOW_CONFIDENCE (-10) = -25
|
||||
const p = makeProperty({ resultType: ResultType.FUTURE_AVAILABILITY, confidenceScore: 0.40 })
|
||||
expect(calcConfidenceModifier(p)).toBe(-25)
|
||||
})
|
||||
})
|
||||
|
||||
// ── applyHardFilters ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('applyHardFilters', () => {
|
||||
it('passes a fully compatible property-need pair', () => {
|
||||
const result = applyHardFilters(makeNeed(), makeProperty())
|
||||
expect(result.excluded).toBe(false)
|
||||
expect(result.severePenalty).toBe(0)
|
||||
})
|
||||
|
||||
it('excludes when asset types are incompatible', () => {
|
||||
const need = makeNeed({ assetType: AssetType.OFFICE })
|
||||
const prop = makeProperty({ assetType: AssetType.RETAIL })
|
||||
const result = applyHardFilters(need, prop)
|
||||
expect(result.excluded).toBe(true)
|
||||
expect(result.reason).toMatch(/Nutzungstyp/)
|
||||
})
|
||||
|
||||
it('does NOT exclude MIXED asset type for any need', () => {
|
||||
const need = makeNeed({ assetType: AssetType.OFFICE })
|
||||
const prop = makeProperty({ assetType: AssetType.MIXED })
|
||||
expect(applyHardFilters(need, prop).excluded).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes when property area is below the 85% minimum tolerance', () => {
|
||||
// need.min = 200, tolerance threshold = 200 * 0.85 = 170. Use 150 m².
|
||||
const need = makeNeed({ requiredArea: { min: 200, max: 400 } })
|
||||
const prop = makeProperty({ areaSqm: 150 })
|
||||
const result = applyHardFilters(need, prop)
|
||||
expect(result.excluded).toBe(true)
|
||||
expect(result.reason).toMatch(/Fläche/)
|
||||
})
|
||||
|
||||
it('does NOT exclude when area is at the 85% tolerance boundary', () => {
|
||||
// 170 = 200 * 0.85, NOT less-than, so should pass
|
||||
const need = makeNeed({ requiredArea: { min: 200, max: 400 } })
|
||||
const prop = makeProperty({ areaSqm: 170 })
|
||||
expect(applyHardFilters(need, prop).excluded).toBe(false)
|
||||
})
|
||||
|
||||
it('excludes when rent exceeds 150% of max budget', () => {
|
||||
// maxBudget = 50, exclusion threshold = 50 * 1.50 = 75. Use 80 CHF/m².
|
||||
const need = makeNeed({ budgetRange: { maxPerSqm: 50, currency: 'CHF' } })
|
||||
const prop = makeProperty({ rentPricePerSqm: 80 })
|
||||
const result = applyHardFilters(need, prop)
|
||||
expect(result.excluded).toBe(true)
|
||||
expect(result.reason).toMatch(/Budget/)
|
||||
})
|
||||
|
||||
it('does NOT exclude occupied properties but applies a 25-point severe penalty', () => {
|
||||
const prop = makeProperty({ availabilityStatus: AvailabilityStatus.OCCUPIED })
|
||||
const result = applyHardFilters(makeNeed(), prop)
|
||||
expect(result.excluded).toBe(false)
|
||||
expect(result.severePenalty).toBe(25)
|
||||
})
|
||||
|
||||
it('excludes when the property city is in the excluded locations list', () => {
|
||||
const need = makeNeed({ excludedLocations: ['Zürich'] })
|
||||
const prop = makeProperty({ location: { city: 'Zürich', country: 'CH' } })
|
||||
const result = applyHardFilters(need, prop)
|
||||
expect(result.excluded).toBe(true)
|
||||
expect(result.reason).toMatch(/ausgeschlossen/)
|
||||
})
|
||||
})
|
||||
|
||||
// ── calculateScore ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateScore', () => {
|
||||
it('scores a perfect match (right city, area, budget, available now) above 85', () => {
|
||||
const output = calculateScore(makeNeed(), makeProperty())
|
||||
expect(output.excluded).toBe(false)
|
||||
expect(output.finalScore).toBeGreaterThanOrEqual(85)
|
||||
})
|
||||
|
||||
it('scores a weak match (wrong city, over budget, poor DQ, external market) below 50', () => {
|
||||
const need = makeNeed({ preferredLocations: ['Zürich'] })
|
||||
const prop = makeProperty({
|
||||
location: { city: 'Basel', country: 'CH' },
|
||||
rentPricePerSqm: 70, // 140% of max budget 50 — not excluded but severe penalty
|
||||
resultType: ResultType.EXTERNAL_MARKET,
|
||||
confidenceScore: 0.45, // triggers LOW_CONFIDENCE -10 stacking
|
||||
dataQuality: {
|
||||
score: 0.30, // CRITICAL → -15
|
||||
freshness: FreshnessStatus.STALE,
|
||||
missingCriticalFields: [],
|
||||
missingOptionalFields: [],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
const output = calculateScore(need, prop)
|
||||
expect(output.excluded).toBe(false)
|
||||
expect(output.finalScore).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('returns finalScore 0 and excluded=true for a hard-filtered property', () => {
|
||||
const need = makeNeed({ assetType: AssetType.OFFICE })
|
||||
const prop = makeProperty({ assetType: AssetType.RETAIL })
|
||||
const output = calculateScore(need, prop)
|
||||
expect(output.excluded).toBe(true)
|
||||
expect(output.finalScore).toBe(0)
|
||||
expect(output.excludedReason).toBeTruthy()
|
||||
})
|
||||
|
||||
it('produces identical scores on repeated calls with the same inputs (determinism)', () => {
|
||||
const need = makeNeed()
|
||||
const prop = makeProperty()
|
||||
const first = calculateScore(need, prop)
|
||||
const second = calculateScore(need, prop)
|
||||
expect(first.finalScore).toBe(second.finalScore)
|
||||
expect(first.hardMatchScore).toBe(second.hardMatchScore)
|
||||
expect(first.softFactorScore).toBe(second.softFactorScore)
|
||||
expect(first.dataQualityModifier).toBe(second.dataQualityModifier)
|
||||
expect(first.confidenceModifier).toBe(second.confidenceModifier)
|
||||
})
|
||||
|
||||
it('finalScore equals the weighted formula: hardScore*0.6 + softScore*0.4 + modifiers (clamped 0–100)', () => {
|
||||
const output = calculateScore(makeNeed(), makeProperty())
|
||||
const expected = Math.round(
|
||||
Math.min(100, Math.max(0,
|
||||
output.hardMatchScore * 0.60 +
|
||||
output.softFactorScore * 0.40 +
|
||||
output.dataQualityModifier +
|
||||
output.confidenceModifier,
|
||||
)),
|
||||
)
|
||||
// No mustHave criteria, no severePenalty in the base fixture — formula matches exactly
|
||||
expect(output.finalScore).toBe(expected)
|
||||
})
|
||||
|
||||
it('excellent DQ and high confidence push finalScore above the base composite', () => {
|
||||
// With DQ +5 and confidence +3, finalScore > hardScore*0.6 + softScore*0.4
|
||||
const output = calculateScore(makeNeed(), makeProperty())
|
||||
const baseComposite = output.hardMatchScore * 0.60 + output.softFactorScore * 0.40
|
||||
expect(output.finalScore).toBeGreaterThan(Math.round(baseComposite))
|
||||
})
|
||||
|
||||
it('low DQ and future availability type push finalScore below the base composite', () => {
|
||||
const prop = makeProperty({
|
||||
resultType: ResultType.FUTURE_AVAILABILITY,
|
||||
dataQuality: {
|
||||
score: 0.30, // CRITICAL -15
|
||||
freshness: FreshnessStatus.OUTDATED,
|
||||
missingCriticalFields: [],
|
||||
missingOptionalFields: [],
|
||||
warnings: [],
|
||||
},
|
||||
})
|
||||
const output = calculateScore(makeNeed(), prop)
|
||||
const baseComposite = output.hardMatchScore * 0.60 + output.softFactorScore * 0.40
|
||||
expect(output.finalScore).toBeLessThan(Math.round(baseComposite))
|
||||
})
|
||||
|
||||
it('occupied property penalty reduces finalScore by 25 compared to available counterpart', () => {
|
||||
const baseOutput = calculateScore(makeNeed(), makeProperty())
|
||||
const occupiedOutput = calculateScore(
|
||||
makeNeed(),
|
||||
makeProperty({ availabilityStatus: AvailabilityStatus.OCCUPIED }),
|
||||
)
|
||||
// severePenalty = 25, applied post-filter
|
||||
expect(baseOutput.finalScore - occupiedOutput.finalScore).toBe(25)
|
||||
})
|
||||
|
||||
it('returns non-empty positiveFactors for a strong match', () => {
|
||||
const output = calculateScore(makeNeed(), makeProperty())
|
||||
expect(output.positiveFactors.length).toBeGreaterThan(0)
|
||||
expect(output.positiveFactors[0].score).toBeGreaterThanOrEqual(70)
|
||||
})
|
||||
|
||||
it('provides allHardFactors with area, location, budget, timing for every match', () => {
|
||||
const output = calculateScore(makeNeed(), makeProperty())
|
||||
const criteria = output.allHardFactors.map(f => f.criterion)
|
||||
expect(criteria).toContain('area')
|
||||
expect(criteria).toContain('location')
|
||||
expect(criteria).toContain('budget')
|
||||
expect(criteria).toContain('timing')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/features/**', 'src/lib/**'],
|
||||
exclude: ['src/**/*.test.*', 'src/**/*.spec.*'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user