d14f86478a
New token system in global.css (near-white surfaces, one ink-blue accent, no sub-12px type), self-hosted Schibsted Grotesk, rewritten primitives (buttons, fields, modal shell, segmented, disclosure, skeleton), a borderless 56px header with an SV | EN language toggle, an Icon component replacing glyphs, page skeletons that keep the layout during loading, and leave-type colours moved out of TS into CSS variables. Feature blocks keep their structure for later phases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
766 lines
31 KiB
TypeScript
766 lines
31 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||
import { render, screen, waitFor } 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 * as api from '@/services/api'
|
||
import { useWorkspace, useAuth } from '@/context'
|
||
import {
|
||
makeAuthContextValue,
|
||
makeWorkspaceContextValue,
|
||
makeAnalyticsResult,
|
||
makeParentalLeaveResult,
|
||
makePlannedDaysResult,
|
||
makeScraperFetchResult,
|
||
makeWorkspaceMembersResult,
|
||
makePlannedLeaveDay,
|
||
makeOptimizationTipsResult,
|
||
makeLeaveWishesResult,
|
||
makePlanProposalResult,
|
||
makeParentalLeaveFetch,
|
||
makeTierData,
|
||
makeTip,
|
||
} from '../../tests/fixtures'
|
||
|
||
const mockNavigate = vi.fn()
|
||
|
||
vi.mock('@/hooks')
|
||
vi.mock('@/context')
|
||
vi.mock('@/services/api')
|
||
vi.mock('react-router-dom', async () => {
|
||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||
return { ...actual, useNavigate: () => mockNavigate }
|
||
})
|
||
|
||
// 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>,
|
||
}))
|
||
|
||
// Dashboard hands off to Onboarding entirely when there are no children yet —
|
||
// stub it out so this file only asserts the hand-off happens, not
|
||
// Onboarding's own behaviour (covered by Onboarding.test.tsx).
|
||
vi.mock('@/pages/Onboarding', () => ({
|
||
Onboarding: () => <div data-testid="onboarding-page">Onboarding</div>,
|
||
}))
|
||
|
||
const renderWithRouter = (component: React.ReactElement) => {
|
||
return render(<BrowserRouter>{component}</BrowserRouter>)
|
||
}
|
||
|
||
describe('Dashboard', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks()
|
||
vi.mocked(useAuth).mockReturnValue(makeAuthContextValue())
|
||
vi.mocked(useWorkspace).mockReturnValue(
|
||
makeWorkspaceContextValue({
|
||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||
workspaceId: 'ws-1',
|
||
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
|
||
selectedChildId: 'c-1',
|
||
personalDataUuid: 'pd-1',
|
||
parentName: 'Test Parent',
|
||
})
|
||
)
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
parentProjections: [],
|
||
combinedDaysUsed: 30,
|
||
combinedDaysLeft: 270,
|
||
combinedExpiringAtB4: 100,
|
||
},
|
||
})
|
||
)
|
||
vi.mocked(hooks.useParentalLeave).mockReturnValue(
|
||
makeParentalLeaveResult({
|
||
data: {
|
||
id: 'pf-1',
|
||
personalDataUuid: 'pd-1',
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
source: 'scraped',
|
||
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',
|
||
},
|
||
})
|
||
)
|
||
vi.mocked(hooks.usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({ id: '1', date: '2026-06-15' }),
|
||
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }),
|
||
],
|
||
})
|
||
)
|
||
vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult())
|
||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
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' },
|
||
],
|
||
})
|
||
)
|
||
vi.mocked(hooks.useOptimizationTips).mockReturnValue(makeOptimizationTipsResult())
|
||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult())
|
||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
|
||
// Default: nothing saved yet for whoever's balance form gets opened —
|
||
// `useLatestBalance` (a real hook, imported directly by Dashboard.tsx to
|
||
// bypass the `vi.mock('@/hooks')` above) falls back to the form's
|
||
// preset. Tests that care about a saved balance override this.
|
||
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
|
||
})
|
||
|
||
it('renders loading spinner initially', () => {
|
||
vi.mocked(useWorkspace).mockReturnValue(
|
||
makeWorkspaceContextValue({
|
||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||
workspaceId: 'ws-1',
|
||
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
|
||
selectedChildId: 'c-1',
|
||
personalDataUuid: 'pd-1',
|
||
parentName: 'Test Parent',
|
||
isLoading: true,
|
||
})
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||
})
|
||
|
||
it('hands off to Onboarding when there are no children yet', () => {
|
||
vi.mocked(useWorkspace).mockReturnValue(
|
||
makeWorkspaceContextValue({
|
||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||
workspaceId: 'ws-1',
|
||
children: [],
|
||
selectedChildId: null,
|
||
personalDataUuid: 'pd-1',
|
||
parentName: 'Test Parent',
|
||
})
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('onboarding-page')).toBeInTheDocument()
|
||
expect(screen.queryByTestId('dashboard')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('refetches workspace members once onboarding hands off (children goes from empty to non-empty)', async () => {
|
||
// Regression: a couple's partner is added mid-onboarding via a direct
|
||
// `api.addPartner` call, invisible to this component's own
|
||
// `useWorkspaceMembers` instance (fetched once, on mount, back when
|
||
// there was only one member). `Onboarding` only ever refreshes
|
||
// `WorkspaceContext` — flipping `children` from empty to non-empty is
|
||
// the only externally-observable signal this component gets that the
|
||
// member list it already fetched might now be stale.
|
||
const refreshMembers = vi.fn().mockResolvedValue(undefined)
|
||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({ refresh: refreshMembers, data: [] }),
|
||
)
|
||
const soloWorkspace = makeWorkspaceContextValue({
|
||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||
workspaceId: 'ws-1',
|
||
children: [],
|
||
selectedChildId: null,
|
||
personalDataUuid: 'pd-1',
|
||
parentName: 'Test Parent',
|
||
})
|
||
vi.mocked(useWorkspace).mockReturnValue(soloWorkspace)
|
||
|
||
const { rerender } = renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('onboarding-page')).toBeInTheDocument()
|
||
expect(refreshMembers).not.toHaveBeenCalled()
|
||
|
||
// Onboarding finishes: `WorkspaceContext.children` flips non-empty.
|
||
vi.mocked(useWorkspace).mockReturnValue(
|
||
makeWorkspaceContextValue({
|
||
...soloWorkspace,
|
||
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-01-01' }],
|
||
selectedChildId: 'c-1',
|
||
})
|
||
)
|
||
rerender(
|
||
<BrowserRouter>
|
||
<Dashboard />
|
||
</BrowserRouter>,
|
||
)
|
||
|
||
await waitFor(() => expect(refreshMembers).toHaveBeenCalledTimes(1))
|
||
})
|
||
|
||
describe('estimated-figures badge', () => {
|
||
it('is hidden once real parental-leave balances have been fetched (the default mock)', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.queryByTestId('dashboard-estimate-badge')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('is shown when no parental-leave balance has ever been fetched, labelling the figures as estimates', () => {
|
||
vi.mocked(hooks.useParentalLeave).mockReturnValue(makeParentalLeaveResult({ data: null }))
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('dashboard-estimate-badge')).toHaveTextContent('Uppskattat')
|
||
})
|
||
})
|
||
|
||
describe('Hero', () => {
|
||
it('uses the forward-looking framing when there are no planned days yet', () => {
|
||
vi.mocked(hooks.usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
|
||
'Du har 270 dagar att planera. Utan en plan försvinner 100 av dem när Child 1 fyller 4.',
|
||
)
|
||
})
|
||
|
||
it('uses the warning framing once days are planned, with the 4th-birthday date', () => {
|
||
// selectedChild birth_date is 2022-01-01 and there is no parent
|
||
// projection in this test's default mock, so the 4th birthday is
|
||
// derived client-side as plain date arithmetic (not a business rule).
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent(
|
||
'100 dagar riskerar att försvinna när Child 1 fyller 4 (1 januari 2026).',
|
||
)
|
||
})
|
||
|
||
it('uses the backend-provided 4th birthday when a parent projection is available', () => {
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
parentProjections: [
|
||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 },
|
||
],
|
||
combinedDaysUsed: 30,
|
||
combinedDaysLeft: 210,
|
||
combinedExpiringAtB4: 60,
|
||
},
|
||
})
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('1 juni 2029')
|
||
})
|
||
})
|
||
|
||
describe('Age4Bars', () => {
|
||
it('renders each parent bar using the backend-resolved savableCapAtB4, never re-derived', () => {
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
parentProjections: [
|
||
{ personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 },
|
||
{ personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 },
|
||
],
|
||
combinedDaysUsed: 31,
|
||
combinedDaysLeft: 279,
|
||
combinedExpiringAtB4: 191,
|
||
},
|
||
})
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('40 av 48 dagar kan sparas')
|
||
expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Alex')
|
||
expect(screen.getByTestId('age4-bar-expiring-pd-2')).toHaveTextContent('191 dagar förfaller')
|
||
expect(screen.getByTestId('age4-bar-pd-2')).toHaveTextContent('Sam')
|
||
})
|
||
|
||
it('is not rendered when there are no parent projections yet', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.queryByTestId('age4-bars')).not.toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
describe('YearTimeline', () => {
|
||
it('renders one row per year from the child\'s birth to the 4th birthday', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
for (const year of [2022, 2023, 2024, 2025, 2026]) {
|
||
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
|
||
}
|
||
})
|
||
|
||
it('navigates to the calendar at the clicked band\'s date', async () => {
|
||
const user = userEvent.setup()
|
||
vi.mocked(hooks.usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [makePlannedLeaveDay({ id: '1', date: '2024-06-15', dayType: 'vacation' })],
|
||
})
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
await user.click(screen.getByTestId('year-timeline-band-2024-0'))
|
||
expect(mockNavigate).toHaveBeenCalledWith('/calendar?date=2024-06-15')
|
||
})
|
||
})
|
||
|
||
describe('Tips', () => {
|
||
it('is hidden entirely when there are no tips', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.queryByTestId('dashboard-tips')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('renders tip cards when tips exist', () => {
|
||
vi.mocked(hooks.useOptimizationTips).mockReturnValue(
|
||
makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] })
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument()
|
||
expect(screen.getAllByTestId('tip-card')).toHaveLength(1)
|
||
})
|
||
})
|
||
|
||
it('renders the folded plan-proposal section', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('plan-fold')).toBeInTheDocument()
|
||
})
|
||
|
||
it('shows overview metrics inside the collapsed "more details" section', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('dashboard-more-details')).toBeInTheDocument()
|
||
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(
|
||
makeWorkspaceContextValue({
|
||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||
workspaceId: 'ws-1',
|
||
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
|
||
selectedChildId: null,
|
||
personalDataUuid: 'pd-1',
|
||
parentName: 'Test Parent',
|
||
})
|
||
)
|
||
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 Swedish section headings', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/)
|
||
expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument()
|
||
})
|
||
|
||
// The combined expiring-at-4 figure reflects the currently saved plan, while
|
||
// the Plan tab's own figure (PlanResults.tsx) reflects an unsaved proposal —
|
||
// the label must say so, so the two screens are never confused for showing
|
||
// the same thing. See PlanAge4Projection vs Age4Projection in types/entities.ts.
|
||
it('labels the combined expiring-at-4 metric as reflecting the current saved plan', () => {
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('metric-expiring-combined')).toHaveTextContent(
|
||
'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)',
|
||
)
|
||
})
|
||
|
||
it('labels the per-parent expiring-at-4 metric as reflecting the current saved plan', () => {
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
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, savableCapAtB4: 96 },
|
||
{ 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, savableCapAtB4: 96 },
|
||
],
|
||
combinedDaysUsed: 50,
|
||
combinedDaysLeft: 190,
|
||
combinedExpiringAtB4: 104,
|
||
},
|
||
})
|
||
)
|
||
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent(
|
||
'Förfaller vid 4 år om nuvarande plan följs – Alex',
|
||
)
|
||
})
|
||
|
||
it('displays error message if analytics fails', () => {
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: null,
|
||
error: 'Failed to load analytics',
|
||
})
|
||
)
|
||
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(
|
||
makeParentalLeaveResult({
|
||
data: null,
|
||
})
|
||
)
|
||
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(
|
||
makeScraperFetchResult({
|
||
start: mockStart,
|
||
})
|
||
)
|
||
|
||
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('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(
|
||
makeWorkspaceContextValue({
|
||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||
workspaceId: 'ws-1',
|
||
children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }],
|
||
selectedChildId: null,
|
||
personalDataUuid: 'pd-1',
|
||
parentName: 'Test Parent',
|
||
})
|
||
)
|
||
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(
|
||
makeScraperFetchResult({
|
||
sessionId: 'session1',
|
||
pollData: { status: 'authenticating' },
|
||
isRunning: true,
|
||
})
|
||
)
|
||
|
||
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(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({ id: '1', date: '2026-06-15' }),
|
||
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }),
|
||
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'lagsta' }),
|
||
],
|
||
})
|
||
)
|
||
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
|
||
})
|
||
|
||
it('shows analytics loading state', () => {
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: null,
|
||
isLoading: true,
|
||
})
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||
})
|
||
|
||
it('shows planned days loading state', () => {
|
||
vi.mocked(hooks.usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: null,
|
||
isLoading: true,
|
||
})
|
||
)
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders with zero planned days', () => {
|
||
vi.mocked(hooks.usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [],
|
||
})
|
||
)
|
||
|
||
renderWithRouter(<Dashboard />)
|
||
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
|
||
})
|
||
|
||
it('displays metrics with expiring days at B4', () => {
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
parentProjections: [],
|
||
combinedDaysUsed: 50,
|
||
combinedDaysLeft: 200,
|
||
combinedExpiringAtB4: 104,
|
||
},
|
||
})
|
||
)
|
||
|
||
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(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
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, savableCapAtB4: 96 },
|
||
],
|
||
combinedDaysUsed: 30,
|
||
combinedDaysLeft: 210,
|
||
combinedExpiringAtB4: 104,
|
||
},
|
||
})
|
||
)
|
||
|
||
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(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
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, savableCapAtB4: 96 },
|
||
{ 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, savableCapAtB4: 96 },
|
||
],
|
||
combinedDaysUsed: 50,
|
||
combinedDaysLeft: 190,
|
||
combinedExpiringAtB4: 104,
|
||
},
|
||
})
|
||
)
|
||
|
||
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(
|
||
makeWorkspaceMembersResult({
|
||
data: [],
|
||
isLoading: true,
|
||
})
|
||
)
|
||
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
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, savableCapAtB4: 96 },
|
||
{ 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, savableCapAtB4: 96 },
|
||
],
|
||
combinedDaysUsed: 50,
|
||
combinedDaysLeft: 190,
|
||
combinedExpiringAtB4: 104,
|
||
},
|
||
})
|
||
)
|
||
|
||
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(
|
||
makeAnalyticsResult({
|
||
data: {
|
||
workspaceId: 'ws-1',
|
||
childId: 'c-1',
|
||
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, savableCapAtB4: 96 },
|
||
{ 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, savableCapAtB4: 96 },
|
||
],
|
||
combinedDaysUsed: 50,
|
||
combinedDaysLeft: 190,
|
||
combinedExpiringAtB4: 104,
|
||
},
|
||
})
|
||
)
|
||
|
||
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')
|
||
})
|
||
|
||
describe('manual balance entry — parent picker', () => {
|
||
it('shows a parent picker before the form when the workspace has two members', async () => {
|
||
const user = userEvent.setup()
|
||
renderWithRouter(<Dashboard />)
|
||
|
||
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
|
||
|
||
expect(screen.getByTestId('dashboard-balance-parent-picker')).toBeInTheDocument()
|
||
expect(screen.getByTestId('dashboard-balance-parent-pd-1')).toHaveTextContent('Alex')
|
||
expect(screen.getByTestId('dashboard-balance-parent-pd-2')).toHaveTextContent('Sam')
|
||
})
|
||
|
||
it('opens the form directly, with no picker, for a solo workspace', async () => {
|
||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
data: [{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }],
|
||
})
|
||
)
|
||
const user = userEvent.setup()
|
||
renderWithRouter(<Dashboard />)
|
||
|
||
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
|
||
|
||
expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument()
|
||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||
})
|
||
|
||
it('posts the chosen parent\'s uuid to saveManualFetch', async () => {
|
||
const user = userEvent.setup()
|
||
renderWithRouter(<Dashboard />)
|
||
|
||
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
|
||
await user.click(screen.getByTestId('dashboard-balance-parent-pd-2'))
|
||
expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument()
|
||
|
||
await user.click(await screen.findByRole('button', { name: 'Spara' }))
|
||
|
||
await waitFor(() =>
|
||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||
expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' })
|
||
)
|
||
)
|
||
})
|
||
|
||
it('shows an error when saving a manual balance fails', async () => {
|
||
const user = userEvent.setup()
|
||
vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('save failed'))
|
||
renderWithRouter(<Dashboard />)
|
||
|
||
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
|
||
await user.click(screen.getByTestId('dashboard-balance-parent-pd-1'))
|
||
await user.click(await screen.findByRole('button', { name: 'Spara' }))
|
||
|
||
expect(await screen.findByText('save failed')).toBeInTheDocument()
|
||
})
|
||
|
||
it('prefills the manual-entry form with the parent\'s saved balance', async () => {
|
||
vi.mocked(api.getParentalLeave).mockResolvedValue(
|
||
makeParentalLeaveFetch({
|
||
personalDataUuid: 'pd-2',
|
||
childId: 'c-1',
|
||
dubbeldagarLeft: 7,
|
||
tierData: makeTierData({ sjukpenningLeft: 65, lagstaNivaLeft: 8 }),
|
||
}),
|
||
)
|
||
const user = userEvent.setup()
|
||
renderWithRouter(<Dashboard />)
|
||
|
||
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
|
||
await user.click(screen.getByTestId('dashboard-balance-parent-pd-2'))
|
||
|
||
expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument()
|
||
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('65')
|
||
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('8')
|
||
expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('7')
|
||
})
|
||
|
||
it('falls back to the preset when the parent has no saved balance yet', async () => {
|
||
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
|
||
const user = userEvent.setup()
|
||
renderWithRouter(<Dashboard />)
|
||
|
||
await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ }))
|
||
await user.click(screen.getByTestId('dashboard-balance-parent-pd-1'))
|
||
|
||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||
// Couple workspace -> 240-day preset, split 195:45.
|
||
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('195')
|
||
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('45')
|
||
expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('60')
|
||
})
|
||
})
|
||
})
|