import { describe, it, expect } from 'vitest' import { softFactorEnrichmentService } from '../../../services/softFactorEnrichmentService' import { ResultType } from '../../../domain/enums' import { makeProperty } from './fixtures' import type { Property } from '../../../domain/property' // Helper: create a property with only location context — no address that could // accidentally trigger another location's keyword rule (e.g. 'Bahnhofstrasse' // in the default fixture triggers the Zürich CBD rule for any city). function locProp(city: string, district?: string): Property { return makeProperty({ location: { city, district: district ?? '', country: 'CH' }, address: { street: 'Teststrasse', houseNumber: '1', postalCode: '0000', city, country: 'CH' }, }) } // ── Exact keyword match — Zürich CBD ────────────────────────────────────────── describe('softFactorEnrichmentService — exact keyword match (Zürich CBD)', () => { it('returns prestige 0.92 for Zürich CBD (Bahnhofstrasse address keyword)', () => { const prop = makeProperty() // default fixture has address.street: 'Bahnhofstrasse' → CBD match const result = softFactorEnrichmentService.estimate('prestige', prop) expect(result).not.toBeNull() expect(result!.score).toBeCloseTo(0.92, 1) }) it('returns very high accessibility (≥ 0.95) for Zürich CBD', () => { const prop = makeProperty() const result = softFactorEnrichmentService.estimate('accessibility', prop) expect(result!.score).toBeGreaterThanOrEqual(0.95) }) it('returns a non-empty label string', () => { const result = softFactorEnrichmentService.estimate('prestige', makeProperty()) expect(result!.label).toBeTruthy() expect(typeof result!.label).toBe('string') }) }) // ── Zürich-West tech cluster ────────────────────────────────────────────────── describe('softFactorEnrichmentService — Zürich-West tech cluster (Kreis 5)', () => { it('returns talentAccess ≥ 0.90 for Zürich-West Technopark', () => { // Use 'kreis 5' as district keyword — address.city = 'Zürich' is neutral const prop = locProp('Zürich', 'Kreis 5') const result = softFactorEnrichmentService.estimate('talentAccess', prop) expect(result!.score).toBeGreaterThanOrEqual(0.90) }) it('returns higher ESG score for Zürich-West (0.75) than Zürich CBD (0.52)', () => { const cbd = makeProperty() // CBD via Bahnhofstrasse address const west = locProp('Zürich', 'Kreis 5') const cbdEsg = softFactorEnrichmentService.estimate('esg', cbd) const westEsg = softFactorEnrichmentService.estimate('esg', west) expect(westEsg!.score).toBeGreaterThan(cbdEsg!.score) }) it('returns higher flexibility score for Zürich-West than Zürich CBD', () => { const cbd = makeProperty() const west = locProp('Zürich', 'Kreis 5') expect(softFactorEnrichmentService.estimate('flexibility', west)!.score) .toBeGreaterThan(softFactorEnrichmentService.estimate('flexibility', cbd)!.score) }) }) // ── City-level fuzzy match ──────────────────────────────────────────────────── describe('softFactorEnrichmentService — city-level fallback', () => { it('returns a result for "Zürich" city without a district', () => { const result = softFactorEnrichmentService.estimate('accessibility', locProp('Zürich')) expect(result).not.toBeNull() expect(result!.score).toBeGreaterThan(0) expect(result!.score).toBeLessThanOrEqual(1) }) it('returns a result for Basel city', () => { const result = softFactorEnrichmentService.estimate('talentAccess', locProp('Basel')) expect(result).not.toBeNull() expect(result!.score).toBeGreaterThan(0) }) it('returns taxEnvironment > 0.90 for Zug (lowest taxes in CH)', () => { const result = softFactorEnrichmentService.estimate('taxEnvironment', locProp('Zug')) expect(result!.score).toBeGreaterThan(0.90) }) it('Zug prestige is higher than Winterthur prestige', () => { const zugResult = softFactorEnrichmentService.estimate('prestige', locProp('Zug')) const wintResult = softFactorEnrichmentService.estimate('prestige', locProp('Winterthur')) expect(zugResult!.score).toBeGreaterThan(wintResult!.score) }) }) // ── Swiss generic fallback ──────────────────────────────────────────────────── describe('softFactorEnrichmentService — Swiss generic fallback', () => { it('returns a non-null estimate for an unknown Swiss city', () => { const result = softFactorEnrichmentService.estimate('prestige', locProp('Münsingen')) expect(result).not.toBeNull() expect(result!.score).toBeGreaterThan(0) expect(result!.score).toBeLessThanOrEqual(1) }) it('label includes "Schweiz" for generic Swiss fallback', () => { // 'Kleindorf' matches no rule → Swiss generic fallback const result = softFactorEnrichmentService.estimate('accessibility', locProp('Kleindorf')) expect(result!.label).toMatch(/Schweiz/i) }) }) // ── Score range invariant ───────────────────────────────────────────────────── describe('softFactorEnrichmentService — score range invariant (all keys, 0–1)', () => { const locations = ['Zürich', 'Basel', 'Zug', 'Bern'] as const const keys = ['prestige', 'accessibility', 'talentAccess', 'esg', 'taxEnvironment', 'footfall'] as const for (const city of locations) { for (const key of keys) { it(`score is 0–1 for ${key} in ${city}`, () => { const result = softFactorEnrichmentService.estimate(key, locProp(city)) if (result !== null) { expect(result.score).toBeGreaterThanOrEqual(0) expect(result.score).toBeLessThanOrEqual(1) } }) } } }) // ── Pre-Market vs Market Signal enrichment ──────────────────────────────────── describe('softFactorEnrichmentService — Pre-Market vs Market Signal', () => { it('returns identical estimate for same location regardless of resultType', () => { // The enrichment service is location-only — resultType is irrelevant. // FUTURE_AVAILABILITY (pre-market) and EXTERNAL_MARKET should produce the same soft factor score. const sharedLocation = { city: 'Zürich', district: 'Oerlikon', country: 'CH' } const sharedAddress = { street: 'Thurgauerstrasse', houseNumber: '1', postalCode: '8050', city: 'Zürich', country: 'CH' } const futureSignal = makeProperty({ location: sharedLocation, address: sharedAddress, resultType: ResultType.FUTURE_AVAILABILITY, }) const marketResult = makeProperty({ location: sharedLocation, address: sharedAddress, resultType: ResultType.EXTERNAL_MARKET, }) const futureEst = softFactorEnrichmentService.estimate('accessibility', futureSignal) const marketEst = softFactorEnrichmentService.estimate('accessibility', marketResult) expect(futureEst?.score).toBe(marketEst?.score) expect(futureEst?.label).toBe(marketEst?.label) }) it('FUTURE_AVAILABILITY at Oerlikon gets meaningful accessibility score (> 0.70)', () => { const prop = makeProperty({ location: { city: 'Zürich', district: 'Oerlikon', country: 'CH' }, address: { street: 'Thurgauerstrasse', houseNumber: '40', postalCode: '8050', city: 'Zürich', country: 'CH' }, resultType: ResultType.FUTURE_AVAILABILITY, }) const result = softFactorEnrichmentService.estimate('accessibility', prop) expect(result!.score).toBeGreaterThan(0.70) }) })