import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { Settings } from './Settings' import * as hooks from '@/hooks' import { useWorkspace } from '@/context' import * as api from '@/services/api' import { LanguageProvider } from '@/i18n' vi.mock('@/hooks') vi.mock('@/context') vi.mock('@/services/api') // Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree // to work. Settings.tsx only cares that its content renders inside Layout, so // stub Layout out — same pattern used by Calendar.test.tsx. vi.mock('@/components/layout/Layout', () => ({ Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) function renderSettings() { return render() } // The language selector tests exercise real locale switching, which needs an // actual in the tree (the other tests in this file render // Settings unwrapped and get the i18n module's non-reactive default context, // which is fine since they never touch the locale). function renderSettingsWithLanguageProvider() { return render( , ) } const CURRENT_YEAR = new Date().getFullYear() // Turns 4 three years from now -> vacation table should span CURRENT_YEAR..CURRENT_YEAR+3 const CHILD_BIRTH_DATE = `${CURRENT_YEAR - 1}-01-01` describe('Settings', () => { const mockWorkspaceContext = { workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced' as const, planningMode: 'solo' as const, createdAt: '2026-01-01', }, isLoading: false, error: null, workspaceId: 'ws-1', setWorkspaceId: vi.fn(), refetch: vi.fn(), children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }], selectedChildId: 'c-1', selectChild: vi.fn(), personalDataUuid: 'pd-1', parentName: 'Test Parent', refreshContext: vi.fn(), } const mockVacationDays = [ { id: 'vd-1', personalDataUuid: 'pd-1', workspaceId: 'ws-1', year: CURRENT_YEAR, totalDays: 10, savedDaysIn: 2, createdAt: '2026-01-01T00:00:00', updatedAt: '2026-01-01T00:00:00', }, ] const mockChildDetails = { id: 'c-1', workspaceId: 'ws-1', name: 'Child 1', birthDate: CHILD_BIRTH_DATE, preschoolStartDate: null, preschoolPlanningDaysPerYear: 6, createdAt: '2026-01-01T00:00:00', } let mockUpsertVacationDays: ReturnType let mockUpdateChildDetails: ReturnType let mockUpsertDayOverride: ReturnType let mockRemoveDayOverride: ReturnType const mockCalendarWindow = { startYear: CURRENT_YEAR, endYear: CURRENT_YEAR + 3, minYear: 2026, maxYear: 2035, days: [], } beforeEach(() => { vi.clearAllMocks() mockUpsertVacationDays = vi.fn().mockResolvedValue(undefined) mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined) mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined) mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined) vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any) vi.mocked(hooks.useVacationDays).mockReturnValue({ data: mockVacationDays, isLoading: false, error: null, upsert: mockUpsertVacationDays, } as any) vi.mocked(hooks.useShowWeekNumbers).mockReturnValue([false, vi.fn()] as any) vi.mocked(hooks.useChildSettings).mockReturnValue({ data: mockChildDetails, isLoading: false, error: null, update: mockUpdateChildDetails, } as any) vi.mocked(hooks.useDayOverrides).mockReturnValue({ data: [], isLoading: false, error: null, upsert: mockUpsertDayOverride, remove: mockRemoveDayOverride, } as any) vi.mocked(hooks.useCalendarDays).mockReturnValue({ data: mockCalendarWindow, isLoading: false, error: null, } as any) vi.mocked(api.updateWorkspace).mockResolvedValue({ ...mockWorkspaceContext.workspace } as any) vi.mocked(api.updateParentSettings).mockResolvedValue({} as any) vi.mocked(api.addPartner).mockResolvedValue({} as any) }) it('renders loading spinner while workspace loads', () => { vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspaceContext, isLoading: true } as any) renderSettings() expect(screen.getByText(/Laddar inställningar/)).toBeInTheDocument() }) it('renders consistently Swedish section headings', () => { renderSettings() expect(screen.getByRole('heading', { name: 'Barn' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Din profil' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Planering' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Semesterdagar' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Kalender' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Undantag i kalendern' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Röda dagar' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Partner' })).toBeInTheDocument() }) describe('vacation days table', () => { it('renders one row per year from the current year through the year the child turns 4', () => { renderSettings() for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) { expect(screen.getByTestId(`vacation-year-row-${year}`)).toBeInTheDocument() } expect(screen.queryByTestId(`vacation-year-row-${CURRENT_YEAR + 4}`)).not.toBeInTheDocument() }) it('prefills a year that already has saved data', () => { renderSettings() const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`) as HTMLInputElement expect(totalInput.value).toBe('10') expect(savedInput.value).toBe('2') }) it('defaults a year with no saved data to 25 vacation days', () => { renderSettings() const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR + 1}`) as HTMLInputElement expect(totalInput.value).toBe('25') }) it('saves the edited year with its total days when Spara is clicked', async () => { renderSettings() const year = CURRENT_YEAR + 1 const totalInput = screen.getByTestId(`vacation-total-days-${year}`) fireEvent.change(totalInput, { target: { value: '30' } }) fireEvent.click(screen.getByTestId(`vacation-save-${year}`)) await waitFor(() => expect(mockUpsertVacationDays).toHaveBeenCalledWith({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', year, totalDays: 30, savedDaysIn: 0, }), ) }) it('saves saved-days-in alongside total days', async () => { renderSettings() const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`) fireEvent.change(savedInput, { target: { value: '7' } }) fireEvent.click(screen.getByTestId(`vacation-save-${CURRENT_YEAR}`)) await waitFor(() => expect(mockUpsertVacationDays).toHaveBeenCalledWith({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', year: CURRENT_YEAR, totalDays: 10, savedDaysIn: 7, }), ) }) }) describe('planning mode', () => { it('reflects the workspace planning mode', () => { renderSettings() expect(screen.getByTestId('planning-mode-solo')).toBeChecked() }) it('saves the selected planning mode together with the planning goal', async () => { renderSettings() fireEvent.click(screen.getByTestId('planning-mode-couple')) fireEvent.click(screen.getByTestId('save-planning-goal-button')) await waitFor(() => expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { planningGoal: 'balanced', planningMode: 'couple', }), ) }) }) describe('child settings', () => { it('prefills birth date and preschool planning days from the child', () => { renderSettings() const birthInput = screen.getByTestId('birth-date-input') as HTMLInputElement const preschoolInput = screen.getByTestId('vacation-days-field') as HTMLInputElement expect(birthInput.value).toBe(CHILD_BIRTH_DATE) expect(preschoolInput.value).toBe('6') }) it('saves birth date and preschool planning days together', async () => { renderSettings() const preschoolInput = screen.getByTestId('vacation-days-field') fireEvent.change(preschoolInput, { target: { value: '10' } }) fireEvent.click(screen.getByTestId('save-child-button')) await waitFor(() => expect(mockUpdateChildDetails).toHaveBeenCalledWith({ birthDate: CHILD_BIRTH_DATE, preschoolPlanningDaysPerYear: 10, }), ) }) }) it('saves parent profile fields', async () => { renderSettings() fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } }) fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } }) fireEvent.click(screen.getByTestId('save-parent-button')) await waitFor(() => expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', { salary_type: 'monthly', employment_status: 'employed', }), ) }) it('adds a partner', async () => { renderSettings() fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } }) fireEvent.click(screen.getByTestId('add-partner-button')) await waitFor(() => expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Partner Name')) }) it('toggles week numbers', () => { const setShowWeekNumbers = vi.fn() vi.mocked(hooks.useShowWeekNumbers).mockReturnValue([false, setShowWeekNumbers] as any) renderSettings() fireEvent.click(screen.getByTestId('show-week-numbers-checkbox')) expect(setShowWeekNumbers).toHaveBeenCalledWith(true) }) describe('language selector', () => { // Node's own experimental `localStorage` global shadows jsdom's working // implementation in this environment and throws on every call — stub a // real in-memory Storage, matching the idiom used in // WorkspaceContext.test.tsx and useCalendarPreferences.test.ts. function createStorageMock(): Storage { let store: Record = {} return { getItem: (key: string) => (key in store ? store[key]! : null), setItem: (key: string, value: string) => { store[key] = value }, removeItem: (key: string) => { delete store[key] }, clear: () => { store = {} }, key: (index: number) => Object.keys(store)[index] ?? null, get length() { return Object.keys(store).length }, } } beforeEach(() => { vi.stubGlobal('localStorage', createStorageMock()) }) afterEach(() => { vi.unstubAllGlobals() }) it('is labelled understandably regardless of the active language', () => { renderSettingsWithLanguageProvider() expect(screen.getByText('Språk / Language')).toBeInTheDocument() expect(screen.getByText('Välj språk / Select language')).toBeInTheDocument() }) it('offers automatic, Swedish, and English options', () => { renderSettingsWithLanguageProvider() const select = screen.getByTestId('language-select') as HTMLSelectElement const optionValues = Array.from(select.options).map((o) => o.value) expect(optionValues).toEqual(['auto', 'sv', 'en']) }) it('defaults to "auto" when no explicit preference is stored', () => { renderSettingsWithLanguageProvider() const select = screen.getByTestId('language-select') as HTMLSelectElement expect(select.value).toBe('auto') }) it('switching to English updates the UI immediately, without a reload', () => { renderSettingsWithLanguageProvider() expect(screen.getByRole('heading', { name: 'Inställningar' })).toBeInTheDocument() fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Inställningar' })).not.toBeInTheDocument() }) it('persists the explicit choice to localStorage', () => { renderSettingsWithLanguageProvider() fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) expect(localStorage.getItem('language_preference')).toBe('en') }) it('going back to "Automatic" clears the stored preference', () => { renderSettingsWithLanguageProvider() fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'auto' } }) expect(localStorage.getItem('language_preference')).toBeNull() }) }) describe('red days list (§3.8)', () => { it('passes the fetched calendar window through to the list, four years at a time', () => { vi.mocked(hooks.useCalendarDays).mockReturnValue({ data: { startYear: CURRENT_YEAR, endYear: CURRENT_YEAR + 3, minYear: 2026, maxYear: 2035, days: [ { date: `${CURRENT_YEAR}-01-01`, name: 'Nyårsdagen', dayType: 'red_day', isOverride: false, overrideIsWorkingDay: null, isFree: true, }, ], }, isLoading: false, error: null, } as any) renderSettings() expect(screen.getByTestId('red-days-range')).toHaveTextContent(`${CURRENT_YEAR}–${CURRENT_YEAR + 3}`) expect(screen.getByTestId(`red-days-item-${CURRENT_YEAR}-01-01`)).toHaveTextContent('Nyårsdagen') }) it('requests the next 4-year window when paginating forward', () => { renderSettings() fireEvent.click(screen.getByTestId('red-days-next')) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR + 4, 4) }) it('requests the previous 4-year window when paginating backward', () => { // minYear well below the current window so the "previous" button isn't disabled. vi.mocked(hooks.useCalendarDays).mockReturnValue({ data: { ...mockCalendarWindow, minYear: 2000 }, isLoading: false, error: null, } as any) renderSettings() fireEvent.click(screen.getByTestId('red-days-prev')) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4) }) }) })