import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { PeriodPopover } from './PeriodPopover'
import { makeLeaveWish } from '../../../tests/fixtures'
describe('PeriodPopover', () => {
const mockOnClose = vi.fn()
const mockOnSave = vi.fn()
const mockOnDelete = vi.fn()
const defaultProps = {
isOpen: true,
wish: null,
startDate: '2028-06-15',
endDate: '2028-06-20',
parents: [{ id: 'p1', name: 'Parent 1' }],
onSave: mockOnSave,
onDelete: mockOnDelete,
onClose: mockOnClose,
}
beforeEach(() => {
vi.clearAllMocks()
})
/** The three delete scopes sit behind "Ta bort…" — press it first. */
function openDeleteMenu() {
fireEvent.click(screen.getByTestId('period-delete-menu-button'))
}
it('returns null when not open', () => {
const { container } = render()
expect(container.firstChild).toBeNull()
})
describe('the delete menu', () => {
it('keeps the three delete scopes hidden until "Ta bort…" is pressed', () => {
render()
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
expect(screen.getByTestId('period-delete-menu-button')).toHaveTextContent('Ta bort…')
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'false')
openDeleteMenu()
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('period-delete-group')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-day-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-all-years-button')).toBeInTheDocument()
})
it('folds the scopes away again on a second press', () => {
render()
openDeleteMenu()
openDeleteMenu()
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
})
it('starts folded again each time the popover reopens', () => {
const { rerender } = render()
openDeleteMenu()
rerender()
rerender()
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
})
})
describe('ad-hoc band (no wish)', () => {
it('shows the ad-hoc notice and date range instead of editable fields', () => {
render()
expect(screen.getByTestId('period-adhoc-notice')).toBeInTheDocument()
expect(screen.getByText('2028-06-15 – 2028-06-20')).toBeInTheDocument()
expect(screen.queryByTestId('period-name-input')).not.toBeInTheDocument()
})
it('does not render a save button', () => {
render()
expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument()
})
it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => {
render()
openDeleteMenu()
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar')
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toHaveAttribute(
'title',
'Den här perioden är inte kopplad till en återkommande plan.',
)
})
it('shows an inline confirmation instead of deleting immediately', () => {
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument()
})
it('calls onDelete with the "year" scope after confirming inline', async () => {
mockOnDelete.mockResolvedValue(undefined)
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
})
it('calls onDelete with the "day" scope for the day button', async () => {
mockOnDelete.mockResolvedValue(undefined)
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
})
it('does not call onDelete when the inline confirmation is cancelled', () => {
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.queryByTestId('period-delete-confirm')).not.toBeInTheDocument()
expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument()
})
})
describe('wish-linked band', () => {
const wish = makeLeaveWish({
id: 'lw-1',
name: 'Sommar',
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
windowEndDay: 26,
firstYear: 2028,
// One year of a repeating wish: the series link is what makes it
// "repeat" now, and what makes the this-year/every-year choice real.
seriesId: 'series-1',
lastYear: null,
parentMode: 'either',
})
it('prefills name/dates/parent/repeat from the wish', () => {
// startDate/endDate here are the clicked band's real placed dates
// (the 2028 occurrence, which happens to equal the wish's first
// occurrence) — see the dedicated "later-year occurrence" tests below
// for the case where they diverge from `wish.firstYear`.
render(
,
)
expect(screen.getByTestId('period-name-input')).toHaveValue('Sommar')
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15')
expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26')
expect(screen.getByTestId('period-parent-select')).toHaveValue('either')
expect(screen.getByTestId('period-repeat-yearly')).toBeChecked()
})
it('falls back to the wish\'s window-derived dates when no placed dates are supplied', () => {
render()
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15')
expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26')
})
it('shows the wrapping window correctly for a Jul-style wish (end year = firstYear + 1)', () => {
const jul = makeLeaveWish({
windowStartMonth: 12,
windowStartDay: 20,
windowEndMonth: 1,
windowEndDay: 16,
firstYear: 2028,
})
render()
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-20')
expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-16')
})
it('prefills the clicked later-year occurrence\'s dates, not the wish\'s first-occurrence window', () => {
// The wish was first taken in 2026; the user clicked its 2028 band.
const yearlyFrom2026 = makeLeaveWish({
windowStartMonth: 6,
windowStartDay: 15,
windowEndMonth: 7,
windowEndDay: 26,
firstYear: 2026,
seriesId: 'series-1',
})
render(
,
)
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15')
expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26')
// Non-date fields still come from the wish itself, unaffected by which
// occurrence was clicked.
expect(screen.getByTestId('period-repeat-yearly')).toBeChecked()
expect(screen.getByTestId('period-parent-select')).toHaveValue('either')
})
it('prefills a flexible wish\'s actual placed dates, not its bounding window', () => {
// A flexible/wrapping wish whose *window* spans Dec 20 - Jan 16, but
// the allocator actually placed this occurrence a few days later.
const flexible = makeLeaveWish({
windowStartMonth: 12,
windowStartDay: 20,
windowEndMonth: 1,
windowEndDay: 16,
firstYear: 2028,
})
render(
,
)
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-27')
expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-10')
})
it('shows "until age 4" checked when lastYear is null', () => {
render()
expect(screen.getByTestId('period-until-age4')).toBeChecked()
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
})
it('shows an explicit last-year input when lastYear is set', () => {
render()
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2030)
})
it('unchecking "until age 4" reveals a last-year input seeded from the start date\'s year', () => {
render()
expect(screen.getByTestId('period-until-age4')).toBeChecked()
fireEvent.click(screen.getByTestId('period-until-age4'))
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2028)
})
it('re-checking "until age 4" hides the last-year input and clears it on save', async () => {
// The end year is series-wide, so the flow starts by widening the scope
// — the control is disabled until then (see the scope tests above).
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.click(screen.getByTestId('period-scope-series'))
fireEvent.click(screen.getByTestId('period-until-age4'))
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: null })))
})
it('editing the last-year input updates the value sent on save', async () => {
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.click(screen.getByTestId('period-scope-series'))
fireEvent.change(screen.getByTestId('period-last-year-input'), { target: { value: '2032' } })
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2032)
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: 2032 })))
})
// Per-year vs whole-series editing. Only a real choice once the
// occurrence belongs to a series — that is what materialising the years
// into their own rows bought (Easter moves, so one year has to be able to
// move without the rest).
describe('this year vs every year', () => {
it('offers the choice for an occurrence of a repeating wish', () => {
render()
expect(screen.getByTestId('period-scope-fieldset')).toBeInTheDocument()
expect(screen.getByTestId('period-scope-occurrence')).toBeChecked()
expect(screen.getByTestId('period-scope-series')).not.toBeChecked()
})
it('does not offer it for a one-off period, where there is nothing to choose between', () => {
render()
expect(screen.queryByTestId('period-scope-fieldset')).not.toBeInTheDocument()
})
it('hides it again when the user turns repeating off', () => {
render()
fireEvent.click(screen.getByTestId('period-repeat-never'))
expect(screen.queryByTestId('period-scope-fieldset')).not.toBeInTheDocument()
})
it('saves this year only by default', async () => {
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() =>
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ scope: 'occurrence' })),
)
})
it('saves every year once "Alla år" is picked', async () => {
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.click(screen.getByTestId('period-scope-series'))
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() =>
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ scope: 'series' })),
)
})
it('locks the series end year until the edit is series-wide', () => {
// `lastYear` belongs to the whole series, so the backend rejects a
// change to it under the this-year-only scope. Say so in the UI
// rather than let the user hit a 422.
render()
expect(screen.getByTestId('period-last-year-input')).toBeDisabled()
expect(screen.getByTestId('period-last-year-locked-hint')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('period-scope-series'))
expect(screen.getByTestId('period-last-year-input')).toBeEnabled()
expect(screen.queryByTestId('period-last-year-locked-hint')).not.toBeInTheDocument()
})
})
it('toggles to "Never" repeat and hides the yearly options', () => {
render()
fireEvent.click(screen.getByTestId('period-repeat-never'))
expect(screen.queryByTestId('period-yearly-options')).not.toBeInTheDocument()
})
it('calls onSave with the edited name, dates and recurrence', async () => {
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.change(screen.getByTestId('period-name-input'), { target: { value: 'Sommarledighet' } })
fireEvent.change(screen.getByTestId('period-end-input'), { target: { value: '2028-08-02' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith({
name: 'Sommarledighet',
startDate: '2028-06-15',
endDate: '2028-08-02',
parentMode: 'either',
parentPersonalDataUuid: null,
recurrence: 'yearly',
// Defaults to the narrow scope: an edit must not silently rewrite
// the other years of the series.
scope: 'occurrence',
lastYear: null,
wantsDubbeldag: false,
})
})
})
it('sends lastYear: null when switched to Never even if a last year was set', async () => {
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.click(screen.getByTestId('period-repeat-never'))
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ recurrence: 'once', lastYear: null }))
})
})
it('switches parentMode to specific when a member is selected', async () => {
mockOnSave.mockResolvedValue(undefined)
render(
,
)
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'p2' }),
)
})
})
it('switches parentMode back to "both" after picking a specific member', async () => {
mockOnSave.mockResolvedValue(undefined)
render(
,
)
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'both' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(
expect.objectContaining({ parentMode: 'both', parentPersonalDataUuid: null }),
)
})
})
describe('dubbeldagar checkbox', () => {
it('is enabled only when parentMode is "both"', () => {
render()
expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).not.toBeDisabled()
})
it('is disabled for a non-"both" parentMode', () => {
render()
expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).toBeDisabled()
})
it('sends wantsDubbeldag: true only when checked in "both" mode', async () => {
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.click(screen.getByTestId('period-wants-dubbeldag-checkbox'))
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true })))
})
it('never sends wantsDubbeldag: true outside "both" mode, even if it was checked before switching', async () => {
mockOnSave.mockResolvedValue(undefined)
render()
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'either' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: false })))
})
})
// Intent changed (was: '"this day" is disabled for a wish-linked band').
// A single day removed from a planner-placed period is now recorded as a
// `PlannedDayExclusion`, so it no longer reappears on the next sync and
// the option is live — see Calendar.tsx's `excludeDay`.
it('enables "this day" for a wish-linked band and explains what it does', () => {
render()
openDeleteMenu()
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute(
'title',
'Bara den dagen tas bort. Resten av perioden ligger kvar, och planeraren lägger inte tillbaka dagen.',
)
expect(screen.getByTestId('period-delete-year-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).not.toBeDisabled()
})
it('calls onDelete with the "day" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
})
it('names the date and the parent in the "this day" confirmation', () => {
render(
,
)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Ta bort 17 juni 2028 för Alex?',
)
})
it('falls back to the generic confirmation when the parent is unknown', () => {
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här dagen?',
)
})
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
mockOnDelete.mockRejectedValue('boom')
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
})
})
it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears'))
})
it('calls onDelete with the "year" scope for a wish-linked band', async () => {
mockOnDelete.mockResolvedValue(undefined)
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
})
it('shows the wish-linked "this period" and "every year" labels', () => {
render()
openDeleteMenu()
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden')
expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år')
})
it('shows a scope-specific confirmation message for each delete button', () => {
render()
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
)
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort perioden — alla år?',
)
})
it('displays a local error when onSave rejects', async () => {
mockOnSave.mockRejectedValue(new Error('save exploded'))
render()
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('save exploded')
})
})
it('displays a generic error when onSave rejects with a non-Error value', async () => {
mockOnSave.mockRejectedValue('boom')
render()
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).not.toHaveTextContent('boom')
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
})
})
it('displays the error prop directly', () => {
render()
expect(screen.getByRole('alert')).toHaveTextContent('server said no')
})
it('shows the loading state while saving', () => {
render()
expect(screen.getByTestId('period-save-button')).toBeDisabled()
expect(screen.getByTestId('period-save-button')).toHaveTextContent('Sparar…')
})
it('resolves a preset-created title through its message key, not the stale stored name', () => {
const presetWish = makeLeaveWish({ name: 'Stale Cached Name', nameMessageKey: 'wishEditor.seasonSommar' })
render()
expect(screen.getByTestId('period-popover-title')).toHaveTextContent('Sommar')
})
})
describe('close behaviour', () => {
it('calls onClose on the close button', () => {
render()
const closeButton = screen.getByLabelText('Stäng')
expect(closeButton.querySelector('[data-icon="close"]')).toBeInTheDocument()
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalled()
})
it('calls onClose on overlay click but not on content click', () => {
render()
fireEvent.click(screen.getByTestId('period-popover__content'))
expect(mockOnClose).not.toHaveBeenCalled()
fireEvent.click(screen.getByTestId('period-popover__overlay'))
expect(mockOnClose).toHaveBeenCalled()
})
it('calls onClose on the cancel button', () => {
render()
fireEvent.click(screen.getByTestId('period-cancel-button'))
expect(mockOnClose).toHaveBeenCalled()
})
})
})