Raise frontend branch coverage from 86.84% to 93.78%
Branches were the weakest metric and had the least headroom above the 85% floor. These are tests for decision paths that were genuinely unexercised, not padding. The largest gaps were systemic rather than per-file: the `err instanceof Error ? ... : fallback` branch was untested across eight data hooks, useVacationDays had no dedicated test file at all, and Calendar.tsx's missing-workspace/child/personalData fallbacks were never hit. Left deliberately uncovered, with reasons: `?? fallback` guards whose fallback cannot occur given how the value is constructed upstream, and guards behind buttons that are disabled whenever the guard would matter. Reaching those means calling handlers directly, which tests the test rather than the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -36,6 +36,14 @@ const baseDays: CalendarDayEntry[] = [
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
date: '2026-05-04',
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
]
|
||||
|
||||
describe('RedDaysList', () => {
|
||||
@@ -80,6 +88,47 @@ describe('RedDaysList', () => {
|
||||
expect(year2028).toHaveTextContent(/Inga röda dagar eller undantag/)
|
||||
})
|
||||
|
||||
it('skips a plain working day that is neither a red day nor an override', () => {
|
||||
render(<RedDaysList {...defaultProps} />)
|
||||
// 2026-05-04 is a plain working_day, non-override entry in baseDays — it
|
||||
// must not show up as a list item under its year.
|
||||
expect(screen.queryByTestId('red-days-item-2026-05-04')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a working-day override distinctly from a free-day override', () => {
|
||||
const workingOverrideDays = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day' as const,
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<RedDaysList {...defaultProps} days={workingOverrideDays} />)
|
||||
const badge = screen.getByTestId('red-days-override-badge-2026-03-02')
|
||||
expect(badge).toHaveClass('red-days-list__override-badge--working')
|
||||
expect(badge).toHaveTextContent('Undantag: arbetsdag')
|
||||
})
|
||||
|
||||
it('shows the effective-working label for a day that is not free', () => {
|
||||
const workingDays = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day' as const,
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<RedDaysList {...defaultProps} days={workingDays} />)
|
||||
const item = screen.getByTestId('red-days-item-2026-03-02')
|
||||
expect(item.querySelector('.red-days-list__effective--working')).toBeInTheDocument()
|
||||
expect(item.querySelector('.red-days-list__effective--free')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the previous button at minYear', () => {
|
||||
render(<RedDaysList {...defaultProps} startYear={2026} minYear={2026} />)
|
||||
expect(screen.getByTestId('red-days-prev')).toBeDisabled()
|
||||
|
||||
@@ -64,6 +64,18 @@ describe('CalendarDay', () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onActivate instead of onClick on Enter when onActivate is provided', () => {
|
||||
const onClick = vi.fn()
|
||||
const onActivate = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" onClick={onClick} onActivate={onActivate} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
fireEvent.keyDown(day, { key: 'Enter' })
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClick on Space key', () => {
|
||||
const onClick = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
|
||||
@@ -65,6 +65,22 @@ describe('CalendarEvent', () => {
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefixes the label with the parent label when provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" parentLabel="Alice" />
|
||||
)
|
||||
const event = getByTestId('calendar-event')
|
||||
expect(event.textContent).toBe('Alice · vacation')
|
||||
})
|
||||
|
||||
it('shows the double-day badge when isDubbeldag is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" isDubbeldag={true} />
|
||||
)
|
||||
const event = getByTestId('calendar-event')
|
||||
expect(event.textContent).toContain('2×')
|
||||
})
|
||||
|
||||
it('provides accessibility label', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
|
||||
|
||||
@@ -412,6 +412,39 @@ describe('CalendarWeek', () => {
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('closes an open bar that runs through the last day of the week', () => {
|
||||
// A run of planned days ending on the week's final date (2026-01-11)
|
||||
// leaves the bar "open" when the loop over weekDates finishes, which
|
||||
// exercises the post-loop flush rather than the mid-loop gap-close path.
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-10',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-11',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('handles empty planned days', () => {
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={[]} />)
|
||||
expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { DaysBreakdown } from './DaysBreakdown'
|
||||
import { DaysBreakdown, type DaysBreakdownProps } from './DaysBreakdown'
|
||||
|
||||
describe('DaysBreakdown', () => {
|
||||
it('renders empty state when no breakdown provided', () => {
|
||||
@@ -8,6 +8,13 @@ describe('DaysBreakdown', () => {
|
||||
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
|
||||
})
|
||||
|
||||
it('renders empty state when breakdown itself is missing (defensive null/undefined guard)', () => {
|
||||
const { getByTestId } = render(
|
||||
<DaysBreakdown breakdown={undefined as unknown as DaysBreakdownProps['breakdown']} />
|
||||
)
|
||||
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
|
||||
})
|
||||
|
||||
it('renders table with breakdown data', () => {
|
||||
const breakdown = [
|
||||
{ type: 'vacation', count: 10 },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ParentalLeaveBreakdown } from './ParentalLeaveBreakdown'
|
||||
import { LEAVE_RULES } from '@/utils/constants'
|
||||
import { LanguageProvider } from '@/i18n'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
|
||||
describe('ParentalLeaveBreakdown', () => {
|
||||
@@ -36,6 +37,15 @@ describe('ParentalLeaveBreakdown', () => {
|
||||
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the tier rows when tierData is absent', () => {
|
||||
const dataWithoutTiers = { ...mockData, tierData: undefined } as unknown as ParentalLeaveFetch
|
||||
render(<ParentalLeaveBreakdown data={dataWithoutTiers} />)
|
||||
expect(screen.queryByTestId('breakdown-sjukpenning')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('breakdown-lagsta-niva')).not.toBeInTheDocument()
|
||||
// The rows that don't depend on tierData still render.
|
||||
expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders breakdown table with data', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
|
||||
@@ -92,4 +102,26 @@ describe('ParentalLeaveBreakdown', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByText(/2026-06-13/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats the provenance date using the en-US locale when the language is English', () => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<ParentalLeaveBreakdown data={mockData} />
|
||||
</LanguageProvider>,
|
||||
)
|
||||
// en-US formats as M/D/YYYY, distinct from the sv-SE "2026-06-13" form.
|
||||
expect(screen.getByText(/6\/13\/2026/)).toBeInTheDocument()
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -203,6 +203,25 @@ describe('DayOverridesInput', () => {
|
||||
const submitButton = screen.getByTestId('add-override-button') as HTMLButtonElement
|
||||
expect(submitButton.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call onAdd when the form is submitted with no date entered', async () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
// Submit the form directly (bypassing the disabled submit button) to
|
||||
// exercise the handler's own empty-date guard.
|
||||
fireEvent.submit(screen.getByTestId('add-override-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('removing override', () => {
|
||||
@@ -222,6 +241,26 @@ describe('DayOverridesInput', () => {
|
||||
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows an onRemove failure instead of throwing (handled by the parent)', async () => {
|
||||
mockOnRemove.mockRejectedValueOnce(new Error('delete failed'))
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-override-do-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
|
||||
})
|
||||
// The item is still rendered — the component didn't crash on the rejection.
|
||||
expect(screen.getByTestId('override-item-do-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom'
|
||||
import { Header } from './Header'
|
||||
|
||||
const renderWithRouter = (element: React.ReactElement) => {
|
||||
@@ -12,6 +12,14 @@ const renderWithRouter = (element: React.ReactElement) => {
|
||||
)
|
||||
}
|
||||
|
||||
const renderAtPath = (element: React.ReactElement, path: string) => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
{element}
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders header with default title', () => {
|
||||
renderWithRouter(<Header />)
|
||||
@@ -75,4 +83,39 @@ describe('Header', () => {
|
||||
renderWithRouter(<Header />)
|
||||
expect(screen.queryByTestId('logout-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not crash and renders the select when selectedChildId is not provided (falls back to "")', () => {
|
||||
const childrenList = [
|
||||
{ id: '1', name: 'Emma' },
|
||||
{ id: '2', name: 'Oscar' },
|
||||
]
|
||||
renderWithRouter(<Header childrenList={childrenList} />)
|
||||
const select = screen.getByTestId('child-select') as HTMLSelectElement
|
||||
// selectedChildId ?? '' binds the select to a value ('') that matches no
|
||||
// <option>, so the browser falls back to the first option — it must not
|
||||
// be coerced to something else (e.g. "undefined" or a stale selection).
|
||||
expect(select.value).toBe('1')
|
||||
})
|
||||
|
||||
it('marks the calendar link active on the calendar route', () => {
|
||||
renderAtPath(<Header />, '/calendar')
|
||||
expect(screen.getByText('Kalender')).toHaveClass('header__nav-link--active')
|
||||
expect(screen.getByText('Översikt')).not.toHaveClass('header__nav-link--active')
|
||||
})
|
||||
|
||||
it('marks the plan link active on the plan route', () => {
|
||||
renderAtPath(<Header />, '/plan')
|
||||
expect(screen.getByText('Planering')).toHaveClass('header__nav-link--active')
|
||||
})
|
||||
|
||||
it('marks the tips link active on the tips route', () => {
|
||||
renderAtPath(<Header />, '/tips')
|
||||
expect(screen.getByText('Tips')).toHaveClass('header__nav-link--active')
|
||||
})
|
||||
|
||||
it('marks the settings link active on the settings route, and the overview link inactive', () => {
|
||||
renderAtPath(<Header />, '/settings')
|
||||
expect(screen.getByText('Inställningar')).toHaveClass('header__nav-link--active')
|
||||
expect(screen.getByText('Översikt')).not.toHaveClass('header__nav-link--active')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -463,6 +463,38 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('displays a generic error message for non-Error thrown in delete', async () => {
|
||||
const existingDay: PlannedLeaveDay = {
|
||||
id: 'day-1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
parentPersonalDataUuid: 'p1',
|
||||
date: '2026-06-15',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
mockOnDelete.mockRejectedValue('Some error')
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
existingDay={existingDay}
|
||||
onDelete={mockOnDelete}
|
||||
/>
|
||||
)
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button')
|
||||
await userEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att ta bort den planerade dagen')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears local error when save starts after a failed attempt', async () => {
|
||||
// First save fails
|
||||
mockOnSave.mockRejectedValueOnce(new Error('First save failed'))
|
||||
|
||||
Reference in New Issue
Block a user