import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { Calendar } from './Calendar' import { useWorkspace } from '@/context' import { usePlannedDays, useCalendarDays, useDayOverrides, useShowWeekNumbers, getLastLeaveType, useLeaveWishes, useWorkspaceMembers, usePlanSync, } from '@/hooks' import { LanguageProvider } from '@/i18n' import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal' import type { PeriodPopoverProps, PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover' import type { PresetRailProps, SeasonPreset } from '@/components/calendar/PresetRail' import { makeWorkspaceContextValue, makePlannedDaysResult, makeCalendarDaysResult, makeDayOverridesResult, makeShowWeekNumbersTuple, makePlannedLeaveDay, makeLeaveWishesResult, makeWorkspaceMembersResult, makePlanSyncResult, makeLeaveWish, makePersonalData, makePlanSyncResponse, makePlanChange, makeWindowOutcome, makeRejection, makePlanProposal, makePlanApplyResponse, } from '../../tests/fixtures' import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar' vi.mock('@/context', () => ({ useWorkspace: vi.fn(), })) vi.mock('@/hooks', () => ({ usePlannedDays: vi.fn(), useShowWeekNumbers: vi.fn(() => [false]), getLastLeaveType: vi.fn(() => 'vacation'), setLastLeaveType: vi.fn(), useCalendarDays: vi.fn(), useDayOverrides: vi.fn(), useLeaveWishes: vi.fn(), useWorkspaceMembers: vi.fn(), usePlanSync: vi.fn(), })) vi.mock('@/components/layout/Layout', () => ({ Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) vi.mock('@/components/common/LoadingSpinner', () => ({ LoadingSpinner: () =>
Loading...
, })) vi.mock('@/components/modals/PlannedDayModal', () => ({ // Exposes the props this test suite cares about as data attributes/buttons // so tests can assert on Calendar.tsx's wiring without rendering the real // modal (that component has its own exhaustive test suite). PlannedDayModal: (props: PlannedDayModalProps) => (
{props.onDelete && ( )} {props.onToggleOverride && ( )}
), })) vi.mock('@/components/calendar/PeriodPopover', () => ({ PeriodPopover: (props: PeriodPopoverProps) => (
), })) const SOMMAR_PRESET: SeasonPreset = { key: 'season-sommar', labelKey: 'wishEditor.seasonSommar', startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, defaultWeeks: 6, } vi.mock('@/components/calendar/PresetRail', () => ({ PresetRail: (props: PresetRailProps) => (
), })) describe('Calendar', () => { beforeEach(() => { vi.clearAllMocks() }) const mockWorkspace = makeWorkspaceContextValue({ selectedChildId: 'child-1', personalDataUuid: 'person-1', }) const mockPlannedDays = makePlannedDaysResult() const mockCalendarWindow = makeCalendarDaysResult() const mockDayOverrides = makeDayOverridesResult() const mockLeaveWishes = makeLeaveWishesResult() const mockMembers = makeWorkspaceMembersResult() const mockSync = makePlanSyncResult() beforeEach(() => { vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow) vi.mocked(useDayOverrides).mockReturnValue(mockDayOverrides) vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes) vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers) vi.mocked(usePlanSync).mockReturnValue(mockSync) vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) }) function todayDateString(): string { const today = new Date() const year = today.getFullYear() const month = String(today.getMonth() + 1).padStart(2, '0') const day = String(today.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } it('renders loading state while data is loading', () => { vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspace, isLoading: true }) render() expect(screen.getByText('Loading...')).toBeInTheDocument() }) it('renders 7 weekday headers in Monday-first Swedish order', () => { render() const weekdays = screen.getAllByText(/^(Mån|Tis|Ons|Tor|Fre|Lör|Sön)$/) expect(weekdays).toHaveLength(7) const weekdayOrder = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'] weekdays.forEach((el, idx) => { expect(el.textContent).toBe(weekdayOrder[idx]) }) }) it('renders day cells for all weeks in a month', () => { render() const calendarDays = screen.getAllByTestId('calendar-day') expect(calendarDays.length).toBeGreaterThan(28) expect(calendarDays.length % 7).toBe(0) }) it('opens the day popover when clicking a current-month day', () => { render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) if (currentMonthDay) { fireEvent.click(currentMonthDay) expect(screen.getByTestId('modal')).toBeInTheDocument() } }) it('does not open the day popover when clicking disabled (non-current-month) day', () => { render() const calendarDays = screen.getAllByTestId('calendar-day') const disabledDay = calendarDays.find((el) => el.classList.contains('calendar-day--disabled')) if (disabledDay) { fireEvent.click(disabledDay) expect(disabledDay).toHaveClass('calendar-day--disabled') } }) it('displays current month title', () => { render() const title = screen.getByRole('heading', { level: 2 }) expect(title.textContent).toMatch(/\d{4}/) }) it('handles next/prev month navigation', () => { render() fireEvent.click(screen.getByLabelText('Nästa månad')) fireEvent.click(screen.getByLabelText('Föregående månad')) expect(screen.getByTestId('calendar')).toBeInTheDocument() }) it('highlights date range live while dragging, before pointerup', () => { render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) expect(currentMonthDays.length).toBeGreaterThanOrEqual(2) const startDay = currentMonthDays[0] as HTMLElement const endDay = currentMonthDays[3] as HTMLElement fireEvent.pointerDown(startDay) fireEvent.pointerEnter(endDay) const selectedDaysInRange = Array.from(calendarDays).filter((el) => el.classList.contains('calendar-day--selected')) expect(selectedDaysInRange.length).toBeGreaterThan(0) }) it('jumps forward/backward via month and year dropdowns', () => { render() const title = screen.getByRole('heading', { level: 2 }) const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement fireEvent.change(monthSelect, { target: { value: '5' } }) expect(monthSelect.value).toBe('5') fireEvent.change(yearSelect, { target: { value: '2035' } }) expect(yearSelect.value).toBe('2035') }) it('year dropdown is derived from the calendar window\'s minYear/maxYear', () => { vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }) }), ) render() const title = screen.getByRole('heading', { level: 2 }) const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value)) expect(Math.min(...options)).toBe(2026) expect(Math.max(...options)).toBe(2055) }) it('always includes the current year even before the calendar window has loaded', () => { vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true })) render() const title = screen.getByRole('heading', { level: 2 }) const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value)) expect(options).toContain(new Date().getFullYear()) }) describe('§3.4 red day / klämdag shading', () => { it('shades a statutory red day and shows its name', () => { const today = todayDateString() vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, name: 'Påskdagen; Söndag', dayType: 'red_day', isFree: true })], }), }), ) render() const redDay = document.querySelector('.calendar-day--red-day') expect(redDay).toBeInTheDocument() expect(redDay).toHaveAttribute('title', 'Påskdagen') }) }) describe('§Task7 day popover replaces the override toolbar', () => { it('does not render an override-mode toggle in the toolbar', () => { render() expect(screen.queryByTestId('calendar-override-toggle')).not.toBeInTheDocument() expect(screen.queryByTestId('calendar-override-hint')).not.toBeInTheDocument() }) it('passes the clicked date\'s calendar metadata as dayInfo to the day popover', () => { const today = todayDateString() vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, isFree: true })] }), }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) expect(screen.getByTestId('modal')).toHaveAttribute('data-day-info', 'yes') }) it('toggling the day popover\'s override switch marks a free day as a working day', async () => { const today = todayDateString() const upsert = vi.fn().mockResolvedValue(undefined) const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined) vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }), refetch: refetchCalendarWindow, }), ) vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() })) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-toggle-override')) await waitFor(() => expect(upsert).toHaveBeenCalledWith({ workspaceId: 'ws-1', date: today, isWorkingDay: true, note: null }), ) await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalled()) }) it('removes an existing override when toggled again', async () => { const today = todayDateString() const removeOverride = vi.fn().mockResolvedValue(undefined) vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })], }), }), ) vi.mocked(useDayOverrides).mockReturnValue( makeDayOverridesResult({ data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null, createdAt: null, updatedAt: null }], upsert: vi.fn(), remove: removeOverride, }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-toggle-override')) await waitFor(() => expect(removeOverride).toHaveBeenCalledWith('ov-1')) }) it('surfaces an override failure through the modal\'s overrideError prop', async () => { const today = todayDateString() const upsert = vi.fn().mockRejectedValue(new Error('quota exceeded')) vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }), }), ) vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() })) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-toggle-override')) await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', 'quota exceeded')) }) }) describe('§Task7 saving a day pins it', () => { it('pins a brand-new day created through the day popover', async () => { const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) fireEvent.click(screen.getByTestId('modal-save')) await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }))) }) it('pins an existing day edited through the day popover', async () => { const today = todayDateString() const update = vi.fn().mockResolvedValue(makePlannedLeaveDay()) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })], update, }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-save')) await waitFor(() => expect(update).toHaveBeenCalledWith('pd-1', expect.objectContaining({ is_pinned: true })), ) }) it('shows a generic error when saving a day rejects with a non-Error value', async () => { const add = vi.fn().mockRejectedValue('boom') vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) fireEvent.click(screen.getByTestId('modal-save')) // The mocked modal doesn't surface `error`, but the save must not // crash and must leave the modal open with the day still selected. await waitFor(() => expect(add).toHaveBeenCalled()) expect(screen.getByTestId('modal')).toBeInTheDocument() }) it('shows a generic error when deleting a day rejects with a non-Error value', async () => { const today = todayDateString() const remove = vi.fn().mockRejectedValue('boom') vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })], remove, }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-delete')) await waitFor(() => expect(remove).toHaveBeenCalled()) expect(screen.getByTestId('modal')).toBeInTheDocument() }) }) describe('§Task7 fixing the hardcoded single parent', () => { it('feeds every workspace member into the day popover\'s parents list', () => { vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Alex' }), makePersonalData({ uuid: 'pd-b', name: 'Sam' })], }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) expect(screen.getByTestId('modal')).toHaveAttribute('data-parents-count', '2') }) it('falls back to the logged-in parent alone while members have not loaded', () => { vi.mocked(useWorkspaceMembers).mockReturnValue(makeWorkspaceMembersResult({ data: [] })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) expect(screen.getByTestId('modal')).toHaveAttribute('data-parents-count', '1') }) }) describe('§Task7 drag-select creates a period', () => { it('creates a fixed, once wish covering the dragged range and syncs', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) const startDay = currentMonthDays[0] as HTMLElement const endDay = currentMonthDays[3] as HTMLElement fireEvent.pointerDown(startDay) fireEvent.pointerEnter(endDay) fireEvent.pointerUp(window) await waitFor(() => expect(add).toHaveBeenCalledWith( expect.objectContaining({ recurrence: 'once', windowMode: 'fixed', parentMode: 'specific', parentPersonalDataUuid: 'person-1', }), ), ) // No day popover opens for a multi-day drag — the period is created directly. expect(screen.queryByTestId('modal')).not.toBeInTheDocument() }) it('does not open the day popover for a single-day drag (falls back to the normal click path)', () => { render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))! fireEvent.pointerDown(currentMonthDay) fireEvent.pointerUp(window) expect(screen.getByTestId('modal')).toBeInTheDocument() }) it('shows an error toast when creating the period fails', async () => { const add = vi.fn().mockRejectedValue(new Error('validation failed')) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('validation failed')) }) }) describe('§Task7 band click opens the period popover', () => { it('opens the period popover with the resolved wish when a wish-linked band is clicked', () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() const band = screen.getAllByTestId(/^calendar-band-/)[0]! fireEvent.click(band) expect(screen.getByTestId('period-popover')).toHaveAttribute('data-wish-id', 'lw-1') }) it('opens the period popover with a null wish for an ad-hoc band', () => { const today = todayDateString() vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], }), ) render() const band = screen.getAllByTestId(/^calendar-band-/)[0]! fireEvent.click(band) expect(screen.getByTestId('period-popover')).toHaveAttribute('data-wish-id', 'null') }) it('saving a period edit updates the wish and re-syncs', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) const update = vi.fn().mockResolvedValue(wish) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-save')) await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ name: 'Edited name' }))) await waitFor(() => expect(sync).toHaveBeenCalled()) expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument() }) it('deleting a wish-linked period removes the wish and re-syncs', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) const remove = vi.fn().mockResolvedValue(undefined) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete')) await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1')) await waitFor(() => expect(sync).toHaveBeenCalled()) }) it('undo after deleting a wish-linked period recreates it and re-syncs', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' }) const remove = vi.fn().mockResolvedValue(undefined) const add = vi.fn().mockResolvedValue(wish) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete')) const undoButton = await screen.findByTestId('calendar-toast-undo') fireEvent.click(undoButton) await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sommar' }))) }) it('deleting a wish-linked period no longer present locally shows a toast with no undo', async () => { const today = todayDateString() const remove = vi.fn().mockResolvedValue(undefined) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }), ) // The wish is gone from the local list by the time delete runs (e.g. a // concurrent refresh) — there is nothing to offer as an undo, even // though the sync still reports (and toasts) a real change. vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [], remove })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete')) await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1')) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toBeInTheDocument()) expect(screen.queryByTestId('calendar-toast-undo')).not.toBeInTheDocument() }) it('deleting an ad-hoc period removes the underlying planned days directly (no sync)', async () => { const today = todayDateString() const remove = vi.fn().mockResolvedValue(undefined) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], remove, }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete')) await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1')) // Sync was still called once, from the initial auto-sync on mount — // but not a second time for this ad-hoc delete (it isn't wish-driven). expect(sync).toHaveBeenCalledTimes(1) }) it('closes the popover on close/cancel', () => { const today = todayDateString() vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) expect(screen.getByTestId('period-popover')).toBeInTheDocument() fireEvent.click(screen.getByTestId('period-close')) expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument() }) }) describe('§Task7 preset rail', () => { it('creates the selected preset as a flexible, yearly wish', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() fireEvent.click(screen.getByTestId('preset-click')) await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ windowMode: 'flexible', recurrence: 'yearly' })), ) await waitFor(() => expect(sync).toHaveBeenCalled()) }) it('navigates the month view to the resolved placement and flashes the band', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset', date: '2030-07-10' })], }), ) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() fireEvent.click(screen.getByTestId('preset-click')) await waitFor(() => { const title = screen.getByRole('heading', { level: 2 }) const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement expect(yearSelect.value).toBe('2030') }) }) it('shows an error toast when creating the preset fails', async () => { const add = vi.fn().mockRejectedValue(new Error('no priority left')) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) render() fireEvent.click(screen.getByTestId('preset-click')) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('no priority left')) }) }) describe('§Task7 sync toast + undo', () => { it('shows a toast summarizing what changed after a sync with changes', async () => { const created = makeLeaveWish({ id: 'lw-new', name: 'Sommar' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' }), makePlanChange({ newWishId: 'lw-new' })] }), ) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Sommar')) expect(screen.getByTestId('calendar-toast')).toHaveTextContent('2') }) it('shows no toast when the sync produces no changes', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) await waitFor(() => expect(add).toHaveBeenCalled()) expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) it('explains a fully-starved sync (no changes, nothing funded) instead of showing nothing', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [], proposal: makePlanProposal({ outcomes: [ makeWindowOutcome({ wishId: 'lw-new', startDate: '2026-06-01', endDate: '2026-06-14', weeksWished: 2, weeksFunded: 0, shortfallDays: 10, data: { wish_name: 'Sommar', wish_name_message_key: null }, }), ], }), }), ) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) const toast = await screen.findByTestId('calendar-toast') expect(toast).toHaveTextContent('Sommar') expect(toast).toHaveTextContent('10') }) it('shows no toast when a sync with no changes also has no outcome for the touched wish (genuine no-op)', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [], proposal: makePlanProposal({ outcomes: [] }), }), ) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) await waitFor(() => expect(add).toHaveBeenCalled()) expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) it('reports both what changed and what fell short on a partially-funded sync', async () => { const created = makeLeaveWish({ id: 'lw-new', name: 'Sommar' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })], proposal: makePlanProposal({ outcomes: [ makeWindowOutcome({ wishId: 'lw-new', startDate: '2026-06-01', endDate: '2026-06-14', weeksWished: 2, weeksFunded: 1, shortfallDays: 5, data: { wish_name: 'Sommar', wish_name_message_key: null }, }), ], }), }), ) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) const toast = await screen.findByTestId('calendar-toast') // What changed (existing "N day(s) changed" summary) ... expect(toast).toHaveTextContent('ändrades') // ... and what fell short. expect(toast).toHaveTextContent('5') expect(toast).toHaveTextContent('saknas') }) it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [], applyResult: makePlanApplyResponse({ accepted: false, rejections: [ makeRejection({ messageKey: 'planApply.rejection.insufficientVacationBalance', data: { parent_personal_data_uuid: 'pd-a', year: 2026, needed: 5, remaining: 2 }, }), ], }), }), ) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) const toast = await screen.findByTestId('calendar-toast') expect(toast).toHaveTextContent('Förälder A') expect(toast.textContent).not.toContain('pd-a') }) it('undo re-syncs after reverting a period edit to its previous state', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1', name: 'Sommar' }) const update = vi.fn().mockResolvedValue(wish) // Every call (including the initial auto-sync on mount, which is not // under test here) reports the same non-empty diff — the point of // this test is the UNDO's re-sync, not what any particular sync call // returns. const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() // Let the initial auto-sync (unrelated to this test) settle first. await waitFor(() => expect(sync).toHaveBeenCalledTimes(1)) fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-save')) const undoButton = await screen.findByTestId('calendar-toast-undo') const syncCallsBeforeUndo = sync.mock.calls.length fireEvent.click(undoButton) await waitFor(() => expect(update).toHaveBeenLastCalledWith('lw-1', expect.objectContaining({ name: 'Sommar' })), ) // Undo re-syncs (days are derived — reverting the wish and re-syncing // is the correct undo, not touching planned days directly). await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo)) }) it('dismisses the toast on demand', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) const dismissButton = await screen.findByTestId('calendar-toast-dismiss') fireEvent.click(dismissButton) expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) it('undo after creating a period from a drag removes the wish and re-syncs', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const remove = vi.fn().mockResolvedValue(undefined) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-new' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) const undoButton = await screen.findByTestId('calendar-toast-undo') const syncCallsBeforeUndo = sync.mock.calls.length fireEvent.click(undoButton) await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-new')) await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo)) }) it('undo after selecting a preset removes the wish and re-syncs', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) const add = vi.fn().mockResolvedValue(created) const remove = vi.fn().mockResolvedValue(undefined) const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, remove, data: [created] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() fireEvent.click(screen.getByTestId('preset-click')) const undoButton = await screen.findByTestId('calendar-toast-undo') const syncCallsBeforeUndo = sync.mock.calls.length fireEvent.click(undoButton) await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset')) await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo)) }) it('undo after deleting an ad-hoc period recreates the deleted days', async () => { const today = todayDateString() const remove = vi.fn().mockResolvedValue(undefined) const add = vi.fn().mockResolvedValue(makePlannedLeaveDay()) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [ makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1', dayType: 'vacation', isDubbeldag: false, isPinned: true, }), ], remove, add, }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete')) const undoButton = await screen.findByTestId('calendar-toast-undo') fireEvent.click(undoButton) await waitFor(() => expect(add).toHaveBeenCalledWith( expect.objectContaining({ workspace_id: 'ws-1', child_id: 'child-1', parent_personal_data_uuid: 'person-1', date: today, day_type: 'vacation', is_dubbeldag: false, is_pinned: true, }), ), ) }) }) describe('§Task7 initial auto-sync', () => { it('syncs once per workspace on mount without user interaction', async () => { const sync = vi.fn().mockResolvedValue(makePlanSyncResponse()) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) const { rerender } = render() await waitFor(() => expect(sync).toHaveBeenCalledWith('ws-1')) rerender() // No extra auto-sync from a plain re-render of the same workspace. expect(sync).toHaveBeenCalledTimes(1) }) it('does not crash when the workspace id is not yet available', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: null, selectedChildId: null, personalDataUuid: null }), ) expect(() => render()).not.toThrow() expect(mockSync.sync).not.toHaveBeenCalled() }) }) describe('defensive fallbacks', () => { it('falls back to safe defaults when workspace/child/personalData are unavailable', () => { const noWorkspace = makeWorkspaceContextValue({ selectedChildId: null, workspace: null, personalDataUuid: null, }) vi.mocked(useWorkspace).mockReturnValue(noWorkspace) render() expect(usePlannedDays).toHaveBeenCalledWith(undefined) expect(useCalendarDays).toHaveBeenCalledWith('', expect.any(Number), expect.any(Number)) expect(useDayOverrides).toHaveBeenCalledWith('') }) it('treats a missing calendar window as having no days yet', () => { vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true })) render() expect(document.querySelector('.calendar-day--red-day')).not.toBeInTheDocument() expect(screen.getByTestId('calendar')).toBeInTheDocument() }) it('treats missing planned days as an empty list for lookups and bar rendering', () => { vi.mocked(usePlannedDays).mockReturnValue({ ...mockPlannedDays, data: null }) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no') }) it('renders the month/year header in English when the locale is English', () => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined, }) try { render( , ) const title = screen.getByRole('heading', { level: 2 }) const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement expect(monthSelect.getAttribute('aria-label')).toBe('Select month') } finally { Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true }) vi.unstubAllGlobals() } }) it('shows the week-number column when the preference is enabled', () => { vi.mocked(useShowWeekNumbers).mockReturnValueOnce(makeShowWeekNumbersTuple(true)) render() expect(document.querySelector('.calendar__grid--with-week-numbers')).toBeInTheDocument() }) it('defaults the new-day popover to "vacation" when there is no last-used leave type', () => { vi.mocked(getLastLeaveType).mockReturnValueOnce(null) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) expect(screen.getByTestId('modal')).toHaveAttribute('data-default-day-type', 'vacation') }) it('lets the popover offer deletion when the clicked date already has a planned day', () => { const today = todayDateString() vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })], }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes') }) it('deleting an existing day through the popover removes it', async () => { const today = todayDateString() const remove = vi.fn().mockResolvedValue(undefined) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', parentPersonalDataUuid: 'person-1', date: today })], remove, }), ) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-delete')) await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1')) }) it('closes the day popover via its close button', () => { render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) expect(screen.getByTestId('modal')).toBeInTheDocument() fireEvent.click(screen.getByTestId('modal-close')) expect(screen.queryByTestId('modal')).not.toBeInTheDocument() }) it('shows a generic override error when the mutation rejects with a non-Error value', async () => { const today = todayDateString() const upsert = vi.fn().mockRejectedValue('network exploded') vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, dayType: 'weekend', isFree: true })] }), }), ) vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() })) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-toggle-override')) await waitFor(() => { const attr = screen.getByTestId('modal').getAttribute('data-override-error') expect(attr).not.toBe('') expect(attr).not.toBe('network exploded') }) }) it('treats a missing day-overrides list as empty when clearing an override', async () => { const today = todayDateString() const removeOverride = vi.fn() vi.mocked(useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: makeCalendarWindow({ days: [makeCalendarDayEntry({ date: today, isOverride: true, overrideIsWorkingDay: false, isFree: true })], }), }), ) // `data` is undefined here (e.g. before the overrides fetch resolves) — // the `(dayOverridesHook.data ?? []).find(...)` fallback must kick in. vi.mocked(useDayOverrides).mockReturnValue({ ...makeDayOverridesResult({ isLoading: true, upsert: vi.fn(), remove: removeOverride }), data: undefined, } as unknown as ReturnType) render() const calendarDays = screen.getAllByTestId('calendar-day') const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today)) fireEvent.click(todayCell!) fireEvent.click(screen.getByTestId('modal-toggle-override')) await waitFor(() => expect(screen.getByTestId('modal')).toHaveAttribute('data-override-error', '')) expect(removeOverride).not.toHaveBeenCalled() }) it('does not attempt an override toggle when the workspace id is missing', () => { const upsert = vi.fn() vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: 'person-1' })) vi.mocked(useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], upsert, remove: vi.fn() })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.click(currentMonthDay!) fireEvent.click(screen.getByTestId('modal-toggle-override')) expect(upsert).not.toHaveBeenCalled() }) it('shows a generic error toast for a non-Error rejection while syncing', async () => { const sync = vi.fn().mockRejectedValue('boom') vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() await waitFor(() => { const toastEl = screen.getByTestId('calendar-toast') expect(toastEl).toHaveTextContent('Det gick inte att synka planen') }) }) it('shows a generic error toast for a non-Error rejection when creating a period from a drag', async () => { const add = vi.fn().mockRejectedValue('boom') vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att skapa perioden'), ) }) it('does not create a period from a drag when the workspace id is missing', () => { const add = vi.fn() vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue({ workspace: null, personalDataUuid: null })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) expect(add).not.toHaveBeenCalled() }) it('shows a generic error toast for a non-Error rejection on undo', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) const update = vi.fn().mockResolvedValueOnce(wish).mockRejectedValueOnce('boom') const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-1' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-save')) const undoButton = await screen.findByTestId('calendar-toast-undo') fireEvent.click(undoButton) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Det gick inte att ångra ändringen')) }) it('does not save a period edit for an ad-hoc band (no wishId)', async () => { const today = todayDateString() const update = vi.fn() vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ update, data: [] })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-save')) expect(update).not.toHaveBeenCalled() }) it('shows a generic error when saving a period edit rejects with a non-Error value', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) const update = vi.fn().mockRejectedValue('boom') vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-save')) await waitFor(() => { const attr = screen.getByTestId('period-popover').getAttribute('data-error') expect(attr).not.toBe('') expect(attr).not.toBe('boom') }) }) it('shows an error toast when deleting a period fails', async () => { const today = todayDateString() const wish = makeLeaveWish({ id: 'lw-1' }) const remove = vi.fn().mockRejectedValue(new Error('cannot delete')) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove })) vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })], }), ) render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) fireEvent.click(screen.getByTestId('period-delete')) await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete')) }) it('shows the Error message directly when the initial sync rejects with an Error', async () => { const sync = vi.fn().mockRejectedValue(new Error('workspace not found')) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('workspace not found')) }) it('does not navigate or flash a band when the preset is created but the follow-up sync fails', async () => { const created = makeLeaveWish({ id: 'lw-preset' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValueOnce(makePlanSyncResponse()).mockRejectedValueOnce(new Error('sync failed')) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() await waitFor(() => expect(sync).toHaveBeenCalledTimes(1)) const yearBefore = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value fireEvent.click(screen.getByTestId('preset-click')) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed')) const yearAfter = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value expect(yearAfter).toBe(yearBefore) }) it('actually scrolls the calendar into view when the placement lands after a preset sync', async () => { const scrollIntoView = vi.fn() Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', { value: scrollIntoView, configurable: true, }) const created = makeLeaveWish({ id: 'lw-preset' }) const add = vi.fn().mockResolvedValue(created) const sync = vi .fn() .mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset', date: '2031-03-05' })] })) vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() fireEvent.click(screen.getByTestId('preset-click')) await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()) }) it('deleting an ad-hoc band whose days are no longer present shows no toast', async () => { const today = todayDateString() // The band's dates exist in the grid, but the underlying planned days // have already been removed by the time delete runs (a defensive // fallback path — should not throw or show a toast for zero days). vi.mocked(usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })], }), ) const { rerender } = render() fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!) // Swap the data out from under the handler right before deleting — // requires a re-render for the new hook return value to take effect. vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] })) rerender() fireEvent.click(screen.getByTestId('period-delete')) expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument() }) it('summarizes a change for a wish not present in the local wish list as a generic period', async () => { const created = makeLeaveWish({ id: 'lw-new' }) const add = vi.fn().mockResolvedValue(created) const sync = vi.fn().mockResolvedValue( makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'some-other-wish-id' })] }), ) // The wish list doesn't (yet) include the wish the change refers to. vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] })) vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync })) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled')) fireEvent.pointerDown(currentMonthDays[0] as HTMLElement) fireEvent.pointerEnter(currentMonthDays[2] as HTMLElement) fireEvent.pointerUp(window) await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Perioden')) }) }) })