b76fb7417e
Warnings such as "vacation days at risk of being lost" kept showing after the planner had already resolved them. The planner itself was firing correctly -- PlanSyncProvider syncs on every plan-input change -- so the fault was in the warnings, in two independent places. The vacation-expiry warning was computed before allocation. It read `vacation_forfeiting`, which the loader derives from already-committed usage, while `_assign_tier3` deliberately spends at-risk vacation days first precisely to avoid that forfeiture. The figure therefore counted days the plan was in the middle of saving. `_vacation_expiry_warnings` now subtracts the vacation days this run actually placed for that parent and year, clamped at zero -- sound because the bank is FIFO, so a day spent in year Y retires one expiring day one-for-one. The full carry-forward simulation is still not duplicated inside the ledger. The Dashboard then never refreshed them. Its proposal came from a one-shot effect guarded by `proposal === null`, with no planVersion dependency, so the card kept rendering the pre-replan warning until remount -- while `syncPlan` was already returning a fresh proposal that both call sites discarded. PlanSyncProvider now exposes that proposal behind a monotonic counter, and usePlanProposal prefers it once any sync has completed, keeping `propose()` for the first load. Also adds a manual recompute button to Settings, which only bumps planVersion and reports what the resulting sync did, and fixes partner add/remove never bumping planVersion at all -- that gap meant gaining or losing a parent never re-ran the allocator, though it changes who days can be assigned to. Rename stays cosmetic. Verified in the browser: a 2031 warning of 15 days dropped to 12 after three vacation days were planned, without a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014jPFUcCJ5AKXmPVC1anhXe
1027 lines
41 KiB
TypeScript
1027 lines
41 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react'
|
|
import { Settings } from './Settings'
|
|
import * as hooks from '@/hooks'
|
|
import { useWorkspace, usePlanSyncContextOptional } from '@/context'
|
|
import * as api from '@/services/api'
|
|
import { ApiRequestError } from '@/services/errors'
|
|
import { LanguageProvider } from '@/i18n'
|
|
import type { BalanceFlowProps } from '@/components/balance/BalanceFlow'
|
|
import type { CalendarExceptionsProps } from '@/components/calendar/CalendarExceptions'
|
|
import type { PartnerInvitePanelProps } from '@/components/settings/PartnerInvitePanel'
|
|
import {
|
|
makeWorkspaceContextValue,
|
|
makeWorkspaceMembersResult,
|
|
makeVacationDaysResult,
|
|
makeShowWeekNumbersTuple,
|
|
makeChildSettingsResult,
|
|
makeDayOverridesResult,
|
|
makeCalendarDaysResult,
|
|
makePreschoolPlanningDaysResult,
|
|
makeAnalyticsResult,
|
|
makeAge4Projection,
|
|
makeParentYearBalance,
|
|
makeVacationYearBalance,
|
|
makePlanSyncContextValue,
|
|
makeEvictionPreview,
|
|
} from '../../tests/fixtures'
|
|
|
|
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>,
|
|
}))
|
|
|
|
// `BalanceFlow` and `CalendarExceptions` each own an extensive test suite of
|
|
// their own (BalanceFlow.test.tsx, CalendarExceptions.test.tsx) — Settings
|
|
// only needs to prove it wires the right props through to each, not
|
|
// re-exercise their internals.
|
|
let lastBalanceFlowProps: BalanceFlowProps | null = null
|
|
vi.mock('@/components/balance/BalanceFlow', () => ({
|
|
BalanceFlow: (props: BalanceFlowProps) => {
|
|
lastBalanceFlowProps = props
|
|
return <div data-testid="balance-flow-stub" />
|
|
},
|
|
}))
|
|
|
|
let lastCalendarExceptionsProps: CalendarExceptionsProps | null = null
|
|
vi.mock('@/components/calendar/CalendarExceptions', () => ({
|
|
CalendarExceptions: (props: CalendarExceptionsProps) => {
|
|
lastCalendarExceptionsProps = props
|
|
return <div data-testid="calendar-exceptions-stub" />
|
|
},
|
|
}))
|
|
|
|
// `PartnerInvitePanel` owns its own test suite (PartnerInvitePanel.test.tsx)
|
|
// — Settings only needs to prove it's mounted with the right workspace id.
|
|
let lastPartnerInvitePanelProps: PartnerInvitePanelProps | null = null
|
|
vi.mock('@/components/settings/PartnerInvitePanel', () => ({
|
|
PartnerInvitePanel: (props: PartnerInvitePanelProps) => {
|
|
lastPartnerInvitePanelProps = props
|
|
return <div data-testid="partner-invite-panel-stub" />
|
|
},
|
|
}))
|
|
|
|
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 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',
|
|
}
|
|
|
|
const selfMember = {
|
|
uuid: 'pd-1',
|
|
workspaceId: 'ws-1',
|
|
name: 'Test Parent',
|
|
salaryType: 'monthly' as const,
|
|
employmentStatus: 'employed' as const,
|
|
createdAt: '2026-01-01T00:00:00',
|
|
}
|
|
|
|
const partnerMember = {
|
|
uuid: 'pd-2',
|
|
workspaceId: 'ws-1',
|
|
name: 'Sam',
|
|
salaryType: 'monthly' as const,
|
|
employmentStatus: 'employed' as const,
|
|
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>
|
|
let mockRefreshMembers: ReturnType<typeof vi.fn>
|
|
let mockBumpPlanVersion: ReturnType<typeof vi.fn>
|
|
|
|
const mockCalendarWindow = {
|
|
startYear: CURRENT_YEAR,
|
|
endYear: CURRENT_YEAR + 3,
|
|
minYear: 2026,
|
|
maxYear: 2035,
|
|
days: [],
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
lastBalanceFlowProps = null
|
|
lastCalendarExceptionsProps = null
|
|
lastPartnerInvitePanelProps = null
|
|
mockUpsertVacationDays = vi.fn().mockResolvedValue(undefined)
|
|
mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined)
|
|
mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined)
|
|
mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined)
|
|
mockRefreshMembers = vi.fn().mockResolvedValue(undefined)
|
|
mockBumpPlanVersion = vi.fn()
|
|
|
|
vi.mocked(useWorkspace).mockReturnValue(
|
|
makeWorkspaceContextValue({
|
|
workspace: {
|
|
id: 'ws-1',
|
|
name: 'Test Workspace',
|
|
createdAt: '2026-01-01',
|
|
},
|
|
workspaceId: 'ws-1',
|
|
children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }],
|
|
selectedChildId: 'c-1',
|
|
personalDataUuid: 'pd-1',
|
|
parentName: 'Test Parent',
|
|
bumpPlanVersion: mockBumpPlanVersion,
|
|
})
|
|
)
|
|
// Default: no sync in flight / completed yet — the recompute button's
|
|
// own tests override this to simulate `PlanSyncProvider`'s background
|
|
// sync landing.
|
|
vi.mocked(usePlanSyncContextOptional).mockReturnValue(makePlanSyncContextValue())
|
|
// Default: solo workspace (only the logged-in user) — the add-partner
|
|
// form should be visible. Tests that need a linked partner override this.
|
|
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
|
makeWorkspaceMembersResult({
|
|
data: [selfMember],
|
|
refresh: mockRefreshMembers,
|
|
})
|
|
)
|
|
vi.mocked(hooks.useVacationDays).mockReturnValue(
|
|
makeVacationDaysResult({
|
|
data: mockVacationDays,
|
|
upsert: mockUpsertVacationDays,
|
|
})
|
|
)
|
|
// Default: no analytics data (matches a fresh/no-child workspace) — the
|
|
// vacation table's "at risk" column then shows 0, not blocking on it.
|
|
// Tests exercising the at-risk column override this.
|
|
vi.mocked(hooks.useAnalytics).mockReturnValue(makeAnalyticsResult({ data: null }))
|
|
vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, vi.fn()))
|
|
vi.mocked(hooks.useChildSettings).mockReturnValue(
|
|
makeChildSettingsResult({
|
|
data: mockChildDetails,
|
|
update: mockUpdateChildDetails,
|
|
})
|
|
)
|
|
vi.mocked(hooks.useDayOverrides).mockReturnValue(
|
|
makeDayOverridesResult({
|
|
data: [],
|
|
upsert: mockUpsertDayOverride,
|
|
remove: mockRemoveDayOverride,
|
|
})
|
|
)
|
|
vi.mocked(hooks.useCalendarDays).mockReturnValue(
|
|
makeCalendarDaysResult({
|
|
data: mockCalendarWindow,
|
|
})
|
|
)
|
|
vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue(makePreschoolPlanningDaysResult())
|
|
// `useDebouncedCallback` is a real hook exercised end-to-end by its own
|
|
// test suite and by `VacationDaysTable.test.tsx` — here it's mocked to
|
|
// fire immediately (no timers to juggle) since these tests only care
|
|
// that Settings wires the right save handler through, not about the
|
|
// debounce timing itself.
|
|
vi.mocked(hooks.useDebouncedCallback).mockImplementation(
|
|
((callback: (...args: unknown[]) => void) => callback) as typeof hooks.useDebouncedCallback,
|
|
)
|
|
vi.mocked(api.updateParentSettings).mockResolvedValue({
|
|
uuid: 'pd-1',
|
|
name: 'Test Parent',
|
|
salary_type: 'monthly',
|
|
employment_status: 'employed',
|
|
})
|
|
vi.mocked(api.addPartner).mockResolvedValue({
|
|
uuid: 'pd-2',
|
|
name: 'Sam',
|
|
salary_type: 'monthly',
|
|
employment_status: 'employed',
|
|
})
|
|
vi.mocked(api.renameWorkspaceParent).mockResolvedValue(partnerMember)
|
|
vi.mocked(api.removeWorkspaceParent).mockResolvedValue(undefined)
|
|
})
|
|
|
|
it('renders loading spinner while workspace loads', () => {
|
|
vi.mocked(useWorkspace).mockReturnValue(
|
|
makeWorkspaceContextValue({
|
|
workspace: {
|
|
id: 'ws-1',
|
|
name: 'Test Workspace',
|
|
createdAt: '2026-01-01',
|
|
},
|
|
workspaceId: 'ws-1',
|
|
children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }],
|
|
selectedChildId: 'c-1',
|
|
personalDataUuid: 'pd-1',
|
|
parentName: 'Test Parent',
|
|
isLoading: true,
|
|
})
|
|
)
|
|
renderSettings()
|
|
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders the four panels as h2 headings, in order, with the family panel\'s h3 subsections', () => {
|
|
renderSettings()
|
|
const h2s = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)
|
|
expect(h2s).toEqual(['Barn & familj', 'Saldon', 'Kalender & språk', 'Undantag & röda dagar'])
|
|
|
|
const h3s = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent)
|
|
expect(h3s).toEqual(['Din profil', 'Partner'])
|
|
})
|
|
|
|
it('keeps the settings-page root testid', () => {
|
|
renderSettings()
|
|
expect(screen.getByTestId('settings-page')).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('surfaces the analytics-computed at-risk figure for a year, joined by member and year', () => {
|
|
vi.mocked(hooks.useAnalytics).mockReturnValue(
|
|
makeAnalyticsResult({
|
|
data: {
|
|
workspaceId: 'ws-1',
|
|
childId: 'c-1',
|
|
parentProjections: [
|
|
makeAge4Projection({
|
|
personalDataUuid: 'pd-1',
|
|
childId: 'c-1',
|
|
yearBalances: [
|
|
makeParentYearBalance({
|
|
personalDataUuid: 'pd-1',
|
|
childId: 'c-1',
|
|
year: CURRENT_YEAR,
|
|
vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 6, expiringAge: 6, expiringOverCap: 0 }),
|
|
}),
|
|
],
|
|
}),
|
|
],
|
|
combinedDaysUsed: 0,
|
|
combinedDaysLeft: 0,
|
|
combinedExpiringAtB4: 0,
|
|
yearTotals: [],
|
|
},
|
|
})
|
|
)
|
|
renderSettings()
|
|
expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR}`)).toHaveTextContent('6')
|
|
// A year with no matching yearBalance falls back to 0, not crashing.
|
|
expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR + 1}`)).toHaveTextContent('0')
|
|
})
|
|
|
|
it('autosaves an edited year with its total days, with no Save button', async () => {
|
|
renderSettings()
|
|
const year = CURRENT_YEAR + 1
|
|
expect(screen.queryByTestId(`vacation-save-${year}`)).not.toBeInTheDocument()
|
|
const totalInput = screen.getByTestId(`vacation-total-days-${year}`)
|
|
fireEvent.change(totalInput, { target: { value: '30' } })
|
|
|
|
await waitFor(() =>
|
|
expect(mockUpsertVacationDays).toHaveBeenCalledWith({
|
|
personalDataUuid: 'pd-1',
|
|
workspaceId: 'ws-1',
|
|
year,
|
|
totalDays: 30,
|
|
savedDaysIn: 0,
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('does not disable the vacation inputs while the family panel is saving', async () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('save-family-button'))
|
|
// The vacation table has no shared saving flag with the family panel
|
|
// any more — each row autosaves and reports its own status.
|
|
expect(screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`)).not.toBeDisabled()
|
|
await waitFor(() => expect(screen.getByTestId('save-family-button')).not.toBeDisabled())
|
|
})
|
|
|
|
it('renders one table per workspace member, self first, keyed on (personalDataUuid, year)', async () => {
|
|
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
|
makeWorkspaceMembersResult({
|
|
data: [selfMember, partnerMember],
|
|
refresh: mockRefreshMembers,
|
|
}),
|
|
)
|
|
vi.mocked(hooks.useVacationDays).mockReturnValue(
|
|
makeVacationDaysResult({
|
|
data: [
|
|
...mockVacationDays,
|
|
{
|
|
id: 'vd-2',
|
|
personalDataUuid: 'pd-2',
|
|
workspaceId: 'ws-1',
|
|
year: CURRENT_YEAR,
|
|
totalDays: 20,
|
|
savedDaysIn: 3,
|
|
createdAt: '2026-01-01T00:00:00',
|
|
updatedAt: '2026-01-01T00:00:00',
|
|
},
|
|
],
|
|
upsert: mockUpsertVacationDays,
|
|
}),
|
|
)
|
|
renderSettings()
|
|
|
|
const selfTable = screen.getByTestId('vacation-days-table-pd-1')
|
|
const partnerTable = screen.getByTestId('vacation-days-table-pd-2')
|
|
expect(
|
|
(within(selfTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement).value,
|
|
).toBe('10')
|
|
expect(
|
|
(within(partnerTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement).value,
|
|
).toBe('20')
|
|
|
|
// Self's table must come first in document order.
|
|
const tables = screen.getAllByTestId(/^vacation-days-table-/)
|
|
expect(tables[0]).toBe(selfTable)
|
|
expect(tables[1]).toBe(partnerTable)
|
|
})
|
|
|
|
it("saves a partner's edit under the partner's own personalDataUuid, not the logged-in user's", async () => {
|
|
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
|
makeWorkspaceMembersResult({
|
|
data: [selfMember, partnerMember],
|
|
refresh: mockRefreshMembers,
|
|
}),
|
|
)
|
|
renderSettings()
|
|
|
|
const partnerTable = screen.getByTestId('vacation-days-table-pd-2')
|
|
fireEvent.change(within(partnerTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`), {
|
|
target: { value: '18' },
|
|
})
|
|
|
|
await waitFor(() =>
|
|
expect(mockUpsertVacationDays).toHaveBeenCalledWith(
|
|
expect.objectContaining({ personalDataUuid: 'pd-2', year: CURRENT_YEAR, totalDays: 18 }),
|
|
),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('family panel — child + profile (one combined Save)', () => {
|
|
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 child details and the parent profile together with one Save button', async () => {
|
|
renderSettings()
|
|
const preschoolInput = screen.getByTestId('vacation-days-field')
|
|
fireEvent.change(preschoolInput, { target: { value: '10' } })
|
|
fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } })
|
|
fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } })
|
|
fireEvent.click(screen.getByTestId('save-family-button'))
|
|
|
|
await waitFor(() =>
|
|
expect(mockUpdateChildDetails).toHaveBeenCalledWith({
|
|
birthDate: CHILD_BIRTH_DATE,
|
|
preschoolPlanningDaysPerYear: 10,
|
|
}),
|
|
)
|
|
await waitFor(() =>
|
|
expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', {
|
|
salary_type: 'monthly',
|
|
employment_status: 'employed',
|
|
}),
|
|
)
|
|
// Salary type feeds the allocator — the parent-settings save must
|
|
// re-plan, not just refetch. (Child-details bumps separately via
|
|
// useChildSettings.update, exercised by useChildSettings.test.ts.)
|
|
await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('shows an error when the combined family save fails', async () => {
|
|
vi.mocked(api.updateParentSettings).mockRejectedValue(new Error('save failed'))
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('save-family-button'))
|
|
expect(await screen.findByTestId('save-message')).toHaveTextContent('save failed')
|
|
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('preschool planning days — per-year editing', () => {
|
|
it('renders one row per vacation year, wired to usePreschoolPlanningDays', () => {
|
|
renderSettings()
|
|
for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) {
|
|
expect(screen.getByTestId(`preschool-year-row-${year}`)).toBeInTheDocument()
|
|
}
|
|
expect(hooks.usePreschoolPlanningDays).toHaveBeenCalledWith('c-1', CURRENT_YEAR)
|
|
})
|
|
|
|
it('autosaves an edited planned-days value via setPlanned', async () => {
|
|
const mockSetPlanned = vi.fn().mockResolvedValue(undefined)
|
|
vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue(
|
|
makePreschoolPlanningDaysResult({ setPlanned: mockSetPlanned }),
|
|
)
|
|
renderSettings()
|
|
|
|
fireEvent.change(screen.getByTestId(`preschool-planned-${CURRENT_YEAR}`), { target: { value: '7' } })
|
|
|
|
await waitFor(() => expect(mockSetPlanned).toHaveBeenCalledWith(7))
|
|
})
|
|
|
|
it('autosaves an edited taken-days value via recordTaken', async () => {
|
|
const mockRecordTaken = vi.fn().mockResolvedValue(undefined)
|
|
vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue(
|
|
makePreschoolPlanningDaysResult({ recordTaken: mockRecordTaken }),
|
|
)
|
|
renderSettings()
|
|
|
|
fireEvent.change(screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`), { target: { value: '4' } })
|
|
|
|
await waitFor(() => expect(mockRecordTaken).toHaveBeenCalledWith(4))
|
|
})
|
|
|
|
it('leaves takenDays blank (not 0) when it has never been recorded', () => {
|
|
vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue(
|
|
makePreschoolPlanningDaysResult({
|
|
data: { id: 'ppd-1', childId: 'c-1', year: CURRENT_YEAR, plannedDays: 6, takenDays: null, isDefault: false, createdAt: null, updatedAt: null },
|
|
}),
|
|
)
|
|
renderSettings()
|
|
expect((screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`) as HTMLInputElement).value).toBe('')
|
|
})
|
|
})
|
|
|
|
it('mounts PartnerInvitePanel in the family section with the workspace id', () => {
|
|
renderSettings()
|
|
expect(screen.getByTestId('partner-invite-panel-stub')).toBeInTheDocument()
|
|
expect(lastPartnerInvitePanelProps).toEqual({ workspaceId: 'ws-1' })
|
|
})
|
|
|
|
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'))
|
|
await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled())
|
|
})
|
|
|
|
it('bumps planVersion after adding a partner, triggering a sync (a new partner changes who the allocator can assign days to)', async () => {
|
|
renderSettings()
|
|
fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } })
|
|
fireEvent.click(screen.getByTestId('add-partner-button'))
|
|
|
|
await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('does not bump planVersion when adding a partner fails', async () => {
|
|
vi.mocked(api.addPartner).mockRejectedValue(new Error('boom'))
|
|
renderSettings()
|
|
fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } })
|
|
fireEvent.click(screen.getByTestId('add-partner-button'))
|
|
|
|
await screen.findByTestId('save-message')
|
|
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows an add-partner error without a raw message_key when the name is rejected', async () => {
|
|
vi.mocked(api.addPartner).mockRejectedValue(
|
|
new ApiRequestError(422, {
|
|
code: 'workspace.parents.invalidName',
|
|
message: 'workspace.parents.invalidName',
|
|
details: { messageKey: 'workspace.parents.invalidName', data: {} },
|
|
}),
|
|
)
|
|
renderSettings()
|
|
fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'X' } })
|
|
fireEvent.click(screen.getByTestId('add-partner-button'))
|
|
|
|
const message = await screen.findByTestId('save-message')
|
|
expect(message).toHaveTextContent('Partnerns namn är ogiltigt.')
|
|
})
|
|
|
|
describe('workspace members', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
|
makeWorkspaceMembersResult({
|
|
data: [selfMember, partnerMember],
|
|
refresh: mockRefreshMembers,
|
|
})
|
|
)
|
|
})
|
|
|
|
it('describes a linked member as an unclaimed placeholder, not an already-linked account', () => {
|
|
renderSettings()
|
|
// The old copy ("Din partner är kopplad...") falsely implies an
|
|
// account link that doesn't exist for a placeholder member added via
|
|
// "Lägg till partner" — replaced by the `invite` namespace's copy,
|
|
// which correctly describes an invite as the way to connect one.
|
|
expect(screen.getByText(/Den här platsen är förberedd åt din partner/)).toBeInTheDocument()
|
|
expect(screen.queryByText('Din partner är kopplad till detta arbetsutrymme.')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows the linked partner and hides the add-partner form', () => {
|
|
renderSettings()
|
|
expect(screen.getByTestId('member-name-pd-2')).toHaveTextContent('Sam')
|
|
expect(screen.queryByTestId('add-partner-button')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows a "you" badge on the logged-in user\'s own row', () => {
|
|
renderSettings()
|
|
expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('Test Parent')
|
|
expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('(Du)')
|
|
})
|
|
|
|
it('does not offer a remove button on the logged-in user\'s own row', () => {
|
|
renderSettings()
|
|
expect(screen.queryByTestId('remove-button-pd-1')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renames a member and refreshes the member list', async () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('rename-button-pd-2'))
|
|
fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Samuel' } })
|
|
fireEvent.click(screen.getByTestId('rename-save-pd-2'))
|
|
|
|
await waitFor(() =>
|
|
expect(api.renameWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2', 'Samuel'),
|
|
)
|
|
await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled())
|
|
})
|
|
|
|
it('allows renaming the logged-in user\'s own profile too', () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('rename-button-pd-1'))
|
|
expect(screen.getByTestId('rename-input-pd-1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('cancels a rename without calling the API', () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('rename-button-pd-2'))
|
|
fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Something else' } })
|
|
fireEvent.click(screen.getByTestId('rename-cancel-pd-2'))
|
|
|
|
expect(screen.queryByTestId('rename-input-pd-2')).not.toBeInTheDocument()
|
|
expect(api.renameWorkspaceParent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('requires an in-app confirmation before removing a member', async () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
|
|
expect(api.removeWorkspaceParent).not.toHaveBeenCalled()
|
|
expect(screen.getByTestId('remove-confirm-pd-2')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
|
|
await waitFor(() =>
|
|
expect(api.removeWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2'),
|
|
)
|
|
})
|
|
|
|
it('cancelling the remove confirmation does not call the API', () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
fireEvent.click(screen.getByTestId('remove-cancel-pd-2'))
|
|
|
|
expect(screen.queryByTestId('remove-confirm-pd-2')).not.toBeInTheDocument()
|
|
expect(api.removeWorkspaceParent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refreshes the member list after a successful removal', async () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
|
|
|
|
await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled())
|
|
})
|
|
|
|
it('bumps planVersion after removing a partner, triggering a sync (fewer parents changes who the allocator can assign days to)', async () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
|
|
|
|
await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1))
|
|
})
|
|
|
|
it('does not bump planVersion when removing a partner fails', async () => {
|
|
vi.mocked(api.removeWorkspaceParent).mockRejectedValue(new Error('boom'))
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
|
|
|
|
await screen.findByTestId('member-action-error-pd-2')
|
|
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not bump planVersion for a rename — cosmetic, nothing for a re-plan to fix', async () => {
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('rename-button-pd-2'))
|
|
fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Samuel' } })
|
|
fireEvent.click(screen.getByTestId('rename-save-pd-2'))
|
|
|
|
await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled())
|
|
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows the has-login refusal without a raw message_key', async () => {
|
|
vi.mocked(api.removeWorkspaceParent).mockRejectedValue(
|
|
new ApiRequestError(422, {
|
|
code: 'workspace.parents.remove.hasLogin',
|
|
message: 'workspace.parents.remove.hasLogin',
|
|
details: { messageKey: 'workspace.parents.remove.hasLogin', data: {} },
|
|
}),
|
|
)
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
|
|
|
|
const message = await screen.findByTestId('member-action-error-pd-2')
|
|
expect(message).toHaveTextContent('den här personen har ett eget konto och egen inloggning')
|
|
expect(message).not.toHaveTextContent('workspace.parents.remove.hasLogin')
|
|
})
|
|
|
|
it('names each blocker on a hasDependentData refusal, not a raw message_key', async () => {
|
|
vi.mocked(api.removeWorkspaceParent).mockRejectedValue(
|
|
new ApiRequestError(422, {
|
|
code: 'workspace.parents.remove.hasDependentData',
|
|
message: 'workspace.parents.remove.hasDependentData',
|
|
details: {
|
|
messageKey: 'workspace.parents.remove.hasDependentData',
|
|
data: { blockers: ['leave_wishes', 'planned_leave'] },
|
|
},
|
|
}),
|
|
)
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
|
|
|
|
const message = await screen.findByTestId('member-action-error-pd-2')
|
|
expect(message).toHaveTextContent('önskemål om ledighet')
|
|
expect(message).toHaveTextContent('planerade lediga dagar')
|
|
expect(message).not.toHaveTextContent('hasDependentData')
|
|
})
|
|
|
|
it('shows the remove-self refusal without a raw message_key', async () => {
|
|
// Defence in depth: the UI never renders a remove button for the
|
|
// caller's own row, but the handler still routes a self-removal
|
|
// refusal from the backend through the same safe error mapping.
|
|
vi.mocked(api.removeWorkspaceParent).mockRejectedValue(
|
|
new ApiRequestError(422, {
|
|
code: 'workspace.parents.remove.self',
|
|
message: 'workspace.parents.remove.self',
|
|
details: { messageKey: 'workspace.parents.remove.self', data: {} },
|
|
}),
|
|
)
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('remove-button-pd-2'))
|
|
fireEvent.click(screen.getByTestId('remove-confirm-pd-2'))
|
|
|
|
const message = await screen.findByTestId('member-action-error-pd-2')
|
|
expect(message).not.toHaveTextContent('workspace.parents.remove.self')
|
|
})
|
|
})
|
|
|
|
describe('Saldon panel — BalanceFlow wiring', () => {
|
|
it('renders BalanceFlow in panel mode with the workspace members and selected child', () => {
|
|
renderSettings()
|
|
expect(screen.getByTestId('balance-flow-stub')).toBeInTheDocument()
|
|
expect(lastBalanceFlowProps).toMatchObject({
|
|
mode: 'panel',
|
|
personalDataUuid: 'pd-1',
|
|
workspaceId: 'ws-1',
|
|
selectedChildId: 'c-1',
|
|
})
|
|
expect(lastBalanceFlowProps?.members).toHaveLength(1)
|
|
})
|
|
|
|
it("falls back to the workspace's first child when no child is selected yet", () => {
|
|
vi.mocked(useWorkspace).mockReturnValue(
|
|
makeWorkspaceContextValue({
|
|
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
|
workspaceId: 'ws-1',
|
|
children: [{ id: 'c-9', name: 'Child 9', birth_date: CHILD_BIRTH_DATE }],
|
|
selectedChildId: null,
|
|
personalDataUuid: 'pd-1',
|
|
parentName: 'Test Parent',
|
|
})
|
|
)
|
|
renderSettings()
|
|
expect(lastBalanceFlowProps?.selectedChildId).toBe('c-9')
|
|
})
|
|
|
|
it('refreshes members when BalanceFlow reports a save', () => {
|
|
renderSettings()
|
|
lastBalanceFlowProps?.onSaved?.()
|
|
expect(mockRefreshMembers).toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows a loading message instead of BalanceFlow while members are loading', () => {
|
|
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
|
makeWorkspaceMembersResult({ data: [], isLoading: true, refresh: mockRefreshMembers })
|
|
)
|
|
renderSettings()
|
|
// Both the partner list (family panel) and the Saldon panel gate on
|
|
// the same `membersLoading` flag, so the message appears twice.
|
|
expect(screen.getAllByText('Laddar medlemmar…')).toHaveLength(2)
|
|
expect(screen.queryByTestId('balance-flow-stub')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('toggles week numbers', () => {
|
|
const setShowWeekNumbers = vi.fn()
|
|
vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, setShowWeekNumbers))
|
|
renderSettings()
|
|
fireEvent.click(screen.getByTestId('show-week-numbers-checkbox'))
|
|
expect(setShowWeekNumbers).toHaveBeenCalledWith(true)
|
|
})
|
|
|
|
describe('take PL on red days', () => {
|
|
it('renders unchecked by default', () => {
|
|
renderSettings()
|
|
expect(screen.getByTestId('take-pl-on-red-days-checkbox')).not.toBeChecked()
|
|
})
|
|
|
|
it('renders checked when the workspace has it enabled', () => {
|
|
vi.mocked(useWorkspace).mockReturnValue(
|
|
makeWorkspaceContextValue({
|
|
workspace: {
|
|
id: 'ws-1',
|
|
name: 'Test Workspace',
|
|
createdAt: '2026-01-01',
|
|
takePlOnRedDays: true,
|
|
},
|
|
workspaceId: 'ws-1',
|
|
children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }],
|
|
selectedChildId: 'c-1',
|
|
personalDataUuid: 'pd-1',
|
|
parentName: 'Test Parent',
|
|
})
|
|
)
|
|
renderSettings()
|
|
expect(screen.getByTestId('take-pl-on-red-days-checkbox')).toBeChecked()
|
|
})
|
|
|
|
it('saves the setting and refreshes the workspace context on toggle', async () => {
|
|
vi.mocked(api.updateWorkspace).mockResolvedValue({
|
|
id: 'ws-1',
|
|
name: 'Test Workspace',
|
|
createdAt: '2026-01-01',
|
|
takePlOnRedDays: true,
|
|
})
|
|
const mockRefreshContext = vi.fn().mockResolvedValue(undefined)
|
|
vi.mocked(useWorkspace).mockReturnValue(
|
|
makeWorkspaceContextValue({
|
|
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
|
workspaceId: 'ws-1',
|
|
children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }],
|
|
selectedChildId: 'c-1',
|
|
personalDataUuid: 'pd-1',
|
|
parentName: 'Test Parent',
|
|
refreshContext: mockRefreshContext,
|
|
bumpPlanVersion: mockBumpPlanVersion,
|
|
})
|
|
)
|
|
renderSettings()
|
|
|
|
fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox'))
|
|
|
|
await waitFor(() =>
|
|
expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { takePlOnRedDays: true })
|
|
)
|
|
expect(mockRefreshContext).toHaveBeenCalled()
|
|
// Whether PL may be taken on red days changes what the allocator can
|
|
// place leave on — the toggle must re-plan, not just refresh context.
|
|
expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('shows an error message when saving fails', async () => {
|
|
vi.mocked(api.updateWorkspace).mockRejectedValue(new Error('network down'))
|
|
renderSettings()
|
|
|
|
fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox'))
|
|
|
|
expect(await screen.findByTestId('save-message')).toHaveTextContent('network down')
|
|
expect(mockBumpPlanVersion).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
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('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('manual recompute button', () => {
|
|
it('bumps planVersion (never calling api.syncPlan directly) and shows a spinner label while waiting', () => {
|
|
renderSettings()
|
|
|
|
fireEvent.click(screen.getByTestId('recompute-plan-button'))
|
|
|
|
expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)
|
|
expect(api.syncPlan).not.toHaveBeenCalled()
|
|
expect(screen.getByTestId('recompute-plan-button')).toHaveTextContent('Räknar om…')
|
|
expect(screen.getByTestId('recompute-plan-button')).toBeDisabled()
|
|
})
|
|
|
|
it('reports success once PlanSyncProvider\'s triggered sync lands with a newer proposal', async () => {
|
|
const { rerender } = renderSettings()
|
|
|
|
fireEvent.click(screen.getByTestId('recompute-plan-button'))
|
|
|
|
// Simulates `PlanSyncProvider`'s background sync (triggered by the
|
|
// `bumpPlanVersion()` call above) completing: its context value now
|
|
// carries a newer `latestProposalVersion` and no eviction.
|
|
vi.mocked(usePlanSyncContextOptional).mockReturnValue(
|
|
makePlanSyncContextValue({ latestProposalVersion: 1, pendingEvictionPreview: null }),
|
|
)
|
|
rerender(<Settings />)
|
|
|
|
const message = await screen.findByTestId('save-message')
|
|
expect(message).toHaveTextContent('Planen räknades om.')
|
|
expect(screen.getByTestId('recompute-plan-button')).not.toBeDisabled()
|
|
expect(screen.getByTestId('recompute-plan-button')).toHaveTextContent('Räkna om planen')
|
|
})
|
|
|
|
it('reports that nothing was saved when the triggered sync would evict', async () => {
|
|
const { rerender } = renderSettings()
|
|
|
|
fireEvent.click(screen.getByTestId('recompute-plan-button'))
|
|
|
|
vi.mocked(usePlanSyncContextOptional).mockReturnValue(
|
|
makePlanSyncContextValue({
|
|
latestProposalVersion: 1,
|
|
pendingEvictionPreview: makeEvictionPreview({ wouldEvict: true }),
|
|
}),
|
|
)
|
|
rerender(<Settings />)
|
|
|
|
const message = await screen.findByTestId('save-message')
|
|
expect(message).toHaveTextContent('Omräkningen skulle ta bort redan inplanerade dagar')
|
|
})
|
|
|
|
it('ignores a version bump/eviction from an unrelated sync while idle', () => {
|
|
const { rerender } = renderSettings()
|
|
|
|
// No recompute click happened — `latestProposalVersion` moving on its
|
|
// own (e.g. a vacation-day autosave elsewhere bumped `planVersion`)
|
|
// must not surface a message this button never triggered.
|
|
vi.mocked(usePlanSyncContextOptional).mockReturnValue(
|
|
makePlanSyncContextValue({ latestProposalVersion: 1, pendingEvictionPreview: null }),
|
|
)
|
|
rerender(<Settings />)
|
|
|
|
expect(screen.queryByTestId('save-message')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Undantag & röda dagar panel — CalendarExceptions wiring', () => {
|
|
it('passes the fetched calendar window and override handlers through', () => {
|
|
renderSettings()
|
|
expect(screen.getByTestId('calendar-exceptions-stub')).toBeInTheDocument()
|
|
expect(lastCalendarExceptionsProps).toMatchObject({
|
|
startYear: CURRENT_YEAR,
|
|
years: 4,
|
|
workspaceId: 'ws-1',
|
|
})
|
|
expect(lastCalendarExceptionsProps?.onUpsertOverride).toBe(mockUpsertDayOverride)
|
|
expect(lastCalendarExceptionsProps?.onRemoveOverride).toBe(mockRemoveDayOverride)
|
|
})
|
|
|
|
it('requests the next 4-year window when paginating forward', () => {
|
|
renderSettings()
|
|
act(() => lastCalendarExceptionsProps?.onNextWindow())
|
|
expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR + 4, 4)
|
|
})
|
|
|
|
it('requests the previous 4-year window when paginating backward', () => {
|
|
renderSettings()
|
|
act(() => lastCalendarExceptionsProps?.onPrevWindow())
|
|
expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4)
|
|
})
|
|
})
|
|
})
|