76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
416 lines
15 KiB
TypeScript
416 lines
15 KiB
TypeScript
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 }) => <div>{children}</div>,
|
||
}))
|
||
|
||
function renderSettings() {
|
||
return render(<Settings />)
|
||
}
|
||
|
||
// The language selector tests exercise real locale switching, which needs an
|
||
// actual <LanguageProvider> 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(
|
||
<LanguageProvider>
|
||
<Settings />
|
||
</LanguageProvider>,
|
||
)
|
||
}
|
||
|
||
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<typeof vi.fn>
|
||
let mockUpdateChildDetails: ReturnType<typeof vi.fn>
|
||
let mockUpsertDayOverride: ReturnType<typeof vi.fn>
|
||
let mockRemoveDayOverride: ReturnType<typeof vi.fn>
|
||
|
||
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<string, string> = {}
|
||
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)
|
||
})
|
||
})
|
||
})
|