61e5487078
Free days (Sat/Sun/red days, subject to DayOverride) can never be vacation, and can only be parental leave when the workspace opts in via take_pl_on_red_days. The allocator already honoured this; the hand-pin path bypassed it entirely. - PlanningService gains optional workspace_repo/day_override_repo deps and _reject_illegal_free_day, enforced in add_planned_day and update. Reuses allocation_service.is_free_day rather than re-deriving it, and no-ops when the deps are unset so existing callers are unaffected. - update_planned_day had no ValidationError handler at all, so a validation failure there 500'd instead of 422'ing. Fixed. - Calendar filters the pin write loop by the day's isFree; the modal explains free days stay off, and that pinning opts a day out of automatic re-planning. Vacation days at risk of forfeiture were computed and sent over the wire but never rendered: an at-risk column with the age-out/over-cap split, and a dashboard key figure. PlanProposal.warnings was likewise parsed but rendered nowhere, for all three kinds. Adds PlanWarningCard in a Risks section above the tips, with parent names resolved and dates formatted. POST /api/plan/sync now fires from the workspace layer on every plan-input change rather than only from Calendar, so a Settings edit or a pinned-day save re-plans day types instead of merely refetching. Overlapping bumps coalesce into one trailing run; a rejection cannot wedge the in-flight guard. eviction_preview was silently dropped by the frontend despite always being on the wire, and is now surfaced through context. Verified: backend 890 passed (91.49% coverage), frontend 1297 passed, ruff/eslint/tsc clean. Browser-checked in Swedish and English. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
227 lines
9.1 KiB
TypeScript
227 lines
9.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { render, fireEvent, screen, act } from '@testing-library/react'
|
|
import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable'
|
|
|
|
const ROWS: VacationDaysTableRow[] = [
|
|
{ year: 2026, totalDays: 10, savedDaysIn: 2, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
|
{ year: 2027, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
|
{ year: 2028, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
|
]
|
|
|
|
function renderTable(
|
|
rows: VacationDaysTableRow[],
|
|
onSave: (year: number, totalDays: number, savedDaysIn: number) => Promise<void>,
|
|
) {
|
|
return render(<VacationDaysTable rows={rows} onSave={onSave} personalDataUuid="pd-1" />)
|
|
}
|
|
|
|
describe('VacationDaysTable', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('renders one row per year', () => {
|
|
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
|
expect(screen.getByTestId('vacation-year-row-2026')).toBeInTheDocument()
|
|
expect(screen.getByTestId('vacation-year-row-2027')).toBeInTheDocument()
|
|
expect(screen.getByTestId('vacation-year-row-2028')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows the total days and saved days for each year', () => {
|
|
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
|
const totalInput = screen.getByTestId('vacation-total-days-2026') as HTMLInputElement
|
|
const savedInput = screen.getByTestId('vacation-saved-days-2026') as HTMLInputElement
|
|
expect(totalInput.value).toBe('10')
|
|
expect(savedInput.value).toBe('2')
|
|
})
|
|
|
|
it('autosaves a total-days edit after the debounce delay, without a Save button', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined)
|
|
renderTable(ROWS, onSave)
|
|
|
|
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } })
|
|
expect(onSave).not.toHaveBeenCalled()
|
|
expect(screen.queryByTestId('vacation-save-2027')).not.toBeInTheDocument()
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
|
|
expect(onSave).toHaveBeenCalledWith(2027, 30, 0)
|
|
})
|
|
|
|
it('autosaves a saved-days-in edit', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined)
|
|
renderTable(ROWS, onSave)
|
|
|
|
fireEvent.change(screen.getByTestId('vacation-saved-days-2026'), { target: { value: '5' } })
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
|
|
expect(onSave).toHaveBeenCalledWith(2026, 10, 5)
|
|
})
|
|
|
|
it('does not mix up edits between rows', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined)
|
|
renderTable(ROWS, onSave)
|
|
|
|
fireEvent.change(screen.getByTestId('vacation-total-days-2026'), { target: { value: '15' } })
|
|
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '20' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
|
|
expect(onSave).toHaveBeenCalledWith(2026, 15, 2)
|
|
expect(onSave).toHaveBeenCalledWith(2027, 20, 0)
|
|
expect(onSave).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('collapses rapid successive edits to the same row into a single save', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined)
|
|
renderTable(ROWS, onSave)
|
|
|
|
const input = screen.getByTestId('vacation-total-days-2027')
|
|
fireEvent.change(input, { target: { value: '26' } })
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
})
|
|
fireEvent.change(input, { target: { value: '27' } })
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
|
|
expect(onSave).toHaveBeenCalledTimes(1)
|
|
expect(onSave).toHaveBeenCalledWith(2027, 27, 0)
|
|
})
|
|
|
|
it('shows a saving then saved indicator around the autosave', async () => {
|
|
let resolveSave: () => void = () => {}
|
|
const onSave = vi.fn(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
resolveSave = resolve
|
|
}),
|
|
)
|
|
renderTable(ROWS, onSave)
|
|
|
|
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '30' } })
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparar')
|
|
|
|
await act(async () => {
|
|
resolveSave()
|
|
await Promise.resolve()
|
|
})
|
|
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent('Sparat')
|
|
})
|
|
|
|
it('shows an error indicator when the autosave fails, without losing the typed value', async () => {
|
|
const onSave = vi.fn().mockRejectedValue(new Error('network blew up'))
|
|
renderTable(ROWS, onSave)
|
|
|
|
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '99' } })
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
|
|
expect(screen.getByTestId('vacation-status-2027')).toHaveTextContent(/gick inte|kunde inte|error/i)
|
|
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('99')
|
|
})
|
|
|
|
it('does not clobber an unsaved edit when the parent re-renders with fresh rows mid-debounce', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined)
|
|
const { rerender } = render(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
|
|
|
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } })
|
|
|
|
// Simulate a background refetch (e.g. from another row's save bumping
|
|
// `data`) landing mid-debounce, still carrying the OLD server value for
|
|
// this row.
|
|
rerender(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
|
|
|
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40')
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
expect(onSave).toHaveBeenCalledWith(2027, 40, 0)
|
|
})
|
|
|
|
it('accepts a fresh server value once the row is no longer dirty', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined)
|
|
const { rerender } = render(<VacationDaysTable rows={ROWS} onSave={onSave} personalDataUuid="pd-1" />)
|
|
|
|
fireEvent.change(screen.getByTestId('vacation-total-days-2027'), { target: { value: '40' } })
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(800)
|
|
})
|
|
expect(onSave).toHaveBeenCalledWith(2027, 40, 0)
|
|
|
|
// Once the save resolved the row is clean again — a later prop update
|
|
// (e.g. from a real refetch confirming the save) should be reflected.
|
|
const updatedRows = ROWS.map((r) => (r.year === 2027 ? { ...r, totalDays: 40 } : r))
|
|
rerender(<VacationDaysTable rows={updatedRows} onSave={onSave} personalDataUuid="pd-1" />)
|
|
expect((screen.getByTestId('vacation-total-days-2027') as HTMLInputElement).value).toBe('40')
|
|
})
|
|
|
|
it('shows an empty message when there are no rows', () => {
|
|
render(<VacationDaysTable rows={[]} onSave={vi.fn().mockResolvedValue(undefined)} personalDataUuid="pd-1" />)
|
|
expect(screen.queryByTestId('vacation-days-table-pd-1')).not.toBeInTheDocument()
|
|
expect(screen.getByText(/Inga år att visa/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('scopes its root testid to the given personalDataUuid, for multiple tables on one page', () => {
|
|
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
|
expect(screen.getByTestId('vacation-days-table-pd-1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows an assumed badge for a year with no entered data (§3.2)', () => {
|
|
const rows = [
|
|
...ROWS,
|
|
{ year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
|
|
]
|
|
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
|
|
|
expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument()
|
|
expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows the at-risk figure in neutral ink when nothing is expiring', () => {
|
|
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
|
|
const atRisk = screen.getByTestId('vacation-at-risk-2026')
|
|
expect(atRisk).toHaveTextContent('0')
|
|
expect(atRisk).not.toHaveClass('vacation-rows__at-risk-value--danger')
|
|
})
|
|
|
|
it('shows the at-risk figure in danger ink when days are expiring, with a tooltip splitting the cause', () => {
|
|
const rows = [
|
|
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 5, expiringAge: 2, expiringOverCap: 3 },
|
|
...ROWS.slice(1),
|
|
]
|
|
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
|
const atRisk = screen.getByTestId('vacation-at-risk-2026')
|
|
expect(atRisk).toHaveTextContent('5')
|
|
expect(atRisk).toHaveClass('vacation-rows__at-risk-value--danger')
|
|
expect(atRisk.title).toContain('2')
|
|
expect(atRisk.title).toContain('3')
|
|
})
|
|
|
|
it('shows only the relevant cause in the at-risk tooltip when the other is zero', () => {
|
|
const rows = [
|
|
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 4, expiringAge: 4, expiringOverCap: 0 },
|
|
...ROWS.slice(1),
|
|
]
|
|
renderTable(rows, vi.fn().mockResolvedValue(undefined))
|
|
const atRisk = screen.getByTestId('vacation-at-risk-2026')
|
|
expect(atRisk.title).toBe('4 dagar har passerat 5-årsgränsen för att spara semester')
|
|
})
|
|
})
|