Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes

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
This commit is contained in:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
+559
View File
@@ -0,0 +1,559 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DevFetch } from './DevFetch'
import * as api from '@/services/api'
import * as errors from '@/services/errors'
import { useScraperFetch } from '@/hooks'
vi.mock('@/services/api')
vi.mock('@/services/errors')
vi.mock('@/hooks', () => ({
useScraperFetch: vi.fn(),
}))
describe('DevFetch', () => {
const mockUserContext: api.UserContext = {
workspace_id: 'ws-test-123',
personal_data_uuid: 'pd-test-456',
parent_name: 'Test Parent',
children: [
{
id: 'child-1',
name: 'Test Child',
birth_date: '2022-01-15',
},
],
}
const emptyUserContext: api.UserContext = {
workspace_id: 'ws-test-123',
personal_data_uuid: 'pd-test-456',
parent_name: 'Test Parent',
children: [],
}
const mockScraperFetchResult = {
sessionId: null,
pollData: null,
resolvedIds: null,
error: null,
isUnauthorized: false,
start: vi.fn(),
cancel: vi.fn(),
isRunning: false,
isTerminal: false,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.getUserContext).mockResolvedValue(mockUserContext)
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
vi.mocked(useScraperFetch).mockReturnValue(mockScraperFetchResult)
})
describe('initial load', () => {
it('loads user context on mount', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(api.getUserContext).toHaveBeenCalled()
})
})
it('displays workspace and personal data when context loads', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/ws-test-123/)).toBeInTheDocument()
expect(screen.getByText(/pd-test-456/)).toBeInTheDocument()
})
})
it('displays children when context includes them', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Test Child \(2022-01-15\)/)).toBeInTheDocument()
})
})
it('displays no children message when context has no children', async () => {
vi.mocked(api.getUserContext).mockResolvedValue(emptyUserContext)
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Inga barn registrerade/)).toBeInTheDocument()
})
})
})
describe('error handling', () => {
it('displays unauthorized error when getUserContext fails with 401', async () => {
const unauthorizedError = new Error('Unauthorized')
vi.mocked(api.getUserContext).mockRejectedValue(unauthorizedError)
vi.mocked(errors.isUnauthorized).mockReturnValue(true)
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
expect(screen.getByText(/Not authenticated/)).toBeInTheDocument()
})
})
it('displays login link when unauthorized error occurs', async () => {
const unauthorizedError = new Error('Unauthorized')
vi.mocked(api.getUserContext).mockRejectedValue(unauthorizedError)
vi.mocked(errors.isUnauthorized).mockReturnValue(true)
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
isUnauthorized: true,
})
render(<DevFetch />)
await waitFor(() => {
const loginLink = screen.getByText(/Logga in först/)
expect(loginLink).toBeInTheDocument()
expect(loginLink).toHaveAttribute('href', '/auth')
})
})
it('displays generic error when getUserContext fails with other errors', async () => {
const genericError = new Error('Network error')
vi.mocked(api.getUserContext).mockRejectedValue(genericError)
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
expect(screen.getByText(/Network error/)).toBeInTheDocument()
})
})
it('handles non-Error exception in getUserContext', async () => {
vi.mocked(api.getUserContext).mockRejectedValue('Unknown error')
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
expect(screen.getByText(/Failed to load context/)).toBeInTheDocument()
})
})
})
describe('refresh button', () => {
it('disables refresh button when no children', async () => {
vi.mocked(api.getUserContext).mockResolvedValue(emptyUserContext)
render(<DevFetch />)
await waitFor(() => {
const refreshButton = screen.getByRole('button', { name: /Refresh/ })
expect(refreshButton).toBeDisabled()
})
})
it('enables refresh button when children exist', async () => {
render(<DevFetch />)
await waitFor(() => {
const refreshButton = screen.getByRole('button', { name: /Refresh/ })
expect(refreshButton).not.toBeDisabled()
})
})
it('calls scraper.start when refresh is clicked', async () => {
const mockStart = vi.fn()
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
start: mockStart,
})
render(<DevFetch />)
const refreshButton = await screen.findByRole('button', { name: /Refresh/ })
fireEvent.click(refreshButton)
expect(mockStart).toHaveBeenCalled()
})
})
describe('create child', () => {
beforeEach(() => {
vi.mocked(api.getUserContext).mockResolvedValue(emptyUserContext)
})
it('shows child creation form when no children exist', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByPlaceholderText(/Barnets namn/)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/Födelsedatum/)).toBeInTheDocument()
})
})
it('hides child creation form when children exist', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(screen.queryByPlaceholderText(/Barnets namn/)).not.toBeInTheDocument()
})
})
it('disables create button when name or date is empty', async () => {
render(<DevFetch />)
await waitFor(() => {
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
expect(createButton).toBeDisabled()
})
})
it('enables create button when both name and date are filled', async () => {
render(<DevFetch />)
await waitFor(() => {
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'New Child' } })
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
})
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
expect(createButton).not.toBeDisabled()
})
it('calls api.createChild with correct parameters', async () => {
const mockCreateChild = vi.fn().mockResolvedValue({ id: 'new-child', name: 'New Child' })
vi.mocked(api.createChild).mockImplementation(mockCreateChild)
render(<DevFetch />)
await waitFor(() => {
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'New Child' } })
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
})
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
fireEvent.click(createButton)
await waitFor(() => {
expect(mockCreateChild).toHaveBeenCalledWith({
workspace_id: 'ws-test-123',
name: 'New Child',
birth_date: '2024-01-01',
})
})
})
it('shows loading state while creating child', async () => {
vi.mocked(api.createChild).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ id: 'new-child', name: 'New Child' }), 100))
)
render(<DevFetch />)
await waitFor(() => {
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'New Child' } })
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
})
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
fireEvent.click(createButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /Skapar…/ })).toBeInTheDocument()
})
})
it('reloads context after successful child creation', async () => {
vi.mocked(api.createChild).mockResolvedValue({ id: 'new-child', name: 'New Child' })
render(<DevFetch />)
await waitFor(() => {
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'New Child' } })
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
})
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
fireEvent.click(createButton)
await waitFor(() => {
expect(api.getUserContext).toHaveBeenCalledTimes(2)
})
})
it('clears form inputs and hides form after successful child creation', async () => {
vi.mocked(api.getUserContext)
.mockResolvedValueOnce(emptyUserContext)
.mockResolvedValueOnce(mockUserContext) // Return non-empty after create
vi.mocked(api.createChild).mockResolvedValue({ id: 'new-child', name: 'New Child' })
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByPlaceholderText(/Barnets namn/)).toBeInTheDocument()
})
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'New Child' } })
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
fireEvent.click(createButton)
// After creation succeeds, the form should be hidden because context now includes children
await waitFor(() => {
expect(screen.queryByPlaceholderText(/Barnets namn/)).not.toBeInTheDocument()
})
})
it('handles error when child creation fails', async () => {
vi.mocked(api.createChild).mockRejectedValue(new Error('Creation failed'))
render(<DevFetch />)
await waitFor(() => {
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'New Child' } })
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
})
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
fireEvent.click(createButton)
await waitFor(() => {
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
expect(screen.getByText(/Creation failed/)).toBeInTheDocument()
})
})
it('handles non-Error exception in child creation', async () => {
vi.mocked(api.createChild).mockRejectedValue('Unknown error')
render(<DevFetch />)
await waitFor(() => {
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
fireEvent.change(nameInput, { target: { value: 'New Child' } })
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
})
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
fireEvent.click(createButton)
await waitFor(() => {
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
expect(screen.getByText(/Failed to create child/)).toBeInTheDocument()
})
})
})
describe('scraper integration', () => {
it('displays resolved IDs when scraper has them', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
resolvedIds: {
workspace_id: 'ws-resolved',
personal_data_uuid: 'pd-resolved',
child_id: 'c-resolved',
},
})
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/workspace_id: ws-resolved/)).toBeInTheDocument()
expect(screen.getByText(/personal_data_uuid: pd-resolved/)).toBeInTheDocument()
expect(screen.getByText(/child_id: c-resolved/)).toBeInTheDocument()
})
})
it('displays scraper error when present', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
error: 'Scraper session failed',
})
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Fel:/)).toBeInTheDocument()
expect(screen.getByText(/Scraper session failed/)).toBeInTheDocument()
})
})
it('displays polling status when scraper is active', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
pollData: {
status: 'authenticating',
message: 'Scanning BankID...',
},
isRunning: true,
})
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Status:/)).toBeInTheDocument()
expect(screen.getByText(/authenticating/)).toBeInTheDocument()
})
})
it('displays QR code when awaiting scan', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
pollData: {
status: 'awaiting_scan',
qr_data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
},
isRunning: true,
})
render(<DevFetch />)
await waitFor(() => {
const qrImage = screen.getByAltText(/BankID QR-kod/) as HTMLImageElement
expect(qrImage).toBeInTheDocument()
expect(qrImage.src).toContain('data:image/png;base64,')
})
})
it('displays fetching message during data fetch', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
pollData: {
status: 'fetching',
},
isRunning: true,
})
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Hämtar data från FK…/)).toBeInTheDocument()
})
})
it('displays success state with result data', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
pollData: {
status: 'done',
result: {
children: [
{
name: 'Scraped Child',
birth_date: '2023-06-15',
},
],
},
},
})
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Klar! Hämtad data:/)).toBeInTheDocument()
expect(screen.getByText(/Scraped Child/)).toBeInTheDocument()
})
})
it('displays cancel button when scraper is running', async () => {
const mockCancel = vi.fn()
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
pollData: {
status: 'authenticating',
},
isRunning: true,
cancel: mockCancel,
})
render(<DevFetch />)
await waitFor(() => {
const cancelButton = screen.getByRole('button', { name: /Avbryt/ })
expect(cancelButton).toBeInTheDocument()
fireEvent.click(cancelButton)
expect(mockCancel).toHaveBeenCalled()
})
})
it('displays cancelled message when scraper session is cancelled', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
pollData: {
status: 'cancelled',
},
})
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Sessionen avbröts./)).toBeInTheDocument()
})
})
it('does not display cancel button when scraper is not running', async () => {
vi.mocked(useScraperFetch).mockReturnValue({
...mockScraperFetchResult,
pollData: {
status: 'done',
},
isRunning: false,
})
render(<DevFetch />)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Avbryt/ })).not.toBeInTheDocument()
})
})
})
describe('page structure and styling', () => {
it('renders the header with tool name', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/🔧 FK Scraper Test Harness/)).toBeInTheDocument()
})
})
it('renders the description text', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Dev-only./)).toBeInTheDocument()
})
})
it('renders context information box when context is loaded', async () => {
render(<DevFetch />)
await waitFor(() => {
expect(screen.getByText(/Workspace:/)).toBeInTheDocument()
expect(screen.getByText(/PersonalData:/)).toBeInTheDocument()
expect(screen.getByText(/Barn:/)).toBeInTheDocument()
})
})
})
})