test: integration coverage for stores, services, MockAIService, and hooks
- Stores (29 tests): compareStore max-4 enforcement, toastStore unique IDs, shortlistStore dialog state, assistantStore context merge + clearConversation - Services (37 tests): needService CRUD, propertyService dashboard summary active-status filter, matchService computeMatchesForNeed + getStrongMatches, futureSignalService summary math (distribution totals, highConfidence count) - MockAIService (18 tests): follow-up question priority ordering, area ambiguity detection (ratio >8 and zero values), max-3 cap, trade-off risk logic (LOW/MEDIUM/HIGH thresholds), match explanation headline tiers, provenance shape - Hooks (20 tests): useNeeds/useProperties/useMatches envelope unwrap, disabled- when-empty guards, useCreateNeed/useCreateProperty mutations, useNeed/useMatchDetail All 257 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<T>(promise: Promise<T>): Promise<T> {
|
||||||
|
// 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user