61e5487078
Free days (Sat/Sun/red days, subject to DayOverride) can never be vacation, and can only be parental leave when the workspace opts in via take_pl_on_red_days. The allocator already honoured this; the hand-pin path bypassed it entirely. - PlanningService gains optional workspace_repo/day_override_repo deps and _reject_illegal_free_day, enforced in add_planned_day and update. Reuses allocation_service.is_free_day rather than re-deriving it, and no-ops when the deps are unset so existing callers are unaffected. - update_planned_day had no ValidationError handler at all, so a validation failure there 500'd instead of 422'ing. Fixed. - Calendar filters the pin write loop by the day's isFree; the modal explains free days stay off, and that pinning opts a day out of automatic re-planning. Vacation days at risk of forfeiture were computed and sent over the wire but never rendered: an at-risk column with the age-out/over-cap split, and a dashboard key figure. PlanProposal.warnings was likewise parsed but rendered nowhere, for all three kinds. Adds PlanWarningCard in a Risks section above the tips, with parent names resolved and dates formatted. POST /api/plan/sync now fires from the workspace layer on every plan-input change rather than only from Calendar, so a Settings edit or a pinned-day save re-plans day types instead of merely refetching. Overlapping bumps coalesce into one trailing run; a rejection cannot wedge the in-flight guard. eviction_preview was silently dropped by the frontend despite always being on the wire, and is now surfaced through context. Verified: backend 890 passed (91.49% coverage), frontend 1297 passed, ruff/eslint/tsc clean. Browser-checked in Swedish and English. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
2519 lines
109 KiB
TypeScript
2519 lines
109 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||
import { useSearchParams, type SetURLSearchParams } from 'react-router-dom'
|
||
import { Calendar } from './Calendar'
|
||
import { useWorkspace } from '@/context'
|
||
import {
|
||
usePlannedDays,
|
||
useCalendarDays,
|
||
useShowWeekNumbers,
|
||
getLastLeaveType,
|
||
useLeaveWishes,
|
||
useWorkspaceMembers,
|
||
usePlanSync,
|
||
usePlannedDayExclusions,
|
||
} 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,
|
||
makeShowWeekNumbersTuple,
|
||
makePlannedLeaveDay,
|
||
makeLeaveWishesResult,
|
||
makeWorkspaceMembersResult,
|
||
makePlanSyncResult,
|
||
makeLeaveWish,
|
||
makePersonalData,
|
||
makePlanSyncResponse,
|
||
makePlanChange,
|
||
makeWindowOutcome,
|
||
makeRejection,
|
||
makePlanProposal,
|
||
makePlanApplyResponse,
|
||
makePlannedDayExclusionsResult,
|
||
makePlannedDayExclusion,
|
||
makeWorkspace,
|
||
} from '../../tests/fixtures'
|
||
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
|
||
|
||
vi.mock('@/context', () => ({
|
||
useWorkspace: vi.fn(),
|
||
}))
|
||
|
||
// Calendar.tsx only reads `useSearchParams` from react-router-dom (no
|
||
// `useNavigate`/route matching), so the rest of the module can stay real —
|
||
// only the search params themselves need to be controllable per test.
|
||
vi.mock('react-router-dom', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('react-router-dom')>()
|
||
return { ...actual, useSearchParams: vi.fn() }
|
||
})
|
||
|
||
const mockSetSearchParams: SetURLSearchParams = vi.fn()
|
||
|
||
vi.mock('@/hooks', () => ({
|
||
usePlannedDays: vi.fn(),
|
||
useShowWeekNumbers: vi.fn(() => [false]),
|
||
getLastLeaveType: vi.fn(() => 'vacation'),
|
||
setLastLeaveType: vi.fn(),
|
||
useCalendarDays: vi.fn(),
|
||
useLeaveWishes: vi.fn(),
|
||
useWorkspaceMembers: vi.fn(),
|
||
usePlanSync: vi.fn(),
|
||
usePlannedDayExclusions: vi.fn(),
|
||
}))
|
||
|
||
vi.mock('@/components/layout/Layout', () => ({
|
||
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</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). Mirrors the
|
||
// real component's `if (!isOpen) return null` — Calendar.tsx can have a
|
||
// non-empty `selectedDates` (so `firstSelectedDate` is set, and this mock
|
||
// gets rendered) while `isModalOpen` is still false, e.g. right after
|
||
// seeding the selection from a `?date=` param without opening the modal.
|
||
PlannedDayModal: (props: PlannedDayModalProps) => {
|
||
if (!props.isOpen) return null
|
||
return (
|
||
<div
|
||
data-testid="modal"
|
||
data-default-day-type={props.defaultDayType ?? ''}
|
||
data-existing-day={props.existingDay ? 'yes' : 'no'}
|
||
data-parents-count={props.parents.length}
|
||
data-is-range-mode={props.isRangeMode ? 'yes' : 'no'}
|
||
data-day-count={props.dayCount ?? ''}
|
||
data-selected-parent-id={props.selectedParentId}
|
||
>
|
||
{/* §todo10/11 default path — the planner decides funding. */}
|
||
<button
|
||
data-testid="modal-save-wish-either"
|
||
onClick={() => void props.onSaveWish({ parentMode: 'either', parentPersonalDataUuid: null, wantsDubbeldag: false })}
|
||
>
|
||
save wish (either)
|
||
</button>
|
||
<button
|
||
data-testid="modal-save-wish-both"
|
||
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: false })}
|
||
>
|
||
save wish (both)
|
||
</button>
|
||
<button
|
||
data-testid="modal-save-wish-both-dubbeldag"
|
||
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: true })}
|
||
>
|
||
save wish (both, dubbeldag)
|
||
</button>
|
||
<button
|
||
data-testid="modal-save-wish-specific"
|
||
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false })}
|
||
>
|
||
save wish (specific)
|
||
</button>
|
||
{/* Deliberately sends `wantsDubbeldag: true` alongside `parentMode:
|
||
'specific'` — the real modal itself disables the checkbox outside
|
||
'both' mode, but Calendar.tsx's `handleSaveWish` must not trust
|
||
that alone; it re-gates on `parentMode` before sending the wish. */}
|
||
<button
|
||
data-testid="modal-save-wish-specific-dubbeldag-attempt"
|
||
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: true })}
|
||
>
|
||
save wish (specific, dubbeldag attempt)
|
||
</button>
|
||
{/* The "Advanced" escape hatch — pins an exact day type for whichever
|
||
parent `selectedParentId` currently names (Calendar.tsx's lifted
|
||
`advancedParentId`, updated live via `onParentChange`). */}
|
||
<button
|
||
data-testid="modal-save-pinned"
|
||
onClick={() => void props.onSavePinned({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
|
||
>
|
||
save pinned (vacation)
|
||
</button>
|
||
<button
|
||
data-testid="modal-save-pinned-sjukpenning"
|
||
onClick={() => void props.onSavePinned({ dayType: 'sjukpenning', parentId: props.selectedParentId, isDubbeldag: false })}
|
||
>
|
||
save pinned (sjukpenning)
|
||
</button>
|
||
<button data-testid="modal-change-parent-b" onClick={() => props.onParentChange?.('pd-b')}>
|
||
switch advanced parent to B
|
||
</button>
|
||
{props.onDelete && (
|
||
<button data-testid="modal-delete" onClick={() => void props.onDelete?.()}>
|
||
delete
|
||
</button>
|
||
)}
|
||
{(props.excludedEntries ?? []).map((entry) => (
|
||
<button
|
||
key={entry.id}
|
||
data-testid={`modal-restore-excluded-${entry.id}`}
|
||
data-parent-name={entry.parentName}
|
||
onClick={() => void props.onRestoreExcluded?.(entry.id)}
|
||
>
|
||
restore {entry.parentName}
|
||
</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-remove-day-label={props.removeDayLabel ?? ''}
|
||
data-remove-day-parent={props.removeDayParentName ?? ''}
|
||
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',
|
||
scope: 'occurrence',
|
||
lastYear: null,
|
||
wantsDubbeldag: false,
|
||
} satisfies PeriodPopoverSavePayload)
|
||
}
|
||
>
|
||
save
|
||
</button>
|
||
<button
|
||
data-testid="period-save-series"
|
||
onClick={() =>
|
||
void props.onSave({
|
||
name: 'Edited name',
|
||
startDate: props.startDate,
|
||
endDate: props.endDate,
|
||
parentMode: 'either',
|
||
parentPersonalDataUuid: null,
|
||
recurrence: 'yearly',
|
||
scope: 'series',
|
||
lastYear: 2031,
|
||
wantsDubbeldag: false,
|
||
} satisfies PeriodPopoverSavePayload)
|
||
}
|
||
>
|
||
save (every year)
|
||
</button>
|
||
<button
|
||
data-testid="period-save-once"
|
||
onClick={() =>
|
||
void props.onSave({
|
||
name: 'Edited name',
|
||
startDate: props.startDate,
|
||
endDate: props.endDate,
|
||
parentMode: 'either',
|
||
parentPersonalDataUuid: null,
|
||
recurrence: 'once',
|
||
scope: 'occurrence',
|
||
lastYear: null,
|
||
wantsDubbeldag: false,
|
||
} satisfies PeriodPopoverSavePayload)
|
||
}
|
||
>
|
||
save once
|
||
</button>
|
||
{/* §todo9 — the three-way remove. */}
|
||
<button data-testid="period-delete-day" onClick={() => void props.onDelete('day')}>
|
||
delete (day)
|
||
</button>
|
||
<button data-testid="period-delete-year" onClick={() => void props.onDelete('year')}>
|
||
delete (year)
|
||
</button>
|
||
<button data-testid="period-delete-all-years" onClick={() => void props.onDelete('allYears')}>
|
||
delete (all years)
|
||
</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 mockLeaveWishes = makeLeaveWishesResult()
|
||
const mockMembers = makeWorkspaceMembersResult()
|
||
const mockSync = makePlanSyncResult()
|
||
const mockExclusions = makePlannedDayExclusionsResult()
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow)
|
||
vi.mocked(useLeaveWishes).mockReturnValue(mockLeaveWishes)
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
|
||
vi.mocked(usePlanSync).mockReturnValue(mockSync)
|
||
vi.mocked(usePlannedDayExclusions).mockReturnValue(mockExclusions)
|
||
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
|
||
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
|
||
})
|
||
|
||
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.getByTestId('page-skeleton')).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('does not render a phantom trailing week when the month ends exactly on a Sunday', () => {
|
||
// May 2026 has 31 days, starting on a Friday: 4 leading days + 31 = 35,
|
||
// an exact multiple of 7 — the naive "always pad and push a trailing
|
||
// week" logic would add a 6th row of 7 disabled June cells that serves
|
||
// no purpose.
|
||
render(<Calendar />)
|
||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||
fireEvent.change(yearSelect, { target: { value: '2026' } })
|
||
fireEvent.change(monthSelect, { target: { value: '4' } })
|
||
|
||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||
expect(calendarDays.length).toBe(35)
|
||
})
|
||
|
||
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('opens the day popover when clicking a disabled (adjacent-month) day — planning a visible day outside the displayed month is deliberate, not dead', () => {
|
||
// Decided behaviour for the drag-across-month-boundary fix: a leading/
|
||
// trailing cell stays dimmed (`calendar-day--disabled` — see
|
||
// CalendarDay.tsx) but is still clickable/drag-selectable, same as any
|
||
// other visible day. Silently doing nothing on a visible cell was the
|
||
// original bug (see CalendarWeek.tsx's day-cell handlers).
|
||
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')
|
||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||
}
|
||
})
|
||
|
||
it('displays the month and year as real title text, capitalised', () => {
|
||
render(<Calendar />)
|
||
const title = screen.getByRole('heading', { level: 2 })
|
||
expect(title.textContent).toMatch(
|
||
/^(Januari|Februari|Mars|April|Maj|Juni|Juli|Augusti|September|Oktober|November|December) \d{4}$/,
|
||
)
|
||
// The title holds no controls: the month/year selects live beside it.
|
||
expect(title.querySelector('select')).toBeNull()
|
||
})
|
||
|
||
it('renders the help popover button in place of the old legend', () => {
|
||
render(<Calendar />)
|
||
expect(screen.getByTestId('calendar-help-button')).toBeInTheDocument()
|
||
expect(document.querySelector('.calendar__legend')).toBeNull()
|
||
})
|
||
|
||
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 monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||
const yearSelect = screen.getByLabelText('Välj år') 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('Today button jumps back to the current month/year after navigating away', () => {
|
||
render(<Calendar />)
|
||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||
|
||
fireEvent.change(monthSelect, { target: { value: '5' } })
|
||
fireEvent.change(yearSelect, { target: { value: '2035' } })
|
||
expect(yearSelect.value).toBe('2035')
|
||
|
||
fireEvent.click(screen.getByText('Idag'))
|
||
|
||
const today = new Date()
|
||
expect(monthSelect.value).toBe(String(today.getMonth()))
|
||
expect(yearSelect.value).toBe(String(today.getFullYear()))
|
||
})
|
||
|
||
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 yearSelect = screen.getByLabelText('Välj år') 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 yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
|
||
expect(options).toContain(new Date().getFullYear())
|
||
})
|
||
|
||
describe('?date= query param seeds the initial month/selection', () => {
|
||
it('opens the month of a valid future ?date= and highlights that day, without opening the modal', () => {
|
||
vi.mocked(useSearchParams).mockReturnValue([
|
||
new URLSearchParams('date=2030-07-15'),
|
||
mockSetSearchParams,
|
||
])
|
||
|
||
render(<Calendar />)
|
||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||
expect(yearSelect.value).toBe('2030')
|
||
expect(monthSelect.value).toBe('6') // July is month index 6
|
||
|
||
const targetDay = screen
|
||
.getAllByTestId('calendar-day')
|
||
.find((el) => el.getAttribute('aria-label')?.startsWith('2030-07-15'))
|
||
expect(targetDay).toHaveClass('calendar-day--selected')
|
||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('falls back to today for an invalid/garbage ?date= param', () => {
|
||
vi.mocked(useSearchParams).mockReturnValue([
|
||
new URLSearchParams('date=not-a-real-date'),
|
||
mockSetSearchParams,
|
||
])
|
||
|
||
render(<Calendar />)
|
||
|
||
const today = new Date()
|
||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||
expect(yearSelect.value).toBe(String(today.getFullYear()))
|
||
expect(monthSelect.value).toBe(String(today.getMonth()))
|
||
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('falls back to today when no ?date= param is present', () => {
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
|
||
|
||
render(<Calendar />)
|
||
|
||
const today = new Date()
|
||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||
expect(yearSelect.value).toBe(String(today.getFullYear()))
|
||
expect(monthSelect.value).toBe(String(today.getMonth()))
|
||
})
|
||
})
|
||
|
||
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()
|
||
})
|
||
})
|
||
|
||
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-pinned'))
|
||
|
||
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-pinned'))
|
||
|
||
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-pinned'))
|
||
|
||
// 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')
|
||
})
|
||
|
||
it('labels a partner-owned band with their actual name, not the generic "Partner" string', () => {
|
||
const today = todayDateString()
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Sam' })],
|
||
}),
|
||
)
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'pd-b' })],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
|
||
expect(screen.getByText(/Sam ·/)).toBeInTheDocument()
|
||
expect(screen.queryByText(/^Partner ·/)).not.toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
describe('§Task7 drag-select opens the day popover on the exact dragged days', () => {
|
||
it('opens the day popover pre-selected on every dragged date, without creating a wish', async () => {
|
||
const add = vi.fn()
|
||
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'))
|
||
const startDay = currentMonthDays[0] as HTMLElement
|
||
const endDay = currentMonthDays[3] as HTMLElement
|
||
|
||
fireEvent.pointerDown(startDay)
|
||
fireEvent.pointerEnter(endDay)
|
||
fireEvent.pointerUp(window)
|
||
|
||
const modal = await screen.findByTestId('modal')
|
||
expect(modal).toHaveAttribute('data-is-range-mode', 'yes')
|
||
expect(modal).toHaveAttribute('data-day-count', '4')
|
||
// A drag (single- or multi-day) never creates a LeaveWish/synced
|
||
// period on its own anymore — only the popover's own save does
|
||
// anything, and that writes planned days directly.
|
||
expect(add).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('opens the day popover in single mode 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)
|
||
|
||
const modal = screen.getByTestId('modal')
|
||
expect(modal).toBeInTheDocument()
|
||
expect(modal).toHaveAttribute('data-is-range-mode', 'no')
|
||
})
|
||
|
||
it('a leading week\'s in-month days are draggable (regression: the week used to be gated by weekDates[0]\'s month, not the displayed month)', () => {
|
||
render(<Calendar />)
|
||
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
|
||
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
|
||
// March 2026 starts on a Sunday, so its first calendar row is a
|
||
// leading week made up of six February days plus March 1st.
|
||
fireEvent.change(yearSelect, { target: { value: '2026' } })
|
||
fireEvent.change(monthSelect, { target: { value: '2' } })
|
||
|
||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||
const marchFirst = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith('2026-03-01'))!
|
||
const febTail = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith('2026-02-23'))!
|
||
|
||
// The previous month's tail day in that same row stays inert...
|
||
expect(febTail).toHaveClass('calendar-day--disabled')
|
||
// ...but March 1st — in-month, in the very same leading week — is not.
|
||
expect(marchFirst).not.toHaveClass('calendar-day--disabled')
|
||
|
||
fireEvent.pointerDown(marchFirst)
|
||
fireEvent.pointerUp(window)
|
||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
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' }),
|
||
'occurrence',
|
||
),
|
||
)
|
||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||
expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('saving a period edit pins exact dates: windowMode flips to fixed with a matching target', async () => {
|
||
// §bug — a preset-born wish is `windowMode: 'flexible'` (a bounding
|
||
// range the planner searches inside, not a placement). Once the user
|
||
// has dragged/typed exact dates in the popover, those dates ARE the
|
||
// placement, so the save must flip the row to 'fixed' with a target
|
||
// matching the edited span. Leaving it 'flexible' turns the (now
|
||
// week-long) bound into a single flexible candidate, which repeats the
|
||
// same calendar date every year on a drifting weekday instead of
|
||
// preserving the weekday.
|
||
const wish = makeLeaveWish({ id: 'lw-1', windowMode: 'flexible' })
|
||
const update = vi.fn().mockResolvedValue(wish)
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-05'), mockSetSearchParams])
|
||
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: '2026-10-05', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||
makePlannedLeaveDay({ id: 'pd-2', date: '2026-10-06', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||
makePlannedLeaveDay({ id: 'pd-3', date: '2026-10-07', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||
makePlannedLeaveDay({ id: 'pd-4', date: '2026-10-08', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||
makePlannedLeaveDay({ id: 'pd-5', date: '2026-10-09', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||
makePlannedLeaveDay({ id: 'pd-6', date: '2026-10-10', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||
makePlannedLeaveDay({ id: 'pd-7', date: '2026-10-11', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' }),
|
||
],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2026-10-05')
|
||
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-end', '2026-10-11')
|
||
fireEvent.click(screen.getByTestId('period-save'))
|
||
|
||
await waitFor(() =>
|
||
expect(update).toHaveBeenCalledWith(
|
||
'lw-1',
|
||
expect.objectContaining({ windowMode: 'fixed', targetDays: 7, targetWeeks: 1 }),
|
||
'occurrence',
|
||
),
|
||
)
|
||
})
|
||
|
||
it('saving one year of a repeating wish writes that year and keeps the scope narrow', async () => {
|
||
// The band clicked here is the 2028 row of a series. Its own year IS
|
||
// what gets written — the old shape had to keep the wish's anchor year
|
||
// and rewrite the shared month/day pattern for every year, because
|
||
// writing `firstYear: 2028` would have dropped the earlier occurrences.
|
||
// There are no "earlier occurrences" on this row to drop any more.
|
||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
|
||
const update = vi.fn().mockResolvedValue(wish)
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
|
||
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: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
expect(screen.getByTestId('period-popover')).toHaveAttribute('data-start', '2028-06-15')
|
||
fireEvent.click(screen.getByTestId('period-save'))
|
||
|
||
await waitFor(() =>
|
||
expect(update).toHaveBeenCalledWith(
|
||
'lw-1',
|
||
expect.objectContaining({ firstYear: 2028 }),
|
||
'occurrence',
|
||
),
|
||
)
|
||
// `lastYear` is series-level, so a this-year-only edit must not carry it.
|
||
expect(update.mock.calls[0]![1]).not.toHaveProperty('lastYear')
|
||
})
|
||
|
||
it('saving "every year" widens the scope and carries the series end year', async () => {
|
||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
|
||
const update = vi.fn().mockResolvedValue(wish)
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
|
||
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: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
fireEvent.click(screen.getByTestId('period-save-series'))
|
||
|
||
await waitFor(() =>
|
||
expect(update).toHaveBeenCalledWith(
|
||
'lw-1',
|
||
expect.objectContaining({ firstYear: 2028, lastYear: 2031 }),
|
||
'series',
|
||
),
|
||
)
|
||
})
|
||
|
||
it('switching an occurrence to "once" detaches it from the series at its own year', async () => {
|
||
// 'once' on a series occurrence means "stop repeating": the clicked
|
||
// year survives (at its own year, which is what the popover shows) and
|
||
// the siblings go. That is series-wide by definition, so the popover
|
||
// sends the narrow scope and the backend still drops the siblings.
|
||
const wish = makeLeaveWish({ id: 'lw-1', firstYear: 2028, seriesId: 'series-1' })
|
||
const update = vi.fn().mockResolvedValue(wish)
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
|
||
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: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
fireEvent.click(screen.getByTestId('period-save-once'))
|
||
|
||
await waitFor(() =>
|
||
expect(update).toHaveBeenCalledWith(
|
||
'lw-1',
|
||
expect.objectContaining({ firstYear: 2028, recurrence: 'once' }),
|
||
'occurrence',
|
||
),
|
||
)
|
||
})
|
||
|
||
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-all-years'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
|
||
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-all-years'))
|
||
|
||
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-all-years'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
|
||
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-year'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
|
||
// An ad-hoc delete removes the planned day directly and never syncs
|
||
// (it isn't wish-driven) — Calendar itself performs no auto-sync of
|
||
// its own any more (see the "§Task7 auto-sync moved to
|
||
// PlanSyncProvider" describe block below).
|
||
expect(sync).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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('defaults a solo household preset to "either" parent', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({ data: [makePersonalData({ uuid: 'pd-a', name: 'Förälder A' })] }),
|
||
)
|
||
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({ parentMode: 'either' })))
|
||
})
|
||
|
||
it('defaults a two-parent household preset to "both" parents', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
data: [
|
||
makePersonalData({ uuid: 'pd-a', name: 'Förälder A' }),
|
||
makePersonalData({ uuid: 'pd-b', name: 'Förälder B' }),
|
||
],
|
||
}),
|
||
)
|
||
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({ parentMode: 'both' })))
|
||
})
|
||
|
||
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 yearSelect = screen.getByLabelText('Välj år') 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', () => {
|
||
// Only the preset rail and period-popover edits still create/sync a
|
||
// `LeaveWish` — a drag no longer does (it just plans exact days
|
||
// directly, with no sync/toast). These tests drive the toast/undo
|
||
// machinery through the preset rail instead.
|
||
it('shows a toast summarizing what changed after a sync with changes', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(
|
||
makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' }), makePlanChange({ newWishId: 'lw-preset' })] }),
|
||
)
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
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-preset' })
|
||
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 />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
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-preset' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(
|
||
makePlanSyncResponse({
|
||
changes: [],
|
||
proposal: makePlanProposal({
|
||
outcomes: [
|
||
makeWindowOutcome({
|
||
wishId: 'lw-preset',
|
||
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 />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
const toast = await screen.findByTestId('calendar-toast')
|
||
expect(toast).toHaveTextContent('Sommar')
|
||
expect(toast).toHaveTextContent('10')
|
||
})
|
||
|
||
it('reports a period\'s shortfall in days, not weeks, when the wish carries an exact targetDays', async () => {
|
||
// `targetDays` (an exact day count) can still be set on a wish (e.g.
|
||
// one created before this drag-select rework, or seeded directly);
|
||
// `targetWeeks` on that same wish is just a rough
|
||
// `Math.ceil(days / 7)` leftover the API still requires — never what
|
||
// the user actually asked for. The toast must report the days
|
||
// figure, not `weeksWished`, once `targetDays` is set. `weeksWished`
|
||
// is deliberately set to a nonsense value here (3) to prove the toast
|
||
// isn't reading it at all, not just that it happens to agree. Driven
|
||
// through a period-popover edit/save (band click), since no UI path
|
||
// creates a fresh wish with `targetDays` anymore.
|
||
const today = todayDateString()
|
||
const wish = makeLeaveWish({ id: 'lw-1', name: 'Ny period', targetDays: 7, targetWeeks: 1 })
|
||
const update = vi.fn().mockResolvedValue(wish)
|
||
const sync = vi.fn().mockResolvedValue(
|
||
makePlanSyncResponse({
|
||
changes: [],
|
||
proposal: makePlanProposal({
|
||
outcomes: [
|
||
makeWindowOutcome({
|
||
wishId: 'lw-1',
|
||
startDate: '2026-09-17',
|
||
endDate: '2026-09-23',
|
||
weeksWished: 3,
|
||
weeksFunded: 0,
|
||
shortfallDays: 5,
|
||
data: { wish_name: 'Ny period', wish_name_message_key: null },
|
||
}),
|
||
],
|
||
}),
|
||
}),
|
||
)
|
||
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 toast = await screen.findByTestId('calendar-toast')
|
||
expect(toast).toHaveTextContent('7')
|
||
expect(toast).toHaveTextContent('dagar')
|
||
expect(toast).not.toHaveTextContent('3 vecko')
|
||
expect(toast).not.toHaveTextContent('veckorna')
|
||
})
|
||
|
||
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-preset' })
|
||
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 />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
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-preset', name: 'Sommar' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(
|
||
makePlanSyncResponse({
|
||
changes: [makePlanChange({ newWishId: 'lw-preset' })],
|
||
proposal: makePlanProposal({
|
||
outcomes: [
|
||
makeWindowOutcome({
|
||
wishId: 'lw-preset',
|
||
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 />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
const toast = await screen.findByTestId('calendar-toast')
|
||
// What changed (the "N days changed" summary with pluralization) ...
|
||
expect(toast).toHaveTextContent('ändrades')
|
||
// ... and what fell short.
|
||
expect(toast).toHaveTextContent('5')
|
||
expect(toast).toHaveTextContent('saknas')
|
||
})
|
||
|
||
it('shows a toast when a flexible window auto-shifted to catch extra red days', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(
|
||
makePlanSyncResponse({
|
||
changes: [makePlanChange({ newWishId: 'lw-preset' })],
|
||
proposal: makePlanProposal({
|
||
outcomes: [
|
||
makeWindowOutcome({
|
||
wishId: 'lw-preset',
|
||
shiftGainDays: 2,
|
||
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 />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
const toast = await screen.findByTestId('calendar-toast')
|
||
expect(toast).toHaveTextContent('Sommar')
|
||
expect(toast).toHaveTextContent('flyttad')
|
||
expect(toast).toHaveTextContent('2')
|
||
})
|
||
|
||
it('does not show the shift toast when the wish with a positive shiftGainDays is not in changes', async () => {
|
||
// A plain re-open (nothing actually touched this wish's dates this
|
||
// sync) must never re-announce a stable, already-applied shift --
|
||
// `changes` membership is what distinguishes the two, not
|
||
// `shiftGainDays > 0` alone.
|
||
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(
|
||
makePlanSyncResponse({
|
||
changes: [],
|
||
proposal: makePlanProposal({
|
||
outcomes: [makeWindowOutcome({ wishId: 'lw-preset', shiftGainDays: 2 })],
|
||
}),
|
||
}),
|
||
)
|
||
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).toHaveBeenCalled())
|
||
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('renders sync rejections through renderRejection, resolving the parent name and never a raw uuid', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||
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 />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
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 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 />)
|
||
|
||
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' }),
|
||
'occurrence',
|
||
),
|
||
)
|
||
// 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-preset' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ newWishId: 'lw-preset' })] }))
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [created] }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
const dismissButton = await screen.findByTestId('calendar-toast-dismiss')
|
||
fireEvent.click(dismissButton)
|
||
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('undo after selecting a preset deletes the wish and re-syncs, leaving day cleanup to the backend', 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 }))
|
||
|
||
const removeDay = vi.fn().mockResolvedValue(undefined)
|
||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ remove: removeDay }))
|
||
|
||
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)
|
||
|
||
// The preset is a repeating wish, so undo takes the whole series.
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-preset', 'series'))
|
||
await waitFor(() => expect(sync.mock.calls.length).toBeGreaterThan(syncCallsBeforeUndo))
|
||
expect(removeDay).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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-year'))
|
||
|
||
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 auto-sync moved to PlanSyncProvider', () => {
|
||
it('performs no sync of its own on mount — that is now PlanSyncProvider, mounted globally inside WorkspaceProvider', () => {
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
render(<Calendar />)
|
||
|
||
expect(sync).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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))
|
||
})
|
||
|
||
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>,
|
||
)
|
||
expect(screen.getByLabelText('Select month')).toBeInTheDocument()
|
||
// The title is real text now, in the English month name.
|
||
expect(screen.getByRole('heading', { level: 2 }).textContent).toMatch(
|
||
/^(January|February|March|April|May|June|July|August|September|October|November|December) \d{4}$/,
|
||
)
|
||
} 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 error toast for a non-Error rejection while syncing', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockRejectedValue('boom')
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
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 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-all-years'))
|
||
|
||
await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
|
||
})
|
||
|
||
it('shows the Error message directly when a sync rejects with an Error', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-preset' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const sync = vi.fn().mockRejectedValue(new Error('workspace not found'))
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
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().mockRejectedValue(new Error('sync failed'))
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
render(<Calendar />)
|
||
|
||
const yearBefore = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
|
||
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed'))
|
||
const yearAfter = (screen.getByLabelText('Välj år') 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-year'))
|
||
|
||
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-preset' })
|
||
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 />)
|
||
fireEvent.click(screen.getByTestId('preset-click'))
|
||
|
||
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('Perioden'))
|
||
})
|
||
})
|
||
|
||
describe('§Task7 drag-select range save writes every dragged day', () => {
|
||
it('writes every dragged date as its own planned day (add for new dates, update for existing ones), all pinned', async () => {
|
||
// Render once first to read off the actual dates the drag will cover
|
||
// (independent of which day of the month the suite happens to run
|
||
// on), then re-render with a pre-existing planned day on the first
|
||
// of those dates — the save must update it in place, not skip or
|
||
// duplicate it.
|
||
const { unmount } = render(<Calendar />)
|
||
const probeDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
|
||
const firstDate = probeDays[0]!.getAttribute('aria-label')!.slice(0, 10)
|
||
unmount()
|
||
|
||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [makePlannedLeaveDay({ id: 'pd-existing', date: firstDate, parentPersonalDataUuid: 'person-1' })],
|
||
add,
|
||
update,
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
const currentMonthDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
|
||
const startDay = currentMonthDays[0] as HTMLElement
|
||
const endDay = currentMonthDays[2] as HTMLElement
|
||
|
||
fireEvent.pointerDown(startDay)
|
||
fireEvent.pointerEnter(endDay)
|
||
fireEvent.pointerUp(window)
|
||
|
||
const modal = await screen.findByTestId('modal')
|
||
expect(modal).toHaveAttribute('data-day-count', '3')
|
||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||
|
||
// The pre-existing day is updated (pinned), the other two are added
|
||
// (also pinned) — never a LeaveWish, never a sync.
|
||
await waitFor(() => expect(update).toHaveBeenCalledWith('pd-existing', expect.objectContaining({ is_pinned: true })))
|
||
await waitFor(() => expect(add).toHaveBeenCalledTimes(2))
|
||
expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }))
|
||
await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument())
|
||
})
|
||
})
|
||
|
||
describe('hand-pin path never lets a free day become vacation (§ free days never consume vacation)', () => {
|
||
// June 2026: the 1st is a Monday, so 06-01..06-07 is a clean Mon–Sun
|
||
// week with 06-06/06-07 as the only free (weekend) days in it.
|
||
const MON = '2026-06-01'
|
||
const SUN = '2026-06-07'
|
||
const SAT_DATE = '2026-06-06'
|
||
const SUN_DATE = '2026-06-07'
|
||
|
||
function mockFreeWeekend() {
|
||
vi.mocked(useCalendarDays).mockReturnValue(
|
||
makeCalendarDaysResult({
|
||
data: makeCalendarWindow({
|
||
days: [
|
||
makeCalendarDayEntry({ date: SAT_DATE, dayType: 'weekend', isFree: true }),
|
||
makeCalendarDayEntry({ date: SUN_DATE, dayType: 'weekend', isFree: true }),
|
||
],
|
||
}),
|
||
}),
|
||
)
|
||
}
|
||
|
||
function selectMonToSun() {
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${MON}`), mockSetSearchParams])
|
||
render(<Calendar />)
|
||
const days = screen.getAllByTestId('calendar-day')
|
||
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(MON))!
|
||
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN))!
|
||
fireEvent.pointerDown(startDay)
|
||
fireEvent.pointerEnter(endDay)
|
||
fireEvent.pointerUp(window)
|
||
}
|
||
|
||
it('pinning a Mon–Sun range as vacation creates 5 rows, skipping the weekend', async () => {
|
||
mockFreeWeekend()
|
||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
|
||
|
||
selectMonToSun()
|
||
const modal = await screen.findByTestId('modal')
|
||
expect(modal).toHaveAttribute('data-day-count', '7')
|
||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||
|
||
await waitFor(() => expect(add).toHaveBeenCalledTimes(5))
|
||
const pinnedDates = add.mock.calls.map((call) => (call[0] as { date: string }).date)
|
||
expect(pinnedDates).not.toContain(SAT_DATE)
|
||
expect(pinnedDates).not.toContain(SUN_DATE)
|
||
})
|
||
|
||
it('pinning the same range as sjukpenning with takePlOnRedDays on creates all 7 rows', async () => {
|
||
mockFreeWeekend()
|
||
vi.mocked(useWorkspace).mockReturnValue(
|
||
makeWorkspaceContextValue({
|
||
selectedChildId: 'child-1',
|
||
personalDataUuid: 'person-1',
|
||
workspace: makeWorkspace({ takePlOnRedDays: true }),
|
||
}),
|
||
)
|
||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
|
||
|
||
selectMonToSun()
|
||
const modal = await screen.findByTestId('modal')
|
||
expect(modal).toHaveAttribute('data-day-count', '7')
|
||
fireEvent.click(screen.getByTestId('modal-save-pinned-sjukpenning'))
|
||
|
||
await waitFor(() => expect(add).toHaveBeenCalledTimes(7))
|
||
})
|
||
|
||
it('shows an error and saves nothing when every selected date is free and ineligible', async () => {
|
||
mockFreeWeekend()
|
||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add }))
|
||
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(`date=${SAT_DATE}`), mockSetSearchParams])
|
||
render(<Calendar />)
|
||
const days = screen.getAllByTestId('calendar-day')
|
||
const startDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SAT_DATE))!
|
||
const endDay = days.find((el) => el.getAttribute('aria-label')?.startsWith(SUN_DATE))!
|
||
fireEvent.pointerDown(startDay)
|
||
fireEvent.pointerEnter(endDay)
|
||
fireEvent.pointerUp(window)
|
||
|
||
const modal = await screen.findByTestId('modal')
|
||
expect(modal).toHaveAttribute('data-day-count', '2')
|
||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||
|
||
await waitFor(() => expect(screen.getByTestId('modal')).toBeInTheDocument())
|
||
expect(add).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe('grid layout: uniform cell size and row height regardless of content', () => {
|
||
it('keeps every week row on the same shared grid definition even when one week contains a long holiday name', () => {
|
||
// Regression guard for the "31 October" bug: a long holiday name
|
||
// ("Alla helgons dag") used to widen only the week row containing it,
|
||
// because each week is its own CSS Grid container and a bare `1fr`
|
||
// track's minimum is its content's own width. jsdom does no layout,
|
||
// so this can't assert pixel widths directly — instead it asserts the
|
||
// structural invariant (every week row uses the exact same
|
||
// `.calendar-week` class, with no per-row width/grid override) and
|
||
// that the shared stylesheet rule those rows resolve against is
|
||
// actually content-safe (`minmax(0, 1fr)`, not a bare `1fr`).
|
||
vi.mocked(useCalendarDays).mockReturnValue(
|
||
makeCalendarDaysResult({
|
||
data: makeCalendarWindow({
|
||
days: [
|
||
makeCalendarDayEntry({
|
||
date: '2026-10-31',
|
||
name: 'Alla helgons dag',
|
||
dayType: 'red_day',
|
||
isFree: true,
|
||
}),
|
||
],
|
||
}),
|
||
}),
|
||
)
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-15'), mockSetSearchParams])
|
||
|
||
const { container } = render(<Calendar />)
|
||
|
||
// The long holiday name actually rendered somewhere in the grid.
|
||
expect(screen.getByText('Alla helgons dag')).toBeInTheDocument()
|
||
|
||
// Every week row shares the exact same class — none carries its own
|
||
// width/grid-template-columns override that could diverge per row.
|
||
const weekRows = Array.from(container.querySelectorAll('.calendar-week'))
|
||
expect(weekRows.length).toBeGreaterThan(3)
|
||
weekRows.forEach((row) => {
|
||
expect(row.className).toBe('calendar-week')
|
||
expect((row as HTMLElement).style.width).toBe('')
|
||
expect((row as HTMLElement).style.gridTemplateColumns).toBe('')
|
||
})
|
||
|
||
// The stylesheet rule those rows all resolve against must be immune
|
||
// to content width: `minmax(0, 1fr)` tracks, and every element
|
||
// inside a cell that could carry a long string sets `min-width: 0`.
|
||
const css = fs.readFileSync(path.resolve(__dirname, 'calendar.css'), 'utf-8')
|
||
expect(css).toMatch(/\.calendar-week\s*{[^}]*grid-template-columns:\s*repeat\(7,\s*minmax\(0,\s*1fr\)\)/)
|
||
expect(css).toMatch(
|
||
/\.calendar-week--with-week-numbers\s*{[^}]*grid-template-columns:\s*50px repeat\(7,\s*minmax\(0,\s*1fr\)\)/,
|
||
)
|
||
expect(css).toMatch(/\.calendar-day\s*{[^}]*min-width:\s*0/)
|
||
expect(css).toMatch(/\.calendar-week__day-cell\s*{[^}]*min-width:\s*0/)
|
||
expect(css).toMatch(/\.calendar-day__holiday-name\s*{[^}]*min-width:\s*0/)
|
||
|
||
// The height half of the same invariant. Each `.calendar-week` is its
|
||
// own grid container, so a cell carrying an extra line (the holiday
|
||
// name) grew only ITS row — October 2026's last row rendered 3.5px
|
||
// taller than the other four. `.calendar__weeks` is therefore a grid
|
||
// with `grid-auto-rows: 1fr`, which on an auto-height container sizes
|
||
// every row to the tallest one. jsdom does no layout, so this can only
|
||
// be asserted against the stylesheet text, exactly like the width
|
||
// rules above — `e2e/tests/calendar-grid-uniform.spec.ts` is what
|
||
// measures it in a real browser.
|
||
expect(css).toMatch(/\.calendar__weeks\s*{[^}]*grid-auto-rows:\s*1fr/)
|
||
expect(css).toMatch(/\.calendar__weeks\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/)
|
||
})
|
||
|
||
it('reserves the same --band-reserved-height on every week row, even though only one week has a band', () => {
|
||
// Regression guard: `--band-reserved-height` used to be computed per
|
||
// week from that week's OWN max concurrent lane count, so a week with
|
||
// a band was taller than a quiet week in the same month. Every row of
|
||
// the displayed month must reserve the same height now.
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-10-05', parentPersonalDataUuid: 'person-1' })],
|
||
}),
|
||
)
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-10-15'), mockSetSearchParams])
|
||
|
||
const { container } = render(<Calendar />)
|
||
|
||
const weekRows = Array.from(container.querySelectorAll('.calendar-week')) as HTMLElement[]
|
||
expect(weekRows.length).toBeGreaterThan(3)
|
||
const reservedHeights = weekRows.map((row) => row.style.getPropertyValue('--band-reserved-height'))
|
||
// Every row reserves the SAME height, and it's non-zero (the month
|
||
// has at least one band) — a week with no band of its own must still
|
||
// match the tallest week's reservation, or its row would render
|
||
// shorter than the others.
|
||
expect(new Set(reservedHeights).size).toBe(1)
|
||
expect(reservedHeights[0]).not.toBe('0px')
|
||
})
|
||
})
|
||
|
||
describe('todo.md #14 — two parents cannot both be given the same days', () => {
|
||
it('saving the pinned/advanced path targets the modal-selected parent, never the logged-in one', async () => {
|
||
const today = todayDateString()
|
||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
|
||
}),
|
||
)
|
||
// Parent A already has a pinned sjukpenning day on `today` — this is
|
||
// exactly the state the bug report described after "parent A takes 2
|
||
// days on PL".
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({
|
||
id: 'pd-a-day',
|
||
date: today,
|
||
parentPersonalDataUuid: 'person-1',
|
||
dayType: 'sjukpenning',
|
||
isPinned: true,
|
||
}),
|
||
],
|
||
add,
|
||
update,
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))!
|
||
fireEvent.click(todayCell)
|
||
|
||
// Opens defaulted to A (the logged-in parent) — A's existing day shows.
|
||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes')
|
||
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'person-1')
|
||
|
||
// Switch the advanced parent to B — B has nothing here yet.
|
||
fireEvent.click(screen.getByTestId('modal-change-parent-b'))
|
||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
|
||
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'pd-b')
|
||
|
||
// Now select vacation for B and save through the advanced/pinned path.
|
||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||
|
||
// A's row must never be touched — the old bug called `update` on it.
|
||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parent_personal_data_uuid: 'pd-b', day_type: 'vacation' })))
|
||
expect(update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('deleting through the advanced path removes the currently-selected parent\'s day, not the other parent\'s', async () => {
|
||
const today = todayDateString()
|
||
const remove = vi.fn().mockResolvedValue(undefined)
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
|
||
}),
|
||
)
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({ id: 'pd-a-day', date: today, parentPersonalDataUuid: 'person-1', dayType: 'sjukpenning' }),
|
||
makePlannedLeaveDay({ id: 'pd-b-day', date: today, parentPersonalDataUuid: 'pd-b', dayType: 'vacation' }),
|
||
],
|
||
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-change-parent-b'))
|
||
fireEvent.click(screen.getByTestId('modal-delete'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-b-day'))
|
||
})
|
||
})
|
||
|
||
describe('todo.md #10/#11 — the manual-add modal defaults to a planner-funded wish', () => {
|
||
it('a click/drag by default creates a fixed, once-off wish (not a pinned day) for the chosen parent mode', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||
const add = vi.fn().mockResolvedValue(created)
|
||
const addDay = vi.fn()
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add: addDay }))
|
||
|
||
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-wish-both'))
|
||
|
||
await waitFor(() =>
|
||
expect(add).toHaveBeenCalledWith(
|
||
expect.objectContaining({ windowMode: 'fixed', recurrence: 'once', parentMode: 'both', wantsDubbeldag: false }),
|
||
),
|
||
)
|
||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||
// The default path never touches planned days directly.
|
||
expect(addDay).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('forwards wantsDubbeldag only when the wish targets both parents', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||
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 currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||
fireEvent.click(currentMonthDay)
|
||
fireEvent.click(screen.getByTestId('modal-save-wish-both-dubbeldag'))
|
||
|
||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true })))
|
||
})
|
||
|
||
it('never sends wantsDubbeldag for a specific-parent wish, even if the modal tried to', async () => {
|
||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||
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 currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||
fireEvent.click(currentMonthDay)
|
||
fireEvent.click(screen.getByTestId('modal-save-wish-specific-dubbeldag-attempt'))
|
||
|
||
await waitFor(() =>
|
||
expect(add).toHaveBeenCalledWith(
|
||
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false }),
|
||
),
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('todo.md #9 — the three-way remove', () => {
|
||
// Intent changed (was: '"day" scope is a no-op for a wish-linked band').
|
||
// The user asked for it to work: a single day removed from a
|
||
// planner-placed period is now recorded as a `PlannedDayExclusion`, which
|
||
// is what stops the next sync re-deriving it.
|
||
it('"day" scope records an exclusion for a wish-linked band, keyed on the band\'s parent', async () => {
|
||
const today = todayDateString()
|
||
const wish = makeLeaveWish({ id: 'lw-1' })
|
||
const remove = vi.fn().mockResolvedValue(undefined)
|
||
const update = vi.fn().mockResolvedValue(wish)
|
||
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-1' }))
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
|
||
vi.mocked(usePlannedDayExclusions).mockReturnValue(
|
||
makePlannedDayExclusionsResult({ exclude }),
|
||
)
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({
|
||
id: 'pd-1',
|
||
date: today,
|
||
wishId: 'lw-1',
|
||
parentPersonalDataUuid: 'person-1',
|
||
childId: 'child-1',
|
||
}),
|
||
],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
fireEvent.click(screen.getByTestId('period-delete-day'))
|
||
|
||
await waitFor(() =>
|
||
expect(exclude).toHaveBeenCalledWith({
|
||
workspaceId: 'ws-1',
|
||
childId: 'child-1',
|
||
parentPersonalDataUuid: 'person-1',
|
||
date: today,
|
||
}),
|
||
)
|
||
// The wish itself is untouched — only this one date goes.
|
||
expect(remove).not.toHaveBeenCalled()
|
||
expect(update).not.toHaveBeenCalled()
|
||
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
|
||
})
|
||
|
||
it('offers an undo that restores the removed day', async () => {
|
||
const today = todayDateString()
|
||
const wish = makeLeaveWish({ id: 'lw-1' })
|
||
const exclude = vi.fn().mockResolvedValue(makePlannedDayExclusion({ id: 'ex-9' }))
|
||
const restore = vi.fn().mockResolvedValue(undefined)
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
|
||
vi.mocked(usePlannedDayExclusions).mockReturnValue(
|
||
makePlannedDayExclusionsResult({ exclude, restore }),
|
||
)
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({
|
||
id: 'pd-1',
|
||
date: today,
|
||
wishId: 'lw-1',
|
||
parentPersonalDataUuid: 'person-1',
|
||
childId: 'child-1',
|
||
}),
|
||
],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
fireEvent.click(screen.getByTestId('period-delete-day'))
|
||
|
||
await waitFor(() => expect(screen.getByTestId('calendar-toast-undo')).toBeInTheDocument())
|
||
fireEvent.click(screen.getByTestId('calendar-toast-undo'))
|
||
|
||
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-9'))
|
||
})
|
||
|
||
it('offers the removed day back from the day popover, long after the toast is gone', async () => {
|
||
const today = todayDateString()
|
||
const restore = vi.fn().mockResolvedValue(undefined)
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
|
||
}),
|
||
)
|
||
vi.mocked(usePlannedDayExclusions).mockReturnValue(
|
||
makePlannedDayExclusionsResult({
|
||
restore,
|
||
data: [
|
||
makePlannedDayExclusion({
|
||
id: 'ex-7',
|
||
date: today,
|
||
parentPersonalDataUuid: 'person-1',
|
||
}),
|
||
],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
const todayCell = screen
|
||
.getAllByTestId('calendar-day')
|
||
.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||
fireEvent.click(todayCell!)
|
||
|
||
const restoreButton = await screen.findByTestId('modal-restore-excluded-ex-7')
|
||
expect(restoreButton).toHaveAttribute('data-parent-name', 'Alex')
|
||
fireEvent.click(restoreButton)
|
||
|
||
await waitFor(() => expect(restore).toHaveBeenCalledWith('ex-7'))
|
||
})
|
||
|
||
it('tells the popover which date and which parent "this day" would remove', async () => {
|
||
const today = todayDateString()
|
||
const wish = makeLeaveWish({ id: 'lw-1' })
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish] }))
|
||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||
makeWorkspaceMembersResult({
|
||
data: [makePersonalData({ uuid: 'person-1', name: 'Alex' })],
|
||
}),
|
||
)
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({
|
||
id: 'pd-1',
|
||
date: today,
|
||
wishId: 'lw-1',
|
||
parentPersonalDataUuid: 'person-1',
|
||
childId: 'child-1',
|
||
}),
|
||
],
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
|
||
const popover = screen.getByTestId('period-popover')
|
||
expect(popover).toHaveAttribute('data-remove-day-parent', 'Alex')
|
||
expect(popover.getAttribute('data-remove-day-label')).toBeTruthy()
|
||
})
|
||
|
||
it('"every year" scope is a no-op for an ad-hoc band (disabled in the UI; nothing recurring to remove)', async () => {
|
||
const today = todayDateString()
|
||
const removeDay = vi.fn()
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
|
||
remove: removeDay,
|
||
}),
|
||
)
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||
|
||
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
|
||
expect(removeDay).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('"day" scope on an ad-hoc band removes only the clicked occurrence\'s first date', async () => {
|
||
const removeDay = vi.fn().mockResolvedValue(undefined)
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({ id: 'pd-1', date: '2027-03-10', wishId: null, parentPersonalDataUuid: 'person-1' }),
|
||
makePlannedLeaveDay({ id: 'pd-2', date: '2027-03-11', wishId: null, parentPersonalDataUuid: 'person-1' }),
|
||
],
|
||
remove: removeDay,
|
||
}),
|
||
)
|
||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2027-03-10'), mockSetSearchParams])
|
||
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
fireEvent.click(screen.getByTestId('period-delete-day'))
|
||
|
||
await waitFor(() => expect(removeDay).toHaveBeenCalledWith('pd-1'))
|
||
expect(removeDay).not.toHaveBeenCalledWith('pd-2')
|
||
})
|
||
|
||
describe('"year" scope on a recurring wish — one row, one delete', () => {
|
||
// Each year of a repeating wish is its own row now, so removing one is
|
||
// a plain delete. The old shape had to narrow `firstYear`/`lastYear` —
|
||
// and, for a middle year, split the wish into two wishes. Both of those
|
||
// contortions are gone; the tests that described them are replaced by
|
||
// the ones below, which assert the behaviour the user asked for
|
||
// (remove exactly this year, leave the rest alone).
|
||
const seriesWish = (firstYear: number) =>
|
||
makeLeaveWish({ id: 'lw-1', seriesId: 'series-1', firstYear, lastYear: 2030 })
|
||
|
||
const renderWithBand = (wish: ReturnType<typeof makeLeaveWish>, isoDate: string) => {
|
||
vi.mocked(usePlannedDays).mockReturnValue(
|
||
makePlannedDaysResult({
|
||
data: [
|
||
makePlannedLeaveDay({
|
||
id: 'pd-1',
|
||
date: isoDate,
|
||
wishId: wish.id,
|
||
parentPersonalDataUuid: 'person-1',
|
||
}),
|
||
],
|
||
}),
|
||
)
|
||
vi.mocked(useSearchParams).mockReturnValue([
|
||
new URLSearchParams(`date=${isoDate}`),
|
||
mockSetSearchParams,
|
||
])
|
||
render(<Calendar />)
|
||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||
}
|
||
|
||
it('removes just the clicked year\'s row, leaving the rest of the series alone', async () => {
|
||
const wish = seriesWish(2028)
|
||
const remove = vi.fn().mockResolvedValue(undefined)
|
||
const update = vi.fn()
|
||
const add = vi.fn()
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useLeaveWishes).mockReturnValue(
|
||
makeLeaveWishesResult({ data: [wish], remove, update, add }),
|
||
)
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
renderWithBand(wish, '2028-06-15')
|
||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
|
||
// No range-narrowing, and above all no second wish: the split hack is gone.
|
||
expect(update).not.toHaveBeenCalled()
|
||
expect(add).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('undo puts the removed year back INTO its series, not beside it', async () => {
|
||
const wish = seriesWish(2028)
|
||
const remove = vi.fn().mockResolvedValue(undefined)
|
||
const add = vi.fn().mockResolvedValue(wish)
|
||
const sync = vi
|
||
.fn()
|
||
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }))
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
renderWithBand(wish, '2028-06-15')
|
||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||
|
||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||
fireEvent.click(undoButton)
|
||
|
||
await waitFor(() =>
|
||
expect(add).toHaveBeenCalledWith(
|
||
expect.objectContaining({ firstYear: 2028, seriesId: 'series-1', recurrence: 'once' }),
|
||
),
|
||
)
|
||
})
|
||
|
||
it('removing the first year of a series is the same plain delete', async () => {
|
||
const wish = seriesWish(2026)
|
||
const remove = vi.fn().mockResolvedValue(undefined)
|
||
const update = vi.fn()
|
||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
renderWithBand(wish, '2026-06-15')
|
||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
|
||
expect(update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('a once-off wish treats "this year" the same as "every year" (there is only one occurrence)', async () => {
|
||
const wish = makeLeaveWish({ id: 'lw-1', seriesId: null, firstYear: 2026 })
|
||
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 }))
|
||
|
||
renderWithBand(wish, '2026-06-15')
|
||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'occurrence'))
|
||
})
|
||
|
||
it('undo after removing the whole series recreates it as a repeating wish', async () => {
|
||
const wish = seriesWish(2028)
|
||
const remove = vi.fn().mockResolvedValue(undefined)
|
||
const add = vi.fn().mockResolvedValue(wish)
|
||
const sync = vi
|
||
.fn()
|
||
.mockResolvedValue(makePlanSyncResponse({ changes: [makePlanChange({ oldWishId: 'lw-1', newWishId: null, kind: 'removed' })] }))
|
||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, add }))
|
||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||
|
||
renderWithBand(wish, '2028-06-15')
|
||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||
|
||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||
fireEvent.click(undoButton)
|
||
|
||
// Rebuilt as the repeating wish it was, spanning the years the series
|
||
// covered — not as the single clicked occurrence.
|
||
await waitFor(() =>
|
||
expect(add).toHaveBeenCalledWith(
|
||
expect.objectContaining({ recurrence: 'yearly', firstYear: 2028, lastYear: 2030 }),
|
||
),
|
||
)
|
||
})
|
||
|
||
it('undo after editing one year of a series restores that row without re-materialising', async () => {
|
||
const wish = seriesWish(2028)
|
||
const update = vi.fn().mockResolvedValue(wish)
|
||
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 }))
|
||
|
||
renderWithBand(wish, '2028-06-15')
|
||
fireEvent.click(screen.getByTestId('period-save'))
|
||
|
||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||
fireEvent.click(undoButton)
|
||
|
||
await waitFor(() => expect(update).toHaveBeenCalledTimes(2))
|
||
const [, undoPatch, undoScope] = update.mock.calls[1]!
|
||
expect(undoScope).toBe('occurrence')
|
||
// The row was already part of a series, so its repeat-ness is restored
|
||
// but the series-level end year is left alone (the backend rejects it
|
||
// under this scope).
|
||
expect(undoPatch).toMatchObject({ recurrence: 'yearly' })
|
||
expect(undoPatch).not.toHaveProperty('lastYear')
|
||
})
|
||
|
||
it('"every year" removes the whole series in one call', async () => {
|
||
const wish = seriesWish(2028)
|
||
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 }))
|
||
|
||
renderWithBand(wish, '2028-06-15')
|
||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||
|
||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1', 'series'))
|
||
})
|
||
})
|
||
})
|
||
})
|