From c5481a05f034cc3abd2d2e0dc38f3df161b9a0d2 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 14 Sep 2026 18:54:56 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- .../components/calendar/RedDaysList.test.tsx | 49 +++ .../components/common/CalendarDay.test.tsx | 12 + .../components/common/CalendarEvent.test.tsx | 16 + .../components/common/CalendarWeek.test.tsx | 33 ++ .../dashboard/DaysBreakdown.test.tsx | 9 +- .../dashboard/ParentalLeaveBreakdown.test.tsx | 34 +- .../inputs/DayOverridesInput.test.tsx | 39 ++ .../src/components/layout/Header.test.tsx | 45 ++- .../modals/PlannedDayModal.test.tsx | 32 ++ frontend/src/hooks/useAnalytics.test.ts | 13 + .../src/hooks/useCalendarPreferences.test.ts | 6 + frontend/src/hooks/useDayOverrides.test.ts | 9 + .../src/hooks/useOptimizationTips.test.ts | 12 + frontend/src/hooks/useParentalLeave.test.ts | 12 + frontend/src/hooks/usePlanApply.test.ts | 27 ++ frontend/src/hooks/usePlanProposal.test.ts | 13 + frontend/src/hooks/useScraperFetch.test.ts | 165 ++++++++ frontend/src/hooks/useVacationDays.test.ts | 84 +++++ .../src/hooks/useWorkspaceMembers.test.ts | 12 + frontend/src/pages/Calendar.test.tsx | 351 +++++++++++++++++- frontend/src/pages/DevFetch.test.tsx | 17 + frontend/src/pages/Tips.test.tsx | 13 + frontend/tests/unit/utils/dates.test.ts | 65 ++++ 23 files changed, 1063 insertions(+), 5 deletions(-) create mode 100644 frontend/src/hooks/useVacationDays.test.ts diff --git a/frontend/src/components/calendar/RedDaysList.test.tsx b/frontend/src/components/calendar/RedDaysList.test.tsx index 36fd9fe..e59a73f 100644 --- a/frontend/src/components/calendar/RedDaysList.test.tsx +++ b/frontend/src/components/calendar/RedDaysList.test.tsx @@ -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() + // 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() + 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() + 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() expect(screen.getByTestId('red-days-prev')).toBeDisabled() diff --git a/frontend/src/components/common/CalendarDay.test.tsx b/frontend/src/components/common/CalendarDay.test.tsx index 3572812..b58ae1a 100644 --- a/frontend/src/components/common/CalendarDay.test.tsx +++ b/frontend/src/components/common/CalendarDay.test.tsx @@ -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( + + ) + 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( diff --git a/frontend/src/components/common/CalendarEvent.test.tsx b/frontend/src/components/common/CalendarEvent.test.tsx index e2aeec8..a416722 100644 --- a/frontend/src/components/common/CalendarEvent.test.tsx +++ b/frontend/src/components/common/CalendarEvent.test.tsx @@ -65,6 +65,22 @@ describe('CalendarEvent', () => { expect(stopPropagation).toHaveBeenCalled() }) + it('prefixes the label with the parent label when provided', () => { + const { getByTestId } = render( + + ) + const event = getByTestId('calendar-event') + expect(event.textContent).toBe('Alice · vacation') + }) + + it('shows the double-day badge when isDubbeldag is true', () => { + const { getByTestId } = render( + + ) + const event = getByTestId('calendar-event') + expect(event.textContent).toContain('2×') + }) + it('provides accessibility label', () => { const { getByTestId } = render( {}} /> diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx index 39aa076..c870f5a 100644 --- a/frontend/src/components/common/CalendarWeek.test.tsx +++ b/frontend/src/components/common/CalendarWeek.test.tsx @@ -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() + const events = screen.getAllByTestId('calendar-event') + expect(events.length).toBeGreaterThan(0) + }) + it('handles empty planned days', () => { render() expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument() diff --git a/frontend/src/components/dashboard/DaysBreakdown.test.tsx b/frontend/src/components/dashboard/DaysBreakdown.test.tsx index 7bcd257..0f64bfa 100644 --- a/frontend/src/components/dashboard/DaysBreakdown.test.tsx +++ b/frontend/src/components/dashboard/DaysBreakdown.test.tsx @@ -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( + + ) + expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än') + }) + it('renders table with breakdown data', () => { const breakdown = [ { type: 'vacation', count: 10 }, diff --git a/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx b/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx index de7e470..e98d30e 100644 --- a/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx +++ b/frontend/src/components/dashboard/ParentalLeaveBreakdown.test.tsx @@ -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() + 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() expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument() @@ -92,4 +102,26 @@ describe('ParentalLeaveBreakdown', () => { render() 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( + + + , + ) + // 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() + } + }) }) diff --git a/frontend/src/components/inputs/DayOverridesInput.test.tsx b/frontend/src/components/inputs/DayOverridesInput.test.tsx index cb195d8..2f9d165 100644 --- a/frontend/src/components/inputs/DayOverridesInput.test.tsx +++ b/frontend/src/components/inputs/DayOverridesInput.test.tsx @@ -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( + + ) + + // 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( + + ) + + 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', () => { diff --git a/frontend/src/components/layout/Header.test.tsx b/frontend/src/components/layout/Header.test.tsx index 831257c..63d4d3d 100644 --- a/frontend/src/components/layout/Header.test.tsx +++ b/frontend/src/components/layout/Header.test.tsx @@ -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( + + {element} + , + ) +} + describe('Header', () => { it('renders header with default title', () => { renderWithRouter(
) @@ -75,4 +83,39 @@ describe('Header', () => { renderWithRouter(
) 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(
) + const select = screen.getByTestId('child-select') as HTMLSelectElement + // selectedChildId ?? '' binds the select to a value ('') that matches no + //