diff --git a/src/hooks/__tests__/useMatches.test.ts b/src/hooks/__tests__/useMatches.test.ts new file mode 100644 index 0000000..6f0a1b2 --- /dev/null +++ b/src/hooks/__tests__/useMatches.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement } from 'react' +import { useMatches, useMatchesByNeed, useMatchesByProperty, useMatchDetail } from '../useMatches' + +function wrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children) +} + +describe('useMatches', () => { + it('returns an array of matches', async () => { + const { result } = renderHook(() => useMatches(), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(Array.isArray(result.current.data)).toBe(true) + expect((result.current.data ?? []).length).toBeGreaterThan(0) + }) + + it('select unwraps the envelope — data is Match[]', async () => { + const { result } = renderHook(() => useMatches(), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const first = result.current.data?.[0] + expect(typeof first?.id).toBe('string') + expect(typeof first?.matchScore).toBe('number') + expect(typeof first?.needId).toBe('string') + expect(typeof first?.propertyId).toBe('string') + }) +}) + +describe('useMatchesByNeed', () => { + it('returns matches for the given needId', async () => { + const { result: all } = renderHook(() => useMatches(), { wrapper: wrapper() }) + await waitFor(() => expect(all.current.isSuccess).toBe(true)) + const needId = all.current.data![0].needId + + const { result } = renderHook(() => useMatchesByNeed(needId), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.every(m => m.needId === needId)).toBe(true) + }) + + it('is disabled when needId is empty string', () => { + const { result } = renderHook(() => useMatchesByNeed(''), { wrapper: wrapper() }) + expect(result.current.fetchStatus).toBe('idle') + }) +}) + +describe('useMatchesByProperty', () => { + it('returns matches for the given propertyId', async () => { + const { result: all } = renderHook(() => useMatches(), { wrapper: wrapper() }) + await waitFor(() => expect(all.current.isSuccess).toBe(true)) + const propertyId = all.current.data![0].propertyId + + const { result } = renderHook(() => useMatchesByProperty(propertyId), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.every(m => m.propertyId === propertyId)).toBe(true) + }) +}) + +describe('useMatchDetail', () => { + it('returns a single match for a valid id', async () => { + const { result: all } = renderHook(() => useMatches(), { wrapper: wrapper() }) + await waitFor(() => expect(all.current.isSuccess).toBe(true)) + const id = all.current.data![0].id + + const { result } = renderHook(() => useMatchDetail(id), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(id) + }) + + it('is disabled when id is empty string', () => { + const { result } = renderHook(() => useMatchDetail(''), { wrapper: wrapper() }) + expect(result.current.fetchStatus).toBe('idle') + }) +}) diff --git a/src/hooks/__tests__/useNeeds.test.ts b/src/hooks/__tests__/useNeeds.test.ts new file mode 100644 index 0000000..21ee014 --- /dev/null +++ b/src/hooks/__tests__/useNeeds.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement } from 'react' +import { useNeeds, useNeed, useCreateNeed } from '../useNeeds' + +function wrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children) +} + +describe('useNeeds', () => { + it('returns an array of needs when data loads', async () => { + const { result } = renderHook(() => useNeeds(), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(Array.isArray(result.current.data)).toBe(true) + expect((result.current.data ?? []).length).toBeGreaterThan(0) + }) + + it('select unwraps the data envelope so data is Need[]', async () => { + const { result } = renderHook(() => useNeeds(), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const first = result.current.data?.[0] + expect(typeof first?.id).toBe('string') + expect(typeof first?.companyName).toBe('string') + }) + + it('accepts refetchOnMount option without changing query key', async () => { + const { result } = renderHook(() => useNeeds({ refetchOnMount: 'always' }), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.length).toBeGreaterThan(0) + }) +}) + +describe('useNeed', () => { + it('returns a single need for a valid id', async () => { + // First fetch all to get a real id + const { result: all } = renderHook(() => useNeeds(), { wrapper: wrapper() }) + await waitFor(() => expect(all.current.isSuccess).toBe(true)) + const id = all.current.data![0].id + + const { result } = renderHook(() => useNeed(id), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(id) + }) + + it('is disabled when id is empty string', () => { + const { result } = renderHook(() => useNeed(''), { wrapper: wrapper() }) + expect(result.current.fetchStatus).toBe('idle') + }) +}) + +describe('useCreateNeed', () => { + it('mutates successfully and returns the created need', async () => { + const { result } = renderHook(() => useCreateNeed(), { wrapper: wrapper() }) + result.current.mutate({ + companyName: 'Hook Test GmbH', + assetType: 'OFFICE', + requiredArea: { min: 200, max: 400 }, + preferredLocations: ['Zürich'], + budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' }, + organizationId: 'org-test', + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.data.companyName).toBe('Hook Test GmbH') + }) +}) diff --git a/src/hooks/__tests__/useProperties.test.ts b/src/hooks/__tests__/useProperties.test.ts new file mode 100644 index 0000000..2cb40cb --- /dev/null +++ b/src/hooks/__tests__/useProperties.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement } from 'react' +import { useProperties, useProperty, usePropertyById, useCreateProperty } from '../useProperties' + +function wrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return ({ children }: { children: React.ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children) +} + +describe('useProperties', () => { + it('returns a non-empty array of properties', async () => { + const { result } = renderHook(() => useProperties(), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(Array.isArray(result.current.data)).toBe(true) + expect((result.current.data ?? []).length).toBeGreaterThan(0) + }) + + it('select unwraps the envelope — data is Property[]', async () => { + const { result } = renderHook(() => useProperties(), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const first = result.current.data?.[0] + expect(typeof first?.id).toBe('string') + expect(typeof first?.areaSqm).toBe('number') + }) + + it('accepts an assetType filter', async () => { + const { result } = renderHook(() => useProperties({ assetType: 'OFFICE' }), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + // Filter may or may not be implemented in mock provider — just verify it doesn't error + expect(result.current.isError).toBe(false) + }) +}) + +describe('useProperty', () => { + it('returns the property for a valid id', async () => { + const { result: all } = renderHook(() => useProperties(), { wrapper: wrapper() }) + await waitFor(() => expect(all.current.isSuccess).toBe(true)) + const id = all.current.data![0].id + + const { result } = renderHook(() => useProperty(id), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(id) + }) +}) + +describe('usePropertyById', () => { + it('is disabled when id is null', () => { + const { result } = renderHook(() => usePropertyById(null), { wrapper: wrapper() }) + expect(result.current.fetchStatus).toBe('idle') + }) + + it('fetches property when id is provided', async () => { + const { result: all } = renderHook(() => useProperties(), { wrapper: wrapper() }) + await waitFor(() => expect(all.current.isSuccess).toBe(true)) + const id = all.current.data![0].id + + const { result } = renderHook(() => usePropertyById(id), { wrapper: wrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.id).toBe(id) + }) +}) + +describe('useCreateProperty', () => { + it('creates a property and returns it', async () => { + const { result } = renderHook(() => useCreateProperty(), { wrapper: wrapper() }) + result.current.mutate({ + title: 'Hook-Test Objekt', + assetType: 'LOGISTICS', + resultType: 'VERIFIED_PORTFOLIO', + location: { city: 'Bern', country: 'CH' }, + areaSqm: 300, + rentPricePerSqm: 100, + availabilityStatus: 'AVAILABLE_NOW', + organizationId: 'org-test', + }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.data.title).toBe('Hook-Test Objekt') + }) +}) diff --git a/src/services/__tests__/futureSignalService.test.ts b/src/services/__tests__/futureSignalService.test.ts new file mode 100644 index 0000000..69c6675 --- /dev/null +++ b/src/services/__tests__/futureSignalService.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { futureSignalService } from '../futureSignalService' + +describe('futureSignalService', () => { + describe('getAll', () => { + it('returns a ListResponse with signal data', async () => { + const result = await futureSignalService.getAll() + expect(Array.isArray(result.data)).toBe(true) + expect(result.data.length).toBeGreaterThan(0) + expect(result.meta.total).toBe(result.data.length) + }) + + it('each signal has required fields', async () => { + const { data } = await futureSignalService.getAll() + for (const s of data) { + expect(typeof s.id).toBe('string') + if (s.propertyId !== undefined) expect(typeof s.propertyId).toBe('string') + expect(typeof s.confidenceScore).toBe('number') + expect(s.confidenceScore).toBeGreaterThanOrEqual(0) + expect(s.confidenceScore).toBeLessThanOrEqual(1) + } + }) + }) + + describe('getById', () => { + it('returns a signal for an existing id', async () => { + const { data: all } = await futureSignalService.getAll() + const id = all[0].id + const result = await futureSignalService.getById(id) + expect(result.data?.id).toBe(id) + }) + + it('returns null for an unknown id', async () => { + const result = await futureSignalService.getById('no-such-signal') + expect(result.data).toBeNull() + }) + }) + + describe('getByProperty', () => { + it('returns only signals for the given propertyId', async () => { + const { data: all } = await futureSignalService.getAll() + const withPropertyId = all.find(s => s.propertyId) + if (!withPropertyId?.propertyId) return // no signals with propertyId in seed data + const result = await futureSignalService.getByProperty(withPropertyId.propertyId) + expect(result.data.every(s => s.propertyId === withPropertyId.propertyId)).toBe(true) + }) + }) + + describe('getSignalSummary', () => { + it('returns totals and distribution', async () => { + const summary = await futureSignalService.getSignalSummary() + expect(typeof summary.total).toBe('number') + expect(typeof summary.highConfidence).toBe('number') + expect(typeof summary.restricted).toBe('number') + expect(typeof summary.needsReview).toBe('number') + expect(typeof summary.avgTimeHorizonMonths).toBe('number') + expect(typeof summary.timeHorizonDistribution.short).toBe('number') + expect(typeof summary.timeHorizonDistribution.medium).toBe('number') + expect(typeof summary.timeHorizonDistribution.long).toBe('number') + }) + + it('highConfidence count matches signals with score >= 0.75', async () => { + const { data: all } = await futureSignalService.getAll() + const expected = all.filter(s => s.confidenceScore >= 0.75).length + const summary = await futureSignalService.getSignalSummary() + expect(summary.highConfidence).toBe(expected) + }) + + it('needsReview count matches unverified signals', async () => { + const { data: all } = await futureSignalService.getAll() + const expected = all.filter(s => !s.isVerified).length + const summary = await futureSignalService.getSignalSummary() + expect(summary.needsReview).toBe(expected) + }) + + it('distribution totals add up to total signal count', async () => { + const summary = await futureSignalService.getSignalSummary() + const { short, medium, long } = summary.timeHorizonDistribution + expect(short + medium + long).toBe(summary.total) + }) + }) +}) diff --git a/src/services/__tests__/matchService.test.ts b/src/services/__tests__/matchService.test.ts new file mode 100644 index 0000000..793284e --- /dev/null +++ b/src/services/__tests__/matchService.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import { matchService } from '../matchService' + +describe('matchService', () => { + describe('getAll', () => { + it('returns a ListResponse with match data', async () => { + const result = await matchService.getAll() + expect(Array.isArray(result.data)).toBe(true) + expect(result.data.length).toBeGreaterThan(0) + expect(result.meta.total).toBe(result.data.length) + }) + + it('each match has required fields', async () => { + const { data } = await matchService.getAll() + for (const m of data) { + expect(typeof m.id).toBe('string') + expect(typeof m.needId).toBe('string') + expect(typeof m.propertyId).toBe('string') + expect(typeof m.matchScore).toBe('number') + expect(m.matchScore).toBeGreaterThanOrEqual(0) + expect(m.matchScore).toBeLessThanOrEqual(100) + } + }) + }) + + describe('getById', () => { + it('returns a match for an existing id', async () => { + const { data: all } = await matchService.getAll() + const id = all[0].id + const result = await matchService.getById(id) + expect(result.data?.id).toBe(id) + }) + + it('returns null for an unknown id', async () => { + const result = await matchService.getById('no-such-match') + expect(result.data).toBeNull() + }) + }) + + describe('getByNeed', () => { + it('returns only matches for the given needId', async () => { + const { data: all } = await matchService.getAll() + const needId = all[0].needId + const result = await matchService.getByNeed(needId) + expect(result.data.every(m => m.needId === needId)).toBe(true) + }) + }) + + describe('getByProperty', () => { + it('returns only matches for the given propertyId', async () => { + const { data: all } = await matchService.getAll() + const propertyId = all[0].propertyId + const result = await matchService.getByProperty(propertyId) + expect(result.data.every(m => m.propertyId === propertyId)).toBe(true) + }) + }) + + describe('getScoreBreakdown', () => { + it('returns a ScoreBreakdown for a valid match', async () => { + const { data: all } = await matchService.getAll() + const matchWithBreakdown = all.find(m => !!m.scoreBreakdown) + if (!matchWithBreakdown) return // seed data may not always have breakdowns + const result = await matchService.getScoreBreakdown(matchWithBreakdown.id) + expect(result.data).not.toBeNull() + }) + + it('returns null for an unknown match', async () => { + const result = await matchService.getScoreBreakdown('no-such-match') + expect(result.data).toBeNull() + }) + }) + + describe('computeMatchesForNeed', () => { + it('returns computed matches for a valid needId', async () => { + const result = await matchService.computeMatchesForNeed('need-001') + expect(result.data.length).toBeGreaterThan(0) + expect(result.data.every(m => m.needId === 'need-001')).toBe(true) + }) + + it('returns empty array for unknown needId', async () => { + const result = await matchService.computeMatchesForNeed('no-such-need') + expect(result.data).toEqual([]) + }) + }) + + describe('getStrongMatches', () => { + it('returns only matches at or above the threshold', async () => { + const minScore = 70 + const result = await matchService.getStrongMatches(minScore) + expect(result.every(m => m.matchScore >= minScore)).toBe(true) + }) + + it('returns at most 5 items', async () => { + const result = await matchService.getStrongMatches(0) + expect(result.length).toBeLessThanOrEqual(5) + }) + + it('each item has propertyTitle and topReason', async () => { + const result = await matchService.getStrongMatches(0) + for (const item of result) { + expect(typeof item.propertyTitle).toBe('string') + expect(typeof item.topReason).toBe('string') + expect(typeof item.matchScore).toBe('number') + } + }) + }) +}) diff --git a/src/services/__tests__/needService.test.ts b/src/services/__tests__/needService.test.ts new file mode 100644 index 0000000..0e653f4 --- /dev/null +++ b/src/services/__tests__/needService.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { needService } from '../needService' + +describe('needService', () => { + describe('getAll', () => { + it('returns a ListResponse with data and meta', async () => { + const result = await needService.getAll() + expect(Array.isArray(result.data)).toBe(true) + expect(result.data.length).toBeGreaterThan(0) + expect(result.meta.total).toBe(result.data.length) + expect(result.meta.page).toBe(1) + expect(result.meta.hasMore).toBe(false) + }) + + it('each need has required fields', async () => { + const { data } = await needService.getAll() + for (const need of data) { + expect(typeof need.id).toBe('string') + expect(typeof need.companyName).toBe('string') + expect(typeof need.assetType).toBe('string') + expect(typeof need.requiredArea.min).toBe('number') + expect(typeof need.requiredArea.max).toBe('number') + } + }) + }) + + describe('getById', () => { + it('returns a need for an existing id', async () => { + const { data: all } = await needService.getAll() + const id = all[0].id + const result = await needService.getById(id) + expect(result.data).not.toBeNull() + expect(result.data?.id).toBe(id) + }) + + it('returns null for a non-existent id', async () => { + const result = await needService.getById('nonexistent-id') + expect(result.data).toBeNull() + }) + }) + + describe('create', () => { + it('creates a new need and returns it', async () => { + const { data: before } = await needService.getAll() + const input = { + companyName: 'Test GmbH', + assetType: 'OFFICE' as const, + requiredArea: { min: 200, max: 400 }, + preferredLocations: ['Zürich'], + budgetRange: { maxPerSqm: 400, maxMonthlyTotal: 10000, currency: 'CHF' as const }, + organizationId: 'org-test', + } + const result = await needService.create(input) + expect(result.data.companyName).toBe('Test GmbH') + expect(typeof result.data.id).toBe('string') + + const { data: after } = await needService.getAll() + expect(after.length).toBe(before.length + 1) + }) + }) + + describe('update', () => { + it('updates a need and reflects the change', async () => { + const { data: all } = await needService.getAll() + const target = all[0] + const result = await needService.update(target.id, { companyName: 'Updated GmbH' }) + expect(result.data.id).toBe(target.id) + expect(result.data.companyName).toBe('Updated GmbH') + }) + }) + + describe('remove', () => { + it('removes a need so it no longer appears in getAll', async () => { + // Create a temporary need to avoid disturbing shared seed data + const created = await needService.create({ + companyName: 'Temp GmbH', + assetType: 'OFFICE' as const, + requiredArea: { min: 100, max: 200 }, + preferredLocations: ['Bern'], + budgetRange: { maxPerSqm: 300, maxMonthlyTotal: 5000, currency: 'CHF' as const }, + organizationId: 'org-test', + }) + const id = created.data.id + await needService.remove(id) + const found = await needService.getById(id) + expect(found.data).toBeNull() + }) + }) +}) diff --git a/src/services/__tests__/propertyService.test.ts b/src/services/__tests__/propertyService.test.ts new file mode 100644 index 0000000..a632ce1 --- /dev/null +++ b/src/services/__tests__/propertyService.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { propertyService } from '../propertyService' + +describe('propertyService', () => { + describe('getAll', () => { + it('returns a ListResponse with data and meta', async () => { + const result = await propertyService.getAll() + expect(Array.isArray(result.data)).toBe(true) + expect(result.data.length).toBeGreaterThan(0) + expect(result.meta.total).toBe(result.data.length) + expect(result.meta.hasMore).toBe(false) + }) + + it('each property has required fields', async () => { + const { data } = await propertyService.getAll() + for (const p of data) { + expect(typeof p.id).toBe('string') + expect(typeof p.title).toBe('string') + expect(typeof p.areaSqm).toBe('number') + expect(typeof p.resultType).toBe('string') + expect(typeof p.location.city).toBe('string') + } + }) + }) + + describe('getById', () => { + it('returns a property for an existing id', async () => { + const { data: all } = await propertyService.getAll() + const id = all[0].id + const result = await propertyService.getById(id) + expect(result.data?.id).toBe(id) + }) + + it('returns null for an unknown id', async () => { + const result = await propertyService.getById('no-such-property') + expect(result.data).toBeNull() + }) + }) + + describe('getDashboardPropertiesSummary', () => { + it('returns total and active counts', async () => { + const summary = await propertyService.getDashboardPropertiesSummary() + expect(typeof summary.total).toBe('number') + expect(typeof summary.active).toBe('number') + expect(summary.active).toBeLessThanOrEqual(summary.total) + }) + + it('active count only includes AVAILABLE_NOW or AVAILABLE_SOON', async () => { + const { data: all } = await propertyService.getAll() + const expectedActive = all.filter( + p => p.availabilityStatus === 'AVAILABLE_NOW' || p.availabilityStatus === 'AVAILABLE_SOON', + ).length + const summary = await propertyService.getDashboardPropertiesSummary() + expect(summary.active).toBe(expectedActive) + }) + }) + + describe('create', () => { + it('creates a property and returns it', async () => { + const input = { + title: 'Test Lagerfläche', + assetType: 'LOGISTICS' as const, + resultType: 'VERIFIED_PORTFOLIO' as const, + location: { city: 'Basel', country: 'CH' as const }, + areaSqm: 500, + rentPricePerSqm: 120, + availabilityStatus: 'AVAILABLE_NOW' as const, + organizationId: 'org-test', + } + const result = await propertyService.create(input) + expect(typeof result.data.id).toBe('string') + expect(result.data.title).toBe('Test Lagerfläche') + }) + }) +}) diff --git a/src/services/ai/__tests__/mockAIService.test.ts b/src/services/ai/__tests__/mockAIService.test.ts new file mode 100644 index 0000000..5b5cd5c --- /dev/null +++ b/src/services/ai/__tests__/mockAIService.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { MockAIService } from '../mock/MockAIService' +import type { ParsedNeedCriteria } from '../../../domain/needBuilder' +import type { TradeOffInput } from '../IAIService' + +// Fast-forward simulated delays so tests don't take seconds +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +async function run(promise: Promise): Promise { + // Advance timers while waiting — handles setTimeout-based delays + const p = promise + vi.runAllTimers() + return p +} + +// ── generateFollowUpQuestions ───────────────────────────────────────────────── + +describe('MockAIService.generateFollowUpQuestions', () => { + it('asks about assetType when missing', async () => { + const criteria: ParsedNeedCriteria = {} + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + expect(result.data.some(q => q.targetField === 'assetType')).toBe(true) + }) + + it('asks about areaRange when missing', async () => { + const criteria: ParsedNeedCriteria = { assetType: 'OFFICE' } + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + expect(result.data.some(q => q.targetField === 'areaRange')).toBe(true) + }) + + it('asks area ambiguity question when max/min > 8', async () => { + const criteria: ParsedNeedCriteria = { + assetType: 'OFFICE', + areaRange: { min: 100, max: 1000 }, // ratio = 10 > 8 + preferredLocations: ['Zürich'], + } + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + const areaQ = result.data.find(q => q.targetField === 'areaRange') + expect(areaQ).toBeDefined() + expect(areaQ?.importance).toBe('required') + expect(areaQ?.questionText).toContain('weit gefasst') + }) + + it('treats zero min or max as ambiguous', async () => { + const criteria: ParsedNeedCriteria = { + assetType: 'OFFICE', + areaRange: { min: 0, max: 500 }, + preferredLocations: ['Zürich'], + } + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + expect(result.data.some(q => q.targetField === 'areaRange')).toBe(true) + }) + + it('does not ask about areaRange when ratio <= 8', async () => { + const criteria: ParsedNeedCriteria = { + assetType: 'OFFICE', + areaRange: { min: 200, max: 600 }, // ratio = 3 + preferredLocations: ['Zürich'], + } + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + expect(result.data.some(q => q.targetField === 'areaRange')).toBe(false) + }) + + it('returns at most 3 questions', async () => { + const criteria: ParsedNeedCriteria = {} // everything missing + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + expect(result.data.length).toBeLessThanOrEqual(3) + }) + + it('returns 0 questions when all fields are present', async () => { + const criteria: ParsedNeedCriteria = { + assetType: 'OFFICE', + areaRange: { min: 300, max: 600 }, + preferredLocations: ['Zürich'], + budgetRange: { maxPerSqm: 400 }, + timing: { earliestMoveIn: '2025-09-01' }, + mustHaveCriteria: ['ÖV-Anbindung'], + } + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + expect(result.data).toHaveLength(0) + }) + + it('priorities required questions first', async () => { + const criteria: ParsedNeedCriteria = { preferredLocations: [] } // missing assetType (required), budgetRange (recommended) + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + if (result.data.length >= 2) { + expect(result.data[0].importance).toBe('required') + } + }) + + it('question ids are unique', async () => { + const criteria: ParsedNeedCriteria = {} + const result = await run(MockAIService.generateFollowUpQuestions(criteria)) + const ids = result.data.map(q => q.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) + +// ── provenance ──────────────────────────────────────────────────────────────── + +describe('MockAIService provenance', () => { + it('returns mock provenance on generateFollowUpQuestions', async () => { + const result = await run(MockAIService.generateFollowUpQuestions({})) + expect(result.provenance.provider).toBe('mock') + expect(result.provenance.source).toBe('mock') + expect(result.provenance.fallbackUsed).toBe(false) + expect(result.provenance.validationPassed).toBe(true) + expect(typeof result.provenance.traceId).toBe('string') + expect(typeof result.provenance.generatedAt).toBe('string') + }) +}) + +// ── summarizeTradeOffs ──────────────────────────────────────────────────────── + +describe('MockAIService.summarizeTradeOffs', () => { + it('returns LOW risk when no HIGH severity trade-offs', async () => { + const tradeoffs: TradeOffInput[] = [ + { concern: 'Lage', severity: 'LOW', mitigation: 'Umgebung gut erschlossen' }, + ] + const result = await run(MockAIService.summarizeTradeOffs(tradeoffs)) + expect(result.data.overallRisk).toBe('LOW') + }) + + it('returns MEDIUM risk when exactly 1 HIGH severity trade-off', async () => { + const tradeoffs: TradeOffInput[] = [ + { concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' }, + ] + const result = await run(MockAIService.summarizeTradeOffs(tradeoffs)) + expect(result.data.overallRisk).toBe('MEDIUM') + }) + + it('returns HIGH risk when 2+ HIGH severity trade-offs', async () => { + const tradeoffs: TradeOffInput[] = [ + { concern: 'Fläche zu klein', severity: 'HIGH', mitigation: '' }, + { concern: 'Budget weit überschritten', severity: 'HIGH', mitigation: '' }, + ] + const result = await run(MockAIService.summarizeTradeOffs(tradeoffs)) + expect(result.data.overallRisk).toBe('HIGH') + }) + + it('returns zero-trade-off headline when list is empty', async () => { + const result = await run(MockAIService.summarizeTradeOffs([])) + expect(result.data.headline).toContain('Keine') + expect(result.data.items).toHaveLength(0) + }) +}) + +// ── generateMatchExplanation ────────────────────────────────────────────────── + +describe('MockAIService.generateMatchExplanation', () => { + const base = { + propertyId: 'prop-1', + propertyTitle: 'Bürofläche Zollstrasse', + propertyCity: 'Zürich', + positiveFactors: [{ criterion: 'Lage', explanation: 'Zentrale Lage', weight: 0.3 }], + negativeFactors: [{ criterion: 'Preis', explanation: 'Etwas über Budget', weight: 0.2 }], + } + + it('generates STARK headline for score >= 78', async () => { + const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 85 })) + expect(result.data.headline.toLowerCase()).toContain('stark') + }) + + it('generates MEDIUM headline for score 52–77', async () => { + const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 65 })) + expect(result.data.headline.toLowerCase()).toContain('gut') + }) + + it('generates WEAK headline for score < 52', async () => { + const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 40 })) + expect(result.data.headline.toLowerCase()).toContain('schwach') + }) + + it('includes property title in summary', async () => { + const result = await run(MockAIService.generateMatchExplanation({ ...base, matchScore: 80 })) + expect(result.data.summary).toContain('Bürofläche Zollstrasse') + }) +}) diff --git a/src/stores/__tests__/assistantStore.test.ts b/src/stores/__tests__/assistantStore.test.ts new file mode 100644 index 0000000..85bdcc5 --- /dev/null +++ b/src/stores/__tests__/assistantStore.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useAssistantStore } from '../assistantStore' +import type { AssistantContext, AssistantMessage } from '../../domain/assistant' + +const ctx: AssistantContext = { + currentRoute: '/demand/results', + workspace: 'DEMAND', + userRole: 'TENANT', + organizationId: 'org-1', +} + +const msg: AssistantMessage = { + id: 'msg-1', + role: 'user', + content: 'Welche Flächen sind verfügbar?', + createdAt: new Date().toISOString(), +} + +describe('assistantStore', () => { + beforeEach(() => { + useAssistantStore.setState({ isOpen: false, context: null, messages: [], isLoading: false, error: null }) + }) + + describe('open / close', () => { + it('opens the assistant', () => { + useAssistantStore.getState().open() + expect(useAssistantStore.getState().isOpen).toBe(true) + }) + + it('closes the assistant', () => { + useAssistantStore.getState().open() + useAssistantStore.getState().close() + expect(useAssistantStore.getState().isOpen).toBe(false) + }) + }) + + describe('setContext', () => { + it('stores the context', () => { + useAssistantStore.getState().setContext(ctx) + expect(useAssistantStore.getState().context).toEqual(ctx) + }) + }) + + describe('updateContext', () => { + it('merges partial context when context exists', () => { + useAssistantStore.getState().setContext(ctx) + useAssistantStore.getState().updateContext({ currentRoute: '/demand/compare' }) + expect(useAssistantStore.getState().context?.currentRoute).toBe('/demand/compare') + expect(useAssistantStore.getState().context?.workspace).toBe('DEMAND') + }) + + it('is a no-op when context is null', () => { + useAssistantStore.getState().updateContext({ currentRoute: '/demand/compare' }) + expect(useAssistantStore.getState().context).toBeNull() + }) + }) + + describe('addMessage', () => { + it('appends messages in order', () => { + const msg2: AssistantMessage = { ...msg, id: 'msg-2', role: 'assistant', content: 'Hier sind die Ergebnisse.' } + useAssistantStore.getState().addMessage(msg) + useAssistantStore.getState().addMessage(msg2) + const { messages } = useAssistantStore.getState() + expect(messages).toHaveLength(2) + expect(messages[0].id).toBe('msg-1') + expect(messages[1].id).toBe('msg-2') + }) + }) + + describe('setLoading', () => { + it('toggles loading state', () => { + useAssistantStore.getState().setLoading(true) + expect(useAssistantStore.getState().isLoading).toBe(true) + useAssistantStore.getState().setLoading(false) + expect(useAssistantStore.getState().isLoading).toBe(false) + }) + }) + + describe('setError', () => { + it('stores and clears error', () => { + useAssistantStore.getState().setError('Verbindungsfehler') + expect(useAssistantStore.getState().error).toBe('Verbindungsfehler') + useAssistantStore.getState().setError(null) + expect(useAssistantStore.getState().error).toBeNull() + }) + }) + + describe('clearConversation', () => { + it('resets messages and error, keeps context', () => { + useAssistantStore.getState().setContext(ctx) + useAssistantStore.getState().addMessage(msg) + useAssistantStore.getState().setError('some error') + useAssistantStore.getState().clearConversation() + expect(useAssistantStore.getState().messages).toHaveLength(0) + expect(useAssistantStore.getState().error).toBeNull() + expect(useAssistantStore.getState().context).toEqual(ctx) + }) + }) +}) diff --git a/src/stores/__tests__/compareStore.test.ts b/src/stores/__tests__/compareStore.test.ts new file mode 100644 index 0000000..9a8008f --- /dev/null +++ b/src/stores/__tests__/compareStore.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useCompareStore } from '../compareStore' +import type { UnifiedMatchResult } from '../../domain/unifiedResult' + +function makeResult(matchId: string, score = 80): UnifiedMatchResult { + return { + matchId, + needId: 'need-1', + matchScore: score, + resultType: 'VERIFIED_PORTFOLIO', + property: { id: 'prop-1' } as never, + match: { id: matchId } as never, + } +} + +describe('compareStore', () => { + beforeEach(() => { + useCompareStore.setState({ compareItems: [] }) + }) + + describe('addToCompare', () => { + it('adds a result', () => { + useCompareStore.getState().addToCompare(makeResult('m-1')) + expect(useCompareStore.getState().compareItems).toHaveLength(1) + }) + + it('does not add duplicate matchId', () => { + const r = makeResult('m-1') + useCompareStore.getState().addToCompare(r) + useCompareStore.getState().addToCompare(r) + expect(useCompareStore.getState().compareItems).toHaveLength(1) + }) + + it('enforces max 4 items', () => { + for (let i = 1; i <= 5; i++) { + useCompareStore.getState().addToCompare(makeResult(`m-${i}`)) + } + expect(useCompareStore.getState().compareItems).toHaveLength(4) + }) + }) + + describe('removeFromCompare', () => { + it('removes item by matchId', () => { + useCompareStore.getState().addToCompare(makeResult('m-1')) + useCompareStore.getState().addToCompare(makeResult('m-2')) + useCompareStore.getState().removeFromCompare('m-1') + expect(useCompareStore.getState().compareItems.map(i => i.matchId)).toEqual(['m-2']) + }) + + it('is a no-op for unknown matchId', () => { + useCompareStore.getState().addToCompare(makeResult('m-1')) + useCompareStore.getState().removeFromCompare('m-x') + expect(useCompareStore.getState().compareItems).toHaveLength(1) + }) + }) + + describe('clearCompare', () => { + it('empties the list', () => { + useCompareStore.getState().addToCompare(makeResult('m-1')) + useCompareStore.getState().addToCompare(makeResult('m-2')) + useCompareStore.getState().clearCompare() + expect(useCompareStore.getState().compareItems).toHaveLength(0) + }) + }) + + describe('isInCompare', () => { + it('returns true when present', () => { + useCompareStore.getState().addToCompare(makeResult('m-1')) + expect(useCompareStore.getState().isInCompare('m-1')).toBe(true) + }) + + it('returns false when absent', () => { + expect(useCompareStore.getState().isInCompare('m-x')).toBe(false) + }) + }) + + describe('isFull', () => { + it('returns false when fewer than 4 items', () => { + useCompareStore.getState().addToCompare(makeResult('m-1')) + expect(useCompareStore.getState().isFull()).toBe(false) + }) + + it('returns true when 4 items are present', () => { + for (let i = 1; i <= 4; i++) useCompareStore.getState().addToCompare(makeResult(`m-${i}`)) + expect(useCompareStore.getState().isFull()).toBe(true) + }) + }) +}) diff --git a/src/stores/__tests__/shortlistStore.test.ts b/src/stores/__tests__/shortlistStore.test.ts new file mode 100644 index 0000000..b66de4a --- /dev/null +++ b/src/stores/__tests__/shortlistStore.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useShortlistStore } from '../shortlistStore' +import type { ShortlistItemInput } from '../../domain/shortlist' + +const item: ShortlistItemInput = { + resultId: 'r-1', + resultType: 'VERIFIED_PORTFOLIO', + title: 'Bürofläche Teststrasse 1', + matchScore: 85, + addedBy: 'user-1', +} + +describe('shortlistStore', () => { + beforeEach(() => { + useShortlistStore.setState({ selectedShortlistId: null, dialogOpen: false, pendingItem: null }) + }) + + describe('setSelectedShortlist', () => { + it('sets the selected id', () => { + useShortlistStore.getState().setSelectedShortlist('sl-42') + expect(useShortlistStore.getState().selectedShortlistId).toBe('sl-42') + }) + + it('clears the selected id with null', () => { + useShortlistStore.getState().setSelectedShortlist('sl-42') + useShortlistStore.getState().setSelectedShortlist(null) + expect(useShortlistStore.getState().selectedShortlistId).toBeNull() + }) + }) + + describe('openAddDialog', () => { + it('opens dialog and stores pending item', () => { + useShortlistStore.getState().openAddDialog(item) + const state = useShortlistStore.getState() + expect(state.dialogOpen).toBe(true) + expect(state.pendingItem).toEqual(item) + }) + }) + + describe('closeAddDialog', () => { + it('closes dialog and clears pending item', () => { + useShortlistStore.getState().openAddDialog(item) + useShortlistStore.getState().closeAddDialog() + const state = useShortlistStore.getState() + expect(state.dialogOpen).toBe(false) + expect(state.pendingItem).toBeNull() + }) + }) +}) diff --git a/src/stores/__tests__/toastStore.test.ts b/src/stores/__tests__/toastStore.test.ts new file mode 100644 index 0000000..8e09b98 --- /dev/null +++ b/src/stores/__tests__/toastStore.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useToastStore } from '../toastStore' + +describe('toastStore', () => { + beforeEach(() => { + useToastStore.setState({ toasts: [] }) + }) + + describe('showToast', () => { + it('adds a toast with message and default severity success', () => { + useToastStore.getState().showToast('Gespeichert') + const { toasts } = useToastStore.getState() + expect(toasts).toHaveLength(1) + expect(toasts[0].message).toBe('Gespeichert') + expect(toasts[0].severity).toBe('success') + expect(toasts[0].duration).toBe(4000) + }) + + it('accepts custom severity and duration', () => { + useToastStore.getState().showToast('Fehler', 'error', 8000) + const { toasts } = useToastStore.getState() + expect(toasts[0].severity).toBe('error') + expect(toasts[0].duration).toBe(8000) + }) + + it('assigns unique ids across multiple toasts', () => { + useToastStore.getState().showToast('A') + useToastStore.getState().showToast('B') + const ids = useToastStore.getState().toasts.map(t => t.id) + expect(new Set(ids).size).toBe(2) + }) + + it('stacks multiple toasts', () => { + useToastStore.getState().showToast('First') + useToastStore.getState().showToast('Second') + expect(useToastStore.getState().toasts).toHaveLength(2) + }) + }) + + describe('dismissToast', () => { + it('removes the toast with the given id', () => { + useToastStore.getState().showToast('Keep') + useToastStore.getState().showToast('Remove') + const { toasts } = useToastStore.getState() + const idToRemove = toasts[1].id + useToastStore.getState().dismissToast(idToRemove) + expect(useToastStore.getState().toasts).toHaveLength(1) + expect(useToastStore.getState().toasts[0].message).toBe('Keep') + }) + + it('is a no-op for unknown id', () => { + useToastStore.getState().showToast('Keep') + useToastStore.getState().dismissToast('nonexistent-id') + expect(useToastStore.getState().toasts).toHaveLength(1) + }) + }) +})