import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { CalendarWeek } from './CalendarWeek' import type { PlannedLeaveDay } from '@/types' import type { CalendarDayProps } from './CalendarDay' import type { CalendarEventProps } from './CalendarEvent' vi.mock('@/i18n', () => ({ useTranslation: () => ({ t: (key: string, _data?: Record) => { const translations: Record = { 'calendar.partner': 'Partner', 'leaveType.vacation': 'Semester', 'leaveType.sjukpenning': 'Sjukpenning', 'leaveType.lagsta': 'Lägsta nivå', } return translations[key] ?? key }, }), })) vi.mock('./CalendarDay', () => ({ CalendarDay: ({ date, isSelected, isCurrentMonth, isToday, onClick, onPointerDown, onPointerEnter, dayType, holidayName, isOverride, overrideIsWorkingDay, }: CalendarDayProps) => (
onPointerDown?.(date)} onPointerEnter={() => onPointerEnter?.(date)} > {date}
), })) vi.mock('./CalendarEvent', () => ({ CalendarEvent: ({ leaveType, isDubbeldag, parentLabel }: CalendarEventProps) => (
{parentLabel} · {leaveType}
), })) describe('CalendarWeek', () => { const defaultProps = { weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'], plannedDays: [], selectedDates: new Set(), parentName: 'Test Parent', personalDataUuid: 'parent-1', isCurrentMonth: true, showWeekNumbers: false, onDayClick: vi.fn(), onPointerDown: vi.fn(), onPointerEnter: vi.fn(), } beforeEach(() => { vi.clearAllMocks() }) describe('week number display', () => { it('shows week number when showWeekNumbers is true', () => { render() // Week containing 2026-01-05 is week 2 expect(screen.getByText(/v\.\d+/)).toBeInTheDocument() }) it('hides week number when showWeekNumbers is false', () => { render() const weekNumberElement = screen.queryByText(/^v\.\d+$/) // The week number is rendered only inside a specific class; when showWeekNumbers is false, // the conditional doesn't render it expect(weekNumberElement).not.toBeInTheDocument() }) it('displays correct week number for a given week', () => { // 2026-01-05 is a Monday in week 2 render() const weekNumberText = screen.getByText('v.2') expect(weekNumberText).toBeInTheDocument() }) }) describe('day rendering', () => { it('renders all 7 days of the week', () => { render() defaultProps.weekDates.forEach((date) => { expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument() }) }) it('renders each day with correct date string', () => { render() defaultProps.weekDates.forEach((date) => { expect(screen.getByText(date)).toBeInTheDocument() }) }) it('marks all days as current month when isCurrentMonth is true', () => { render() defaultProps.weekDates.forEach((date) => { const dayElement = screen.getByTestId(`calendar-day-${date}`) expect(dayElement).toHaveClass('current-month') }) }) it('marks days outside the current month with current-month class set to false', () => { // When a week spans month boundaries, days are marked based on their month vs the first day's month // 2025-12-29 is first, so 12/29-31 are marked as current (Dec), 01/01-04 are marked as outside (Jan) const prevMonthDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] render( , ) // Days from previous month (Dec 2025) should have current-month class (first date determines the month) const decDayElement = screen.getByTestId('calendar-day-2025-12-29') expect(decDayElement).toHaveClass('current-month') // Days from next month should NOT have current-month class const janDayElement = screen.getByTestId('calendar-day-2026-01-01') expect(janDayElement).not.toHaveClass('current-month') }) it('highlights today when date matches current date', () => { const today = new Date() const todayString = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` const weekDates = [ '2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11', ] // Only test if today happens to fall in the week we're rendering if (weekDates.includes(todayString)) { render( , ) const todayElement = screen.getByTestId(`calendar-day-${todayString}`) expect(todayElement).toHaveClass('today') } }) }) describe('selected dates', () => { it('marks selected dates in the week', () => { const selectedDates = new Set(['2026-01-06', '2026-01-07']) render() expect(screen.getByTestId('calendar-day-2026-01-06')).toHaveClass('selected') expect(screen.getByTestId('calendar-day-2026-01-07')).toHaveClass('selected') }) it('does not mark unselected dates', () => { const selectedDates = new Set(['2026-01-06']) render() expect(screen.getByTestId('calendar-day-2026-01-05')).not.toHaveClass('selected') }) it('handles empty selection set', () => { render() defaultProps.weekDates.forEach((date) => { expect(screen.getByTestId(`calendar-day-${date}`)).not.toHaveClass('selected') }) }) }) describe('planned days and bars', () => { it('renders bars for planned days in the week', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() expect(screen.getByTestId('calendar-event')).toBeInTheDocument() }) it('does not render bars for planned days outside the week', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-02-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument() }) it('renders parent name for own planned days', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() }) it('renders "Partner" label for partner planned days', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'other-parent', date: '2026-01-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() expect(screen.getByText(/Partner · Semester/)).toBeInTheDocument() }) it('renders different day types with localized labels', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, { id: '2', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-06', dayType: 'lagsta', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() expect(screen.getByText(/Sjukpenning/)).toBeInTheDocument() expect(screen.getByText(/Lägsta nivå/)).toBeInTheDocument() }) it('marks dubbeldag days with dubbeldag class', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'vacation', isDubbeldag: true, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() const eventElement = screen.getByTestId('calendar-event') expect(eventElement).toHaveClass('dubbeldag') }) it('renders continuous bars spanning multiple days', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', 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-06', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, { id: '3', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-07', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() // Should render at least one bar for the continuous period const events = screen.getAllByTestId('calendar-event') expect(events.length).toBeGreaterThan(0) }) it('renders separate bars for different day types on the same day', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, { id: '2', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'other-parent', date: '2026-01-05', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() const events = screen.getAllByTestId('calendar-event') // Should have separate bars for the two different (parent, dayType) combinations expect(events.length).toBeGreaterThanOrEqual(2) }) it('renders separate bars for same day type but different dubbeldag flag', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', 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-05', dayType: 'vacation', isDubbeldag: true, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() const events = screen.getAllByTestId('calendar-event') 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() }) }) describe('event handlers', () => { it('calls onDayClick when clicking a day in current month', () => { render() const dayElement = screen.getByTestId('calendar-day-2026-01-05') fireEvent.click(dayElement) expect(defaultProps.onDayClick).toHaveBeenCalledWith('2026-01-05') }) it('does not call onDayClick for days outside the week\'s reference month', () => { // When a week spans month boundaries, only days in the same month as the first date // will trigger handlers. 2025-12-29 is first (Dec), so 01/01+ will not trigger handlers. const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] const onDayClick = vi.fn() render( , ) // Click a day outside the reference month (January when December is reference) const janDayElement = screen.getByTestId('calendar-day-2026-01-01') fireEvent.click(janDayElement) expect(onDayClick).not.toHaveBeenCalled() }) it('does not call onDayClick when isCurrentMonth is false', () => { const onDayClick = vi.fn() render( , ) const dayElement = screen.getByTestId('calendar-day-2026-01-05') fireEvent.click(dayElement) expect(onDayClick).not.toHaveBeenCalled() }) it('calls onPointerDown when starting drag on a current-month day', () => { render() const dayElement = screen.getByTestId('calendar-day-2026-01-05') fireEvent.pointerDown(dayElement) expect(defaultProps.onPointerDown).toHaveBeenCalledWith('2026-01-05') }) it('does not call onPointerDown for days outside the week\'s reference month', () => { const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] const onPointerDown = vi.fn() render( , ) const janDayElement = screen.getByTestId('calendar-day-2026-01-01') fireEvent.pointerDown(janDayElement) expect(onPointerDown).not.toHaveBeenCalled() }) it('calls onPointerEnter when dragging through a current-month day', () => { render() const dayElement = screen.getByTestId('calendar-day-2026-01-06') fireEvent.pointerEnter(dayElement) expect(defaultProps.onPointerEnter).toHaveBeenCalledWith('2026-01-06') }) it('does not call onPointerEnter for days outside the week\'s reference month', () => { const weekDatesWithPrevMonth = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] const onPointerEnter = vi.fn() render( , ) const janDayElement = screen.getByTestId('calendar-day-2026-01-01') fireEvent.pointerEnter(janDayElement) expect(onPointerEnter).not.toHaveBeenCalled() }) it('does not call event handlers when isCurrentMonth is false', () => { const onDayClick = vi.fn() const onPointerDown = vi.fn() const onPointerEnter = vi.fn() render( , ) const dayElement = screen.getByTestId('calendar-day-2026-01-05') fireEvent.click(dayElement) fireEvent.pointerDown(dayElement) fireEvent.pointerEnter(dayElement) expect(onDayClick).not.toHaveBeenCalled() expect(onPointerDown).not.toHaveBeenCalled() expect(onPointerEnter).not.toHaveBeenCalled() }) }) describe('complex scenarios', () => { it('handles a week with mixed planned days from both parents', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, { id: '2', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-2', date: '2026-01-05', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, { id: '3', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-06', dayType: 'vacation', isDubbeldag: true, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render( , ) expect(screen.getAllByText(/Alice · Semester/)).toHaveLength(2) expect(screen.getByText(/Partner · Sjukpenning/)).toBeInTheDocument() const dubbeldagElements = screen.getAllByTestId('calendar-event').filter((el) => el.classList.contains('dubbeldag')) expect(dubbeldagElements.length).toBeGreaterThan(0) }) it('handles broken bar rendering with gaps in consecutive days', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', 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-06', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, // Gap on 2026-01-07 { id: '3', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-08', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() const events = screen.getAllByTestId('calendar-event') // Should have at least 2 bars: one for 05-06 and one for 08 expect(events.length).toBeGreaterThanOrEqual(2) }) it('renders all day types with correct colors', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', 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-06', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, { id: '3', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-07', dayType: 'lagsta', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() const events = screen.getAllByTestId('calendar-event') expect(events.length).toBeGreaterThanOrEqual(3) }) it('filters bars to only show those spanning the current day cell', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', 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-06', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] render() // Should render bars for both 2026-01-05 and 2026-01-06 // The bars represent a continuous period, so they should be visible const events = screen.getAllByTestId('calendar-event') expect(events.length).toBeGreaterThan(0) }) }) describe('month boundary edge cases', () => { it('correctly identifies days at month boundaries', () => { // Jan 31 and Feb 1 in the same week const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01'] render( , ) // Jan 31 (index 5) should be in current month const jan31 = screen.getByTestId('calendar-day-2026-01-31') expect(jan31).toHaveClass('current-month') // Feb 1 (index 6) should NOT be in current month (isCurrentMonth only marks first week's month ref) const feb1 = screen.getByTestId('calendar-day-2026-02-01') expect(feb1).not.toHaveClass('current-month') }) it('handles start of month week', () => { const weekDates = ['2026-01-26', '2026-01-27', '2026-01-28', '2026-01-29', '2026-01-30', '2026-01-31', '2026-02-01'] render( , ) // All dates should render weekDates.forEach((date) => { expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument() }) }) it('handles end of month week', () => { const weekDates = ['2025-12-29', '2025-12-30', '2025-12-31', '2026-01-01', '2026-01-02', '2026-01-03', '2026-01-04'] render( , ) // All dates should render weekDates.forEach((date) => { expect(screen.getByTestId(`calendar-day-${date}`)).toBeInTheDocument() }) }) }) describe('memoization and performance', () => { it('memoizes bar computation based on weekDates and plannedDays', () => { const plannedDays: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] const { rerender } = render( , ) expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() // Change unrelated prop (onDayClick) - bars should remain the same const newOnDayClick = vi.fn() rerender( , ) expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() }) it('recomputes bars when plannedDays change', () => { const plannedDays1: PlannedLeaveDay[] = [ { id: '1', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-05', dayType: 'vacation', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] const plannedDays2: PlannedLeaveDay[] = [ ...plannedDays1, { id: '2', workspaceId: 'ws-1', childId: 'child-1', parentPersonalDataUuid: 'parent-1', date: '2026-01-06', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '2026-01-01', updatedAt: '2026-01-01', }, ] const { rerender } = render( , ) expect(screen.getByText(/Alice · Semester/)).toBeInTheDocument() expect(screen.queryByText(/Sjukpenning/)).not.toBeInTheDocument() rerender( , ) expect(screen.getByText(/Sjukpenning/)).toBeInTheDocument() }) }) describe('§3.4/§3.6 calendarDaysByDate pass-through', () => { it('passes matching calendar metadata down to the CalendarDay for that date', () => { const calendarDaysByDate = new Map([ [ '2026-01-06', { date: '2026-01-06', name: 'Trettondedag jul', dayType: 'red_day' as const, isOverride: false, overrideIsWorkingDay: null, isFree: true, }, ], ]) render() const cell = screen.getByTestId('calendar-day-2026-01-06') expect(cell).toHaveAttribute('data-day-type', 'red_day') expect(cell).toHaveAttribute('data-holiday-name', 'Trettondedag jul') expect(cell).toHaveAttribute('data-is-override', 'false') }) it('passes override metadata down for an overridden date', () => { const calendarDaysByDate = new Map([ [ '2026-01-07', { date: '2026-01-07', name: null, dayType: 'working_day' as const, isOverride: true, overrideIsWorkingDay: false, isFree: true, }, ], ]) render() const cell = screen.getByTestId('calendar-day-2026-01-07') expect(cell).toHaveAttribute('data-is-override', 'true') expect(cell).toHaveAttribute('data-override-is-working-day', 'false') }) it('leaves days with no matching entry unaffected when calendarDaysByDate is omitted', () => { render() const cell = screen.getByTestId('calendar-day-2026-01-05') expect(cell).toHaveAttribute('data-day-type', '') expect(cell).toHaveAttribute('data-is-override', 'false') }) }) })