Files
F-r-ldraledigplannerare/frontend/src/pages/Dashboard.test.tsx
T
jacobh2 cf378288f0 Break the Dashboard's age-4 stat down per parent
The Dashboard summed both parents' expiring days into one figure while
the Plan tab already showed them separately. Each parent's own count is
now shown alongside the combined total, which stays and is relabelled as
a sum.

The per-parent numbers were already on the wire as
parentProjections[].expiringAtB4 -- no backend change, and no arithmetic
here. Both figures are rendered exactly as the backend computed them,
since re-deriving them is what previously doubled a parent's entitlement
(87b3eb5).

A single-parent workspace shows only the combined figure, where a
breakdown would just repeat it.

nameResolver moves out of PlanResults.tsx into src/utils/parentNames.ts
so both screens resolve uuid -> name the same way. PlanResults.test.tsx
passes untouched, which is the evidence the extraction preserved
behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:50 +02:00

569 lines
20 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import { Dashboard } from './Dashboard'
import * as hooks from '@/hooks'
import { useWorkspace, useAuth } from '@/context'
vi.mock('@/hooks')
vi.mock('@/context')
vi.mock('@/services/api')
// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree
// to work. Dashboard.tsx only cares that its content renders inside Layout, so
// stub Layout out — same pattern used by Calendar.test.tsx / Settings.test.tsx.
vi.mock('@/components/layout/Layout', () => ({
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const renderWithRouter = (component: React.ReactElement) => {
return render(<BrowserRouter>{component}</BrowserRouter>)
}
describe('Dashboard', () => {
const mockWorkspaceContext = {
workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced' as const, createdAt: '2026-01-01' },
isLoading: false,
error: null,
workspaceId: 'ws-1',
setWorkspaceId: vi.fn(),
refetch: vi.fn(),
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
selectedChildId: 'c-1',
selectChild: vi.fn(),
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
refreshContext: vi.fn(),
}
const mockAnalyticsData = {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [],
combinedDaysUsed: 30,
combinedDaysLeft: 270,
combinedExpiringAtB4: 100,
}
const mockParentalLeaveData = {
id: 'pf-1',
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
childId: 'c-1',
source: 'scraped' as const,
fetchedAt: '2026-06-13T10:00:00Z',
totalDays: 300,
daysUsed: 30,
daysLeft: 270,
reservedDaysTotal: 90,
reservedDaysLeft: 80,
transferableDaysLeft: 50,
dubbeldagarLeft: 60,
tierData: {
sjukpenningTotal: 100,
sjukpenningUsed: 10,
sjukpenningLeft: 90,
lagstaNivaTotal: 200,
lagstaNivaUsed: 20,
lagstaNivaLeft: 180,
},
createdAt: '2026-06-13T10:00:00Z',
updatedAt: '2026-06-13T10:00:00Z',
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAuth).mockReturnValue({
accessToken: 'token',
isAuthenticated: true,
isLoading: false,
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
} as any)
vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any)
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: mockAnalyticsData,
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
vi.mocked(hooks.useParentalLeave).mockReturnValue({
data: mockParentalLeaveData,
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
vi.mocked(hooks.usePlannedDays).mockReturnValue({
data: [
{ id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' },
{ id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' },
],
isLoading: false,
error: null,
add: vi.fn(),
remove: vi.fn(),
} as any)
vi.mocked(hooks.useScraperFetch).mockReturnValue({
sessionId: null,
pollData: null,
resolvedIds: null,
error: null,
isUnauthorized: false,
start: vi.fn(),
cancel: vi.fn(),
isRunning: false,
isTerminal: false,
} as any)
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({
data: [
{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' },
{ uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' },
],
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
})
it('renders loading spinner initially', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
isLoading: true,
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
})
it('shows add child form when no children', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Du har inga barn registrerade ännu/)).toBeInTheDocument()
expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument()
})
it('shows overview metrics when children exist', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
expect(screen.getByText(/Använda dagar/)).toBeInTheDocument()
})
it('shows parental leave breakdown when data exists', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
// ParentalLeaveBreakdown shows an explanatory note about reserved days
// rather than a "Reserverade dagar" heading/row — see
// ParentalLeaveBreakdown.test.tsx ("displays a reserved-days explanatory
// note instead of a countdown").
expect(screen.getByTestId('reserved-days-note')).toHaveTextContent(/reserverade/i)
})
it('renders FK fetch button', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })).toBeInTheDocument()
})
it('disables FK fetch button when no child selected', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
selectedChildId: null,
} as any)
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).toBeDisabled()
})
it('enables FK fetch button when child selected', () => {
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).not.toBeDisabled()
})
it('shows planned days breakdown', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('shows progress chart with real data', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('progress-chart')).toBeInTheDocument()
})
it('shows Swedish section headings', () => {
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/)
expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument()
expect(screen.getByText(/Återstående till 4 års ålder/)).toBeInTheDocument()
})
it('displays error message if analytics fails', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: null,
isLoading: false,
error: 'Failed to load analytics',
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Failed to load analytics/)).toBeInTheDocument()
})
it('shows empty state for parental leave when data is null', () => {
vi.mocked(hooks.useParentalLeave).mockReturnValue({
data: null,
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
})
it('opens QR code modal on FK fetch button click', async () => {
const user = userEvent.setup()
const mockStart = vi.fn()
vi.mocked(hooks.useScraperFetch).mockReturnValue({
sessionId: null,
pollData: null,
resolvedIds: null,
error: null,
isUnauthorized: false,
start: mockStart,
cancel: vi.fn(),
isRunning: false,
isTerminal: false,
} as any)
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
await user.click(button)
expect(mockStart).toHaveBeenCalledWith('c-1')
})
it('shows FK button is not disabled by default with child selected', () => {
renderWithRouter(<Dashboard />)
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).toBeInTheDocument()
expect(button).not.toBeDisabled()
})
it('submits add child form with valid data', async () => {
const user = userEvent.setup()
const mockCreateChild = vi.fn().mockResolvedValue({ id: 'c-2', name: 'New Child', birth_date: '2024-01-15' })
const mockRefreshContext = vi.fn().mockResolvedValue(undefined)
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
refreshContext: mockRefreshContext,
} as any)
const mockApi = await import('@/services/api')
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
renderWithRouter(<Dashboard />)
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
await user.click(submitButton)
expect(mockCreateChild).toHaveBeenCalledWith({
workspace_id: 'ws-1',
name: 'Test Child',
birth_date: '2024-01-15',
})
expect(mockRefreshContext).toHaveBeenCalled()
})
it('shows error when add child form submission fails', async () => {
const user = userEvent.setup()
const mockCreateChild = vi.fn().mockRejectedValue(new Error('API error'))
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
refreshContext: vi.fn(),
} as any)
const mockApi = await import('@/services/api')
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
renderWithRouter(<Dashboard />)
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
await user.click(submitButton)
expect(screen.getByText(/API error/)).toBeInTheDocument()
})
it('disables submit button during form submission', async () => {
const user = userEvent.setup()
const mockCreateChild = vi.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ id: 'c-2' }), 100))
)
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
refreshContext: vi.fn().mockResolvedValue(undefined),
} as any)
const mockApi = await import('@/services/api')
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
renderWithRouter(<Dashboard />)
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
})
it('validates add child form with workspace check', async () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
workspace: null,
refreshContext: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const user = userEvent.setup()
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.click(submitButton)
expect(screen.getByText(/Fyll i alla fält/)).toBeInTheDocument()
})
it('has manual fetch button enabled when child selected', () => {
renderWithRouter(<Dashboard />)
const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ })
expect(manualButton).toBeInTheDocument()
expect(manualButton).not.toBeDisabled()
})
it('disables manual fetch button when no child selected', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
selectedChildId: null,
} as any)
renderWithRouter(<Dashboard />)
const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ })
expect(manualButton).toBeDisabled()
})
it('renders QRCodeModal when scraper is active', () => {
vi.mocked(hooks.useScraperFetch).mockReturnValue({
sessionId: 'session1',
pollData: { status: 'in_progress' },
resolvedIds: null,
error: null,
isUnauthorized: false,
start: vi.fn(),
cancel: vi.fn(),
isRunning: true,
isTerminal: false,
} as any)
renderWithRouter(<Dashboard />)
// QRCodeModal is rendered inside Dashboard
// Verify it was constructed with proper props
expect(screen.getByText(/Hämta från Försäkringskassan/)).toBeInTheDocument()
})
it('renders planned days breakdown with multiple day types', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue({
data: [
{ id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' },
{ id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' },
{ id: '3', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-17', dayType: 'lagsta', isDubbeldag: false, createdAt: '', updatedAt: '' },
],
isLoading: false,
error: null,
add: vi.fn(),
remove: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('shows analytics loading state', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: null,
isLoading: true,
error: null,
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
})
it('shows planned days loading state', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue({
data: null,
isLoading: true,
error: null,
add: vi.fn(),
remove: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
})
it('renders with zero planned days', () => {
vi.mocked(hooks.usePlannedDays).mockReturnValue({
data: [],
isLoading: false,
error: null,
add: vi.fn(),
remove: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('displays metrics with expiring days at B4', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: {
workspaceId: 'ws-1',
childId: 'c-1',
parentProjections: [],
combinedDaysUsed: 50,
combinedDaysLeft: 200,
combinedExpiringAtB4: 104,
},
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
})
it('shows only the combined expiring figure for a single-parent workspace', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: {
...mockAnalyticsData,
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104 },
],
combinedExpiringAtB4: 104,
},
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument()
expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument()
})
it('breaks down expiring days per parent alongside the combined total', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: {
...mockAnalyticsData,
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 },
],
combinedExpiringAtB4: 104,
},
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1')
const samMetric = screen.getByTestId('metric-expiring-parent-pd-2')
const combinedMetric = screen.getByTestId('metric-expiring-combined')
expect(alexMetric).toHaveTextContent('Alex')
expect(alexMetric).toHaveTextContent('60')
expect(samMetric).toHaveTextContent('Sam')
expect(samMetric).toHaveTextContent('44')
expect(combinedMetric).toHaveTextContent('104')
})
it('falls back to a numbered parent label while members are still loading', () => {
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({
data: [],
isLoading: true,
error: null,
refresh: vi.fn(),
} as any)
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: {
...mockAnalyticsData,
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 },
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 },
],
combinedExpiringAtB4: 104,
},
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1')
expect(screen.getByTestId('metric-expiring-parent-pd-2')).toHaveTextContent('Förälder 2')
})
it('falls back to a numbered parent label for a projection with no matching member', () => {
vi.mocked(hooks.useAnalytics).mockReturnValue({
data: {
...mockAnalyticsData,
parentProjections: [
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 },
{ personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 },
],
combinedExpiringAtB4: 104,
},
isLoading: false,
error: null,
refresh: vi.fn(),
} as any)
renderWithRouter(<Dashboard />)
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex')
expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2')
})
})