Build plan-shape test data with the typed factories

PlanResults, Plan and usePlanProposal still hand-rolled proposal objects
inline -- roughly 29 nested literals in PlanResults alone. Hand-writing a
deep shape is the pressure that produced 219 `as any` casts across this
suite, so the factories existing but going unused left the trap armed.

Values an assertion depends on stay inline as overrides; the factories
supply only the scaffolding a reader does not need to see. A type gaining
a field now breaks one factory instead of silently passing thirty tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-15 07:47:25 +02:00
parent fe120ee222
commit 46e89a4359
3 changed files with 327 additions and 359 deletions
@@ -2,11 +2,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { PlanResults } from './PlanResults' import { PlanResults } from './PlanResults'
import { LanguageProvider } from '@/i18n' import { LanguageProvider } from '@/i18n'
import type { PersonalData, PlanProposal } from '@/types' import type { PersonalData } from '@/types'
import { makePersonalData, makePlanProposal, makeWindowOutcome, makePlanWarning, makeSurplusAdvice, makePlanAge4Projection, makeVacationBalance } from '../../../tests/fixtures'
const mockMembers: PersonalData[] = [ const mockMembers: PersonalData[] = [
{ uuid: 'pd1', workspaceId: 'ws1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, makePersonalData({ uuid: 'pd1', name: 'Alex' }),
{ uuid: 'pd2', workspaceId: 'ws1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, makePersonalData({ uuid: 'pd2', name: 'Sam' }),
] ]
// Node's own experimental `localStorage` global shadows jsdom's working // Node's own experimental `localStorage` global shadows jsdom's working
@@ -45,39 +46,6 @@ function renderLocalized(ui: React.ReactElement, locale: 'sv' | 'en') {
return render(<LanguageProvider>{ui}</LanguageProvider>) return render(<LanguageProvider>{ui}</LanguageProvider>)
} }
function buildProposal(overrides: Partial<PlanProposal> = {}): PlanProposal {
return {
days: [],
outcomes: [
{
wishId: 'lw1',
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: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
remedies: [],
},
],
warnings: [],
poolBalances: [
{ parentPersonalDataUuid: 'pd1', childId: 'child1', sjukpenningLeft: 87, lagstaLeft: 0, dubbeldagarLeft: 60 },
],
vacationBalances: [
{ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: false },
],
surplusAdvice: [],
age4Projections: [],
...overrides,
}
}
describe('PlanResults', () => { describe('PlanResults', () => {
const originalLanguage = window.navigator.language const originalLanguage = window.navigator.language
@@ -92,45 +60,34 @@ describe('PlanResults', () => {
}) })
it('shows the "this is a preview" banner', () => { it('shows the "this is a preview" banner', () => {
renderLocalized(<PlanResults proposal={buildProposal()} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={makePlanProposal()} members={mockMembers} />, 'sv')
expect(screen.getByTestId('plan-preview-banner')).toHaveTextContent(/förslag/i) expect(screen.getByTestId('plan-preview-banner')).toHaveTextContent(/förslag/i)
}) })
it('renders a multi-year overview row per year with each outcome as a badge', () => { it('renders a multi-year overview row per year with each outcome as a badge', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
outcomes: [ outcomes: [
{ makeWindowOutcome({
wishId: 'lw1', wishId: 'lw1',
wishName: 'Sommar', wishName: 'Sommar',
wishNameMessageKey: null,
year: 2026, year: 2026,
startDate: '2026-06-15', startDate: '2026-06-15',
endDate: '2026-07-26', endDate: '2026-07-26',
weeksWished: 6, weeksWished: 6,
weeksFunded: 6, weeksFunded: 6,
shortfallDays: 0,
strategy: 'parent_swap',
messageKey: 'planOutcomes.windowExplanation',
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
remedies: [], }),
}, makeWindowOutcome({
{
wishId: 'lw2', wishId: 'lw2',
wishName: 'Jul', wishName: 'Jul',
wishNameMessageKey: null,
year: 2027, year: 2027,
startDate: '2027-12-20', startDate: '2027-12-20',
endDate: '2028-01-16', endDate: '2028-01-16',
weeksWished: 4, weeksWished: 4,
weeksFunded: 4, weeksFunded: 4,
shortfallDays: 0,
strategy: 'solo',
messageKey: 'planOutcomes.windowExplanation',
data: { wish_name: 'Jul', year: 2027, weeks_wished: 4, weeks_funded: 4, shortfall_days: 0 },
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
remedies: [], strategy: 'solo',
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
@@ -142,7 +99,24 @@ describe('PlanResults', () => {
}) })
it('renders a card per window outcome with the day breakdown and explanation', () => { it('renders a card per window outcome with the day breakdown and explanation', () => {
renderLocalized(<PlanResults proposal={buildProposal()} members={mockMembers} />, 'sv') const proposal = makePlanProposal({
poolBalances: [{ parentPersonalDataUuid: 'pd1', childId: 'child1', sjukpenningLeft: 87, lagstaLeft: 0, dubbeldagarLeft: 60 }],
vacationBalances: [{ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: false }],
outcomes: [
makeWindowOutcome({
wishId: 'lw1',
wishName: 'Sommar',
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
weeksWished: 6,
weeksFunded: 6,
costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const card = screen.getByTestId('plan-outcome-lw1-2026') const card = screen.getByTestId('plan-outcome-lw1-2026')
expect(card).toHaveTextContent('Sommar 2026') expect(card).toHaveTextContent('Sommar 2026')
@@ -155,7 +129,22 @@ describe('PlanResults', () => {
}) })
it('renders the window explanation in English when the locale is English', () => { it('renders the window explanation in English when the locale is English', () => {
renderLocalized(<PlanResults proposal={buildProposal()} members={mockMembers} />, 'en') const proposal = makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw1',
wishName: 'Sommar',
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
weeksWished: 6,
weeksFunded: 6,
costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
const card = screen.getByTestId('plan-outcome-lw1-2026') const card = screen.getByTestId('plan-outcome-lw1-2026')
expect(card).toHaveTextContent('Sommar 2026 — 6 weeks wished, 6 weeks funded') expect(card).toHaveTextContent('Sommar 2026 — 6 weeks wished, 6 weeks funded')
@@ -165,12 +154,11 @@ describe('PlanResults', () => {
}) })
it('shows a shortfall banner with its ranked remedies when a window is underfunded', () => { it('shows a shortfall banner with its ranked remedies when a window is underfunded', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
outcomes: [ outcomes: [
{ makeWindowOutcome({
wishId: 'lw1', wishId: 'lw1',
wishName: 'Sommar', wishName: 'Sommar',
wishNameMessageKey: null,
year: 2026, year: 2026,
startDate: '2026-06-15', startDate: '2026-06-15',
endDate: '2026-07-26', endDate: '2026-07-26',
@@ -178,8 +166,6 @@ describe('PlanResults', () => {
weeksFunded: 4, weeksFunded: 4,
shortfallDays: 14, shortfallDays: 14,
strategy: 'solo', strategy: 'solo',
messageKey: 'planOutcomes.windowExplanation',
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 4, shortfall_days: 14 },
costBreakdown: { free: 12, vacation: 10, sjukpenning: 6, lagsta: 0 }, costBreakdown: { free: 12, vacation: 10, sjukpenning: 6, lagsta: 0 },
remedies: [ remedies: [
{ {
@@ -195,7 +181,7 @@ describe('PlanResults', () => {
daysFreed: 6, daysFreed: 6,
}, },
], ],
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
@@ -209,12 +195,11 @@ describe('PlanResults', () => {
}) })
it('renders remedies in English when the locale is English', () => { it('renders remedies in English when the locale is English', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
outcomes: [ outcomes: [
{ makeWindowOutcome({
wishId: 'lw1', wishId: 'lw1',
wishName: 'Sommar', wishName: 'Sommar',
wishNameMessageKey: null,
year: 2026, year: 2026,
startDate: '2026-06-15', startDate: '2026-06-15',
endDate: '2026-07-26', endDate: '2026-07-26',
@@ -222,8 +207,6 @@ describe('PlanResults', () => {
weeksFunded: 4, weeksFunded: 4,
shortfallDays: 14, shortfallDays: 14,
strategy: 'solo', strategy: 'solo',
messageKey: 'planOutcomes.windowExplanation',
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 4, shortfall_days: 14 },
costBreakdown: { free: 12, vacation: 10, sjukpenning: 6, lagsta: 0 }, costBreakdown: { free: 12, vacation: 10, sjukpenning: 6, lagsta: 0 },
remedies: [ remedies: [
{ {
@@ -233,7 +216,7 @@ describe('PlanResults', () => {
daysFreed: 5, daysFreed: 5,
}, },
], ],
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
@@ -243,17 +226,15 @@ describe('PlanResults', () => {
}) })
it('displays the days-lost-at-age-4 warning prominently', () => { it('displays the days-lost-at-age-4 warning prominently', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
warnings: [ warnings: [
{ makePlanWarning({
kind: 'days_lost_at_age_4',
messageKey: 'planWarnings.days_lost_at_age_4',
data: { data: {
parent_personal_data_uuid: 'pd1', parent_personal_data_uuid: 'pd1',
expiring_days: 117, expiring_days: 117,
fourth_birthday: '2030-03-01', fourth_birthday: '2030-03-01',
}, },
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
@@ -263,17 +244,15 @@ describe('PlanResults', () => {
}) })
it('renders the warning body in English, resolving the parent name, when the locale is English', () => { it('renders the warning body in English, resolving the parent name, when the locale is English', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
warnings: [ warnings: [
{ makePlanWarning({
kind: 'days_lost_at_age_4',
messageKey: 'planWarnings.days_lost_at_age_4',
data: { data: {
parent_personal_data_uuid: 'pd1', parent_personal_data_uuid: 'pd1',
expiring_days: 117, expiring_days: 117,
fourth_birthday: '2030-03-01', fourth_birthday: '2030-03-01',
}, },
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
@@ -283,29 +262,33 @@ describe('PlanResults', () => {
}) })
it('resolves parent uuids to names in the balances tables', () => { it('resolves parent uuids to names in the balances tables', () => {
renderLocalized(<PlanResults proposal={buildProposal()} members={mockMembers} />, 'sv') const proposal = makePlanProposal({
poolBalances: [{ parentPersonalDataUuid: 'pd1', childId: 'child1', sjukpenningLeft: 87, lagstaLeft: 0, dubbeldagarLeft: 60 }],
vacationBalances: [{ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: false }],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
expect(screen.getByTestId('plan-pool-balances')).toHaveTextContent('Alex') expect(screen.getByTestId('plan-pool-balances')).toHaveTextContent('Alex')
expect(screen.getByTestId('plan-vacation-balances')).toHaveTextContent('Alex') expect(screen.getByTestId('plan-vacation-balances')).toHaveTextContent('Alex')
}) })
it('does not render the surplus advice or age-4 projection sections when absent', () => { it('does not render the surplus advice or age-4 projection sections when absent', () => {
renderLocalized(<PlanResults proposal={buildProposal()} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={makePlanProposal()} members={mockMembers} />, 'sv')
expect(screen.queryByTestId('plan-surplus-advice')).not.toBeInTheDocument() expect(screen.queryByTestId('plan-surplus-advice')).not.toBeInTheDocument()
expect(screen.queryByTestId('plan-age4-projections')).not.toBeInTheDocument() expect(screen.queryByTestId('plan-age4-projections')).not.toBeInTheDocument()
}) })
it('renders ranked surplus advice when the plan forecasts a surplus (§9.2)', () => { it('renders ranked surplus advice when the plan forecasts a surplus (§9.2)', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
surplusAdvice: [ surplusAdvice: [
{ makeSurplusAdvice({
kind: 'wish_more_leave', kind: 'wish_more_leave',
messageKey: 'planSurplusAdvice.wish_more_leave', messageKey: 'planSurplusAdvice.wish_more_leave',
daysInvolved: 10, daysInvolved: 10,
data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 }, data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 },
}, }),
{ makeSurplusAdvice({
kind: 'use_less_vacation', kind: 'use_less_vacation',
messageKey: 'planSurplusAdvice.use_less_vacation', messageKey: 'planSurplusAdvice.use_less_vacation',
daysInvolved: 10, daysInvolved: 10,
@@ -315,7 +298,7 @@ describe('PlanResults', () => {
years_text: '2026, 2027', years_text: '2026, 2027',
min_vacation_days_per_year: 20, min_vacation_days_per_year: 20,
}, },
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
@@ -328,14 +311,14 @@ describe('PlanResults', () => {
}) })
it('renders surplus advice in English, resolving the parent name, when the locale is English', () => { it('renders surplus advice in English, resolving the parent name, when the locale is English', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
surplusAdvice: [ surplusAdvice: [
{ makeSurplusAdvice({
kind: 'wish_more_leave', kind: 'wish_more_leave',
messageKey: 'planSurplusAdvice.wish_more_leave', messageKey: 'planSurplusAdvice.wish_more_leave',
daysInvolved: 10, daysInvolved: 10,
data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 }, data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 },
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
@@ -346,16 +329,16 @@ describe('PlanResults', () => {
}) })
it('renders the plan-aware age-4 projection table (§3.3 part 2)', () => { it('renders the plan-aware age-4 projection table (§3.3 part 2)', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
age4Projections: [ age4Projections: [
{ makePlanAge4Projection({
parentPersonalDataUuid: 'pd1', parentPersonalDataUuid: 'pd1',
childId: 'child1', childId: 'child1',
fourthBirthday: '2030-03-01', fourthBirthday: '2030-03-01',
projectedLeftAtB4: 106, projectedLeftAtB4: 106,
savableAfterB4: 96, savableAfterB4: 96,
expiringAtB4: 10, expiringAtB4: 10,
}, }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
@@ -374,9 +357,9 @@ describe('PlanResults', () => {
// never mistaken for showing the same thing (types/entities.ts: // never mistaken for showing the same thing (types/entities.ts:
// PlanAge4Projection vs Age4Projection). // PlanAge4Projection vs Age4Projection).
it('labels the age-4 projection section as previewing this unsaved proposal, in Swedish', () => { it('labels the age-4 projection section as previewing this unsaved proposal, in Swedish', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
age4Projections: [ age4Projections: [
{ parentPersonalDataUuid: 'pd1', childId: 'child1', fourthBirthday: '2030-03-01', projectedLeftAtB4: 106, savableAfterB4: 96, expiringAtB4: 10 }, makePlanAge4Projection({ parentPersonalDataUuid: 'pd1', childId: 'child1', fourthBirthday: '2030-03-01', projectedLeftAtB4: 106, savableAfterB4: 96, expiringAtB4: 10 }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
@@ -388,9 +371,9 @@ describe('PlanResults', () => {
}) })
it('labels the age-4 projection section as previewing this unsaved proposal, in English', () => { it('labels the age-4 projection section as previewing this unsaved proposal, in English', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
age4Projections: [ age4Projections: [
{ parentPersonalDataUuid: 'pd1', childId: 'child1', fourthBirthday: '2030-03-01', projectedLeftAtB4: 106, savableAfterB4: 96, expiringAtB4: 10 }, makePlanAge4Projection({ parentPersonalDataUuid: 'pd1', childId: 'child1', fourthBirthday: '2030-03-01', projectedLeftAtB4: 106, savableAfterB4: 96, expiringAtB4: 10 }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
@@ -402,9 +385,9 @@ describe('PlanResults', () => {
}) })
it('shows an assumed badge for a vacation year with no entered data (§3.2)', () => { it('shows an assumed badge for a vacation year with no entered data (§3.2)', () => {
const proposal = buildProposal({ const proposal = makePlanProposal({
vacationBalances: [ vacationBalances: [
{ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: true }, makeVacationBalance({ parentPersonalDataUuid: 'pd1', year: 2026, totalDays: 25, savedDaysIn: 0, alreadyUsed: 6, isAssumed: true }),
], ],
}) })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
@@ -418,34 +401,32 @@ describe('PlanResults', () => {
// (weeksFunded 0, shortfallDays = the full calendar-day duration, an // (weeksFunded 0, shortfallDays = the full calendar-day duration, an
// all-zero costBreakdown) plus the same windowExplanation message_key — the // all-zero costBreakdown) plus the same windowExplanation message_key — the
// only addition is the 'unplaced' strategy label. // only addition is the 'unplaced' strategy label.
function buildUnplacedOutcome() {
return {
wishId: 'lw3',
wishName: 'Vår',
wishNameMessageKey: null,
year: 2027,
startDate: '2027-03-01',
endDate: '2027-05-31',
weeksWished: 6,
weeksFunded: 0,
shortfallDays: 42,
strategy: 'unplaced',
messageKey: 'planOutcomes.windowExplanation',
data: { wish_name: 'Vår', year: 2027, weeks_wished: 6, weeks_funded: 0, shortfall_days: 42 },
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
remedies: [
{
kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: { wish_name: 'Sommar', from_weeks: 6, to_weeks: 4, freed_days: 14 },
daysFreed: 14,
},
],
}
}
it('renders an unplaced flexible wish as a 0%-funded shortfall card with the "unplaced" strategy label (sv)', () => { it('renders an unplaced flexible wish as a 0%-funded shortfall card with the "unplaced" strategy label (sv)', () => {
const proposal = buildProposal({ outcomes: [buildUnplacedOutcome()] }) const proposal = makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw3',
wishName: 'Vår',
year: 2027,
startDate: '2027-03-01',
endDate: '2027-05-31',
weeksWished: 6,
weeksFunded: 0,
shortfallDays: 42,
strategy: 'unplaced',
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
remedies: [
{
kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: { wish_name: 'Sommar', from_weeks: 6, to_weeks: 4, freed_days: 14 },
daysFreed: 14,
},
],
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const card = screen.getByTestId('plan-outcome-lw3-2027') const card = screen.getByTestId('plan-outcome-lw3-2027')
@@ -457,7 +438,30 @@ describe('PlanResults', () => {
}) })
it('renders an unplaced flexible wish with the "unplaced" strategy label in English', () => { it('renders an unplaced flexible wish with the "unplaced" strategy label in English', () => {
const proposal = buildProposal({ outcomes: [buildUnplacedOutcome()] }) const proposal = makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw3',
wishName: 'Vår',
year: 2027,
startDate: '2027-03-01',
endDate: '2027-05-31',
weeksWished: 6,
weeksFunded: 0,
shortfallDays: 42,
strategy: 'unplaced',
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
remedies: [
{
kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: { wish_name: 'Sommar', from_weeks: 6, to_weeks: 4, freed_days: 14 },
daysFreed: 14,
},
],
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
const card = screen.getByTestId('plan-outcome-lw3-2027') const card = screen.getByTestId('plan-outcome-lw3-2027')
@@ -475,34 +479,18 @@ describe('PlanResults', () => {
// see src/utils/wishName.ts) lets every surface re-translate it, exactly // see src/utils/wishName.ts) lets every surface re-translate it, exactly
// like WishEditor already does for the wish list itself. // like WishEditor already does for the wish list itself.
describe('wishNameMessageKey (preset wish names follow the UI language)', () => { describe('wishNameMessageKey (preset wish names follow the UI language)', () => {
function buildPresetOutcome() {
return {
wishId: 'lw1',
wishName: 'STALE_SWEDISH_TEXT',
wishNameMessageKey: 'wishEditor.presetSommar',
year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
weeksWished: 6,
weeksFunded: 6,
shortfallDays: 0,
strategy: 'parent_swap',
messageKey: 'planOutcomes.windowExplanation',
data: {
wish_name: 'STALE_SWEDISH_TEXT',
wish_name_message_key: 'wishEditor.presetSommar',
year: 2026,
weeks_wished: 6,
weeks_funded: 6,
shortfall_days: 0,
},
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
remedies: [],
}
}
it('re-translates the name through the badge, card heading, and explanation in Swedish, ignoring the stale stored name', () => { it('re-translates the name through the badge, card heading, and explanation in Swedish, ignoring the stale stored name', () => {
const proposal = buildProposal({ outcomes: [buildPresetOutcome()] }) const proposal = makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw1',
wishName: 'STALE_SWEDISH_TEXT',
wishNameMessageKey: 'wishEditor.presetSommar',
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
data: { wish_name: 'STALE_SWEDISH_TEXT', wish_name_message_key: 'wishEditor.presetSommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
expect(screen.getByText(/Sommar: 6\/6 veckor/)).toBeInTheDocument() expect(screen.getByText(/Sommar: 6\/6 veckor/)).toBeInTheDocument()
@@ -513,7 +501,17 @@ describe('PlanResults', () => {
}) })
it('re-translates the same name to English when the locale is English', () => { it('re-translates the same name to English when the locale is English', () => {
const proposal = buildProposal({ outcomes: [buildPresetOutcome()] }) const proposal = makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw1',
wishName: 'STALE_SWEDISH_TEXT',
wishNameMessageKey: 'wishEditor.presetSommar',
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
data: { wish_name: 'STALE_SWEDISH_TEXT', wish_name_message_key: 'wishEditor.presetSommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'en')
expect(screen.getByText(/Summer: 6\/6 weeks/)).toBeInTheDocument() expect(screen.getByText(/Summer: 6\/6 weeks/)).toBeInTheDocument()
@@ -524,13 +522,21 @@ describe('PlanResults', () => {
}) })
it('shows the literal name verbatim when wishNameMessageKey is null (free-typed/renamed wish)', () => { it('shows the literal name verbatim when wishNameMessageKey is null (free-typed/renamed wish)', () => {
const outcome = { const proposal = makePlanProposal({
...buildPresetOutcome(), outcomes: [
wishName: 'Resa till mormor', makeWindowOutcome({
wishNameMessageKey: null, wishId: 'lw1',
data: { ...buildPresetOutcome().data, wish_name: 'Resa till mormor', wish_name_message_key: null }, wishName: 'Resa till mormor',
} wishNameMessageKey: null,
const proposal = buildProposal({ outcomes: [outcome] }) year: 2026,
startDate: '2026-06-15',
endDate: '2026-07-26',
weeksWished: 6,
weeksFunded: 6,
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const card = screen.getByTestId('plan-outcome-lw1-2026') const card = screen.getByTestId('plan-outcome-lw1-2026')
@@ -538,17 +544,21 @@ describe('PlanResults', () => {
}) })
it('falls back to the literal name for a key this build does not recognise', () => { it('falls back to the literal name for a key this build does not recognise', () => {
const outcome = { const proposal = makePlanProposal({
...buildPresetOutcome(), outcomes: [
wishName: 'Sommar', makeWindowOutcome({
wishNameMessageKey: 'wishEditor.thisKeyDoesNotExist', wishId: 'lw1',
data: { wishName: 'Sommar',
...buildPresetOutcome().data, wishNameMessageKey: 'wishEditor.thisKeyDoesNotExist',
wish_name: 'Sommar', year: 2026,
wish_name_message_key: 'wishEditor.thisKeyDoesNotExist', startDate: '2026-06-15',
}, endDate: '2026-07-26',
} weeksWished: 6,
const proposal = buildProposal({ outcomes: [outcome] }) weeksFunded: 6,
costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
}),
],
})
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const card = screen.getByTestId('plan-outcome-lw1-2026') const card = screen.getByTestId('plan-outcome-lw1-2026')
@@ -556,27 +566,38 @@ describe('PlanResults', () => {
expect(card).not.toHaveTextContent('wishEditor.thisKeyDoesNotExist') expect(card).not.toHaveTextContent('wishEditor.thisKeyDoesNotExist')
}) })
it('re-translates a lower_priority_wish remedy’s referenced sibling name independently of the outcome’s own wish', () => { it('re-translates a lower_priority_wish remedy\'s referenced sibling name independently of the outcome\'s own wish', () => {
const outcome = { const proposal = makePlanProposal({
...buildPresetOutcome(), outcomes: [
weeksFunded: 4, makeWindowOutcome({
shortfallDays: 14, wishId: 'lw1',
remedies: [ wishName: 'STALE_SWEDISH_TEXT',
{ wishNameMessageKey: 'wishEditor.presetSommar',
kind: 'lower_priority_wish', year: 2026,
messageKey: 'planRemedies.lower_priority_wish', startDate: '2026-06-15',
data: { endDate: '2026-07-26',
wish_name: 'STALE_JUL_TEXT', weeksWished: 6,
wish_name_message_key: 'wishEditor.seasonJul', weeksFunded: 4,
from_weeks: 4, shortfallDays: 14,
to_weeks: 2, strategy: 'parent_swap',
freed_days: 6, costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 },
}, remedies: [
daysFreed: 6, {
}, kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: {
wish_name: 'STALE_JUL_TEXT',
wish_name_message_key: 'wishEditor.seasonJul',
from_weeks: 4,
to_weeks: 2,
freed_days: 6,
},
daysFreed: 6,
},
],
}),
], ],
} })
const proposal = buildProposal({ outcomes: [outcome] })
renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv') renderLocalized(<PlanResults proposal={proposal} members={mockMembers} />, 'sv')
const remedies = screen.getByTestId('plan-remedies-lw1-2026') const remedies = screen.getByTestId('plan-remedies-lw1-2026')
+2 -9
View File
@@ -2,18 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react' import { renderHook, act } from '@testing-library/react'
import { usePlanProposal } from './usePlanProposal' import { usePlanProposal } from './usePlanProposal'
import * as api from '@/services/api' import * as api from '@/services/api'
import { makePlanProposal } from '../../tests/fixtures'
vi.mock('@/services/api') vi.mock('@/services/api')
const mockProposal = { const mockProposal = makePlanProposal()
days: [],
outcomes: [],
warnings: [],
poolBalances: [],
vacationBalances: [],
surplusAdvice: [],
age4Projections: [],
}
describe('usePlanProposal', () => { describe('usePlanProposal', () => {
beforeEach(() => { beforeEach(() => {
+114 -160
View File
@@ -10,6 +10,14 @@ import {
makeWorkspaceMembersResult, makeWorkspaceMembersResult,
makePlanProposalResult, makePlanProposalResult,
makePlanApplyResult, makePlanApplyResult,
makeLeaveWish,
makePlanProposal,
makeProposedDay,
makeWindowOutcome,
makePlanWarning,
makeDayCollision,
makeRejection,
makePlanApplyResponse,
} from '../../tests/fixtures' } from '../../tests/fixtures'
vi.mock('@/hooks') vi.mock('@/hooks')
@@ -42,52 +50,38 @@ function renderPlanInEnglish() {
} }
const mockWish = { const mockWish = makeLeaveWish({
id: 'lw1', id: 'lw1',
workspaceId: 'ws-1', workspaceId: 'ws-1',
childId: null,
name: 'Sommar', name: 'Sommar',
nameMessageKey: 'presets.sommar',
windowStartMonth: 6, windowStartMonth: 6,
windowStartDay: 15, windowStartDay: 15,
windowEndMonth: 7, windowEndMonth: 7,
windowEndDay: 26, windowEndDay: 26,
targetWeeks: 6, targetWeeks: 6,
recurrence: 'yearly' as const, })
firstYear: 2026,
lastYear: null,
priority: 1,
parentMode: 'either' as const,
parentPersonalDataUuid: null,
goalOverride: null,
windowMode: 'fixed' as const,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
}
const mockProposal = { const mockProposal = makePlanProposal({
days: [ days: [
{ makeProposedDay({
date: '2026-06-15', date: '2026-06-15',
parentPersonalDataUuid: 'pd-1', parentPersonalDataUuid: 'pd-1',
childId: 'c-1', childId: 'c-1',
dayType: 'sjukpenning' as const, dayType: 'sjukpenning',
isDubbeldag: false,
wishId: 'lw1', wishId: 'lw1',
reason: 'Sommar', reason: 'Sommar',
}, }),
{ makeProposedDay({
date: '2026-06-16', date: '2026-06-16',
parentPersonalDataUuid: 'pd-1', parentPersonalDataUuid: 'pd-1',
childId: 'c-1', childId: 'c-1',
dayType: 'vacation' as const, dayType: 'vacation',
isDubbeldag: false,
wishId: 'lw1', wishId: 'lw1',
reason: 'Sommar', reason: 'Sommar',
}, }),
], ],
outcomes: [ outcomes: [
{ makeWindowOutcome({
wishId: 'lw1', wishId: 'lw1',
wishName: 'Sommar', wishName: 'Sommar',
wishNameMessageKey: 'presets.sommar', wishNameMessageKey: 'presets.sommar',
@@ -96,26 +90,15 @@ const mockProposal = {
endDate: '2026-07-26', endDate: '2026-07-26',
weeksWished: 6, weeksWished: 6,
weeksFunded: 6, weeksFunded: 6,
shortfallDays: 0,
strategy: 'parent_swap',
messageKey: 'planOutcomes.windowExplanation',
data: { wish_name: 'Sommar', year: 2026, weeks_wished: 6, weeks_funded: 6, shortfall_days: 0 },
costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 }, costBreakdown: { free: 12, vacation: 19, sjukpenning: 11, lagsta: 0 },
remedies: [], }),
},
], ],
warnings: [ warnings: [
{ makePlanWarning({
kind: 'days_lost_at_age_4',
messageKey: 'planWarnings.days_lost_at_age_4',
data: { parent_personal_data_uuid: 'pd-1', expiring_days: 117, fourth_birthday: '2030-03-01' }, data: { parent_personal_data_uuid: 'pd-1', expiring_days: 117, fourth_birthday: '2030-03-01' },
}, }),
], ],
poolBalances: [], })
vacationBalances: [],
surplusAdvice: [],
age4Projections: [],
}
describe('Plan', () => { describe('Plan', () => {
let mockAddWish: ReturnType<typeof vi.fn> let mockAddWish: ReturnType<typeof vi.fn>
@@ -130,16 +113,12 @@ describe('Plan', () => {
mockUpdateWish = vi.fn().mockResolvedValue(mockWish) mockUpdateWish = vi.fn().mockResolvedValue(mockWish)
mockRemoveWish = vi.fn().mockResolvedValue(undefined) mockRemoveWish = vi.fn().mockResolvedValue(undefined)
mockPropose = vi.fn().mockResolvedValue(undefined) mockPropose = vi.fn().mockResolvedValue(undefined)
mockApply = vi.fn().mockResolvedValue({ mockApply = vi.fn().mockResolvedValue(
accepted: true, makePlanApplyResponse({
createdCount: 2, accepted: true,
skippedIdenticalCount: 0, createdCount: 2,
skippedFreeCount: 0, })
replacedCount: 0, )
collisions: [],
rejections: [],
createdDays: [],
})
vi.mocked(useWorkspace).mockReturnValue( vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({ makeWorkspaceContextValue({
@@ -424,24 +403,20 @@ describe('Plan', () => {
}) })
) )
mockApply.mockResolvedValue({ mockApply.mockResolvedValue(
accepted: false, makePlanApplyResponse({
createdCount: 0, accepted: false,
skippedIdenticalCount: 0, collisions: [
skippedFreeCount: 0, makeDayCollision({
replacedCount: 0, date: '2026-06-15',
collisions: [ parentPersonalDataUuid: 'pd-1',
{ childId: 'c-1',
date: '2026-06-15', existingDayType: 'vacation',
parentPersonalDataUuid: 'pd-1', proposedDayType: 'sjukpenning',
childId: 'c-1', }),
existingDayType: 'vacation', ],
proposedDayType: 'sjukpenning', })
}, )
],
rejections: [],
createdDays: [],
})
renderPlan() renderPlan()
@@ -481,16 +456,13 @@ describe('Plan', () => {
rejections: [], rejections: [],
createdDays: [], createdDays: [],
}) })
.mockResolvedValueOnce({ .mockResolvedValueOnce(
accepted: true, makePlanApplyResponse({
createdCount: 2, accepted: true,
skippedIdenticalCount: 0, createdCount: 2,
skippedFreeCount: 0, replacedCount: 1,
replacedCount: 1, })
collisions: [], )
rejections: [],
createdDays: [],
})
renderPlan() renderPlan()
@@ -517,24 +489,20 @@ describe('Plan', () => {
}) })
) )
mockApply.mockResolvedValue({ mockApply.mockResolvedValue(
accepted: false, makePlanApplyResponse({
createdCount: 0, accepted: false,
skippedIdenticalCount: 0, collisions: [
skippedFreeCount: 0, makeDayCollision({
replacedCount: 0, date: '2026-06-15',
collisions: [ parentPersonalDataUuid: 'pd-1',
{ childId: 'c-1',
date: '2026-06-15', existingDayType: 'vacation',
parentPersonalDataUuid: 'pd-1', proposedDayType: 'sjukpenning',
childId: 'c-1', }),
existingDayType: 'vacation', ],
proposedDayType: 'sjukpenning', })
}, )
],
rejections: [],
createdDays: [],
})
renderPlan() renderPlan()
@@ -558,21 +526,18 @@ describe('Plan', () => {
}) })
) )
mockApply.mockResolvedValue({ mockApply.mockResolvedValue(
accepted: true, makePlanApplyResponse({
createdCount: 1, accepted: true,
skippedIdenticalCount: 0, createdCount: 1,
skippedFreeCount: 0, rejections: [
replacedCount: 0, makeRejection({
collisions: [], messageKey: 'planApply.rejection.insufficientBalance',
rejections: [ data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
{ }),
messageKey: 'planApply.rejection.insufficientBalance', ],
data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, })
}, )
],
createdDays: [],
})
renderPlan() renderPlan()
@@ -593,21 +558,18 @@ describe('Plan', () => {
}) })
) )
mockApply.mockResolvedValue({ mockApply.mockResolvedValue(
accepted: true, makePlanApplyResponse({
createdCount: 1, accepted: true,
skippedIdenticalCount: 0, createdCount: 1,
skippedFreeCount: 0, rejections: [
replacedCount: 0, makeRejection({
collisions: [], messageKey: 'planApply.rejection.insufficientBalance',
rejections: [ data: { tier: 'sjukpenning', needed: 2, remaining: 1 },
{ }),
messageKey: 'planApply.rejection.insufficientBalance', ],
data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, })
}, )
],
createdDays: [],
})
vi.stubGlobal('localStorage', { vi.stubGlobal('localStorage', {
getItem: () => null, getItem: () => null,
@@ -654,21 +616,21 @@ describe('Plan', () => {
it('clears success message when user proposes again after successful apply', async () => { it('clears success message when user proposes again after successful apply', async () => {
// Create two different proposals // Create two different proposals
const mockProposal1 = mockProposal const mockProposal1 = mockProposal
const mockProposal2 = { const mockProposal2 = makePlanProposal({
...mockProposal,
days: [ days: [
...mockProposal.days, ...mockProposal.days,
{ makeProposedDay({
date: '2026-07-01', date: '2026-07-01',
parentPersonalDataUuid: 'pd-1', parentPersonalDataUuid: 'pd-1',
childId: 'c-1', childId: 'c-1',
dayType: 'sjukpenning' as const, dayType: 'sjukpenning',
isDubbeldag: false,
wishId: 'lw1', wishId: 'lw1',
reason: 'Sommar', reason: 'Sommar',
}, }),
], ],
} outcomes: mockProposal.outcomes,
warnings: mockProposal.warnings,
})
// Track proposal state changes // Track proposal state changes
let proposalData = mockProposal1 let proposalData = mockProposal1
@@ -743,24 +705,20 @@ describe('Plan', () => {
) )
// Mock apply to return a collision on first call // Mock apply to return a collision on first call
mockApply.mockResolvedValue({ mockApply.mockResolvedValue(
accepted: false, makePlanApplyResponse({
createdCount: 0, accepted: false,
skippedIdenticalCount: 0, collisions: [
skippedFreeCount: 0, makeDayCollision({
replacedCount: 0, date: '2026-06-15',
collisions: [ parentPersonalDataUuid: 'pd-1',
{ childId: 'c-1',
date: '2026-06-15', existingDayType: 'vacation',
parentPersonalDataUuid: 'pd-1', proposedDayType: 'sjukpenning',
childId: 'c-1', }),
existingDayType: 'vacation', ],
proposedDayType: 'sjukpenning', })
}, )
],
rejections: [],
createdDays: [],
})
const { rerender } = renderPlan() const { rerender } = renderPlan()
@@ -797,16 +755,12 @@ describe('Plan', () => {
}) })
) )
mockApply.mockResolvedValue({ mockApply.mockResolvedValue(
accepted: true, makePlanApplyResponse({
createdCount: 5, accepted: true,
skippedIdenticalCount: 0, createdCount: 5,
skippedFreeCount: 0, })
replacedCount: 0, )
collisions: [],
rejections: [],
createdDays: [],
})
renderPlan() renderPlan()