c2e64b504d
Planning used to mean filling in a wish list on one tab, pressing a button to see a suggestion, then pressing another to accept it — with nothing on screen telling you whether a plan was active. Dragging a range on the calendar now creates the period directly, clicking a band opens it for editing, and each change is recomputed and saved in one step. A period repeats never or yearly, matching what the planner can actually express. Clicking a day edits that day: its funding type, which pins it so the planner leaves it alone, and whether the day counts as worked or off. That switch used to be a mode covering the whole grid, which is what made the calendar confusing. The season presets move to a rail beside the grid. Picking one lets the planner choose the placement, then jumps there and flashes it, rather than asking the user to guess dates the engine already knows. A sync that funds nothing now says so, naming the period and how many days it fell short, instead of leaving the screen unchanged and looking broken.
1500 lines
64 KiB
TypeScript
1500 lines
64 KiB
TypeScript
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 }) => <div>{children}</div>,
|
|
}))
|
|
|
|
vi.mock('@/components/common/LoadingSpinner', () => ({
|
|
LoadingSpinner: () => <div>Loading...</div>,
|
|
}))
|
|
|
|
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) => (
|
|
<div
|
|
data-testid="modal"
|
|
data-default-day-type={props.defaultDayType ?? ''}
|
|
data-existing-day={props.existingDay ? 'yes' : 'no'}
|
|
data-parents-count={props.parents.length}
|
|
data-day-info={props.dayInfo ? 'yes' : 'no'}
|
|
data-override-error={props.overrideError ?? ''}
|
|
>
|
|
<button
|
|
data-testid="modal-save"
|
|
onClick={() => void props.onSave({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
|
|
>
|
|
save
|
|
</button>
|
|
{props.onDelete && (
|
|
<button data-testid="modal-delete" onClick={() => void props.onDelete?.()}>
|
|
delete
|
|
</button>
|
|
)}
|
|
{props.onToggleOverride && (
|
|
<button data-testid="modal-toggle-override" onClick={() => void props.onToggleOverride?.()}>
|
|
toggle
|
|
</button>
|
|
)}
|
|
<button data-testid="modal-close" onClick={props.onClose}>
|
|
close
|
|
</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
vi.mock('@/components/calendar/PeriodPopover', () => ({
|
|
PeriodPopover: (props: PeriodPopoverProps) => (
|
|
<div
|
|
data-testid="period-popover"
|
|
data-wish-id={props.wish?.id ?? 'null'}
|
|
data-start={props.startDate}
|
|
data-end={props.endDate}
|
|
data-error={props.error ?? ''}
|
|
>
|
|
<button
|
|
data-testid="period-save"
|
|
onClick={() =>
|
|
void props.onSave({
|
|
name: 'Edited name',
|
|
startDate: props.startDate,
|
|
endDate: props.endDate,
|
|
parentMode: 'either',
|
|
parentPersonalDataUuid: null,
|
|
recurrence: 'yearly',
|
|
lastYear: null,
|
|
} satisfies PeriodPopoverSavePayload)
|
|
}
|
|
>
|
|
save
|
|
</button>
|
|
<button data-testid="period-delete" onClick={() => void props.onDelete()}>
|
|
delete
|
|
</button>
|
|
<button data-testid="period-close" onClick={props.onClose}>
|
|
close
|
|
</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
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) => (
|
|
<div data-testid="preset-rail-mock">
|
|
<button data-testid="preset-click" disabled={props.isBusy} onClick={() => void props.onSelectPreset(SOMMAR_PRESET)}>
|
|
preset
|
|
</button>
|
|
</div>
|
|
),
|
|
}))
|
|
|
|
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(<Calendar />)
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders 7 weekday headers in Monday-first Swedish order', () => {
|
|
render(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
const title = screen.getByRole('heading', { level: 2 })
|
|
expect(title.textContent).toMatch(/\d{4}/)
|
|
})
|
|
|
|
it('handles next/prev month navigation', () => {
|
|
render(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
// 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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
await waitFor(() => expect(sync).toHaveBeenCalledWith('ws-1'))
|
|
|
|
rerender(<Calendar />)
|
|
// 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(<Calendar />)).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(<Calendar />)
|
|
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(
|
|
<LanguageProvider>
|
|
<Calendar />
|
|
</LanguageProvider>,
|
|
)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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<typeof useDayOverrides>)
|
|
|
|
render(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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(<Calendar />)
|
|
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'))
|
|
})
|
|
})
|
|
})
|