From 46e89a4359f9a7d4fb2db90883b654c4e3b3a1e4 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 15 Sep 2026 07:47:25 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- .../components/planning/PlanResults.test.tsx | 401 +++++++++--------- frontend/src/hooks/usePlanProposal.test.ts | 11 +- frontend/src/pages/Plan.test.tsx | 274 +++++------- 3 files changed, 327 insertions(+), 359 deletions(-) diff --git a/frontend/src/components/planning/PlanResults.test.tsx b/frontend/src/components/planning/PlanResults.test.tsx index 2d4cc2e..78c7f8a 100644 --- a/frontend/src/components/planning/PlanResults.test.tsx +++ b/frontend/src/components/planning/PlanResults.test.tsx @@ -2,11 +2,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen } from '@testing-library/react' import { PlanResults } from './PlanResults' 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[] = [ - { uuid: 'pd1', workspaceId: 'ws1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, - { uuid: 'pd2', workspaceId: 'ws1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + makePersonalData({ uuid: 'pd1', name: 'Alex' }), + makePersonalData({ uuid: 'pd2', name: 'Sam' }), ] // Node's own experimental `localStorage` global shadows jsdom's working @@ -45,39 +46,6 @@ function renderLocalized(ui: React.ReactElement, locale: 'sv' | 'en') { return render({ui}) } -function buildProposal(overrides: Partial = {}): 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', () => { const originalLanguage = window.navigator.language @@ -92,45 +60,34 @@ describe('PlanResults', () => { }) it('shows the "this is a preview" banner', () => { - renderLocalized(, 'sv') + renderLocalized(, 'sv') 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', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ outcomes: [ - { + makeWindowOutcome({ 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: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, - remedies: [], - }, - { + }), + makeWindowOutcome({ wishId: 'lw2', wishName: 'Jul', - wishNameMessageKey: null, year: 2027, startDate: '2027-12-20', endDate: '2028-01-16', weeksWished: 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 }, - remedies: [], - }, + strategy: 'solo', + }), ], }) renderLocalized(, 'sv') @@ -142,7 +99,24 @@ describe('PlanResults', () => { }) it('renders a card per window outcome with the day breakdown and explanation', () => { - renderLocalized(, '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(, 'sv') const card = screen.getByTestId('plan-outcome-lw1-2026') expect(card).toHaveTextContent('Sommar 2026') @@ -155,7 +129,22 @@ describe('PlanResults', () => { }) it('renders the window explanation in English when the locale is English', () => { - renderLocalized(, '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(, 'en') const card = screen.getByTestId('plan-outcome-lw1-2026') 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', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ outcomes: [ - { + makeWindowOutcome({ wishId: 'lw1', wishName: 'Sommar', - wishNameMessageKey: null, year: 2026, startDate: '2026-06-15', endDate: '2026-07-26', @@ -178,8 +166,6 @@ describe('PlanResults', () => { weeksFunded: 4, shortfallDays: 14, 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 }, remedies: [ { @@ -195,7 +181,7 @@ describe('PlanResults', () => { daysFreed: 6, }, ], - }, + }), ], }) renderLocalized(, 'sv') @@ -209,12 +195,11 @@ describe('PlanResults', () => { }) it('renders remedies in English when the locale is English', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ outcomes: [ - { + makeWindowOutcome({ wishId: 'lw1', wishName: 'Sommar', - wishNameMessageKey: null, year: 2026, startDate: '2026-06-15', endDate: '2026-07-26', @@ -222,8 +207,6 @@ describe('PlanResults', () => { weeksFunded: 4, shortfallDays: 14, 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 }, remedies: [ { @@ -233,7 +216,7 @@ describe('PlanResults', () => { daysFreed: 5, }, ], - }, + }), ], }) renderLocalized(, 'en') @@ -243,17 +226,15 @@ describe('PlanResults', () => { }) it('displays the days-lost-at-age-4 warning prominently', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ warnings: [ - { - kind: 'days_lost_at_age_4', - messageKey: 'planWarnings.days_lost_at_age_4', + makePlanWarning({ data: { parent_personal_data_uuid: 'pd1', expiring_days: 117, fourth_birthday: '2030-03-01', }, - }, + }), ], }) renderLocalized(, 'sv') @@ -263,17 +244,15 @@ describe('PlanResults', () => { }) it('renders the warning body in English, resolving the parent name, when the locale is English', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ warnings: [ - { - kind: 'days_lost_at_age_4', - messageKey: 'planWarnings.days_lost_at_age_4', + makePlanWarning({ data: { parent_personal_data_uuid: 'pd1', expiring_days: 117, fourth_birthday: '2030-03-01', }, - }, + }), ], }) renderLocalized(, 'en') @@ -283,29 +262,33 @@ describe('PlanResults', () => { }) it('resolves parent uuids to names in the balances tables', () => { - renderLocalized(, '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(, 'sv') expect(screen.getByTestId('plan-pool-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', () => { - renderLocalized(, 'sv') + renderLocalized(, 'sv') expect(screen.queryByTestId('plan-surplus-advice')).not.toBeInTheDocument() expect(screen.queryByTestId('plan-age4-projections')).not.toBeInTheDocument() }) it('renders ranked surplus advice when the plan forecasts a surplus (§9.2)', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ surplusAdvice: [ - { + makeSurplusAdvice({ kind: 'wish_more_leave', messageKey: 'planSurplusAdvice.wish_more_leave', daysInvolved: 10, data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 }, - }, - { + }), + makeSurplusAdvice({ kind: 'use_less_vacation', messageKey: 'planSurplusAdvice.use_less_vacation', daysInvolved: 10, @@ -315,7 +298,7 @@ describe('PlanResults', () => { years_text: '2026, 2027', min_vacation_days_per_year: 20, }, - }, + }), ], }) renderLocalized(, 'sv') @@ -328,14 +311,14 @@ describe('PlanResults', () => { }) it('renders surplus advice in English, resolving the parent name, when the locale is English', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ surplusAdvice: [ - { + makeSurplusAdvice({ kind: 'wish_more_leave', messageKey: 'planSurplusAdvice.wish_more_leave', daysInvolved: 10, data: { parent_personal_data_uuid: 'pd1', child_id: 'child1', weeks: 2 }, - }, + }), ], }) renderLocalized(, 'en') @@ -346,16 +329,16 @@ describe('PlanResults', () => { }) it('renders the plan-aware age-4 projection table (§3.3 part 2)', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ age4Projections: [ - { + makePlanAge4Projection({ parentPersonalDataUuid: 'pd1', childId: 'child1', fourthBirthday: '2030-03-01', projectedLeftAtB4: 106, savableAfterB4: 96, expiringAtB4: 10, - }, + }), ], }) renderLocalized(, 'sv') @@ -374,9 +357,9 @@ describe('PlanResults', () => { // never mistaken for showing the same thing (types/entities.ts: // PlanAge4Projection vs Age4Projection). it('labels the age-4 projection section as previewing this unsaved proposal, in Swedish', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ 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(, 'sv') @@ -388,9 +371,9 @@ describe('PlanResults', () => { }) it('labels the age-4 projection section as previewing this unsaved proposal, in English', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ 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(, 'en') @@ -402,9 +385,9 @@ describe('PlanResults', () => { }) it('shows an assumed badge for a vacation year with no entered data (§3.2)', () => { - const proposal = buildProposal({ + const proposal = makePlanProposal({ 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(, 'sv') @@ -418,34 +401,32 @@ describe('PlanResults', () => { // (weeksFunded 0, shortfallDays = the full calendar-day duration, an // all-zero costBreakdown) plus the same windowExplanation message_key — the // 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)', () => { - 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(, 'sv') 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', () => { - 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(, 'en') 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 // like WishEditor already does for the wish list itself. 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', () => { - 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(, 'sv') 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', () => { - 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(, 'en') 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)', () => { - const outcome = { - ...buildPresetOutcome(), - wishName: 'Resa till mormor', - wishNameMessageKey: null, - data: { ...buildPresetOutcome().data, wish_name: 'Resa till mormor', wish_name_message_key: null }, - } - const proposal = buildProposal({ outcomes: [outcome] }) + const proposal = makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw1', + wishName: 'Resa till mormor', + wishNameMessageKey: null, + year: 2026, + startDate: '2026-06-15', + endDate: '2026-07-26', + weeksWished: 6, + weeksFunded: 6, + costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, + }), + ], + }) renderLocalized(, 'sv') 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', () => { - const outcome = { - ...buildPresetOutcome(), - wishName: 'Sommar', - wishNameMessageKey: 'wishEditor.thisKeyDoesNotExist', - data: { - ...buildPresetOutcome().data, - wish_name: 'Sommar', - wish_name_message_key: 'wishEditor.thisKeyDoesNotExist', - }, - } - const proposal = buildProposal({ outcomes: [outcome] }) + const proposal = makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw1', + wishName: 'Sommar', + wishNameMessageKey: 'wishEditor.thisKeyDoesNotExist', + year: 2026, + startDate: '2026-06-15', + endDate: '2026-07-26', + weeksWished: 6, + weeksFunded: 6, + costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, + }), + ], + }) renderLocalized(, 'sv') const card = screen.getByTestId('plan-outcome-lw1-2026') @@ -556,27 +566,38 @@ describe('PlanResults', () => { 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', () => { - const outcome = { - ...buildPresetOutcome(), - weeksFunded: 4, - shortfallDays: 14, - remedies: [ - { - 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, - }, + it('re-translates a lower_priority_wish remedy\'s referenced sibling name independently of the outcome\'s own wish', () => { + const proposal = makePlanProposal({ + outcomes: [ + makeWindowOutcome({ + wishId: 'lw1', + wishName: 'STALE_SWEDISH_TEXT', + wishNameMessageKey: 'wishEditor.presetSommar', + year: 2026, + startDate: '2026-06-15', + endDate: '2026-07-26', + weeksWished: 6, + weeksFunded: 4, + shortfallDays: 14, + strategy: 'parent_swap', + costBreakdown: { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0 }, + remedies: [ + { + 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(, 'sv') const remedies = screen.getByTestId('plan-remedies-lw1-2026') diff --git a/frontend/src/hooks/usePlanProposal.test.ts b/frontend/src/hooks/usePlanProposal.test.ts index 275115b..cc743b6 100644 --- a/frontend/src/hooks/usePlanProposal.test.ts +++ b/frontend/src/hooks/usePlanProposal.test.ts @@ -2,18 +2,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { usePlanProposal } from './usePlanProposal' import * as api from '@/services/api' +import { makePlanProposal } from '../../tests/fixtures' vi.mock('@/services/api') -const mockProposal = { - days: [], - outcomes: [], - warnings: [], - poolBalances: [], - vacationBalances: [], - surplusAdvice: [], - age4Projections: [], -} +const mockProposal = makePlanProposal() describe('usePlanProposal', () => { beforeEach(() => { diff --git a/frontend/src/pages/Plan.test.tsx b/frontend/src/pages/Plan.test.tsx index 5efb47f..c29321e 100644 --- a/frontend/src/pages/Plan.test.tsx +++ b/frontend/src/pages/Plan.test.tsx @@ -10,6 +10,14 @@ import { makeWorkspaceMembersResult, makePlanProposalResult, makePlanApplyResult, + makeLeaveWish, + makePlanProposal, + makeProposedDay, + makeWindowOutcome, + makePlanWarning, + makeDayCollision, + makeRejection, + makePlanApplyResponse, } from '../../tests/fixtures' vi.mock('@/hooks') @@ -42,52 +50,38 @@ function renderPlanInEnglish() { } -const mockWish = { +const mockWish = makeLeaveWish({ id: 'lw1', workspaceId: 'ws-1', - childId: null, name: 'Sommar', - nameMessageKey: 'presets.sommar', windowStartMonth: 6, windowStartDay: 15, windowEndMonth: 7, windowEndDay: 26, 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: [ - { + makeProposedDay({ date: '2026-06-15', parentPersonalDataUuid: 'pd-1', childId: 'c-1', - dayType: 'sjukpenning' as const, - isDubbeldag: false, + dayType: 'sjukpenning', wishId: 'lw1', reason: 'Sommar', - }, - { + }), + makeProposedDay({ date: '2026-06-16', parentPersonalDataUuid: 'pd-1', childId: 'c-1', - dayType: 'vacation' as const, - isDubbeldag: false, + dayType: 'vacation', wishId: 'lw1', reason: 'Sommar', - }, + }), ], outcomes: [ - { + makeWindowOutcome({ wishId: 'lw1', wishName: 'Sommar', wishNameMessageKey: 'presets.sommar', @@ -96,26 +90,15 @@ const mockProposal = { 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: [ - { - kind: 'days_lost_at_age_4', - messageKey: 'planWarnings.days_lost_at_age_4', + makePlanWarning({ data: { parent_personal_data_uuid: 'pd-1', expiring_days: 117, fourth_birthday: '2030-03-01' }, - }, + }), ], - poolBalances: [], - vacationBalances: [], - surplusAdvice: [], - age4Projections: [], -} +}) describe('Plan', () => { let mockAddWish: ReturnType @@ -130,16 +113,12 @@ describe('Plan', () => { mockUpdateWish = vi.fn().mockResolvedValue(mockWish) mockRemoveWish = vi.fn().mockResolvedValue(undefined) mockPropose = vi.fn().mockResolvedValue(undefined) - mockApply = vi.fn().mockResolvedValue({ - accepted: true, - createdCount: 2, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [], - rejections: [], - createdDays: [], - }) + mockApply = vi.fn().mockResolvedValue( + makePlanApplyResponse({ + accepted: true, + createdCount: 2, + }) + ) vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ @@ -424,24 +403,20 @@ describe('Plan', () => { }) ) - mockApply.mockResolvedValue({ - accepted: false, - createdCount: 0, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [ - { - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - existingDayType: 'vacation', - proposedDayType: 'sjukpenning', - }, - ], - rejections: [], - createdDays: [], - }) + mockApply.mockResolvedValue( + makePlanApplyResponse({ + accepted: false, + collisions: [ + makeDayCollision({ + date: '2026-06-15', + parentPersonalDataUuid: 'pd-1', + childId: 'c-1', + existingDayType: 'vacation', + proposedDayType: 'sjukpenning', + }), + ], + }) + ) renderPlan() @@ -481,16 +456,13 @@ describe('Plan', () => { rejections: [], createdDays: [], }) - .mockResolvedValueOnce({ - accepted: true, - createdCount: 2, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 1, - collisions: [], - rejections: [], - createdDays: [], - }) + .mockResolvedValueOnce( + makePlanApplyResponse({ + accepted: true, + createdCount: 2, + replacedCount: 1, + }) + ) renderPlan() @@ -517,24 +489,20 @@ describe('Plan', () => { }) ) - mockApply.mockResolvedValue({ - accepted: false, - createdCount: 0, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [ - { - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - existingDayType: 'vacation', - proposedDayType: 'sjukpenning', - }, - ], - rejections: [], - createdDays: [], - }) + mockApply.mockResolvedValue( + makePlanApplyResponse({ + accepted: false, + collisions: [ + makeDayCollision({ + date: '2026-06-15', + parentPersonalDataUuid: 'pd-1', + childId: 'c-1', + existingDayType: 'vacation', + proposedDayType: 'sjukpenning', + }), + ], + }) + ) renderPlan() @@ -558,21 +526,18 @@ describe('Plan', () => { }) ) - mockApply.mockResolvedValue({ - accepted: true, - createdCount: 1, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [], - rejections: [ - { - messageKey: 'planApply.rejection.insufficientBalance', - data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, - }, - ], - createdDays: [], - }) + mockApply.mockResolvedValue( + makePlanApplyResponse({ + accepted: true, + createdCount: 1, + rejections: [ + makeRejection({ + messageKey: 'planApply.rejection.insufficientBalance', + data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, + }), + ], + }) + ) renderPlan() @@ -593,21 +558,18 @@ describe('Plan', () => { }) ) - mockApply.mockResolvedValue({ - accepted: true, - createdCount: 1, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [], - rejections: [ - { - messageKey: 'planApply.rejection.insufficientBalance', - data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, - }, - ], - createdDays: [], - }) + mockApply.mockResolvedValue( + makePlanApplyResponse({ + accepted: true, + createdCount: 1, + rejections: [ + makeRejection({ + messageKey: 'planApply.rejection.insufficientBalance', + data: { tier: 'sjukpenning', needed: 2, remaining: 1 }, + }), + ], + }) + ) vi.stubGlobal('localStorage', { getItem: () => null, @@ -654,21 +616,21 @@ describe('Plan', () => { it('clears success message when user proposes again after successful apply', async () => { // Create two different proposals const mockProposal1 = mockProposal - const mockProposal2 = { - ...mockProposal, + const mockProposal2 = makePlanProposal({ days: [ ...mockProposal.days, - { + makeProposedDay({ date: '2026-07-01', parentPersonalDataUuid: 'pd-1', childId: 'c-1', - dayType: 'sjukpenning' as const, - isDubbeldag: false, + dayType: 'sjukpenning', wishId: 'lw1', reason: 'Sommar', - }, + }), ], - } + outcomes: mockProposal.outcomes, + warnings: mockProposal.warnings, + }) // Track proposal state changes let proposalData = mockProposal1 @@ -743,24 +705,20 @@ describe('Plan', () => { ) // Mock apply to return a collision on first call - mockApply.mockResolvedValue({ - accepted: false, - createdCount: 0, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [ - { - date: '2026-06-15', - parentPersonalDataUuid: 'pd-1', - childId: 'c-1', - existingDayType: 'vacation', - proposedDayType: 'sjukpenning', - }, - ], - rejections: [], - createdDays: [], - }) + mockApply.mockResolvedValue( + makePlanApplyResponse({ + accepted: false, + collisions: [ + makeDayCollision({ + date: '2026-06-15', + parentPersonalDataUuid: 'pd-1', + childId: 'c-1', + existingDayType: 'vacation', + proposedDayType: 'sjukpenning', + }), + ], + }) + ) const { rerender } = renderPlan() @@ -797,16 +755,12 @@ describe('Plan', () => { }) ) - mockApply.mockResolvedValue({ - accepted: true, - createdCount: 5, - skippedIdenticalCount: 0, - skippedFreeCount: 0, - replacedCount: 0, - collisions: [], - rejections: [], - createdDays: [], - }) + mockApply.mockResolvedValue( + makePlanApplyResponse({ + accepted: true, + createdCount: 5, + }) + ) renderPlan()