import { vi } from 'vitest'; import { applyReplayDeltas, PlanningOutcome, PlanningRequest, createPlanningCalculation, planLeave, } from './leave-planner.service'; import type { CounterfactualAnalysis, HistoricalEligibilityReason, PlanOutcome, PlanningReason, PlanningTraceState, PlanningTraceUpdate, } from './leave-planner.service'; import type { CounterfactualBaselineOptions } from './leave-planner.core'; 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()); 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 trace = await calculation.trace; expect(trace?.initialState.calculationDate).toBe('2024-05-01'); 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', ); } const consumerDecision = trace?.phases .flatMap(({ decisions }) => decisions) .find(({ deltas }) => deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')), ); expect(consumerDecision).toBeDefined(); if (consumerDecision) expect( ( await calculation.analyzeCounterfactual({ decisionId: consumerDecision.id, placement: result.placements[0], }) ).kind, ).toBe('Counterfactual'); } 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'); let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; 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, })), }, undefined, (value) => (artifacts = value), ), ); expect(result.placements).toHaveLength(2); expect(result.placements.map(({ type }) => type).sort()).toEqual(['cp', 'rtt']); const evaluated = artifacts.trace?.phases.flatMap((phase) => phase.decisions.flatMap((decision) => decision.candidates), ) ?? []; expect(evaluated.some(({ reason }) => reason?.code === 'obligation-solution-limit')).toBe( false, ); 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' }, }); expect(artifacts.trace?.initialState.declaredDays).toEqual(declaredDays); }); 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: [], 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: [], 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: [], 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: [], 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: [], 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 = 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: [], 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: [ { id: 'preferred-friday', type: 'time_window', force: 'preference', startDate: '2026-11-20', endDate: '2026-11-20', placeDays: 1, placeType: 'cp', }, ], }), ); 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, }), ); 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: [ { 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: [], }), ); const supporting = result.placements.find((placement) => placement.supporting); expect(supporting?.ruleIds).toEqual([]); expect(supporting?.balanceId).toBe('cp'); }); it('does not trade a 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: [], }), ); 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: [], }), ); 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: [], }), ); const fourDayPlan = isPlan( await runInNonBrowser({ horizon: { start: '2024-02-01', end: '2024-03-01' }, balances: [balance('cp', 'cp', 4, '2024-02-29')], rules: [], }), ); expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([ '2024-02-09', '2024-02-19', ]); 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: [], }), ); expect(result.placements.map((placement) => placement.date)).toEqual(dates); expect(result.timeOffPeriods).toHaveLength(1); expect(result.timeOffPeriods[0].duration).toBe(9); }); // Async calculation yields to the test timer; allow the planner's ten-second budget. 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: [ { 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', }, ], }), ); 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', ]), ); }, 15_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: [ { id: 'july', type: 'time_window', force: 'preference', startDate: '2027-07-01', endDate: '2027-07-31', placeDays: 4, placeType: 'any', }, ], }), ); 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: [], }), ); 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: [ { id: 'window', type: 'time_window', force: 'obligation', startDate: '2024-01-09', endDate: '2024-01-12', placeDays: 1, placeType: 'cp', }, ], }), ); 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: [ { id: 'fixed', type: 'time_window', force: 'obligation', startDate: '2024-05-06', endDate: '2024-05-06', placeDays: 1, placeType: 'cp', }, ], }; 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 === '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 and delivers its trace later', 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; let traceReady = false; void calculation.outcome.then(() => (outcomeReady = true)); void calculation.trace.then(() => (traceReady = 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(traceReady).toBe(false); expect(worker.terminate).not.toHaveBeenCalled(); worker.emit({ type: 'artifacts', artifacts: {} }); await calculation.trace; 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 prefix 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: PlanningTraceState | 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', 'at-risk'], lastCompletedStage: 'at-risk', }; const initialState: PlanningTraceState = { horizon: request.horizon, balances: [ { id: 'cp', type: 'cp', quantity: 1, remaining: 1, availableFrom: '2024-01-01', expiryDate: '2024-12-31', earned: false, }, ], placements: [], earnedActivations: [], }; const prefix: PlanningTraceUpdate = { initialState, operations: [ { kind: 'phase', phase: { id: 'phase:1', name: 'dates', completed: false, status: 'interrupted', }, }, { kind: 'decision', phaseId: 'phase:1', decision: { id: 'decision:1', action: 'choose', dates: ['2024-01-02'] }, }, { kind: 'candidate', phaseId: 'phase:1', decisionId: 'decision:1', index: 0, mode: 'append', candidate: { status: 'evaluated-rejected', date: '2024-01-02' }, }, ], }; 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: PlanningTraceState; interruption: PlanningReason; }, ): void { if (message.type === 'plan') { queueMicrotask(() => { this.onmessage?.({ data: { type: 'checkpoint', plan: checkpoint, state: checkpointState }, } as MessageEvent); this.onmessage?.({ data: { type: 'trace-update', totalMs: 0, update: prefix }, } 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 trace = await calculation.trace; expect(trace?.status).toBe('failed'); expect(trace?.error?.code).toBe('worker-error'); expect(trace?.phases[0].decisions[0].candidates).toHaveLength(1); 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('does not let excluded trace transfer time consume worker liveness before the outcome', async () => { const expected = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), ); class TraceAwareWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(): void { this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 0 } } as MessageEvent); setTimeout(() => { this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 300 }, } as MessageEvent); this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts', artifacts: {} }, } as MessageEvent); }, 300); } terminate(): void {} } vi.stubGlobal('Worker', TraceAwareWorker); try { expect( await planLeave({ horizon, balances: [balance('cp', 'cp', 1)], rules: [], deadlineMs: 100, }), ).toEqual(expected); } finally { 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'; 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 trace = await calculation.trace; expect(trace?.initialState.declaredDays).toEqual(originalDeclarations); const deltas = trace?.phases.flatMap((phase) => phase.decisions.flatMap((decision) => decision.deltas), ) ?? []; const placements = deltas.filter((delta) => delta.kind === 'placement-added'); expect(placements.length).toBeGreaterThan(0); for (const delta of placements) { expect(delta.placement?.date).not.toBe('2024-01-10'); } const debits = deltas.filter( (delta) => delta.kind === 'balance-changed' && delta.remainingBefore! > delta.remainingAfter!, ); expect(debits.length).toBeGreaterThan(0); for (const debit of debits) expect(debit.remainingBefore! - debit.remainingAfter!).toBe(1); 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: [], 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: [], 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('unplaced-at-risk'); } const trace = await calculation.trace; expect(trace?.initialState.declaredDays).toEqual(original); const evaluated = trace?.phases.flatMap((phase) => phase.decisions.flatMap((decision) => decision.candidates), ); const fixedDates = new Set(original?.map(({ date }) => date)); for (const candidate of evaluated?.filter(({ status }) => status === 'chosen') ?? []) expect( (candidate.dates ?? [candidate.date]).some( (date) => date && fixedDates.has(date), ), ).toBe(false); expect( evaluated?.some( (candidate) => candidate.comparison?.after.objectives.some( (objective) => objective.id === 'holiday-week-value' && objective.value === 3, ) && candidate.comparison.after.objectives.some( (objective) => objective.id === 'non-working-day-gain' && objective.value === 5, ), ), ).toBe(true); 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: 'holiday-week-value', value: 0 }), ); expect(alternative?.comparison?.after.objectives).toContainEqual( expect.objectContaining({ id: 'non-working-day-gain', value: 7 }), ); expect(outcome).toEqual(before); } finally { vi.stubGlobal('window', browserWindow); } }); it('detaches declared dates and types across results, explanations, trace 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: [], 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 trace = await calculation.trace; expect(trace?.initialState.declaredDays).toEqual(original); expect(trace?.nonWorkingDates).toContainEqual({ date: '2024-01-02', reason: expect.objectContaining({ code: 'declared-day' }), }); 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' }, historicalStatus: 'ineligible', }); expect(outcome).toEqual(frozenOutcome); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', 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' }, }); expect((await calculation.trace)?.initialState.horizon).toEqual( calculation.snapshot.horizon, ); } finally { vi.stubGlobal('window', browserWindow); } }); it('replays full balance metadata when an existing balance changes', () => { const state = { balances: [ { id: 'earned:producer:2024-01-04', type: 'rtt' as const, quantity: 0, remaining: 0, availableFrom: '2024-01-01', expiryDate: '2024-12-31', earned: false, }, ], placements: new Map(), earnedActivations: [], }; applyReplayDeltas(state, [ { kind: 'balance-changed', balanceId: 'earned:producer:2024-01-04', remainingBefore: 0, remainingAfter: 1, balance: { id: 'earned:producer:2024-01-04', type: 'rtt', quantity: 1, remaining: 1, availableFrom: '2024-01-05', expiryDate: '2024-12-31', earned: true, sourceRuleId: 'producer', }, }, ]); expect(state.balances[0]).toEqual({ id: 'earned:producer:2024-01-04', type: 'rtt', quantity: 1, remaining: 1, availableFrom: '2024-01-05', expiryDate: '2024-12-31', earned: true, sourceRuleId: 'producer', }); }); it('applies placement delta balance changes to the replayed balance', () => { const state = { balances: [ { id: 'cp', type: 'cp' as const, quantity: 1, remaining: 1, availableFrom: '2024-01-01', expiryDate: '2024-12-31', earned: false, }, ], placements: new Map(), earnedActivations: [], }; applyReplayDeltas(state, [ { kind: 'placement-added', balanceId: 'cp', remainingBefore: 1, remainingAfter: 0, placement: { date: '2024-01-04', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false, }, }, ]); expect(state.balances[0]).toEqual({ id: 'cp', type: 'cp', quantity: 1, remaining: 0, availableFrom: '2024-01-01', expiryDate: '2024-12-31', earned: false, }); }); it('keeps the planning outcome identical when trace instrumentation is enabled', async () => { const core = await import('./leave-planner.core'); const request = { horizon: { start: '2024-01-01', end: '2024-02-01' }, balances: [balance('cp', 'cp', 3, '2024-01-31')], rules: [], }; const withoutTrace = core.calculatePlanningInternal(request); let traceStatus: string | undefined; const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => { traceStatus = artifacts.trace?.status; }); expect(withTrace).toEqual(withoutTrace); expect(traceStatus).toBe('ready'); }); 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: [], }); 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-09' : '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('allows trace counterfactuals while later obligations are still pending', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-05-01', end: '2024-05-10' }, balances: [balance('cp', 'cp', 2, '2024-12-31')], rules: [ { id: 'early-window', type: 'time_window', force: 'obligation', startDate: '2024-05-02', endDate: '2024-05-03', placeDays: 1, placeType: 'cp', }, { id: 'late-window', type: 'time_window', force: 'obligation', startDate: '2024-05-06', endDate: '2024-05-07', placeDays: 1, placeType: 'cp', }, ], }); const outcome = await calculation.outcome; const trace = await calculation.trace; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan' || !trace) return; const earlyPlacement = outcome.placements.find((placement) => placement.ruleIds.includes('early-window'), ); expect(earlyPlacement).toBeDefined(); if (!earlyPlacement) return; const decision = trace.phases .flatMap((phase) => phase.decisions) .find((candidate) => candidate.deltas.some( (delta) => delta.kind === 'placement-added' && delta.placement?.ruleIds.includes('early-window'), ), ); expect(decision).toBeDefined(); if (!decision) return; const analysis = await calculation.analyzeCounterfactual({ decisionId: decision.id, placement: earlyPlacement, }); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; expect( ['2024-05-02', '2024-05-03'].map( (date) => analysis.results.find((result) => result.date === date)?.status, ), ).toEqual(['eligible', 'eligible']); expect( analysis.results.find((result) => result.date === earlyPlacement.date)?.status, ).toBe('eligible'); } 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: [], }); 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 === 'supporting-placements', )?.value, ).toBe(1); } finally { vi.stubGlobal('window', browserWindow); } }); it('cancels superseded planning and trace 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 trace = { status: 'ready' as const, initialState: { horizon: expected.horizon, balances: [], placements: [], earnedActivations: [], }, phases: [], completedStages: [], }; 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: { trace } }, } 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 pending.trace)?.error?.code).toBe('stale-calculation'); expect(await materializing.outcome).toEqual(expected); expect((await materializing.trace)?.error?.code).toBe('stale-calculation'); expect(await materializing.explanationError).toBeUndefined(); expect(await latest.outcome).toEqual(expected); expect((await latest.trace)?.status).toBe('ready'); 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: [], }); 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: [], }; const expected = isPlan(await runInNonBrowser(request)); const selected = expected.placements[0]; const trace = { status: 'ready' as const, initialState: { horizon: expected.horizon, balances: [], placements: [], earnedActivations: [], }, phases: [], completedStages: expected.completedStages, }; 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: 'explanation', explanation: { kind: 'PlanExplanation', placements: [] }, }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-start', artifacts: {}, trace, }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-complete' } } 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 calculation.trace; 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: [], }; const expected = isPlan(await runInNonBrowser(request)); const trace = { status: 'ready' as const, initialState: { horizon: expected.horizon, balances: [], placements: [], earnedActivations: [], }, phases: [], completedStages: expected.completedStages, }; 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-start', artifacts: {}, trace }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-complete' } } 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; await previous.trace; 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'); const trace = await calculation.trace; const earnedBalanceDelta = trace?.phases .flatMap((phase) => phase.decisions) .flatMap((decision) => decision.deltas) .find((delta) => delta.balance?.id === earned.balanceId); expect(earnedBalanceDelta?.balance).toMatchObject({ id: earned.balanceId, type: earned.type, earned: true, }); } finally { vi.stubGlobal('window', browserWindow); } }); it('replays a trace decision from its pre-decision state', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-02-01' }, balances: [balance('cp', 'cp', 2, '2024-01-31')], rules: [ { id: 'wide', type: 'time_window', force: 'obligation', startDate: '2024-01-01', endDate: '2024-01-31', placeDays: 1, placeType: 'cp', }, ], }); const outcome = await calculation.outcome; const trace = await calculation.trace; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan' || !trace) return; const decision = trace.phases .flatMap((phase) => phase.decisions) .find((candidate) => candidate.deltas.some((delta) => delta.kind === 'placement-added'), ); const placement = decision?.deltas.find( (delta) => delta.kind === 'placement-added', )?.placement; expect(decision).toBeDefined(); expect(placement).toBeDefined(); if (!decision || !placement) return; const laterPlacement = outcome.placements.find( (candidate) => candidate.date !== placement.date, ); expect(laterPlacement).toBeDefined(); if (!laterPlacement) return; const analysis = await calculation.analyzeCounterfactual({ decisionId: decision.id, placement, }); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; expect( analysis.results.find((result) => result.date === laterPlacement.date)?.status, ).toBe('eligible'); expect( analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus, ).toBe('ineligible'); } finally { vi.stubGlobal('window', browserWindow); } }); it('keeps source-state ineligibility separate from unexamined trace history', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-01-11' }, balances: [ { ...balance('cp', 'cp', 1, '2024-01-07'), availableFrom: '2024-01-04', }, ], rules: [], }); const outcome = await calculation.outcome; const trace = await calculation.trace; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan' || !trace) return; const decision = trace.phases .flatMap((phase) => phase.decisions) .find((candidate) => candidate.deltas.some( (delta) => delta.kind === 'placement-added' && delta.placement?.date === '2024-01-04', ), ); const placement = decision?.deltas.find( (delta) => delta.kind === 'placement-added' && delta.placement?.date === '2024-01-04', )?.placement; expect(placement).toBeDefined(); expect(decision).toBeDefined(); if (!placement || !decision) return; const selection = { decisionId: decision.id, placement }; const historicalEligibility = await calculation.historicalEligibility(selection); expect(historicalEligibility).toContainEqual({ date: '2024-01-02', reason: expect.objectContaining({ code: 'unavailable' }), }); expect(historicalEligibility).toContainEqual({ date: '2024-01-08', reason: expect.objectContaining({ code: 'expired' }), }); const analysis = await calculation.analyzeCounterfactual(selection); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; expect(analysis.results.find((result) => result.date === '2024-01-02')).toMatchObject({ status: 'ineligible', historicalStatus: 'ineligible', reason: { code: 'unavailable' }, historicalReason: { code: 'unavailable' }, }); expect(analysis.results.find((result) => result.date === '2024-01-08')).toMatchObject({ status: 'ineligible', historicalStatus: 'ineligible', reason: { code: 'expired' }, historicalReason: { code: 'expired' }, }); expect(analysis.results.find((result) => result.date === '2024-01-04')).toMatchObject({ historicalStatus: 'chosen', historicalReason: undefined, }); } finally { vi.stubGlobal('window', browserWindow); } }); it('replays branch-local ancestors without applying the selected decision twice', async () => { const request = { horizon: { start: '2024-01-01', end: '2024-01-10' }, balances: [balance('cp', 'cp', 1)], rules: [], }; const plan = isPlan(await runInNonBrowser(request)); const firstPlacement = plan.placements[0]; if (!firstPlacement) throw new Error('Expected the plan to contain a placement.'); const selected = { ...firstPlacement, date: '2024-01-03' }; const outcome = { ...plan, placements: [selected] }; const committedPlacement = { ...selected, date: '2024-01-01' }; const parentPlacement = { ...selected, date: '2024-01-02' }; const initialState: PlanningTraceState = { horizon: request.horizon, balances: [ { id: 'cp', type: 'cp', quantity: 5, remaining: 5, availableFrom: '2024-01-01', expiryDate: '2024-01-31', earned: false, }, ], placements: [], earnedActivations: [], }; const committedDeltas = [ { kind: 'placement-added' as const, date: committedPlacement.date, balanceId: 'cp', placement: committedPlacement, }, { kind: 'balance-changed' as const, balanceId: 'cp', remainingBefore: 5, remainingAfter: 4, balance: { id: 'cp', type: 'cp' as const, quantity: 5, remaining: 4, availableFrom: '2024-01-02', expiryDate: '2024-01-31', earned: true, sourceRuleId: 'producer', }, }, ]; const parentDeltas = [ { kind: 'placement-added' as const, date: parentPlacement.date, balanceId: 'cp', placement: parentPlacement, }, { kind: 'balance-changed' as const, balanceId: 'cp', remainingBefore: 4, remainingAfter: 3, }, ]; const selectedTransition = [ { kind: 'placement-moved' as const, fromDate: parentPlacement.date, toDate: selected.date, date: selected.date, balanceId: 'cp', previousPlacement: parentPlacement, placement: selected, }, { kind: 'balance-changed' as const, balanceId: 'cp', remainingBefore: 3, remainingAfter: 2, }, ]; const update: PlanningTraceUpdate = { initialState, operations: [ { kind: 'phase', phase: { id: 'obligations', name: 'obligations', completed: true, status: 'completed', }, }, { kind: 'decision', phaseId: 'obligations', decision: { id: 'committed', action: 'choose', dates: ['2024-01-01'] }, }, ...committedDeltas.map((delta, index) => ({ kind: 'delta' as const, phaseId: 'obligations', decisionId: 'committed', index, delta, })), { kind: 'decision', phaseId: 'obligations', decision: { id: 'branch-parent', action: 'choose', dates: [parentPlacement.date], branch: { deltas: parentDeltas }, }, }, { kind: 'decision', phaseId: 'obligations', decision: { id: 'branch-selected', action: 'choose', dates: [selected.date], branch: { parentDecisionId: 'branch-parent', deltas: selectedTransition, }, }, }, ...selectedTransition.map((delta, index) => ({ kind: 'delta' as const, phaseId: 'obligations', decisionId: 'branch-selected', index, delta, })), { kind: 'decision', phaseId: 'obligations', decision: { id: 'self-parent', action: 'choose', dates: [selected.date], branch: { parentDecisionId: 'self-parent', deltas: [] }, }, }, ], }; const response: CounterfactualAnalysis = { kind: 'Counterfactual', calculationId: 'branch-transport-test', placement: selected, hypothetical: true, baseline: { objectives: [] }, results: [], }; let receivedState: PlanningTraceState | undefined; let receivedOptions: CounterfactualBaselineOptions | undefined; class BranchTraceWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(message: { type: string; state?: PlanningTraceState; options?: CounterfactualBaselineOptions; }): void { if (message.type === 'plan') { this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-start', artifacts: {}, trace: { status: 'ready', initialState, phases: [], completedStages: outcome.completedStages, nonWorkingDates: [], error: undefined, }, }, } as MessageEvent); this.onmessage?.({ data: { type: 'trace-update', totalMs: 0, update }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-complete', traceTransferMs: 0 }, } as MessageEvent); return; } receivedState = message.state; receivedOptions = message.options; if (message.type === 'historical-eligibility') { queueMicrotask(() => this.onmessage?.({ data: { type: 'historical-eligibility-result', entries: [ { date: '2024-01-03', reason: { code: 'unavailable', message: 'The selected entitlement is not available yet.', }, } satisfies HistoricalEligibilityReason, ], }, } as MessageEvent), ); return; } queueMicrotask(() => this.onmessage?.({ data: { type: 'counterfactual-result', analysis: response }, } as MessageEvent), ); } terminate(): void {} } const browserWindow = globalThis.window; vi.stubGlobal('window', {}); vi.stubGlobal('Worker', BranchTraceWorker); try { const calculation = createPlanningCalculation(request); await calculation.trace; const historicalEligibility = await calculation.historicalEligibility({ decisionId: 'branch-selected', placement: selected, }); expect(historicalEligibility).toEqual([ { date: '2024-01-03', reason: { code: 'unavailable', message: 'The selected entitlement is not available yet.', }, }, ]); expect(receivedState?.balances[0]).toMatchObject({ earned: true, sourceRuleId: 'producer', }); expect(receivedOptions).toEqual({ removeDate: '2024-01-02', compareOriginalState: true, }); const analysis = await calculation.analyzeCounterfactual({ decisionId: 'branch-selected', placement: selected, }); expect(analysis.kind).toBe('Counterfactual'); expect(receivedState?.placements.map((placement) => placement.date)).toEqual([ '2024-01-01', '2024-01-02', ]); expect(receivedState?.balances[0]?.remaining).toBe(3); expect(receivedState?.balances[0]).toMatchObject({ availableFrom: '2024-01-02', earned: true, sourceRuleId: 'producer', }); expect(receivedOptions).toEqual({ removeDate: '2024-01-02', compareOriginalState: true, }); await expect( calculation.analyzeCounterfactual({ decisionId: 'self-parent', placement: selected, }), ).rejects.toThrow('The selected trace decision is unavailable.'); } finally { vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('does not inherit unrelated rule-invalid history into a trace decision', 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-01-31')], rules: [ { id: 'early-window', type: 'time_window', force: 'obligation', startDate: '2024-01-02', endDate: '2024-01-04', placeDays: 1, placeType: 'cp', }, { id: 'late-window', type: 'time_window', force: 'obligation', startDate: '2024-01-03', endDate: '2024-01-05', placeDays: 1, placeType: 'cp', }, ], }); const outcome = await calculation.outcome; const trace = await calculation.trace; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan' || !trace) return; const decision = trace.phases .flatMap((phase) => phase.decisions) .find((candidate) => candidate.deltas.some((delta) => delta.kind === 'placement-added'), ); const placement = decision?.deltas.find( (delta) => delta.kind === 'placement-added', )?.placement; expect(decision).toBeDefined(); expect(placement).toBeDefined(); if (!decision || !placement) return; const candidates = trace.phases.flatMap((phase) => phase.decisions.flatMap((item) => item.candidates), ); const decisionDates = new Set( decision.candidates.flatMap( (candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []), ), ); const unrelatedIneligible = candidates.find( (candidate) => candidate.status === 'ineligible' && candidate.reason?.code === 'rule-invalid' && candidate.date && !decisionDates.has(candidate.date), ); expect(unrelatedIneligible).toBeDefined(); if (!unrelatedIneligible?.date) return; const analysis = await calculation.analyzeCounterfactual({ decisionId: decision.id, placement, }); expect(analysis.kind).toBe('Counterfactual'); if (analysis.kind !== 'Counterfactual') return; expect( analysis.results.find((result) => result.date === unrelatedIneligible.date) ?.historicalStatus, ).toBe('not-examined'); expect( analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus, ).toBe('ineligible'); } finally { vi.stubGlobal('window', browserWindow); } }); it('reconstructs the returned plan from the original trace deltas', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-02-01' }, balances: [balance('cp', 'cp', 4, '2024-01-31')], rules: [], }); const outcome = await calculation.outcome; const trace = await calculation.trace; expect(outcome.kind).toBe('Plan'); expect(trace?.status).toBe('ready'); if (outcome.kind !== 'Plan' || !trace) return; const placements = new Map( trace.initialState.placements.map((placement) => [placement.date, placement]), ); for (const delta of trace.phases.flatMap((phase) => phase.decisions.flatMap((decision) => decision.deltas), )) { if (delta.previousPlacement) placements.delete(delta.previousPlacement.date); if (delta.placement) placements.set(delta.placement.date, delta.placement); } expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual( outcome.placements, ); expect(trace.phases.map((phase) => phase.name)).toEqual( expect.arrayContaining([ 'obligations', 'at-risk', 'holiday-week-value', 'non-working-day-gain', 'longest-work-sequence', 'short-sequence-deficit', 'supporting-placements', 'expiry', 'dates', ]), ); expect( trace.phases .flatMap((phase) => phase.decisions) .flatMap((decision) => decision.candidates).length, ).toBeGreaterThan(20); } finally { vi.stubGlobal('window', browserWindow); } }); it('records loaded-plan pruning and full quality comparisons without truncation', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); try { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-04-01' }, balances: [balance('cp', 'cp', 25, '2024-03-31')], rules: [], }); expect((await calculation.outcome).kind).toBe('Plan'); const trace = await calculation.trace; const decisions = trace?.phases.flatMap((phase) => phase.decisions) ?? []; const candidates = decisions.flatMap((decision) => decision.candidates); expect( candidates.some( (candidate) => candidate.status === 'not-examined' && candidate.reason?.code === 'loaded-plan-sampling', ), ).toBe(true); const comparison = candidates.find((candidate) => candidate.comparison)?.comparison; expect(comparison?.before.objectives.length).toBeGreaterThan(1); expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length); expect( candidates.some((candidate) => candidate.comparison && candidate.sourceDate), ).toBe(true); const chosenComparisons = decisions.flatMap((decision) => decision.candidates .filter((candidate) => candidate.status === 'chosen' && candidate.comparison) .map((candidate) => ({ decision, candidate })), ); expect(chosenComparisons.length).toBeGreaterThan(0); for (const { decision, candidate: chosen } of chosenComparisons) { expect( decision.deltas.some( (delta) => (delta.kind === 'placement-moved' && delta.toDate === chosen.date && delta.balanceId === chosen.placement?.balanceId) || (delta.kind === 'placement-added' && delta.date === chosen.date && delta.balanceId === chosen.placement?.balanceId) || (delta.kind === 'placement-removed' && delta.date === chosen.date), ), ).toBe(true); } const phasesWithMultipleAcceptedMutations = trace?.phases.filter((phase) => { const acceptedMutations = phase.decisions.filter( (decision) => decision.candidates.some( (candidate) => candidate.status === 'chosen' && candidate.comparison, ) && decision.deltas.some( (delta) => delta.kind === 'placement-moved' || delta.kind === 'placement-added' || delta.kind === 'placement-removed' || delta.kind === 'placement-updated', ), ); return acceptedMutations.length > 1; }); expect(phasesWithMultipleAcceptedMutations?.length).toBeGreaterThan(0); } finally { vi.stubGlobal('window', browserWindow); } }); it('records every eligible balance when a preference keeps the first assignment', 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'); const trace = await calculation.trace; const candidates = trace?.phases .find((phase) => phase.name === 'preference:window') ?.decisions.flatMap((decision) => decision.candidates) .filter((candidate) => candidate.date === '2024-01-02' && candidate.placement) .map((candidate) => ({ balanceId: candidate.placement!.balanceId, status: candidate.status, reason: candidate.reason?.code, })) ?? []; expect(candidates.slice(0, 2)).toEqual([ { balanceId: 'earlier', status: 'chosen', reason: undefined }, { balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' }, ]); } finally { vi.stubGlobal('window', browserWindow); } }); it('offers no explanation trace 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(); expect(await invalidCalculation.trace).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' }, ]); const trace = await conflictCalculation.trace; expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false); } 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'); const trace = await calculation.trace; expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true); expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true); } 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 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: [], deadlineMs: 20, }, 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, ); }); it('publishes the worker outcome before delayed explanation artifacts', async () => { const expected = isPlan( await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), ); const trace = { status: 'ready' as const, initialState: { horizon, balances: [], placements: [], earnedActivations: [], }, phases: [], completedStages: [], }; class DelayedArtifactsWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(): void { queueMicrotask(() => this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent), ); setTimeout( () => this.onmessage?.({ data: { type: 'artifacts', artifacts: { trace } }, } as MessageEvent), 20, ); } terminate(): void {} } vi.stubGlobal('Worker', DelayedArtifactsWorker); try { const calculation = createPlanningCalculation({ horizon, balances: [balance('cp', 'cp', 1)], rules: [], }); let traceReady = false; void calculation.trace.then(() => (traceReady = true)); expect((await calculation.outcome).kind).toBe('Plan'); expect(traceReady).toBe(false); expect((await calculation.trace)?.status).toBe('ready'); } finally { vi.unstubAllGlobals(); } }); it('delivers every streamed evaluation after ten seconds without delaying the outcome', async () => { const request = { horizon: { start: '2024-01-01', end: '2024-12-31' }, balances: [balance('cp', 'cp', 1)], rules: [], }; const expected = isPlan(await runInNonBrowser(request)); const candidateCount = 768; const initialState: PlanningTraceState = { horizon: request.horizon, balances: [], placements: [], earnedActivations: [], }; const operations: PlanningTraceUpdate['operations'][number][] = [ { kind: 'phase', phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' }, }, { kind: 'decision', phaseId: 'phase:1', decision: { id: 'decision:1', action: 'choose', dates: ['2024-02-01'] }, }, ...Array.from({ length: candidateCount }, (_, index) => ({ kind: 'candidate' as const, phaseId: 'phase:1', decisionId: 'decision:1', index, mode: 'append' as const, candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' }, })), ]; const trace = { status: 'ready' as const, initialState, completedStages: ['dates'], }; class SlowTraceWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; terminated = false; postMessage(): void { queueMicrotask(() => { this.onmessage?.({ data: { type: 'result', outcome: expected }, } as MessageEvent); this.onmessage?.({ data: { type: 'explanation', explanation: { kind: 'PlanExplanation', placements: [] }, finalState: initialState, }, } as MessageEvent); }); setTimeout(() => { for (let offset = 0; offset < operations.length; offset += 64) this.onmessage?.({ data: { type: 'trace-update', totalMs: 0, update: { initialState: offset === 0 ? initialState : undefined, operations: operations.slice(offset, offset + 64), }, }, } as MessageEvent); this.onmessage?.({ data: { type: 'trace-status-index', statuses: new Map([['2024-02-01', 'evaluated-rejected']]), nonWorkingDates: [], }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-start', artifacts: {}, trace }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent); }, 10_001); } terminate(): void { this.terminated = true; } } const browserWindow = globalThis.window; vi.stubGlobal('window', {}); vi.stubGlobal('Worker', SlowTraceWorker); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance', 'Date'] }); vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); try { const calculation = createPlanningCalculation(request); let traceReady = false; const tracePromise = calculation.trace.then((value) => { traceReady = true; return value; }); expect(await calculation.outcome).toEqual(expected); expect((await calculation.explanation)?.kind).toBe('PlanExplanation'); expect(traceReady).toBe(false); const traceStarted = performance.now(); await vi.advanceTimersByTimeAsync(10_001); await vi.runAllTimersAsync(); const loadedTrace = await tracePromise; expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000); expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount); expect(loadedTrace?.status).toBe('ready'); } finally { vi.useRealTimers(); vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('drains queued trace packets in batches of at most sixteen', async () => { const request = { horizon: { start: '2024-01-01', end: '2024-12-31' }, balances: [balance('cp', 'cp', 1)], rules: [], }; const expected = isPlan(await runInNonBrowser(request)); const packetCount = 17; const initialState: PlanningTraceState = { horizon: request.horizon, balances: [], placements: [], earnedActivations: [], }; const trace = { status: 'ready' as const, initialState, completedStages: ['dates'], }; const updates = Array.from( { length: packetCount }, (_, index): PlanningTraceUpdate => ({ initialState: index === 0 ? initialState : undefined, operations: index === 0 ? [ { kind: 'phase', phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed', }, }, { kind: 'decision', phaseId: 'phase:1', decision: { id: 'decision:1', action: 'choose', dates: ['2024-02-01'], }, }, { kind: 'candidate', phaseId: 'phase:1', decisionId: 'decision:1', index, mode: 'append', candidate: { status: 'evaluated-rejected', date: '2024-02-01', }, }, ] : [ { kind: 'candidate', phaseId: 'phase:1', decisionId: 'decision:1', index, mode: 'append', candidate: { status: 'evaluated-rejected', date: '2024-02-01', }, }, ], }), ); let performanceNowCalls = 0; let performanceNowCallsAtTermination = -1; let terminationCount = 0; let acknowledgementCount = 0; class BatchedTraceWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(message?: { type?: string }): void { if (message?.type === 'trace-ack') { acknowledgementCount++; return; } this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent); this.onmessage?.({ data: { type: 'explanation', explanation: { kind: 'PlanExplanation', placements: [] }, finalState: initialState, }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-start', artifacts: {}, trace }, } as MessageEvent); for (const update of updates) this.onmessage?.({ data: { type: 'trace-update', totalMs: 0, update }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-complete', traceTransferMs: 0 }, } as MessageEvent); } terminate(): void { terminationCount++; performanceNowCallsAtTermination = performanceNowCalls; } } const browserWindow = globalThis.window; const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); vi.spyOn(performance, 'now').mockImplementation(() => { performanceNowCalls++; return 0; }); vi.stubGlobal('window', {}); vi.stubGlobal('Worker', BatchedTraceWorker); try { const calculation = createPlanningCalculation(request); const loadedTrace = await calculation.trace; expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(packetCount); expect(timeoutSpy.mock.calls.filter(([, delay]) => delay === 0)).toHaveLength(2); expect(terminationCount).toBe(1); expect(performanceNowCallsAtTermination).toBe(2); expect(acknowledgementCount).toBe(0); } finally { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('acknowledges marked trace packets only after applying them and waits for the tail', async () => { const request = { horizon: { start: '2024-01-01', end: '2024-12-31' }, balances: [balance('cp', 'cp', 1)], rules: [], }; const expected = isPlan(await runInNonBrowser(request)); const initialState: PlanningTraceState = { horizon: request.horizon, balances: [], placements: [], earnedActivations: [], }; const trace = { status: 'ready' as const, initialState, completedStages: ['dates'], }; const phase = { kind: 'phase' as const, phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' as const }, }; const decision = { kind: 'decision' as const, phaseId: 'phase:1', decision: { id: 'decision:1', action: 'choose' as const, dates: ['2024-02-01'] }, }; const candidate = (index: number) => ({ kind: 'candidate' as const, phaseId: 'phase:1', decisionId: 'decision:1', index, mode: 'append' as const, candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' }, }); const firstUpdate: PlanningTraceUpdate = { initialState, operations: [phase, decision, candidate(0)], }; const tailUpdate: PlanningTraceUpdate = { operations: [candidate(1)] }; let acknowledgePackets = 0; let tailSent = false; let resolveFirstAcknowledgement!: () => void; const firstAcknowledgement = new Promise( (resolve) => (resolveFirstAcknowledgement = resolve), ); class AcknowledgedTraceWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage(message: { type?: string; count?: number }): void { if (message.type === 'trace-ack') { acknowledgePackets += message.count ?? 0; if (acknowledgePackets === 1) { tailSent = true; queueMicrotask(() => this.onmessage?.({ data: { type: 'trace-update', totalMs: 0, update: tailUpdate, acknowledge: true, }, } as MessageEvent), ); resolveFirstAcknowledgement(); } else if (acknowledgePackets === 2) queueMicrotask(() => this.onmessage?.({ data: { type: 'artifacts-complete', traceTransferMs: 0 }, } as MessageEvent), ); return; } this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent); this.onmessage?.({ data: { type: 'explanation', explanation: { kind: 'PlanExplanation', placements: [] }, finalState: initialState, }, } as MessageEvent); this.onmessage?.({ data: { type: 'artifacts-start', artifacts: {}, trace }, } as MessageEvent); this.onmessage?.({ data: { type: 'trace-update', totalMs: 0, update: firstUpdate, acknowledge: true, }, } as MessageEvent); } terminate(): void {} } const browserWindow = globalThis.window; vi.stubGlobal('window', {}); vi.stubGlobal('Worker', AcknowledgedTraceWorker); try { const calculation = createPlanningCalculation(request); let traceReady = false; const tracePromise = calculation.trace.then((result) => { traceReady = true; return result; }); await calculation.outcome; expect(acknowledgePackets).toBe(0); expect(traceReady).toBe(false); await firstAcknowledgement; expect(tailSent).toBe(true); expect(traceReady).toBe(false); const loadedTrace = await tracePromise; expect(acknowledgePackets).toBe(2); expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(2); } finally { vi.unstubAllGlobals(); vi.stubGlobal('window', browserWindow); } }); it('keeps a valid outcome when trace 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.trace)?.status).toBe('failed'); expect((await calculation.explanationError)?.code).toBe('worker-error'); } finally { vi.unstubAllGlobals(); } }); });