import { createOptimizationPresetRules } from './rule-service'; import { vi } from '../../testing/vitest-compat'; import { PlanningOutcome, PlanningRequest, createPlanningCalculation, planLeave, } from './leave-planner.service'; import type { CounterfactualAnalysis, PlanOutcome, PlanningReason, PlanningStateSnapshot, } from './leave-planner.service'; import { OPTIMIZATION_PRESETS } from './planning-objectives'; const supportingEconomy = { id: 'economy', type: 'optimization' as const, configuration: OPTIMIZATION_PRESETS.supportingPlacements, }; const nonWorkingGain = { id: 'gain', type: 'optimization' as const, configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain, }; const horizon = { start: '2024-01-01', end: '2025-01-01' }; const balance = ( id: string, type: 'cp' | 'rtt' | 'other', quantity: number, expiryDate = '2024-12-31', ) => ({ id, type, quantity, availableFrom: '2024-01-01', expiryDate, }); function isPlan(outcome: PlanningOutcome) { if (outcome.kind !== 'Plan') throw new Error(`Expected plan, got ${outcome.kind}`); return outcome; } async function runInNonBrowser(request: Parameters[0]) { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { return await planLeave(request); } finally { vi.stubGlobal('window', browserWindow); } } beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); afterEach(() => vi.useRealTimers()); const presets = createOptimizationPresetRules(); describe('calculated work-sequence and time-off-period statistics', () => { it('keeps fractional work means and merges consecutive declared days across a weekend', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-11' }, balances: [], rules: [], declaredDays: ['2024-01-05', '2024-01-08'].map((date) => ({ date, type: 'other', })), }), ); expect(result.metrics).toMatchObject({ nonWorkingDayGain: 2, averageWorkSequence: 2.5, longestWorkSequence: 3, averageTimeOffPeriod: 4, longestTimeOffPeriod: 4, }); }); it.each([ { scenario: 'averages the 10, 5 and 15 working days including both horizon edges', horizon: { start: '2024-01-08', end: '2024-02-21' }, declaredDates: ['2024-01-22', '2024-01-30'], metrics: { averageWorkSequence: 10, longestWorkSequence: 15, averageTimeOffPeriod: 2, longestTimeOffPeriod: 3, nonWorkingDayGain: 2, }, }, { scenario: 'keeps the unrounded mean of work sequences of 3, 1 and 6 days', horizon: { start: '2024-01-02', end: '2024-01-18' }, declaredDates: ['2024-01-05', '2024-01-09'], metrics: { averageWorkSequence: 10 / 3, longestWorkSequence: 6, averageTimeOffPeriod: 2, longestTimeOffPeriod: 3, nonWorkingDayGain: 2, }, }, { scenario: 'averages one-day and two-day breaks without adding zero-length work samples', horizon: { start: '2024-01-02', end: '2024-01-12' }, declaredDates: ['2024-01-03', '2024-01-09', '2024-01-10'], metrics: { averageWorkSequence: 5 / 3, longestWorkSequence: 3, averageTimeOffPeriod: 1.5, longestTimeOffPeriod: 2, nonWorkingDayGain: 0, }, }, { scenario: 'does not count or split work on intervening holidays and weekends', horizon: { start: '2024-05-06', end: '2024-05-15' }, declaredDates: [], metrics: { averageWorkSequence: 5, longestWorkSequence: 5, averageTimeOffPeriod: null, longestTimeOffPeriod: null, nonWorkingDayGain: 0, }, }, { scenario: 'retains an empty work average and a measured zero when every workday is declared', horizon: { start: '2024-01-08', end: '2024-01-13' }, declaredDates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11', '2024-01-12'], metrics: { averageWorkSequence: null, longestWorkSequence: 0, averageTimeOffPeriod: 9, longestTimeOffPeriod: 9, nonWorkingDayGain: 4, }, }, { scenario: 'distinguishes no samples from zero gain in a weekend-only horizon', horizon: { start: '2024-01-06', end: '2024-01-08' }, declaredDates: [], metrics: { averageWorkSequence: null, longestWorkSequence: 0, averageTimeOffPeriod: null, longestTimeOffPeriod: null, nonWorkingDayGain: 0, }, }, { scenario: 'counts Christmas on a Saturday once alongside its Sunday in a declared break', horizon: { start: '2021-12-20', end: '2021-12-28' }, declaredDates: ['2021-12-24'], metrics: { averageWorkSequence: 2.5, longestWorkSequence: 4, averageTimeOffPeriod: 3, longestTimeOffPeriod: 3, nonWorkingDayGain: 2, }, }, ])('$scenario', async ({ horizon, declaredDates, metrics }) => { const result = isPlan( await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays: declaredDates.map((date) => ({ date, type: 'other' })), }), ); expect(result.placements).toEqual([]); expect(result.metrics).toMatchObject(metrics); }); }); describe('declared holiday patterns and captured entitlement date', () => { const producer = { id: 'producer', type: 'if_then' as const, force: 'obligation' as const, dayOfWeek: 3, placement: 'before' as const, placeDays: 2, placeType: 'cp' as const, rewardDays: 1, rewardType: 'other' as const, }; const fixed = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' as const })); const request = { horizon: { start: '2024-04-29', end: '2024-05-08' }, balances: [], rules: [producer], declaredDays: fixed, }; it.each([ ['before', ['2024-04-29', '2024-04-30']], ['after', ['2024-05-02', '2024-05-03']], ['around', ['2024-04-30', '2024-05-02']], ['auto', ['2024-04-29', '2024-04-30']], ] as const)( 'credits the normal %s pattern, fully declared or mixed, without duplicate TIME_WINDOW debits', async (placement, dates) => { for (const mixed of [false, true]) { const declaredDays = (mixed ? dates.slice(0, 1) : dates).map((date) => ({ date, type: 'cp' as const, })); const result = isPlan( await runInNonBrowser({ ...request, declaredDays, balances: mixed ? [balance('cp', 'cp', 1, '2025-12-31')] : [], rules: [ { ...producer, placement, rewardDays: 0 }, { id: 'shared', type: 'time_window', force: 'obligation', startDate: dates[0], endDate: dates[1], placeDays: 2, placeType: 'cp', }, ], }), ); expect(result.placements.map(({ date }) => date)).toEqual(mixed ? [dates[1]] : []); expect( result.placements.every( ({ ruleIds }) => ruleIds.includes('producer') && ruleIds.includes('shared'), ), ).toBe(true); expect(result.ifThenContributions).toMatchObject([ { occurrence: '2024-05-01', declaredDays, placementDates: mixed ? [dates[1]] : [], }, ]); expect(result.declaredDays).toEqual(declaredDays); } }, ); it.each(['cp', 'rtt', 'other', 'any'] as const)( 'accepts compatible %s declarations in an entirely fixed preference', async (placeType) => { const declaredDays = fixed.map((day, index) => ({ ...day, type: placeType === 'any' ? index ? ('other' as const) : ('rtt' as const) : placeType, })); const result = isPlan( await runInNonBrowser({ ...request, declaredDays, rules: [{ ...producer, force: 'preference', placeType }], }), ); expect(result.preferences).toMatchObject([ { ruleId: 'producer', satisfied: 1, completeOccurrences: 1 }, ]); expect(result.placements).toEqual([]); expect(result.remainingBalances).toMatchObject([ { id: 'earned:producer:2024-05-01', quantity: 1, availableFrom: '2024-05-02', expiryDate: '2025-05-02', }, ]); }, ); it('does not skip an incompatible normal position, complete a partial preference, or invent a holiday', async () => { const declaredDays = [fixed[0], { date: '2024-04-30', type: 'rtt' as const }]; const conflict = await runInNonBrowser({ ...request, declaredDays, balances: [balance('cp', 'cp', 3)], }); expect(conflict.kind).toBe('PlanningConflict'); if (conflict.kind === 'PlanningConflict') expect(conflict.conflictingRuleIds).toContain('producer'); const preference = isPlan( await runInNonBrowser({ ...request, declaredDays, rules: [{ ...producer, force: 'preference' }], }), ); expect(preference.preferences[0].satisfied).toBe(0); expect(preference.remainingBalances).toEqual([]); const noHoliday = isPlan( await runInNonBrowser({ ...request, horizon: { start: '2024-05-13', end: '2024-05-17' }, declaredDays: fixed.map(({ type }, index) => ({ type, date: `2024-05-${13 + index}`, })), }), ); expect(noHoliday.ifThenContributions).toBeUndefined(); expect(noHoliday.remainingBalances).toEqual([]); expect( ( await runInNonBrowser({ ...request, horizon: { start: '2024-04-30', end: '2024-05-08' }, }) ).kind, ).toBe('PlanningConflict'); }); it('reserves only the unfilled positions of future IF_THEN preferences', async () => { const result = isPlan( await runInNonBrowser({ ...request, balances: [ balance('cp', 'cp', 1, '2025-12-31'), balance('rtt', 'rtt', 1, '2025-06-01'), ], rules: [ { id: 'any-first', type: 'time_window', force: 'preference', startDate: '2024-05-06', endDate: '2024-05-06', placeDays: 1, placeType: 'any', }, { ...producer, force: 'preference', rewardDays: 0 }, { id: 'rtt-last', type: 'time_window', force: 'preference', startDate: '2024-05-07', endDate: '2024-05-07', placeDays: 1, placeType: 'rtt', }, ], }), ); expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1, 1]); expect(result.placements).toMatchObject([ { date: '2024-05-06', type: 'cp' }, { date: '2024-05-07', type: 'rtt' }, ]); }); it('requires every real occurrence and creates each rule/occurrence entitlement only once', async () => { const submitted = { ...request, horizon: { start: '2024-04-29', end: '2024-05-10' }, rules: [ { ...producer, placeDays: 1 }, { ...producer, id: 'second', placeDays: 1 }, ], declaredDays: [ { date: '2024-04-30', type: 'cp' as const }, { date: '2024-05-07', type: 'cp' as const }, ], }; const result = isPlan(await runInNonBrowser(submitted)); expect(result.remainingBalances.map(({ id }) => id).sort()).toEqual([ 'earned:producer:2024-05-01', 'earned:producer:2024-05-08', 'earned:second:2024-05-01', 'earned:second:2024-05-08', ]); expect(result.remainingBalances.every(({ quantity }) => quantity === 1)).toBe(true); expect( ( await runInNonBrowser({ ...submitted, declaredDays: submitted.declaredDays.slice(0, 1), }) ).kind, ).toBe('PlanningConflict'); }); it.each([ ['2024-05-01', true], ['2024-05-02', false], ['2024-05-03', false], ] as const)( 'adds only strictly future rights on %s, also for newly placed historical patterns', async (today, added) => { vi.setSystemTime(new Date(`${today}T12:00:00Z`)); for (const declared of [true, false]) { const result = isPlan( await runInNonBrowser({ ...request, declaredDays: declared ? fixed : [], balances: declared ? [] : [balance('cp', 'cp', 2, '2025-12-31')], }), ); expect(result.calculationDate).toBe(today); expect(result.remainingBalances.filter(({ earned }) => earned)).toHaveLength( added ? 1 : 0, ); expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ added, availableFrom: '2024-05-02', }); } }, ); it.each([ ['2024-01-01', '2024-05-03'], ['2024-05-03', '2024-05-03'], ['2024-05-03', '2024-04-29'], ])( 'reconciles an externally funded extension on %s with consumer starting %s and rejects a self-financing extension', async (today, consumerStart) => { vi.setSystemTime(new Date(`${today}T12:00:00Z`)); const extender = { id: 'extend', type: 'time_window' as const, force: 'obligation' as const, startDate: '2024-05-02', endDate: '2024-05-02', placeDays: 1, placeType: 'cp' as const, }; const result = isPlan( await runInNonBrowser({ ...request, declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }], balances: [balance('cp', 'cp', 1, '2025-12-31')], rules: [ producer, extender, { id: 'later', type: 'time_window', force: 'obligation', startDate: consumerStart, endDate: '2024-05-07', placeDays: 2, placeType: 'other', }, ], }), ); expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ availableFrom: '2024-05-06', expiryDate: '2025-05-06', }); expect( result.placements.find(({ balanceId }) => balanceId.startsWith('earned:'))?.date, ).toBe('2024-05-07'); const impossible = await runInNonBrowser({ ...request, declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }], rules: [producer, { ...extender, placeType: 'other' }], }); expect(impossible.kind).toBe('PlanningConflict'); }, ); it.each(['2024-04-29', '2025-05-07'])( 'reconciles a future entitlement expiry before its consumer starting %s', async (consumerStart) => { vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); const { isWorkingPlanningDate } = await import('./declared-non-working-days'); const { addPlanningDays } = await import('./planning-date'); const declaredDays: import('./leave-planner.core').DeclaredNonWorkingDay[] = [...fixed]; for (let date = '2024-05-03'; date <= '2025-05-05'; date = addPlanningDays(date, 1)) { if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'rtt' }); } const result = isPlan( await runInNonBrowser({ ...request, horizon: { start: '2024-04-29', end: '2025-05-08' }, declaredDays, balances: [balance('cp', 'cp', 1, '2025-12-31')], rules: [ { ...producer, force: 'preference' }, { id: 'consumer', type: 'time_window', force: 'obligation', startDate: consumerStart, endDate: '2025-05-07', placeDays: 1, placeType: 'other', }, { id: 'extend', type: 'time_window', force: 'obligation', startDate: '2024-05-02', endDate: '2024-05-02', placeDays: 1, placeType: 'cp', }, ], }), ); expect(result.placements).toMatchObject([ { date: '2024-05-02', balanceId: 'cp' }, { date: '2025-05-07', balanceId: 'earned:producer:2024-05-01', ruleIds: ['consumer'], }, ]); expect( result.ifThenContributions?.find( ({ ruleId, occurrence }) => ruleId === 'producer' && occurrence === '2024-05-01', )?.earnedEntitlement, ).toMatchObject({ availableFrom: '2025-05-06', expiryDate: '2026-05-06', added: true }); }, ); it('funds a later IF_THEN obligation after an obligatory window extends a reported generator', async () => { vi.setSystemTime(new Date('2024-05-03T12:00:00Z')); const result = isPlan( await runInNonBrowser({ ...request, horizon: { start: '2024-04-29', end: '2024-05-11' }, declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }], balances: [balance('cp', 'cp', 1, '2025-12-31')], rules: [ { ...producer, force: 'preference' }, { id: 'extend', type: 'time_window', force: 'obligation', startDate: '2024-05-02', endDate: '2024-05-02', placeDays: 1, placeType: 'cp', }, { id: 'consumer', type: 'if_then', force: 'obligation', dayOfWeek: 4, placement: 'after', placeDays: 1, placeType: 'other', rewardDays: 0, }, ], }), ); expect(result.placements).toMatchObject([ { date: '2024-05-02', balanceId: 'cp', ruleIds: ['extend'] }, { date: '2024-05-10', balanceId: 'earned:producer:2024-05-01', ruleIds: ['consumer'] }, ]); expect( result.ifThenContributions?.find( ({ ruleId, occurrence }) => ruleId === 'producer' && occurrence === '2024-05-01', )?.earnedEntitlement, ).toMatchObject({ availableFrom: '2024-05-06', added: true }); }); it('compares a generating preference with an ordinary obligation solution before spending higher-priority leave', async () => { vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-29', end: '2024-05-07' }, declaredDays: [{ date: '2024-04-30', type: 'rtt' }], balances: [ balance('cp', 'cp', 1, '2025-12-31'), balance('rtt', 'rtt', 1, '2025-12-31'), ], rules: [ { id: 'mandatory', type: 'time_window', force: 'obligation', startDate: '2024-05-06', endDate: '2024-05-06', placeDays: 1, placeType: 'cp', }, { id: 'first', type: 'time_window', force: 'preference', startDate: '2024-04-29', endDate: '2024-04-29', placeDays: 1, placeType: 'cp', }, { ...producer, force: 'preference', placement: 'around', placeType: 'rtt', rewardType: 'cp', }, ], }), ); expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1]); expect(result.placements.find(({ date }) => date === '2024-04-29')?.balanceId).toBe('cp'); expect(result.placements.find(({ date }) => date === '2024-05-06')?.balanceId).toBe( 'earned:producer:2024-05-01', ); expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ availableFrom: '2024-05-03', added: true, }); }); it.each([false, true])( 'chooses between competing generators in the original preference order (reversed: %s)', async (reversed) => { vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); const producers = [ { ...producer, id: 'before', force: 'preference' as const }, { ...producer, id: 'after', force: 'preference' as const, placement: 'after' as const, }, ]; if (reversed) producers.reverse(); const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-29', end: '2024-05-11' }, declaredDays: [ { date: '2024-04-29', type: 'cp' }, { date: '2024-05-03', type: 'cp' }, { date: '2024-05-06', type: 'other' }, ], balances: [balance('cp', 'cp', 1, '2025-12-31')], rules: [ { id: 'consumer', type: 'time_window', force: 'obligation', startDate: '2024-05-10', endDate: '2024-05-10', placeDays: 1, placeType: 'other', }, ...producers, ], }), ); expect( result.preferences.map(({ ruleId, satisfied }) => ({ ruleId, satisfied })), ).toEqual([ { ruleId: producers[0].id, satisfied: 1 }, { ruleId: producers[1].id, satisfied: 0 }, ]); expect(result.placements.find(({ type }) => type === 'cp')?.date).toBe( reversed ? '2024-05-02' : '2024-04-30', ); expect(result.placements.find(({ date }) => date === '2024-05-10')?.balanceId).toBe( `earned:${producers[0].id}:2024-05-01`, ); expect( result.ifThenContributions?.filter(({ earnedEntitlement }) => earnedEntitlement), ).toHaveLength(1); }, ); it('keeps a future right usable on its expiry date and excludes comparisons before availability or after expiry', async () => { vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ ...request, horizon: { start: '2024-04-29', end: '2025-05-07' }, declaredDays: [fixed[1]], rules: [ { ...producer, force: 'preference', placeDays: 1 }, { id: 'expiry-consumer', type: 'time_window', force: 'preference', startDate: '2025-05-02', endDate: '2025-05-02', placeDays: 1, placeType: 'other', }, ], }); const result = isPlan(await calculation.outcome); const placement = result.placements.find( ({ balanceId }) => balanceId === 'earned:producer:2024-05-01', ); expect(placement?.date).toBe('2025-05-02'); if (!placement) throw new Error('Expected the earned entitlement to fund its last valid date.'); const comparison = await calculation.analyzeCounterfactual(placement); if (comparison.kind !== 'Counterfactual') throw new Error('Expected date comparisons.'); expect(comparison.results.find(({ date }) => date === '2024-04-29')?.reason?.code).toBe( 'unavailable', ); expect(comparison.results.find(({ date }) => date === '2025-05-02')?.status).toBe( 'eligible', ); expect(comparison.results.find(({ date }) => date === '2025-05-05')?.reason?.code).toBe( 'expired', ); } finally { vi.unstubAllGlobals(); } }); it.each(['worker-unavailable', 'deadline', 'validation'])( 'keeps the launch date in %s outcomes after midnight', async (failure) => { vi.stubGlobal('Worker', undefined); vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); const calculation = createPlanningCalculation({ ...request, ...(failure === 'deadline' ? { deadlineMs: -1 } : {}), ...(failure === 'validation' ? { balances: [balance('invalid', 'cp', -1)] } : {}), }); vi.setSystemTime(new Date('2024-05-01T22:00:01Z')); expect(calculation.snapshot.calculationDate).toBe('2024-05-01'); expect(await calculation.outcome).toMatchObject({ calculationDate: '2024-05-01', kind: failure === 'worker-unavailable' ? 'PlanningIncomplete' : 'InvalidInput', }); vi.unstubAllGlobals(); }, ); it('includes declared context beyond the horizon in the complete generator period', async () => { const result = isPlan( await runInNonBrowser({ ...request, horizon: { start: '2024-04-29', end: '2024-05-02' }, declaredDays: [ ...fixed, { date: '2024-05-02', type: 'rtt' }, { date: '2024-05-03', type: 'other' }, ], }), ); expect(result.remainingBalances).toMatchObject([ { availableFrom: '2024-05-06', expiryDate: '2025-05-06' }, ]); expect(result.timeOffPeriods).toMatchObject([ { end: '2024-05-05', declaredDates: ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'], }, ]); }); it('captures Paris before the direct import wait and keeps snapshot, late artifacts and comparisons on that date', async () => { vi.stubGlobal('window', undefined); try { vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); const calculation = createPlanningCalculation({ ...request, rules: [ producer, { id: 'later', type: 'time_window', force: 'obligation', startDate: '2024-05-03', endDate: '2024-05-03', placeDays: 1, placeType: 'other', }, ], }); expect(calculation.snapshot.calculationDate).toBe('2024-05-01'); vi.setSystemTime(new Date('2024-05-01T22:00:01Z')); const result = isPlan(await calculation.outcome); expect(result.calculationDate).toBe('2024-05-01'); expect(await calculation.explanation).toMatchObject({ calculationDate: '2024-05-01', declaredDays: fixed, ifThenContributions: result.ifThenContributions, }); const comparison = await calculation.analyzeCounterfactual(result.placements[0]); expect(comparison.kind).toBe('Counterfactual'); if (comparison.kind === 'Counterfactual') { expect( comparison.results.find(({ date }) => date === '2024-04-30')?.reason?.code, ).toBe('declared-day'); expect(comparison.results.find(({ date }) => date === '2024-05-02')?.status).toBe( 'ineligible', ); expect(comparison.results.find(({ date }) => date === '2024-05-03')?.status).toBe( 'eligible', ); } } finally { vi.unstubAllGlobals(); } }); }); describe('planLeave', () => { it.each([ ['obligation', false], ['obligation', true], ['preference', false], ['preference', true], ] as const)( 'uses declared holiday-pattern CP to earn future Other leave for the remaining TIME_WINDOW day (producer: %s, mixed: %s)', async (force, mixed) => { vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); try { const declaredDays = [ ...(mixed ? [] : [{ date: '2024-04-29', type: 'cp' as const }]), { date: '2024-04-30', type: 'cp' as const }, { date: '2024-05-06', type: 'other' as const }, ]; const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-29', end: '2024-05-07' }, balances: mixed ? [balance('cp', 'cp', 1, '2025-12-31')] : [], declaredDays, rules: [ { id: 'producer', type: 'if_then', force, dayOfWeek: 3, placement: 'before', placeDays: 2, placeType: 'cp', rewardDays: 1, rewardType: 'other', }, { id: 'consumer', type: 'time_window', force: 'obligation', startDate: '2024-05-03', endDate: '2024-05-06', placeDays: 2, placeType: 'other', }, ], }), ); expect(result.declaredDays).toEqual(declaredDays); expect(result.placements.filter(({ type }) => type === 'other')).toEqual([ { date: '2024-05-03', type: 'other', balanceId: 'earned:producer:2024-05-01', ruleIds: ['consumer'], supporting: false, }, ]); expect(result.placements.filter(({ type }) => type === 'cp')).toHaveLength( mixed ? 1 : 0, ); expect(result.remainingBalances).toEqual([]); } finally { vi.useRealTimers(); } }, ); it.each(['obligation', 'preference'] as const)( 'completes five CP days with four remaining CP and a fixed Wednesday CP (%s)', async (force) => { const declaredDays = [{ date: '2024-01-10', type: 'cp' as const }]; const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [balance('cp', 'cp', 4, '2024-01-12')], declaredDays, rules: [ { id: 'week', type: 'time_window', force, startDate: '2024-01-08', endDate: '2024-01-12', placeDays: 5, placeType: 'cp', }, ], }), ); expect(result.declaredDays).toEqual(declaredDays); expect(result.placements.map(({ date }) => date)).toEqual([ '2024-01-08', '2024-01-09', '2024-01-11', '2024-01-12', ]); expect(result.placements.every(({ ruleIds }) => ruleIds.includes('week'))).toBe(true); expect(result.remainingBalances).toEqual([]); expect(result.timeWindowContributions).toEqual([ { ruleId: 'week', label: 'week', requested: 5, declaredDays, placementDates: result.placements.map(({ date }) => date), }, ]); if (force === 'preference') expect(result.preferences).toMatchObject([{ requested: 5, satisfied: 5 }]); }, ); it.each(['cp', 'rtt', 'other'] as const)( 'shares a fixed %s day between typed and ANY obligations and preferences without a balance', async (type) => { const declaredDays = [{ date: '2024-01-10', type }]; const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [], declaredDays, rules: (['obligation', 'preference'] as const).flatMap((force) => ([type, 'any'] as const).map((placeType) => ({ id: `${force}-${placeType}`, type: 'time_window', force, startDate: '2024-01-08', endDate: '2024-01-12', placeDays: 1, placeType, })), ), }), ); expect(result.placements).toEqual([]); expect(result.remainingBalances).toEqual([]); expect(result.declaredDays).toEqual(declaredDays); expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1]); expect(result.metrics.preferenceSatisfaction).toEqual([1, 1]); expect(result.timeWindowContributions).toHaveLength(4); for (const contribution of result.timeWindowContributions ?? []) expect(contribution).toMatchObject({ requested: 1, declaredDays, placementDates: [], }); }, ); it('caps each rule contribution at its quantity while preserving every surplus declaration', async () => { const declaredDays = [ { date: '2024-01-10', type: 'cp' as const }, { date: '2024-01-08', type: 'cp' as const }, { date: '2024-01-09', type: 'cp' as const }, ]; const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [], declaredDays, rules: [ { id: 'exact', force: 'obligation', placeDays: 2, placeType: 'cp' }, { id: 'preferred', force: 'preference', placeDays: 1, placeType: 'any' }, ].map((rule) => ({ ...rule, type: 'time_window', startDate: '2024-01-08', endDate: '2024-01-12', })), }), ); expect(result.declaredDays).toEqual(declaredDays); expect(result.placements).toEqual([]); expect(result.preferences).toMatchObject([{ requested: 1, satisfied: 1 }]); expect(result.timeWindowContributions).toMatchObject([ { ruleId: 'exact', requested: 2, declaredDays: [declaredDays[1], declaredDays[2]], placementDates: [], }, { ruleId: 'preferred', requested: 1, declaredDays: [declaredDays[1]], placementDates: [], }, ]); }); it('credits inclusive window endpoints only inside the submitted half-open horizon', async () => { const declaredDays = ['08', '09', '10', '11', '12'].map((day) => ({ date: `2024-01-${day}`, type: 'cp' as const, })); const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-09', end: '2024-01-12' }, balances: [], declaredDays, rules: [ { id: 'left', force: 'obligation', startDate: '2024-01-08', endDate: '2024-01-10', placeDays: 2, }, { id: 'right', force: 'obligation', startDate: '2024-01-10', endDate: '2024-01-12', placeDays: 2, }, { id: 'before', force: 'preference', startDate: '2024-01-08', endDate: '2024-01-08', placeDays: 1, }, { id: 'after', force: 'preference', startDate: '2024-01-12', endDate: '2024-01-12', placeDays: 1, }, ].map((rule) => ({ ...rule, type: 'time_window', placeType: 'cp' })), }), ); expect(result.placements).toEqual([]); expect(result.declaredDays).toEqual(declaredDays); expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([0, 0]); expect( result.timeWindowContributions?.map(({ declaredDays }) => declaredDays.map(({ date }) => date), ), ).toEqual([ ['2024-01-09', '2024-01-10'], ['2024-01-10', '2024-01-11'], ]); }); it('keeps an incompatible fixed RTT while placing additional CP on other dates', async () => { const declaredDays = [{ date: '2024-01-10', type: 'rtt' as const }]; const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [balance('cp', 'cp', 3, '2024-01-12')], declaredDays, rules: [ { id: 'cp-only', type: 'time_window', force: 'obligation', startDate: '2024-01-08', endDate: '2024-01-12', placeDays: 3, placeType: 'cp', }, ], }), ); expect(result.declaredDays).toEqual(declaredDays); expect(result.placements).toHaveLength(3); expect( result.placements.every(({ date, type }) => date !== '2024-01-10' && type === 'cp'), ).toBe(true); expect(result.remainingBalances).toEqual([]); expect(result.timeWindowContributions).toBeUndefined(); }); it('preserves preference order when a partially declared higher preference needs the shared date', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [ balance('cp', 'cp', 1, '2025-01-01'), balance('rtt', 'rtt', 1, '2025-01-01'), ], declaredDays: [{ date: '2024-01-10', type: 'cp' }], rules: [ { id: 'higher-cp', startDate: '2024-01-10', endDate: '2024-01-11', placeDays: 2, placeType: 'cp', }, { id: 'lower-rtt', startDate: '2024-01-11', endDate: '2024-01-11', placeDays: 1, placeType: 'rtt', }, ].map((rule) => ({ ...rule, type: 'time_window', force: 'preference' })), }), ); expect(result.preferences).toMatchObject([ { ruleId: 'higher-cp', requested: 2, satisfied: 2 }, { ruleId: 'lower-rtt', requested: 1, satisfied: 0, reason: { code: 'higher-priority' }, }, ]); expect(result.placements).toContainEqual( expect.objectContaining({ date: '2024-01-11', type: 'cp', ruleIds: ['higher-cp'] }), ); expect(result.declaredDays).toEqual([{ date: '2024-01-10', type: 'cp' }]); }); it('keeps the joint search exhaustive when fixed credits leave a small number of new placements', async () => { const core = await import('./leave-planner.core'); const result = isPlan( core.calculatePlanningInternal({ horizon: { start: '2024-01-08', end: '2024-04-17' }, balances: [ balance('cp', 'cp', 1, '2025-01-01'), balance('rtt', 'rtt', 1, '2025-01-01'), ], declaredDays: [ { date: '2024-01-08', type: 'cp' }, { date: '2024-01-09', type: 'rtt' }, ], rules: ['cp', 'rtt'].map((placeType) => ({ id: placeType, type: 'time_window', force: 'obligation', startDate: '2024-01-08', endDate: '2024-04-16', placeDays: 2, placeType, })), }), ); expect(result.placements).toHaveLength(2); expect(result.placements.map(({ type }) => type).sort()).toEqual(['cp', 'rtt']); expect( result.timeWindowContributions?.map(({ declaredDays }) => declaredDays.length), ).toEqual([1, 1]); }); it('does not explain a fixed contribution as a free date when a higher typed preference occupies the rest of the window', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [ balance('cp', 'cp', 1, '2025-01-01'), balance('rtt', 'rtt', 1, '2025-01-01'), ], declaredDays: [{ date: '2024-01-10', type: 'cp' }], rules: [ { id: 'higher-rtt', startDate: '2024-01-11', endDate: '2024-01-11', placeDays: 1, placeType: 'rtt', }, { id: 'lower-cp', startDate: '2024-01-10', endDate: '2024-01-11', placeDays: 2, placeType: 'cp', }, ].map((rule) => ({ ...rule, type: 'time_window', force: 'preference' })), }), ); expect(result.preferences).toMatchObject([ { ruleId: 'higher-rtt', satisfied: 1 }, { ruleId: 'lower-cp', satisfied: 1, reason: { code: 'higher-priority' } }, ]); }); it('explains an impossible joint obligation after compatible declarations have been credited', async () => { const core = await import('./leave-planner.core'); let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; const declaredDays = [{ date: '2024-01-08', type: 'cp' as const }]; const result = core.calculatePlanningInternal( { horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 1)], declaredDays, labels: { 'three-cp': 'Three CP including Monday', 'one-rtt': 'Wednesday RTT' }, rules: [ { id: 'three-cp', startDate: '2024-01-08', endDate: '2024-01-10', placeDays: 3, placeType: 'cp', }, { id: 'one-rtt', startDate: '2024-01-10', endDate: '2024-01-10', placeDays: 1, placeType: 'rtt', }, ].map((rule) => ({ ...rule, type: 'time_window', force: 'obligation' })), }, undefined, (value) => (artifacts = value), ); expect(result).toMatchObject({ kind: 'PlanningConflict', conflictingRuleIds: ['three-cp', 'one-rtt'], reasons: [{ code: 'joint-date-conflict' }], }); expect(artifacts.explanation).toMatchObject({ kind: 'ConflictExplanation', conflictingRules: [ { id: 'three-cp', label: 'Three CP including Monday' }, { id: 'one-rtt', label: 'Wednesday RTT' }, ], bottleneck: { code: 'joint-date-conflict' }, }); }); it('distinguishes insufficient compatible leave from malformed rules without converting a fixed RTT', async () => { const submitted = { horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [balance('cp', 'cp', 4)], declaredDays: [{ date: '2024-01-10', type: 'rtt' as const }], rules: [ { id: 'cp-only', type: 'time_window', force: 'obligation', startDate: '2024-01-08', endDate: '2024-01-12', placeDays: 5, placeType: 'cp', }, ], }; expect(await runInNonBrowser(submitted)).toMatchObject({ kind: 'PlanningConflict', reasons: [{ code: 'typed-capacity' }], }); submitted.rules[0].placeDays = -1; expect(await runInNonBrowser(submitted)).toMatchObject({ kind: 'InvalidInput' }); expect(submitted.declaredDays).toEqual([{ date: '2024-01-10', type: 'rtt' }]); }); it('keeps declared non-working days fixed without consuming their remaining balances', async () => { const declaredDays = [ { date: '2024-01-02', type: 'cp' as const }, { date: '2024-01-03', type: 'rtt' as const }, { date: '2024-01-05', type: 'other' as const }, { date: '2026-10-06', type: 'other' as const }, ]; const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-06' }, balances: [balance('remaining-cp', 'cp', 4, '2024-01-05')], rules: [presets[0]], declaredDays, }), ); expect(result).toMatchObject({ declaredDays }); expect(result.placements.map(({ date }) => date)).toEqual(['2024-01-04']); expect(result.remainingBalances).toMatchObject([{ id: 'remaining-cp', quantity: 3 }]); }); it('accepts declarations without a source balance or a new placement', async () => { const result = isPlan( await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays: [{ date: '2024-01-02', type: 'other' }], }), ); expect(result.declaredDays).toEqual([{ date: '2024-01-02', type: 'other' }]); expect(result.placements).toEqual([]); expect(result.remainingBalances).toEqual([]); }); it('forms Friday–Sunday from an ordinary declared Other day without a balance debit', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-09' }, balances: [], rules: [], declaredDays: [{ date: '2024-01-05', type: 'other' }], }), ); expect(result.placements).toEqual([]); expect(result.remainingBalances).toEqual([]); expect(result.timeOffPeriods).toEqual([ { start: '2024-01-05', end: '2024-01-07', duration: 3, placementDates: [], declaredDates: ['2024-01-05'], nonWorkingDates: ['2024-01-06', '2024-01-07'], nonWorkingDayGain: 2, }, ]); expect(result.metrics).toMatchObject({ nonWorkingDayGain: 2, averageWorkSequence: 2, longestWorkSequence: 3, averageTimeOffPeriod: 3, longestTimeOffPeriod: 3, }); }); it('merges declarations, placements, weekends and national holidays without counting dates twice', async () => { const declaredDays = [ { date: '2024-05-06', type: 'cp' as const }, { date: '2024-05-07', type: 'rtt' as const }, { date: '2024-05-10', type: 'other' as const }, ]; const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-05-06', end: '2024-05-14' }, balances: [ { ...balance('cp', 'cp', 1, '2024-05-13'), availableFrom: '2024-05-13' }, ], rules: [presets[0]], declaredDays, }), ); expect(result.declaredDays).toEqual(declaredDays); expect(result.placements.map(({ date }) => date)).toEqual(['2024-05-13']); expect(result.remainingBalances).toEqual([]); expect(result.timeOffPeriods).toEqual([ { start: '2024-05-04', end: '2024-05-13', duration: 10, placementDates: ['2024-05-13'], declaredDates: declaredDays.map(({ date }) => date), nonWorkingDates: [ '2024-05-04', '2024-05-05', '2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12', ], nonWorkingDayGain: 6, }, ]); expect(result.metrics).toMatchObject({ nonWorkingDayGain: 6, holidayWeekValue: 5, averageWorkSequence: null, longestWorkSequence: 0, averageTimeOffPeriod: 10, longestTimeOffPeriod: 10, }); }); it('uses declared days to split work sequences while weekends and holidays alone never split them', async () => { const withDeclarations = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-20' }, balances: [], rules: [], declaredDays: [ { date: '2024-01-05', type: 'other' }, { date: '2024-01-09', type: 'cp' }, ], }), ); expect(withDeclarations.metrics).toMatchObject({ longestWorkSequence: 8, shortSequenceDeficit: 4, }); const withoutDeclarations = isPlan( await runInNonBrowser({ horizon: { start: '2024-05-06', end: '2024-05-15' }, balances: [], rules: [], }), ); expect(withoutDeclarations.timeOffPeriods).toEqual([]); expect(withoutDeclarations.metrics).toMatchObject({ longestWorkSequence: 5, shortSequenceDeficit: 0, nonWorkingDayGain: 0, }); }); it('extends only anchored periods with adjacent outside declarations and keeps placements inside the submitted horizon', async () => { const selectedHorizon = { start: '2024-01-03', end: '2024-01-05' }; const declaredDays = [ { date: '2024-01-02', type: 'cp' as const }, { date: '2024-01-03', type: 'other' as const }, { date: '2024-01-05', type: 'rtt' as const }, { date: '2024-01-10', type: 'other' as const }, ]; const result = isPlan( await runInNonBrowser({ horizon: selectedHorizon, balances: [ { ...balance('cp', 'cp', 2, '2024-01-04'), availableFrom: '2024-01-04' }, ], rules: [presets[0]], declaredDays, }), ); expect(result.horizon).toEqual(selectedHorizon); expect(result.declaredDays).toEqual(declaredDays); expect(result.placements.map(({ date }) => date)).toEqual(['2024-01-04']); expect(result.remainingBalances[0].quantity).toBe(1); expect(result.timeOffPeriods).toHaveLength(1); expect(result.timeOffPeriods[0]).toMatchObject({ start: '2023-12-30', end: '2024-01-07', duration: 9, placementDates: ['2024-01-04'], declaredDates: ['2024-01-02', '2024-01-03', '2024-01-05'], nonWorkingDayGain: 5, }); expect(result.metrics).toMatchObject({ nonWorkingDayGain: 5, averageWorkSequence: null, longestWorkSequence: 0, averageTimeOffPeriod: 9, longestTimeOffPeriod: 9, }); const unanchored = isPlan( await runInNonBrowser({ horizon: selectedHorizon, balances: [], rules: [presets[0]], declaredDays: declaredDays.filter(({ date }) => date !== '2024-01-03'), }), ); expect(unanchored.timeOffPeriods).toEqual([]); expect(unanchored.metrics).toMatchObject({ nonWorkingDayGain: 0, averageWorkSequence: 2, longestWorkSequence: 2, averageTimeOffPeriod: null, longestTimeOffPeriod: null, }); }); it.each([ ['2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'], ['2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'], ])( 'keeps explicit historical and multi-year bounds from %s to %s around declarations', async (start, end, date, periodStart, periodEnd) => { const result = isPlan( await runInNonBrowser({ horizon: { start, end }, balances: [], rules: [], declaredDays: [{ date, type: 'other' }], }), ); expect(result.horizon).toEqual({ start, end }); expect(result.placements).toEqual([]); expect(result.timeOffPeriods).toHaveLength(1); expect(result.timeOffPeriods[0]).toMatchObject({ start: periodStart, end: periodEnd, declaredDates: [date], }); }, ); it.each([ [false, '2024-05-06', '2024-05-13'], [true, '2024-05-06', '2024-05-13'], [false, '2024-05-06', '2024-05-10'], [false, '2024-05-06', '2024-05-09'], [false, '2024-05-10', '2024-05-11'], [true, '2024-05-10', '2024-05-11'], ] as const)( 'completes a genuine public-holiday week with fixed declarations and additional placement: %s', async (mixed, start, end) => { const declaredDays = ['2024-05-06', '2024-05-07', ...(mixed ? [] : ['2024-05-10'])].map( (date) => ({ date, type: 'other' as const }), ); const result = isPlan( await runInNonBrowser({ horizon: { start, end }, rules: [presets[0]], declaredDays, balances: mixed ? [{ ...balance('cp', 'cp', 1, '2024-05-10'), availableFrom: '2024-05-10' }] : [], }), ); expect(result.metrics.holidayWeekValue).toBe(5); expect(result.placements.map(({ date }) => date)).toEqual(mixed ? ['2024-05-10'] : []); expect(result.timeOffPeriods[0].nonWorkingDates).not.toContain('2024-05-06'); }, ); it('does not treat a fully declared ordinary week as a national public-holiday week', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [], rules: [], declaredDays: [ '2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11', '2024-01-12', ].map((date) => ({ date, type: 'other' })), }), ); expect(result.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 4, longestWorkSequence: 0, }); }); it.each([ { declaredStart: '2024-01-02', declaredEnd: '2025-04-30', horizon: { start: '2024-01-02', end: '2024-01-03' }, period: { start: '2023-12-30', end: '2025-04-29' }, }, { declaredStart: '2024-12-30', declaredEnd: '2026-01-06', horizon: { start: '2024-12-30', end: '2024-12-31' }, period: { start: '2024-12-28', end: '2026-01-05' }, }, { declaredStart: '2023-11-29', declaredEnd: '2025-01-03', horizon: { start: '2025-01-02', end: '2025-01-03' }, period: { start: '2023-11-29', end: '2025-01-02' }, }, ])( 'keeps a contiguous outside declaration chain $declaredStart–$declaredEnd across the prepared calendar buffer', async ({ declaredStart, declaredEnd, horizon, period }) => { const { isWorkingPlanningDate } = await import('./declared-non-working-days'); const { addPlanningDays } = await import('./planning-date'); const declaredDays: NonNullable[number][] = []; for ( let date: string = declaredStart; date < declaredEnd; date = addPlanningDays(date, 1) ) if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' }); const result = isPlan( await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }), ); expect(result.timeOffPeriods).toHaveLength(1); expect(result.timeOffPeriods[0]).toMatchObject({ ...period, declaredDates: declaredDays.map(({ date }) => date), }); expect(result.timeOffPeriods[0].duration).toBe( result.timeOffPeriods[0].nonWorkingDates.length + declaredDays.length, ); expect(result.placements).toEqual([]); }, ); it('preserves holiday-week priority over a larger natural-day gain around declarations', async () => { const request: PlanningRequest = { horizon: { start: '2026-11-01', end: '2026-12-01' }, balances: [balance('cp', 'cp', 2, '2026-11-30')], rules: presets, declaredDays: [ { date: '2026-11-09', type: 'other' }, { date: '2026-11-10', type: 'rtt' }, ], }; const result = isPlan(await runInNonBrowser(request)); expect(result.placements.map(({ date }) => date)).toEqual(['2026-11-12', '2026-11-13']); expect(result.metrics).toMatchObject({ unplacedAtRisk: 0, holidayWeekValue: 3, nonWorkingDayGain: 5, }); const withPreference = isPlan( await runInNonBrowser({ ...request, rules: [ presets[0], ...[ { id: 'preferred-friday', type: 'time_window', force: 'preference', startDate: '2026-11-20', endDate: '2026-11-20', placeDays: 1, placeType: 'cp', }, ], ...presets.slice(1), ], }), ); expect(withPreference.preferences[0].satisfied).toBe(1); expect(withPreference.metrics).toMatchObject({ unplacedAtRisk: 0, holidayWeekValue: 0 }); const supporting = isPlan( await runInNonBrowser({ ...request, balances: [balance('cp', 'cp', 2, '2027-01-01')] }), ); expect(supporting.placements.map(({ date }) => date)).toEqual(['2026-11-12', '2026-11-13']); expect(supporting.placements.every(({ supporting }) => supporting)).toBe(true); expect(supporting.metrics).toMatchObject({ holidayWeekValue: 3, supportingPlacements: 2 }); }); it.each([ null, {}, [null], [{ date: '2024-02-30', type: 'cp' }], [{ date: '2024-01-06', type: 'cp' }], [{ date: '2024-05-08', type: 'cp' }], [{ date: '2024-01-02', type: 'any' }], [ { date: '2024-01-02', type: 'cp' }, { date: '2024-01-02', type: 'rtt' }, ], ])('reports malformed declarations as invalid planning input: %j', async (declaredDays) => { const outcome = await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays, } as unknown as PlanningRequest); expect(outcome).toMatchObject({ kind: 'InvalidInput', reasons: [{ code: 'declared-days' }], }); }); it.each([ ['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'], ['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'], ['two years', '2024-01-01', '2026-01-01', '2025-12-24', '2025-12-24', '2025-12-25'], ['three years', '2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'], ])( 'plans a light %s horizon with dated provenance and French holidays', async (_, start, end, placementDate, periodStart, periodEnd) => { const result = isPlan( await runInNonBrowser({ horizon: { start, end }, balances: [ { id: 'expired', type: 'rtt', quantity: 2, availableFrom: '2020-01-01', expiryDate: '2020-12-31', }, { id: 'dated-cp', type: 'cp', quantity: 1, availableFrom: placementDate, expiryDate: placementDate, }, ], rules: [ { id: 'chosen-date', type: 'time_window', force: 'obligation', startDate: placementDate, endDate: placementDate, placeDays: 1, placeType: 'cp', }, ], }), ); expect(result.horizon).toEqual({ start, end }); expect(result.placements).toEqual([ { date: placementDate, balanceId: 'dated-cp', type: 'cp', ruleIds: ['chosen-date'], supporting: false, }, ]); expect(result.timeOffPeriods).toHaveLength(1); expect(result.timeOffPeriods[0]).toMatchObject({ start: periodStart, end: periodEnd }); expect(result.remainingBalances.find((item) => item.id === 'expired')).toMatchObject({ quantity: 2, type: 'rtt', availableFrom: '2020-01-01', expiryDate: '2020-12-31', classification: 'unavoidable-expiry', }); }, ); it('rejects malformed horizons before calculation', async () => { const result = await planLeave({ horizon: { start: '2025-01-01', end: '2024-01-01' }, balances: [], rules: [], }); expect(result.kind).toBe('InvalidInput'); if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order'); }); it.each([ ['2024-02-30', '2025-01-01', 'horizon-start'], ['2024-13-01', '2025-01-01', 'horizon-start'], ['2024-01-01', '2024-00-01', 'horizon-end'], ['not-a-date', '2025-01-01', 'horizon-start'], ])('reports an invalid horizon date without throwing: %s to %s', async (start, end, code) => { const outcome = await planLeave({ horizon: { start, end }, balances: [], rules: [] }); expect(outcome.kind).toBe('InvalidInput'); if (outcome.kind === 'InvalidInput') expect(outcome.reasons.map((item) => item.code)).toContain(code); }); it.each([ [ 'invalid balance quantity', { balances: [{ id: 'cp', type: 'cp', quantity: -1, expiryDate: '2025-01-01' }], rules: [], }, 'balance-quantity', ], [ 'invalid balance expiry', { balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: 'not-a-date' }], rules: [], }, 'balance-expiry', ], [ 'invalid rule selector', { balances: [], rules: [ { id: 'rule', type: 'time_window', startDate: '2024-01-01', endDate: '2024-01-02', placeDays: 1, placeType: 'holiday', }, ], }, 'rule-selector', ], ] as const)('returns a table-driven validation reason for %s', async (_, request, code) => { const result = await runInNonBrowser({ horizon, ...request }); expect(result.kind).toBe('InvalidInput'); if (result.kind === 'InvalidInput') expect(result.reasons.some((candidate) => candidate.code === code)).toBe(true); }); it('plans dated balances without mutating the request and classifies the remainder', async () => { const request = { horizon, balances: [ { ...balance('expired', 'cp', 1, '2023-12-31'), availableFrom: '2023-01-01' }, balance('future', 'rtt', 1, '2026-01-01'), ], rules: [], }; const before = JSON.stringify(request); const result = isPlan(await runInNonBrowser(request)); expect(JSON.stringify(request)).toBe(before); expect(result.placements.length).toBeLessThanOrEqual(1); expect(result.remainingBalances[0].classification).toBe('unavoidable-expiry'); }); it('migrates a legacy balance without availability to the horizon start', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-06-01', end: '2024-06-02' }, balances: [{ id: 'legacy', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }], rules: [], }), ); expect(result.remainingBalances[0].availableFrom).toBe('2024-06-01'); }); it.each([ ['unavoidable-expiry', '2024-05-31', '2024-01-01', 'unavoidable-expiry'], ['at-risk', '2024-06-10', '2024-06-10', 'available-after-horizon'], ['deferred', '2024-07-01', '2024-06-11', 'available-after-horizon'], ] as const)( 'classifies a remaining balance as %s', async (classification, expiryDate, availableFrom, reasonCode) => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-06-01', end: '2024-06-10' }, balances: [ { id: classification, type: 'cp', quantity: 1, availableFrom, expiryDate, }, ], rules: [], }), ); expect(result.remainingBalances[0].classification).toBe(classification); expect(result.remainingBalances[0].reason.code).toBe(reasonCode); }, ); it.each([ ['no-working-date', '2024-06-08', '2024-06-10', []], ['all-dates-occupied', '2024-06-03', '2024-06-04', [{ date: '2024-06-03', type: 'cp' }]], ['preserved', '2024-06-04', '2024-06-13', [{ date: '2024-06-07', type: 'cp' }]], ] as const)( 'explains a remaining deferred balance as %s without treating it as unavailable', async (code, start, end, declaredDays) => { const result = isPlan( await runInNonBrowser({ horizon: { start, end }, balances: [ { ...balance('other', 'other', 1, '2025-05-10'), availableFrom: start }, ], declaredDays, rules: [], }), ); expect(result.placements).toEqual([]); expect(result.remainingBalances).toMatchObject([ { id: 'other', quantity: 1, classification: 'deferred', reason: { code }, }, ]); }, ); it('satisfies exact TIME_WINDOW obligations and reports typed conflicts', async () => { const rule = { id: 'cp-window', type: 'time_window', force: 'obligation', startDate: '2024-03-01', endDate: '2024-03-05', placeDays: 2, placeType: 'cp', }; const plan = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 2)], rules: [rule] }), ); expect(plan.placements).toHaveLength(2); expect(plan.placements.every((placement) => placement.ruleIds).valueOf()).toBe(true); const conflict = await runInNonBrowser({ horizon, balances: [balance('rtt', 'rtt', 2)], rules: [rule], }); expect(conflict.kind).toBe('PlanningConflict'); }); it('lets one ANY obligation preserve a typed balance for another obligation', async () => { const rules = [ { id: 'any', type: 'time_window', force: 'obligation', startDate: '2024-04-01', endDate: '2024-04-02', placeDays: 1, placeType: 'any', }, { id: 'cp', type: 'time_window', force: 'obligation', startDate: '2024-04-01', endDate: '2024-02-02', placeDays: 1, placeType: 'cp', }, ]; // The second window is intentionally corrected below to keep this table readable. rules[1].endDate = '2024-04-02'; const plan = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 1)], rules: [presets[0], ...rules], }), ); expect(plan.placements).toHaveLength(2); expect(plan.placements.find((placement) => placement.ruleIds.includes('cp'))?.type).toBe( 'cp', ); expect( plan.placements.some( (placement) => placement.ruleIds.includes('any') && placement.ruleIds.includes('cp'), ), ).toBe(true); }); it('supports IF_THEN patterns, merged periods, and deterministic results', async () => { const rule = { id: 'holiday', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 2, placeType: 'cp', placement: 'auto', rewardDays: 1, rewardType: 'rtt', }; const request = { horizon: { start: '2024-04-01', end: '2024-05-10' }, balances: [balance('cp', 'cp', 4), balance('rtt', 'rtt', 1, '2026-01-01')], rules: [rule], }; const first = isPlan(await runInNonBrowser(request)); const second = isPlan(await runInNonBrowser(request)); expect(first).toEqual(second); expect(first.placements.length).toBeGreaterThanOrEqual(4); expect(first.timeOffPeriods[0].duration).toBeGreaterThan(2); }); it('selects a joint obligation assignment by later preference quality', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-02-01', end: '2024-02-10' }, balances: [balance('cp', 'cp', 1)], rules: [ { id: 'obligation', type: 'time_window', force: 'obligation', startDate: '2024-02-01', endDate: '2024-02-05', placeDays: 1, placeType: 'cp', }, { id: 'preference', type: 'time_window', force: 'preference', startDate: '2024-02-05', endDate: '2024-02-05', placeDays: 1, placeType: 'cp', }, ], }), ); expect( result.placements.find((placement) => placement.ruleIds.includes('obligation'))?.date, ).toBe('2024-02-05'); expect(result.preferences[0].satisfied).toBe(1); }); it('preserves an at-risk balance while satisfying a deferred obligation', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-04' }, balances: [ { ...balance('cp', 'cp', 1, '2024-01-02'), availableFrom: '2024-01-02', }, { ...balance('rtt', 'rtt', 1, '2025-01-01'), availableFrom: '2024-01-02', }, ], rules: [ presets[0], ...[ { id: 'rtt-obligation', type: 'time_window', force: 'obligation', startDate: '2024-01-02', endDate: '2024-01-03', placeDays: 1, placeType: 'rtt', }, ], ], }), ); expect( result.placements.find((placement) => placement.ruleIds.includes('rtt-obligation')) ?.date, ).toBe('2024-01-03'); expect(result.placements.find((placement) => placement.balanceId === 'cp')?.date).toBe( '2024-01-02', ); }); it('does not let a generating period reward finance an earlier merged period placement', async () => { const result = await runInNonBrowser({ horizon: { start: '2024-04-25', end: '2024-05-15' }, balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 3, '2026-01-01')], rules: [ { id: 'producer', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'before', rewardDays: 1, rewardType: 'rtt', }, { id: 'consumer', type: 'if_then', force: 'obligation', dayOfWeek: 4, placeDays: 4, placeType: 'rtt', placement: 'before', rewardDays: 0, }, ], }); expect(result.kind).toBe('PlanningConflict'); }); it('chains an earned balance into a later obligation with provenance', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-25', end: '2024-05-08' }, balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')], rules: [ { id: 'producer', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 1, rewardType: 'rtt', }, { id: 'consumer', type: 'time_window', force: 'obligation', startDate: '2024-05-07', endDate: '2024-05-07', placeDays: 1, placeType: 'rtt', }, ], }), ); const earned = result.placements.find((placement) => placement.ruleIds.includes('consumer'), ); expect(earned?.balanceId).toBe('earned:producer:2024-05-01'); expect(earned?.ruleIds).toEqual(['consumer']); }); it('allows a later preference producer to fund an earlier consumer chronologically', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-25', end: '2024-05-08' }, balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')], rules: [ { id: 'consumer', type: 'time_window', force: 'preference', startDate: '2024-05-07', endDate: '2024-05-07', placeDays: 1, placeType: 'rtt', }, { id: 'producer', type: 'if_then', force: 'preference', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 1, rewardType: 'rtt', }, ], }), ); expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1]); expect(result.placements).toEqual( expect.arrayContaining([ expect.objectContaining({ date: '2024-05-02', balanceId: 'cp', ruleIds: ['producer'], }), expect.objectContaining({ date: '2024-05-07', balanceId: 'earned:producer:2024-05-01', ruleIds: ['consumer'], }), ]), ); }); it('preserves preference priority when one later producer earns only one day', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-25', end: '2024-05-08' }, balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')], rules: [ { id: 'first', type: 'time_window', force: 'preference', startDate: '2024-05-06', endDate: '2024-05-06', placeDays: 1, placeType: 'rtt', }, { id: 'second', type: 'time_window', force: 'preference', startDate: '2024-05-07', endDate: '2024-05-07', placeDays: 1, placeType: 'rtt', }, { id: 'producer', type: 'if_then', force: 'preference', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 1, rewardType: 'rtt', }, ], }), ); expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 0, 1]); expect(result.placements).toEqual( expect.arrayContaining([ expect.objectContaining({ date: '2024-05-06', ruleIds: ['first'] }), ]), ); }); it('replays the current preference when an earlier retry creates its entitlement', async () => { vi.setSystemTime(new Date('2021-01-01T12:00:00Z')); const result = isPlan( await runInNonBrowser({ horizon: { start: '2021-05-20', end: '2021-11-03' }, balances: [ { id: 'cp', type: 'cp', quantity: 0, availableFrom: '2021-05-20', expiryDate: '2022-01-01', }, { id: 'rtt', type: 'rtt', quantity: 1, availableFrom: '2021-05-20', expiryDate: '2021-08-01', }, ], rules: [ { id: 'wednesday-producer', type: 'if_then', force: 'preference', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 1, rewardType: 'rtt', }, { id: 'monday-chain', type: 'if_then', force: 'preference', dayOfWeek: 1, placeDays: 1, placeType: 'rtt', placement: 'after', rewardDays: 1, rewardType: 'cp', }, ], }), ); expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 2]); expect(result.placements).toEqual( expect.arrayContaining([ expect.objectContaining({ date: '2021-07-15', ruleIds: ['wednesday-producer'] }), expect.objectContaining({ date: '2021-11-02', ruleIds: ['monday-chain'] }), ]), ); }); it('retains a useful supporting placement while preserving its provenance', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-05', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1, '2025-01-01')], rules: [nonWorkingGain, supportingEconomy], }), ); const supporting = result.placements.find((placement) => placement.supporting); expect(supporting?.ruleIds).toEqual(['gain']); expect(supporting?.balanceId).toBe('cp'); }); it('does not trade an earlier short-sequence deficit for supporting placement gain', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-01', end: '2024-02-01' }, balances: [balance('cp-four', 'cp', 4, '2025-01-01')], rules: [ { id: 'deficit', type: 'optimization', configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit, }, nonWorkingGain, supportingEconomy, ], }), ); expect(result.metrics.shortSequenceDeficit).toBe(0); }); it('retains supporting provenance when a placement is moved', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-01', end: '2024-01-29' }, balances: [balance('cp-six', 'cp', 6, '2025-01-01')], rules: [ nonWorkingGain, { id: 'longest', type: 'optimization', configuration: OPTIMIZATION_PRESETS.longestWorkSequence, }, supportingEconomy, ], }), ); expect(result.metrics.supportingPlacements).toBe( result.placements.filter((placement) => placement.supporting).length, ); expect(result.placements.length).toBeGreaterThan(0); expect(result.placements.every((placement) => placement.supporting)).toBe(true); }); it('orders non-working gain, longest sequence, and final dates lexicographically', async () => { const twoDayPlan = isPlan( await runInNonBrowser({ horizon: { start: '2024-02-01', end: '2024-03-01' }, balances: [balance('cp', 'cp', 2, '2024-02-29')], rules: presets, }), ); const fourDayPlan = isPlan( await runInNonBrowser({ horizon: { start: '2024-02-01', end: '2024-03-01' }, balances: [balance('cp', 'cp', 4, '2024-02-29')], rules: presets, }), ); expect(twoDayPlan.metrics).toMatchObject({ nonWorkingDayGain: 4, longestWorkSequence: 9, shortSequenceDeficit: 0, }); expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([ '2024-02-02', '2024-02-16', ]); expect(fourDayPlan.placements.map((placement) => placement.date)).toEqual([ '2024-02-02', '2024-02-09', '2024-02-16', '2024-02-23', ]); }); it.each([ [ '11 November', { start: '2026-11-01', end: '2026-12-01' }, '2026-11-30', ['2026-11-09', '2026-11-10', '2026-11-12', '2026-11-13'], ], [ '14 July', { start: '2027-07-01', end: '2027-08-01' }, '2027-07-31', ['2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16'], ], ] as const)('takes the full week around %s', async (_, holidayHorizon, expiryDate, dates) => { const result = isPlan( await runInNonBrowser({ horizon: holidayHorizon, balances: [balance('cp', 'cp', 4, expiryDate)], rules: presets, }), ); expect(result.placements.map((placement) => placement.date)).toEqual(dates); expect(result.timeOffPeriods).toHaveLength(1); expect(result.timeOffPeriods[0].duration).toBe(9); }); // This full configured year is measured separately; allow instrumentation overhead without a planning deadline. it('uses the 11 November and 14 July weeks in the representative default plan', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2026-08-29', end: '2027-08-29' }, balances: [ { ...balance('cp', 'cp', 10, '2027-08-29'), availableFrom: '2026-08-29', }, { ...balance('rtt', 'rtt', 10, '2027-08-29'), availableFrom: '2026-08-29', }, { ...balance('other', 'other', 4, '2027-08-29'), availableFrom: '2026-08-29', }, ], rules: [ presets[0], ...[ { id: 'default-samedi-malin', name: 'Samedi Malin', type: 'if_then', force: 'preference', dayOfWeek: 6, placeDays: 2, placeType: 'cp', placement: 'auto', rewardDays: 1, rewardType: 'other', }, { id: 'default-christmas', name: 'Christmas holidays', type: 'time_window', force: 'preference', startDate: '2026-12-20', endDate: '2027-01-03', placeDays: 5, placeType: 'any', }, ], ...presets.slice(1), ], }), ); expect(result.placements.map((placement) => placement.date)).toEqual( expect.arrayContaining([ '2026-11-09', '2026-11-10', '2026-11-12', '2026-11-13', '2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16', ]), ); }, 120_000); it('moves a satisfied time-window preference to complete a holiday week', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2027-07-01', end: '2027-08-01' }, balances: [balance('cp', 'cp', 4, '2027-07-31')], rules: [ presets[0], ...[ { id: 'july', type: 'time_window', force: 'preference', startDate: '2027-07-01', endDate: '2027-07-31', placeDays: 4, placeType: 'any', }, ], ...presets.slice(1), ], }), ); expect(result.preferences[0].satisfied).toBe(4); expect(result.placements.map((placement) => placement.date)).toEqual([ '2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16', ]); }); it('uses deferred leave as supporting placements to complete a holiday week', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2027-07-01', end: '2027-08-01' }, balances: [balance('cp', 'cp', 4, '2028-07-31')], rules: [ { id: 'holiday', type: 'optimization', configuration: OPTIMIZATION_PRESETS.holidayWeekValue, }, supportingEconomy, ], }), ); expect(result.placements.map((placement) => placement.date)).toEqual([ '2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16', ]); expect(result.placements.every((placement) => placement.supporting)).toBe(true); }); it('moves a time-window obligation to improve a later quality stage', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-08', end: '2024-01-15' }, balances: [balance('cp', 'cp', 1)], rules: [ presets[0], ...[ { id: 'window', type: 'time_window', force: 'obligation', startDate: '2024-01-09', endDate: '2024-01-12', placeDays: 1, placeType: 'cp', }, ], ...presets.slice(1), ], }), ); expect(result.placements[0].date).toBe('2024-01-12'); }); it('shares future ANY preference demand and preserves preference order', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-02-01', end: '2024-02-12' }, balances: [balance('cp', 'cp', 1)], rules: [ { id: 'first', type: 'time_window', force: 'preference', startDate: '2024-02-05', endDate: '2024-02-08', placeDays: 1, placeType: 'cp', }, { id: 'any', type: 'time_window', force: 'preference', startDate: '2024-02-07', endDate: '2024-02-09', placeDays: 1, placeType: 'any', }, ], }), ); expect(result.placements[0].date).toBe('2024-02-07'); expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1]); }); it.each(['cp', 'any'] as const)( 'shares an existing compatible placement before consuming a day for a %s preference', async (lastSelector) => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-15' }, balances: [balance('cp', 'cp', 3)], rules: [ { id: 'high', type: 'time_window', force: 'preference', startDate: '2024-01-05', endDate: '2024-01-08', placeDays: 2, placeType: 'cp', }, { id: 'middle', type: 'time_window', force: 'preference', startDate: '2024-01-03', endDate: '2024-01-10', placeDays: 1, placeType: 'cp', }, { id: 'low', type: 'time_window', force: 'preference', startDate: '2024-01-12', endDate: '2024-01-12', placeDays: 1, placeType: lastSelector, }, ], }), ); expect(result.preferences.map((preference) => preference.satisfied)).toEqual([2, 1, 1]); }, ); it('uses the earliest unsatisfied preference slot as a cardinality look-ahead', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-08' }, balances: [balance('cp', 'cp', 1)], rules: [ { id: 'flexible', type: 'time_window', force: 'preference', startDate: '2024-01-02', endDate: '2024-01-05', placeDays: 1, placeType: 'any', }, { id: 'second', type: 'time_window', force: 'preference', startDate: '2024-01-05', endDate: '2024-01-05', placeDays: 1, placeType: 'cp', }, { id: 'third', type: 'time_window', force: 'preference', startDate: '2024-01-02', endDate: '2024-01-02', placeDays: 1, placeType: 'cp', }, ], }), ); expect(result.placements[0].date).toBe('2024-01-05'); expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1, 0]); }); it('classifies a later preference blocked by consumed higher-priority capacity', async () => { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-02', end: '2024-01-08' }, balances: [balance('cp', 'cp', 1, '2025-01-01')], rules: [ { id: 'high', type: 'time_window', force: 'preference', startDate: '2024-01-02', endDate: '2024-01-02', placeDays: 1, placeType: 'cp', }, { id: 'low', type: 'time_window', force: 'preference', startDate: '2024-01-05', endDate: '2024-01-05', placeDays: 1, placeType: 'cp', }, ], }), ); expect(result.preferences[0].satisfied).toBe(1); expect(result.preferences[1].reason?.code).toBe('higher-priority'); }); it('keeps holiday occurrences date-only across host timezones', async () => { const previousTimezone = process.env['TZ']; process.env['TZ'] = 'Asia/Tokyo'; try { const result = isPlan( await runInNonBrowser({ horizon: { start: '2023-12-29', end: '2024-01-05' }, balances: [balance('cp', 'cp', 1)], rules: [ { id: 'new-year', type: 'if_then', force: 'obligation', dayOfWeek: 1, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 0, }, ], }), ); expect(result.placements[0].date).toBe('2024-01-02'); } finally { if (previousTimezone === undefined) delete process.env['TZ']; else process.env['TZ'] = previousTimezone; } }); it('rejects around N=1 while supporting valid around splits', async () => { const aroundOne = await runInNonBrowser({ horizon: { start: '2024-04-25', end: '2024-05-15' }, balances: [balance('cp-one', 'cp', 2)], rules: [ { id: 'around-one', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'around', rewardDays: 0, }, ], }); expect(aroundOne.kind).toBe('PlanningConflict'); const aroundPreference = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-25', end: '2024-05-15' }, balances: [balance('cp-pref', 'cp', 1)], rules: [ { id: 'around-preference', type: 'if_then', force: 'preference', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'around', rewardDays: 0, }, ], }), ); expect(aroundPreference.preferences[0].satisfied).toBe(0); const aroundTwo = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-29', end: '2024-05-04' }, balances: [balance('cp-two', 'cp', 2)], rules: [ { id: 'around-two', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 2, placeType: 'cp', placement: 'around', rewardDays: 0, }, ], }), ); expect(aroundTwo.placements).toHaveLength(2); for (const mode of ['before', 'after', 'auto'] as const) { const result = isPlan( await runInNonBrowser({ horizon: { start: '2024-04-25', end: '2024-05-15' }, balances: [balance(`cp-${mode}`, 'cp', 2)], rules: [ { id: `rule-${mode}`, type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: mode, rewardDays: 0, }, ], }), ); expect(result.placements).toHaveLength(2); expect( result.placements.every((placement) => placement.ruleIds.includes(`rule-${mode}`)), ).toBe(true); } }); it.each([ ['2026-01-01', undefined], ['2027-01-01', undefined], ['2027-01-01', 25_000], ] as const)( 'completes every objective on a horizon ending %s after more than ten seconds (budget %s)', async (end, deadlineMs) => { const request: PlanningRequest = { deadlineMs, horizon: { start: '2024-01-01', end }, balances: [ { id: 'cp', type: 'cp', quantity: 3, availableFrom: '2024-05-06', expiryDate: '2024-05-10', }, ], rules: [ presets[0], ...[ { id: 'fixed', type: 'time_window', force: 'obligation', startDate: '2024-05-06', endDate: '2024-05-06', placeDays: 1, placeType: 'cp', }, ], ...presets.slice(1), ], }; const expected = isPlan(await runInNonBrowser(request)); let elapsed = 0; const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed); try { const outcome = await runInNonBrowser({ ...request, progress: (stage) => { if (stage === 'optimization:default-unplaced-at-risk') queueMicrotask(() => (elapsed = 20_000)); }, }); expect(elapsed).toBeGreaterThan(10_000); expect(outcome).toEqual(expected); expect(expected.lastCompletedStage).toBe('dates'); expect(expected.placements).toContainEqual({ date: '2024-05-06', balanceId: 'cp', type: 'cp', ruleIds: ['fixed'], supporting: false, }); } finally { clock.mockRestore(); } }, ); it('returns an incomplete outcome instead of a false conflict at an expired deadline', async () => { const result = await planLeave({ horizon, balances: [balance('cp', 'cp', 5)], rules: [], deadlineMs: 0, }); expect(result.kind).toBe('PlanningIncomplete'); }); it('reports worker-unavailable in a browser instead of running the core synchronously', async () => { const result = await planLeave({ horizon, balances: [], rules: [] }); expect(result.kind).toBe('PlanningIncomplete'); if (result.kind === 'PlanningIncomplete') { expect(result.reasons[0].code).toBe('worker-unavailable'); expect(result.incumbent).toBeUndefined(); expect(result.lastCompletedStage).toBeUndefined(); expect(result.completedStages).toEqual([]); } }); it('keeps an expired browser request off the UI thread when a worker exists', async () => { let started = false; let terminated = false; class ZeroBudgetWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; constructor() { started = true; } postMessage(): void {} terminate(): void { terminated = true; } } vi.stubGlobal('Worker', ZeroBudgetWorker); vi.useFakeTimers(); try { const resultPromise = planLeave({ horizon, balances: [], rules: [], deadlineMs: 0 }); await vi.advanceTimersByTimeAsync(30_000); const result = await resultPromise; expect(result.kind).toBe('PlanningIncomplete'); expect(started).toBe(true); expect(terminated).toBe(true); } finally { vi.useRealTimers(); vi.unstubAllGlobals(); } }); }); describe('planLeave transport', () => { it('keeps a responsive phase active beyond repeated liveness windows', async () => { const request = { horizon, balances: [], rules: [] }; const expected = isPlan(await runInNonBrowser(request)); const workers: ResponsiveWorker[] = []; class ResponsiveWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage = vi.fn(); terminate = vi.fn(); constructor() { workers.push(this); } emit(data: unknown) { this.onmessage?.({ data } as MessageEvent); } } vi.stubGlobal('Worker', ResponsiveWorker); vi.useFakeTimers(); try { const calculation = createPlanningCalculation(request); const worker = workers[0]; let outcomeReady = false; void calculation.outcome.then(() => (outcomeReady = true)); for (let tick = 0; tick < 10; tick++) { await vi.advanceTimersByTimeAsync(10_000); worker.emit({ type: 'heartbeat' }); } expect(outcomeReady).toBe(false); expect(worker.terminate).not.toHaveBeenCalled(); worker.emit({ type: 'result', outcome: expected }); expect(await calculation.outcome).toEqual(expected); await vi.advanceTimersByTimeAsync(60_000); expect(worker.terminate).not.toHaveBeenCalled(); worker.emit({ type: 'artifacts', artifacts: {} }); expect(await calculation.outcome).toEqual(expected); expect(worker.postMessage).toHaveBeenCalledOnce(); expect(worker.terminate).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); vi.unstubAllGlobals(); } }); it('terminates an isolated worker that misses the liveness deadline with its incumbent', async () => { const incumbent = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 2)], rules: [], }), ); class HangingWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(): void { this.onmessage?.({ data: { type: 'checkpoint', plan: incumbent }, } as MessageEvent); } terminate(): void { terminated = true; } } let terminated = false; vi.stubGlobal('Worker', HangingWorker); vi.useFakeTimers(); try { const resultPromise = planLeave({ horizon, balances: [balance('cp', 'cp', 2)], rules: [], deadlineMs: 10, }); await vi.advanceTimersByTimeAsync(30_000); const result = await resultPromise; expect(result.kind).toBe('PlanningIncomplete'); if (result.kind === 'PlanningIncomplete') { expect(result.incumbent?.kind).toBe('Plan'); expect(result.lastCompletedStage).toBe('dates'); expect(result.reasons[0].code).toBe('worker-error'); } expect(terminated).toBe(true); } finally { vi.useRealTimers(); vi.unstubAllGlobals(); } }); it('keeps the delivered checkpoint and recovers its explanation after watchdog failure', async () => { const core = await import('./leave-planner.core'); const request = { horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1)], rules: [], }; let checkpoint: PlanOutcome | undefined; let checkpointState: PlanningStateSnapshot | undefined; const expected = core.calculatePlanningInternal(request, (plan, state) => { checkpoint = plan; checkpointState = state; }); expect(expected.kind).toBe('Plan'); if (expected.kind !== 'Plan' || !checkpointState) return; checkpoint = { ...expected, completedStages: ['validation', 'obligations', 'optimization:default-unplaced-at-risk'], lastCompletedStage: 'optimization:default-unplaced-at-risk', }; const workers: Array<{ terminated: boolean }> = []; class RecoveringWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; terminated = false; constructor() { workers.push(this); } postMessage( message: | { type: 'plan' } | { type: 'explain-checkpoint'; request: unknown; plan: PlanOutcome; state: PlanningStateSnapshot; interruption: PlanningReason; }, ): void { if (message.type === 'plan') { queueMicrotask(() => this.onmessage?.({ data: { type: 'checkpoint', plan: checkpoint, state: checkpointState }, } as MessageEvent), ); return; } if (message.type === 'explain-checkpoint') queueMicrotask(() => { const recovered = core.explainIncompleteCheckpoint( message.request, message.plan, message.state, message.interruption, ); this.onmessage?.({ data: { type: 'explanation', ...recovered }, } as MessageEvent); }); } terminate(): void { this.terminated = true; } } const browserWindow = globalThis.window; vi.stubGlobal('window', {}); vi.stubGlobal('Worker', RecoveringWorker); vi.useFakeTimers(); try { const calculation = createPlanningCalculation({ ...request, deadlineMs: 20 }); await vi.runAllTicks(); await vi.advanceTimersByTimeAsync(30_000); const outcome = await calculation.outcome; expect(outcome.kind).toBe('PlanningIncomplete'); if (outcome.kind === 'PlanningIncomplete') expect(outcome.incumbent).toEqual(checkpoint); const explanation = await calculation.explanation; expect(explanation?.kind).toBe('PlanExplanation'); if (explanation?.kind === 'PlanExplanation') expect(explanation.incomplete?.reason.code).toBe('worker-error'); expect((await calculation.explanationError)?.code).toBe('worker-error'); expect(workers).toHaveLength(2); expect(workers.every((worker) => worker.terminated)).toBe(true); } finally { vi.useRealTimers(); vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('reports no completed stage when an isolated worker never checkpoints', async () => { class SilentWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(): void { // Deliberately never checkpoint or finish. } terminate(): void {} } vi.stubGlobal('Worker', SilentWorker); vi.useFakeTimers(); try { const resultPromise = planLeave({ horizon, balances: [balance('cp', 'cp', 2)], rules: [], deadlineMs: 10, }); await vi.advanceTimersByTimeAsync(30_000); const result = await resultPromise; expect(result.kind).toBe('PlanningIncomplete'); if (result.kind === 'PlanningIncomplete') { expect(result.incumbent).toBeUndefined(); expect(result.completedStages).toEqual([]); expect(result.lastCompletedStage).toBeUndefined(); expect(result.reasons[0].code).toBe('worker-error'); } } finally { vi.useRealTimers(); vi.unstubAllGlobals(); } }); it.each([undefined, 25_000])( 'sends a detached, callback-free request with budget %s', async (deadlineMs) => { const sourceDate = new Date('2024-01-01T00:00:00Z'); const request = { deadlineMs, horizon: { start: sourceDate, end: '2025-01-01' }, balances: [ { id: 'cp', type: 'cp' as const, quantity: 0, availableFrom: sourceDate, expiryDate: '2025-01-01', }, ], rules: [], }; const expected = isPlan(await runInNonBrowser(request)); let posted: { type: string; request: PlanningRequest } | undefined; class InspectingWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(message: { type: string; request: PlanningRequest }): void { posted = structuredClone(message); this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); } terminate(): void {} } vi.stubGlobal('Worker', InspectingWorker); try { const result = await planLeave(request); expect(result.kind).toBe('Plan'); expect(posted?.request).not.toBe(request); expect(posted?.request.deadlineMs).toBe(deadlineMs); expect(posted?.request.progress).toBeUndefined(); expect(request.horizon.start).toBe(sourceDate); } finally { vi.unstubAllGlobals(); } }, ); it('strips legacy aliases at the worker boundary', async () => { const request = { horizon, balances: [ { id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01', numberOfDays: 99, availabilityDate: '2024-01-01', }, ], rules: [], } as unknown as PlanningRequest; let posted: { type: string; request: PlanningRequest } | undefined; class InspectingWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(message: { type: string; request: PlanningRequest }): void { posted = structuredClone(message); this.onmessage?.({ data: { type: 'result', outcome: { kind: 'Plan' } }, } as MessageEvent); } terminate(): void {} } vi.stubGlobal('Worker', InspectingWorker); try { await planLeave(request); expect(posted?.request.balances[0]).toEqual({ id: 'cp', type: 'cp', quantity: 1, availableFrom: undefined, expiryDate: '2025-01-01', }); } finally { vi.unstubAllGlobals(); } }); }); describe('planning calculation context', () => { it('explains fixed rule credits without debit and keeps them in comparisons after the inputs change', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const submitted: PlanningRequest = { horizon: { start: '2024-01-08', end: '2024-01-20' }, balances: [balance('remaining-cp', 'cp', 1, '2025-01-01')], declaredDays: [{ date: '2024-01-10', type: 'cp' }], labels: { exact: 'Two CP including Wednesday', preferred: 'Two days this week' }, rules: [ { id: 'exact', force: 'obligation', placeType: 'cp' }, { id: 'preferred', force: 'preference', placeType: 'any' }, ].map((rule) => ({ ...rule, type: 'time_window', startDate: '2024-01-08', endDate: '2024-01-12', placeDays: 2, })), }; const originalDeclarations = structuredClone(submitted.declaredDays); const calculation = createPlanningCalculation(submitted); submitted.declaredDays![0].date = '2024-01-11'; submitted.declaredDays![0].type = 'rtt'; if ('placeDays' in submitted.rules[0]) submitted.rules[0].placeDays = 4; if ('endDate' in submitted.rules[0]) submitted.rules[0].endDate = '2024-01-19'; submitted.horizon.end = '2024-01-11'; submitted.labels = { exact: 'Edited later' }; const outcome = isPlan(await calculation.outcome); expect(outcome.placements).toHaveLength(1); expect(outcome.remainingBalances).toEqual([]); expect(outcome.preferences).toMatchObject([{ requested: 2, satisfied: 2 }]); expect(outcome.timeWindowContributions).toEqual([ { ruleId: 'exact', label: 'Two CP including Wednesday', requested: 2, declaredDays: originalDeclarations, placementDates: [outcome.placements[0].date], }, { ruleId: 'preferred', label: 'Two days this week', requested: 2, declaredDays: originalDeclarations, placementDates: [outcome.placements[0].date], }, ]); const explanation = await calculation.explanation; if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected a stable explanation.'); expect(explanation.timeWindowContributions).toEqual(outcome.timeWindowContributions); expect(explanation.placements).toHaveLength(1); expect(explanation.placements[0].comparison).toMatchObject({ before: { objectives: expect.arrayContaining([ expect.objectContaining({ id: 'obligation-validity', value: 1 }), expect.objectContaining({ id: 'preference:preferred', value: 1 }), ]), }, after: { objectives: expect.arrayContaining([ expect.objectContaining({ id: 'obligation-validity', value: 0 }), expect.objectContaining({ id: 'preference:preferred', value: 2 }), ]), }, }); const before = structuredClone(outcome); const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); expect(analysis.results.find(({ date }) => date === '2024-01-10')).toMatchObject({ status: 'ineligible', reason: { code: 'declared-day' }, }); expect(analysis.results.find(({ date }) => date === '2024-01-15')).toMatchObject({ status: 'ineligible', reason: { code: 'rule-invalid' }, }); expect(analysis.results.find(({ date }) => date === '2024-01-11')).toMatchObject({ status: 'eligible', comparison: { after: { objectives: expect.arrayContaining([ expect.objectContaining({ id: 'obligation-validity', value: 0 }), expect.objectContaining({ id: 'preference:preferred', value: 2 }), ]), }, }, }); expect( analysis.results.find(({ date }) => date === outcome.placements[0].date)?.comparison ?.after, ).toEqual(explanation.placements[0].objectiveVector); expect(outcome).toEqual(before); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps a cross-year declared extension in stable explanations and restored date comparisons', async () => { const { isWorkingPlanningDate } = await import('./declared-non-working-days'); const { addPlanningDays } = await import('./planning-date'); const declaredDays: NonNullable[number][] = []; for (let date = '2024-12-31'; date < '2026-01-06'; date = addPlanningDays(date, 1)) if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' }); const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-12-30', end: '2024-12-31' }, balances: [ { ...balance('cp', 'cp', 1, '2024-12-30'), availableFrom: '2024-12-30' }, ], rules: [presets[0]], declaredDays, }); const outcome = isPlan(await calculation.outcome); expect(outcome.timeOffPeriods[0]).toMatchObject({ start: '2024-12-28', end: '2026-01-05', }); expect(outcome.timeOffPeriods[0].nonWorkingDates).toContain('2026-01-01'); const explanation = await calculation.explanation; if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected a stable plan explanation.'); expect(explanation.timeOffPeriods).toEqual(outcome.timeOffPeriods); const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); expect(analysis.results[0].comparison?.after).toEqual( explanation.placements[0].objectiveVector, ); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps declared periods and objective comparisons tied to the same snapshot after current inputs change', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const submitted: PlanningRequest = { horizon: { start: '2026-11-01', end: '2026-12-01' }, balances: [balance('cp', 'cp', 2, '2026-11-30')], rules: presets, declaredDays: [ { date: '2026-11-09', type: 'other' }, { date: '2026-11-10', type: 'rtt' }, ], }; const original = structuredClone(submitted.declaredDays); const calculation = createPlanningCalculation(submitted); submitted.declaredDays![0].date = '2026-11-20'; submitted.declaredDays![0].type = 'cp'; submitted.horizon.end = '2026-11-12'; const outcome = isPlan(await calculation.outcome); const explanation = await calculation.explanation; if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected a stable plan explanation.'); expect(explanation.declaredDays).toEqual(original); expect(explanation.timeOffPeriods).toEqual(outcome.timeOffPeriods); for (const placement of explanation.placements) { expect(placement.timeOffPeriod).toEqual(outcome.timeOffPeriods[0]); expect(placement.finalMetrics).toEqual(outcome.metrics); expect(placement.firstDecisiveObjective).toBe( 'optimization:default-unplaced-at-risk', ); } const before = structuredClone(outcome); const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); const restored = analysis.results.find( ({ date }) => date === outcome.placements[0].date, ); expect(restored?.comparison?.after).toEqual(explanation.placements[0].objectiveVector); const alternative = analysis.results.find(({ date }) => date === '2026-11-20'); expect(alternative?.status).toBe('eligible'); expect(alternative?.comparison?.after.objectives).toContainEqual( expect.objectContaining({ id: 'optimization:default-holiday-week-value', value: 0, }), ); expect(alternative?.comparison?.after.objectives).toContainEqual( expect.objectContaining({ id: 'optimization:default-non-working-day-gain', value: 7, }), ); expect(outcome).toEqual(before); } finally { vi.stubGlobal('window', browserWindow); } }); it('detaches declared dates and types across results, explanations and comparisons', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const submitted: PlanningRequest = { horizon: { start: '2024-01-02', end: '2024-02-01' }, balances: [balance('cp', 'cp', 4, '2024-01-05')], rules: [presets[0]], declaredDays: [ { date: '2024-01-02', type: 'cp' }, { date: '2024-01-03', type: 'rtt' }, { date: '2024-01-05', type: 'other' }, ], }; const original = structuredClone(submitted.declaredDays); const calculation = createPlanningCalculation(submitted); submitted.declaredDays![0].type = 'other'; submitted.horizon.end = '2024-01-03'; expect(calculation.snapshot.declaredDays).toEqual(original); expect(Object.isFrozen(calculation.snapshot.declaredDays![0])).toBe(true); const outcome = isPlan(await calculation.outcome); expect(outcome.declaredDays).toEqual(original); expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-04']); expect(await calculation.explanation).toMatchObject({ kind: 'PlanExplanation', declaredDays: original, placements: [{ placement: { date: '2024-01-04' } }], }); const frozenOutcome = structuredClone(outcome); const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); for (const { date } of original!) expect(analysis.results.find((result) => result.date === date)).toMatchObject({ status: 'ineligible', reason: { code: 'declared-day' }, }); expect(outcome).toEqual(frozenOutcome); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps a multi-year horizon attached to its immutable outcome and explanation', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const request = { horizon: { start: '2024-01-01', end: '2027-01-01' }, balances: [ { id: 'cp', type: 'cp', quantity: 1, availableFrom: '2026-12-24', expiryDate: '2026-12-24', }, ], rules: [ { id: 'final-year', type: 'time_window', force: 'obligation', startDate: '2026-12-24', endDate: '2026-12-24', placeDays: 1, placeType: 'cp', }, ], }; const calculation = createPlanningCalculation(request); request.horizon.end = '2025-01-01'; request.balances[0].expiryDate = '2024-12-31'; expect(calculation.snapshot.horizon).toEqual({ start: '2024-01-01', end: '2027-01-01', }); expect(isPlan(await calculation.outcome).horizon).toEqual(calculation.snapshot.horizon); const explanation = await calculation.explanation; expect(explanation?.kind).toBe('PlanExplanation'); if (explanation?.kind === 'PlanExplanation') expect(explanation.placements[0]).toMatchObject({ placement: { date: '2026-12-24', balanceId: 'cp', ruleIds: ['final-year'] }, balance: { availableFrom: '2026-12-24', expiryDate: '2026-12-24' }, }); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps an immutable submitted snapshot beside the outcome', async () => { const request = { horizon, balances: [balance('cp', 'cp', 1)], rules: [], labels: { cp: 'Paid leave' }, }; const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation(request); request.balances[0].quantity = 99; request.labels.cp = 'Changed later'; expect(calculation.snapshot.balances[0].quantity).toBe(1); expect(calculation.snapshot.labels['cp']).toBe('Paid leave'); expect((await calculation.outcome).kind).toBe('Plan'); } finally { vi.stubGlobal('window', browserWindow); } }); it('lets validation reject an invalid Date in the submitted horizon', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: new Date(Number.NaN), end: '2025-01-01' }, balances: [], rules: [], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('InvalidInput'); if (outcome.kind === 'InvalidInput') expect(outcome.reasons[0].code).toBe('horizon-start'); expect(calculation.snapshot.horizon.start).toBe('Invalid Date'); } finally { vi.stubGlobal('window', browserWindow); } }); it('materializes a chronological explanation after the unchanged outcome', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-01-15' }, balances: [ { ...balance('cp', 'cp', 1, '2026-01-01'), name: 'Paid leave entitlement', }, ], rules: [ { id: 'winter', name: 'Winter break', type: 'time_window', startDate: '2024-01-02', endDate: '2024-01-12', placeDays: 1, placeType: 'cp', }, ], labels: { winter: 'Winter break' }, }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan') return; expect('explanation' in outcome).toBe(false); const explanation = await calculation.explanation; expect(explanation?.kind).toBe('PlanExplanation'); if (explanation?.kind !== 'PlanExplanation') return; expect(explanation.placements).toHaveLength(outcome.placements.length); expect(explanation.placements[0].balance?.name).toBe('Paid leave entitlement'); expect(explanation.placements[0].ruleIds).toContain('winter'); expect(explanation.placements[0].objectiveVector.objectives.length).toBeGreaterThan(1); expect('score' in explanation.placements[0].objectiveVector).toBe(false); expect(explanation.placements[0].firstDecisiveObjective).toBe( explanation.placements[0].comparison.firstDecisiveObjective, ); expect(explanation.placements[0].firstDecisiveObjective).toBe('preference:winter'); const alternatives = await calculation.analyzeCounterfactual(outcome.placements[0]); expect(alternatives.kind).toBe('Counterfactual'); if (alternatives.kind === 'Counterfactual') expect( alternatives.results.find( (result) => result.date === outcome.placements[0].date, )?.comparison?.firstDecisiveObjective, ).toBe('preference:winter'); } finally { vi.stubGlobal('window', browserWindow); } }); it('reports every rule contribution when one placement is shared', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-01-06' }, balances: [balance('cp', 'cp', 1)], rules: [ { id: 'wide', name: 'Wide window', type: 'time_window', force: 'obligation', startDate: '2024-01-02', endDate: '2024-01-05', placeDays: 1, placeType: 'cp', }, { id: 'exact', name: 'Exact day', type: 'time_window', force: 'obligation', startDate: '2024-01-05', endDate: '2024-01-05', placeDays: 1, placeType: 'cp', }, ], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); const explanation = await calculation.explanation; expect(explanation?.kind).toBe('PlanExplanation'); if (explanation?.kind !== 'PlanExplanation') return; expect( explanation.placements[0].contributions .filter((contribution) => contribution.kind === 'rule') .map((contribution) => contribution.ruleId), ).toEqual(['exact', 'wide']); } finally { vi.stubGlobal('window', browserWindow); } }); it('compares the selected placement across the half-open horizon without changing the plan', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1, '2024-12-31')], rules: [presets[3]], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan') return; const before = structuredClone(outcome); const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; expect(analysis.results).toHaveLength(9); expect(analysis.results.some((result) => result.reason?.code === 'non-working')).toBe( true, ); expect( analysis.results.some( (result) => result.status === 'eligible' && result.comparison, ), ).toBe(true); expect(outcome).toEqual(before); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps every mandatory IF_THEN occurrence satisfied in counterfactuals', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-04-25', end: '2024-05-15' }, balances: [balance('cp', 'cp', 2, '2024-12-31')], rules: [ { id: 'wednesday-holidays', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'auto', rewardDays: 0, }, ], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan') return; const firstOccurrence = outcome.placements.find( (placement) => placement.date < '2024-05-05', ); const secondOccurrence = outcome.placements.find( (placement) => placement.date > '2024-05-05', ); expect(firstOccurrence).toBeDefined(); expect(secondOccurrence).toBeDefined(); if (!firstOccurrence || !secondOccurrence) return; const alternateSecondDate = secondOccurrence.date === '2024-05-07' ? '2024-05-10' : '2024-05-07'; const analysis = await calculation.analyzeCounterfactual(firstOccurrence); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; expect( analysis.results.find((result) => result.date === alternateSecondDate), ).toMatchObject({ status: 'ineligible', reason: { code: 'rule-invalid' }, }); } finally { vi.stubGlobal('window', browserWindow); } }); it('preserves supporting provenance in counterfactual placements', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-05', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1, '2025-01-01')], rules: [nonWorkingGain, supportingEconomy], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan') return; const selected = outcome.placements.find((placement) => placement.supporting); expect(selected).toBeDefined(); if (!selected) return; const analysis = await calculation.analyzeCounterfactual(selected); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; const restored = analysis.results.find((result) => result.date === selected.date); expect( restored?.comparison?.after.objectives.find( (objective) => objective.id === 'optimization:economy', )?.value, ).toBe(1); } finally { vi.stubGlobal('window', browserWindow); } }); it('cancels superseded planning workers without changing the latest result', async () => { const browserWindow = globalThis.window; const expected = isPlan( await runInNonBrowser({ horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1)], rules: [], }), ); const workers: Array<{ terminated: boolean }> = []; class SupersededWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; terminated = false; constructor() { workers.push(this); } postMessage(): void { if (workers.length === 2) { this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); return; } if (workers.length !== 3) return; queueMicrotask(() => { this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts', artifacts: {} }, } as MessageEvent); }); } terminate(): void { this.terminated = true; } } vi.stubGlobal('window', {}); vi.stubGlobal('Worker', SupersededWorker); try { const pending = createPlanningCalculation({ horizon: expected.horizon, balances: [balance('cp', 'cp', 1)], rules: [], }); const materializing = createPlanningCalculation({ horizon: expected.horizon, balances: [balance('cp', 'cp', 1)], rules: [], }); const latest = createPlanningCalculation({ horizon: expected.horizon, balances: [balance('cp', 'cp', 1)], rules: [], }); expect(workers).toHaveLength(3); expect(workers[0].terminated).toBe(true); expect(workers[1].terminated).toBe(true); const pendingOutcome = await pending.outcome; expect(pendingOutcome.kind).toBe('PlanningIncomplete'); if (pendingOutcome.kind === 'PlanningIncomplete') expect(pendingOutcome.reasons[0].code).toBe('stale-calculation'); expect(await materializing.outcome).toEqual(expected); expect(await materializing.explanationError).toBeUndefined(); expect(await latest.outcome).toEqual(expected); expect(workers[2].terminated).toBe(true); } finally { vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('discards counterfactual work from an older calculation', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const older = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1)], rules: [presets[3]], }); const olderOutcome = await older.outcome; expect(olderOutcome.kind).toBe('Plan'); if (olderOutcome.kind !== 'Plan') return; createPlanningCalculation({ horizon, balances: [], rules: [] }); const analysis = await older.analyzeCounterfactual(olderOutcome.placements[0]); expect(analysis.kind).toBe('CounterfactualStale'); expect(analysis.results).toEqual([]); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps counterfactual worker failures local and allows a clean retry', async () => { const request = { horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1)], rules: [presets[3]], }; const expected = isPlan(await runInNonBrowser(request)); const selected = expected.placements[0]; const response: CounterfactualAnalysis = { kind: 'Counterfactual', calculationId: 'transport-test', placement: selected, hypothetical: true, baseline: { objectives: [] }, results: [], }; let counterfactualCalls = 0; class RetriableWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; terminated = false; postMessage(message: { type: string }): void { if (message.type === 'plan') { queueMicrotask(() => { this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts', artifacts: {} }, } as MessageEvent); }); return; } counterfactualCalls++; queueMicrotask(() => { if (counterfactualCalls === 1) this.onmessage?.({ data: { type: 'error', code: 'counterfactual-error', message: 'Counterfactual analysis failed locally.', }, } as MessageEvent); else this.onmessage?.({ data: { type: 'counterfactual-result', analysis: response }, } as MessageEvent); }); } terminate(): void { this.terminated = true; } } const browserWindow = globalThis.window; vi.stubGlobal('window', {}); vi.stubGlobal('Worker', RetriableWorker); try { const calculation = createPlanningCalculation(request); expect(await calculation.outcome).toEqual(expected); await expect(calculation.analyzeCounterfactual(selected)).rejects.toThrow( 'Counterfactual analysis failed locally.', ); const analysis = await calculation.analyzeCounterfactual(selected); expect(analysis.kind).toBe('Counterfactual'); expect(analysis.calculationId).toBe(calculation.id); expect(counterfactualCalls).toBe(2); } finally { vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('terminates an active counterfactual worker when a newer calculation starts', async () => { const request = { horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1)], rules: [presets[3]], }; const expected = isPlan(await runInNonBrowser(request)); const workers: Array<{ terminated: boolean }> = []; class SupersededCounterfactualWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; terminated = false; constructor() { workers.push(this); } postMessage(message: { type: string }): void { if (message.type !== 'plan') return; queueMicrotask(() => { this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts', artifacts: {} }, } as MessageEvent); }); } terminate(): void { this.terminated = true; } } const browserWindow = globalThis.window; vi.stubGlobal('window', {}); vi.stubGlobal('Worker', SupersededCounterfactualWorker); try { const previous = createPlanningCalculation(request); await previous.outcome; const pendingAnalysis = previous.analyzeCounterfactual(expected.placements[0]); await vi.waitFor(() => expect(workers).toHaveLength(2)); const latest = createPlanningCalculation(request); const stale = await pendingAnalysis; expect(stale.kind).toBe('CounterfactualStale'); expect(workers[1].terminated).toBe(true); expect((await latest.outcome).kind).toBe('Plan'); } finally { vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('keeps earned-entitlement availability and provenance in counterfactuals', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-04-25', end: '2024-05-15' }, balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')], rules: [ { id: 'wednesday-holidays', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 1, rewardType: 'rtt', }, { id: 'thursday-consumer', type: 'if_then', force: 'obligation', dayOfWeek: 4, placeDays: 1, placeType: 'rtt', placement: 'before', rewardDays: 0, }, ], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan') return; const earned = outcome.placements.find((placement) => placement.balanceId.startsWith('earned:'), ); expect(earned).toBeDefined(); if (!earned) return; const analysis = await calculation.analyzeCounterfactual(earned); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; expect(analysis.placement).toEqual(earned); expect( analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code, ).toBe('unavailable'); } finally { vi.stubGlobal('window', browserWindow); } }); it('evaluates every eligible balance and retains the earlier expiry for an equal preference', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [ balance('later', 'cp', 1, '2026-01-01'), balance('earlier', 'cp', 1, '2025-01-01'), ], rules: [ { id: 'window', type: 'time_window', force: 'preference', startDate: '2024-01-02', endDate: '2024-01-02', placeDays: 1, placeType: 'cp', }, ], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan') return; expect(outcome.placements[0].balanceId).toBe('earlier'); } finally { vi.stubGlobal('window', browserWindow); } }); it('omits a plan explanation for invalid input and explains conflicts', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const invalidCalculation = createPlanningCalculation({ horizon: { start: '2025-01-01', end: '2024-01-01' }, balances: [], rules: [], }); expect((await invalidCalculation.outcome).kind).toBe('InvalidInput'); expect(await invalidCalculation.explanation).toBeUndefined(); const conflictCalculation = createPlanningCalculation({ horizon, balances: [balance('rtt', 'rtt', 1)], rules: [ { id: 'cp-only', name: 'Paid leave only', type: 'time_window', force: 'obligation', startDate: '2024-04-01', endDate: '2024-04-05', placeDays: 1, placeType: 'cp', }, ], }); expect((await conflictCalculation.outcome).kind).toBe('PlanningConflict'); const explanation = await conflictCalculation.explanation; expect(explanation?.kind).toBe('ConflictExplanation'); if (explanation?.kind === 'ConflictExplanation') expect(explanation.conflictingRules).toEqual([ { id: 'cp-only', label: 'Paid leave only' }, ]); } finally { vi.stubGlobal('window', browserWindow); } }); it('marks interrupted and unexecuted phases when no incumbent exists', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon, balances: [balance('cp', 'cp', 5)], rules: [], deadlineMs: 0, }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('PlanningIncomplete'); const explanation = await calculation.explanation; expect(explanation?.kind).toBe('IncompleteExplanation'); } finally { vi.stubGlobal('window', browserWindow); } }); it('explains an incomplete incumbent without reusing the expired planning deadline', async () => { const core = await import('./leave-planner.core'); let clock = 0; let expired = false; const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => { if (expired) clock += 100; return clock; }); try { let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined; const outcome = core.calculatePlanningInternal( { horizon, balances: Array.from({ length: 12 }, (_, index) => balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12), ), rules: presets, deadlineMs: 20, progress: (stage: string) => { if (stage === `optimization:${presets[0].id}`) expired = true; }, }, undefined, (value) => (artifacts = value), ); expect(outcome.kind).toBe('PlanningIncomplete'); if (outcome.kind !== 'PlanningIncomplete') return; expect(outcome.incumbent).toBeDefined(); expect(artifacts?.explanation?.kind).toBe('PlanExplanation'); if (artifacts?.explanation?.kind !== 'PlanExplanation') return; expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline'); expect(artifacts.explanation.placements).toHaveLength( outcome.incumbent?.placements.length ?? 0, ); } finally { clockSpy.mockRestore(); } }); it('publishes the worker outcome before delayed explanation artifacts', async () => { const expected = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), ); class DelayedArtifactsWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(): void { queueMicrotask(() => this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent), ); } terminate(): void {} } vi.stubGlobal('Worker', DelayedArtifactsWorker); try { const calculation = createPlanningCalculation({ horizon, balances: [balance('cp', 'cp', 1)], rules: [], }); expect((await calculation.outcome).kind).toBe('Plan'); } finally { vi.unstubAllGlobals(); } }); it('keeps a valid outcome when explanation delivery fails afterward', async () => { const expected = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), ); class FailingArtifactsWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(): void { queueMicrotask(() => { this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); queueMicrotask(() => this.onerror?.()); }); } terminate(): void {} } vi.stubGlobal('Worker', FailingArtifactsWorker); try { const calculation = createPlanningCalculation({ horizon, balances: [balance('cp', 'cp', 1)], rules: [], }); expect(await calculation.outcome).toEqual(expected); expect((await calculation.explanationError)?.code).toBe('worker-error'); } finally { vi.unstubAllGlobals(); } }); });