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
+1296 -1
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -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 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)
})
})
@@ -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 0100)', () => {
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')
})
})
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
+17
View File
@@ -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.*'],
},
},
})