Files
F-r-ldraledigplannerare/frontend/src/components/vacation-days/VacationDaysTable.test.tsx
T
jacobh2 61e5487078 Free days, vacation forfeiture, and global auto-replanning
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
2026-09-20 13:07:36 +02:00

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