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
254 lines
6.2 KiB
TypeScript
254 lines
6.2 KiB
TypeScript
/**
|
|
* Shared test fixtures — the planning engine (`POST /api/plan/propose`,
|
|
* `POST /api/plan/apply`) and its nested shapes.
|
|
*/
|
|
|
|
import type {
|
|
CostBreakdown,
|
|
DayCollision,
|
|
EvictionCandidate,
|
|
EvictionPreview,
|
|
PlanAge4Projection,
|
|
PlanApplyResponse,
|
|
PlanChange,
|
|
PlanProposal,
|
|
PlanSyncResponse,
|
|
PlanWarning,
|
|
PoolBalance,
|
|
ProposedDay,
|
|
ProposedDayApplyItem,
|
|
Rejection,
|
|
ResourceShortage,
|
|
ShortfallRemedy,
|
|
SurplusAdvice,
|
|
VacationBalance,
|
|
WindowOutcome,
|
|
} from '@/types'
|
|
|
|
export function makeCostBreakdown(overrides: Partial<CostBreakdown> = {}): CostBreakdown {
|
|
return { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0, ...overrides }
|
|
}
|
|
|
|
export function makeShortfallRemedy(overrides: Partial<ShortfallRemedy> = {}): ShortfallRemedy {
|
|
return {
|
|
kind: 'dubbeldag',
|
|
messageKey: 'planRemedies.example',
|
|
data: {},
|
|
daysFreed: 0,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeProposedDay(overrides: Partial<ProposedDay> = {}): ProposedDay {
|
|
return {
|
|
date: '2026-06-15',
|
|
parentPersonalDataUuid: 'pd-a',
|
|
childId: 'child-1',
|
|
dayType: 'sjukpenning',
|
|
isDubbeldag: false,
|
|
wishId: 'lw-1',
|
|
reason: 'Sommar',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** The `/api/plan/apply` request-side counterpart of `ProposedDay` — no
|
|
* `wishId`/`reason`, and `dayType` additionally allows `'free'`. */
|
|
export function makeProposedDayApplyItem(
|
|
overrides: Partial<ProposedDayApplyItem> = {},
|
|
): ProposedDayApplyItem {
|
|
return {
|
|
date: '2026-06-15',
|
|
parentPersonalDataUuid: 'pd-a',
|
|
childId: 'child-1',
|
|
dayType: 'sjukpenning',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeWindowOutcome(overrides: Partial<WindowOutcome> = {}): WindowOutcome {
|
|
return {
|
|
wishId: 'lw-1',
|
|
wishName: 'Sommar',
|
|
wishNameMessageKey: null,
|
|
year: 2026,
|
|
startDate: '2026-06-15',
|
|
endDate: '2026-07-26',
|
|
weeksWished: 6,
|
|
weeksFunded: 6,
|
|
shortfallDays: 0,
|
|
strategy: 'parent_swap',
|
|
messageKey: 'planOutcomes.windowExplanation',
|
|
data: {},
|
|
costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),
|
|
remedies: [],
|
|
shiftGainDays: 0,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makePlanWarning(overrides: Partial<PlanWarning> = {}): PlanWarning {
|
|
return {
|
|
kind: 'days_lost_at_age_4',
|
|
messageKey: 'planWarnings.days_lost_at_age_4',
|
|
data: {},
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makePoolBalance(overrides: Partial<PoolBalance> = {}): PoolBalance {
|
|
return {
|
|
parentPersonalDataUuid: 'pd-a',
|
|
childId: 'child-1',
|
|
sjukpenningLeft: 310,
|
|
lagstaLeft: 90,
|
|
dubbeldagarLeft: 55,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeVacationBalance(overrides: Partial<VacationBalance> = {}): VacationBalance {
|
|
return {
|
|
parentPersonalDataUuid: 'pd-a',
|
|
year: 2026,
|
|
totalDays: 25,
|
|
savedDaysIn: 0,
|
|
alreadyUsed: 0,
|
|
isAssumed: false,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeSurplusAdvice(overrides: Partial<SurplusAdvice> = {}): SurplusAdvice {
|
|
return {
|
|
kind: 'wish_more_leave',
|
|
messageKey: 'planSurplus.wish_more_leave',
|
|
daysInvolved: 0,
|
|
data: {},
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makePlanAge4Projection(overrides: Partial<PlanAge4Projection> = {}): PlanAge4Projection {
|
|
return {
|
|
parentPersonalDataUuid: 'pd-a',
|
|
childId: 'child-1',
|
|
fourthBirthday: '2029-06-01',
|
|
projectedLeftAtB4: 0,
|
|
savableAfterB4: 96,
|
|
expiringAtB4: 0,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makePlanProposal(overrides: Partial<PlanProposal> = {}): PlanProposal {
|
|
return {
|
|
days: [makeProposedDay()],
|
|
outcomes: [makeWindowOutcome()],
|
|
warnings: [],
|
|
poolBalances: [],
|
|
vacationBalances: [],
|
|
surplusAdvice: [],
|
|
age4Projections: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeDayCollision(overrides: Partial<DayCollision> = {}): DayCollision {
|
|
return {
|
|
date: '2026-06-15',
|
|
parentPersonalDataUuid: 'pd-a',
|
|
childId: 'child-1',
|
|
existingDayType: 'vacation',
|
|
proposedDayType: 'sjukpenning',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeRejection(overrides: Partial<Rejection> = {}): Rejection {
|
|
return {
|
|
messageKey: 'planApply.rejection.insufficientBalance',
|
|
data: {},
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makePlanApplyResponse(overrides: Partial<PlanApplyResponse> = {}): PlanApplyResponse {
|
|
return {
|
|
accepted: true,
|
|
createdCount: 0,
|
|
skippedIdenticalCount: 0,
|
|
skippedFreeCount: 0,
|
|
replacedCount: 0,
|
|
collisions: [],
|
|
rejections: [],
|
|
createdDays: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** `POST /api/plan/sync` — one (child, parent, date) slot's delta. See
|
|
* `PlanChange`'s docstring (src/types/entities.ts) for the `kind` vocabulary. */
|
|
export function makePlanChange(overrides: Partial<PlanChange> = {}): PlanChange {
|
|
return {
|
|
kind: 'added',
|
|
date: '2028-06-20',
|
|
parentPersonalDataUuid: 'pd-a',
|
|
childId: 'child-1',
|
|
oldDayType: null,
|
|
newDayType: 'vacation',
|
|
oldIsDubbeldag: null,
|
|
newIsDubbeldag: false,
|
|
oldWishId: null,
|
|
newWishId: 'lw-1',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeEvictionCandidate(overrides: Partial<EvictionCandidate> = {}): EvictionCandidate {
|
|
return {
|
|
date: '2028-06-20',
|
|
parentPersonalDataUuid: 'pd-a',
|
|
childId: 'child-1',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
wishId: 'lw-1',
|
|
wishName: 'Sommar',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makeResourceShortage(overrides: Partial<ResourceShortage> = {}): ResourceShortage {
|
|
return {
|
|
resourceKind: 'vacation',
|
|
parentPersonalDataUuid: 'pd-a',
|
|
tier: null,
|
|
childId: null,
|
|
year: 2028,
|
|
daysEvicted: 1,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** Always present on `PlanSyncResponse`. Defaults to the common (nothing
|
|
* evicted) case — pass `{ wouldEvict: true, candidates: [...], shortages:
|
|
* [...] }` to build the "nothing was written, ask the user" case. */
|
|
export function makeEvictionPreview(overrides: Partial<EvictionPreview> = {}): EvictionPreview {
|
|
return {
|
|
wouldEvict: false,
|
|
candidates: [],
|
|
shortages: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
export function makePlanSyncResponse(overrides: Partial<PlanSyncResponse> = {}): PlanSyncResponse {
|
|
return {
|
|
proposal: makePlanProposal(),
|
|
applyResult: makePlanApplyResponse(),
|
|
changes: [],
|
|
evictionPreview: makeEvictionPreview(),
|
|
...overrides,
|
|
}
|
|
}
|