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,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')
})
})