Keep plan warnings in step with the plan they describe
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
This commit is contained in:
@@ -2,7 +2,7 @@ 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 } from '@/context'
|
||||
import { useWorkspace, usePlanSyncContextOptional } from '@/context'
|
||||
import * as api from '@/services/api'
|
||||
import { ApiRequestError } from '@/services/errors'
|
||||
import { LanguageProvider } from '@/i18n'
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
makeAge4Projection,
|
||||
makeParentYearBalance,
|
||||
makeVacationYearBalance,
|
||||
makePlanSyncContextValue,
|
||||
makeEvictionPreview,
|
||||
} from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
@@ -169,6 +171,10 @@ describe('Settings', () => {
|
||||
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(
|
||||
@@ -515,6 +521,24 @@ describe('Settings', () => {
|
||||
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, {
|
||||
@@ -626,6 +650,34 @@ describe('Settings', () => {
|
||||
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, {
|
||||
@@ -883,6 +935,69 @@ describe('Settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user