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:
Benjamin Sutter
2026-05-24 02:33:12 +02:00
parent 713ef3ec08
commit 0582031930
8 changed files with 1890 additions and 3 deletions
@@ -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 5277 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)
})
})