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() }) }) })