import { vi } from '../../testing/vitest-compat'; import { calculatePlanningInternal } from './leave-planner.core'; import { createPlanningCalculation } from './leave-planner.service'; import type { PlanningRequest } from './leave-planner.service'; import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives'; const loss = { id: 'loss', name: 'Prevent losses', type: 'optimization' as const, force: 'preference' as const, configuration: OPTIMIZATION_PRESETS.unplacedAtRisk, }; const personal = { id: 'personal', name: 'Personal CP day', type: 'time_window' as const, force: 'preference' as const, startDate: '2024-01-02', endDate: '2024-01-02', placeDays: 1, placeType: 'cp' as const, }; const concurrent: PlanningRequest = { horizon: { start: '2024-01-02', end: '2024-01-03' }, balances: [ { id: 'risk-rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' }, { id: 'deferred-cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }, ], rules: [], }; describe('explicit active planning objectives', () => { beforeEach(() => { vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); vi.stubGlobal('window', undefined); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it('rejects a sparse selector list consistently at direct and calculation boundaries', async () => { const request = { ...concurrent, rules: [ { ...loss, configuration: { ...loss.configuration, conditions: new Array(1) } }, ], }; const direct = calculatePlanningInternal(request); const calculation = await createPlanningCalculation(request).outcome; for (const result of [direct, calculation]) expect(result).toMatchObject({ kind: 'InvalidInput', reasons: [{ code: 'invalid-optimization-configuration', ruleIds: ['loss'] }], }); }); it('adds no leave without active rules and retains declared days', async () => { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-02', end: '2024-01-05' }, balances: [{ id: 'rtt', type: 'rtt', quantity: 2, expiryDate: '2024-01-04' }], rules: [], declaredDays: [{ date: '2024-01-03', type: 'other' }], }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('Plan'); if (outcome.kind !== 'Plan') return; expect(outcome.placements).toEqual([]); expect(outcome.declaredDays).toEqual([{ date: '2024-01-03', type: 'other' }]); expect(outcome.remainingBalances[0].quantity).toBe(2); expect(outcome.remainingBalances[0]).toMatchObject({ classification: 'at-risk', reason: { code: 'active-priorities' }, }); }); it.each([ ['z-early', 'a-later'], ['a-early', 'z-later'], ])( 'compares equal expirations before dates without preferring balance identity %s', (earlyId, laterId) => { const result = calculatePlanningInternal({ horizon: { start: '2024-01-09', end: '2024-01-13' }, balances: [ { id: earlyId, type: 'cp', quantity: 1, availableFrom: '2024-01-09', expiryDate: '2024-02-01', }, { id: laterId, type: 'cp', quantity: 1, availableFrom: '2024-01-10', expiryDate: '2024-02-01', }, ], rules: [{ ...personal, startDate: '2024-01-09', endDate: '2024-01-12' }], }); expect(result).toMatchObject({ kind: 'Plan', placements: [{ date: '2024-01-09', balanceId: earlyId }], }); }, ); it('follows the displayed order when loss prevention competes with a personal preference', async () => { const first = await createPlanningCalculation({ ...concurrent, rules: [loss, personal] }) .outcome; const second = await createPlanningCalculation({ ...concurrent, rules: [personal, loss] }) .outcome; if (first.kind !== 'Plan' || second.kind !== 'Plan') throw new Error('Expected valid plans'); expect(first.placements.map((placement) => placement.balanceId)).toEqual(['risk-rtt']); expect(first.metrics.unplacedAtRisk).toBe(0); expect(first.preferences[0].satisfied).toBe(0); expect(second.placements.map((placement) => placement.balanceId)).toEqual(['deferred-cp']); expect(second.metrics.unplacedAtRisk).toBe(1); expect(second.preferences[0].satisfied).toBe(1); }); it('keeps typed obligations valid when an optimization would prefer another balance', async () => { const outcome = await createPlanningCalculation({ ...concurrent, rules: [{ ...personal, force: 'obligation' }, loss], }).outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.placements[0]).toMatchObject({ type: 'cp', balanceId: 'deferred-cp', ruleIds: ['personal'], }); expect(outcome.metrics.unplacedAtRisk).toBe(1); }); it('preserves scarce expiry dates before a lower personal preference', async () => { const outcome = await createPlanningCalculation({ horizon: { start: '2024-01-02', end: '2024-01-04' }, balances: [ { id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }, { id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' }, ], rules: [loss, personal], }).outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.metrics.unplacedAtRisk).toBe(0); expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([ { date: '2024-01-02', type: 'rtt' }, { date: '2024-01-03', type: 'cp' }, ]); expect(outcome.preferences[0].satisfied).toBe(0); }); it('can relocate a short chain before adding a balance with a scarce date', async () => { const outcome = await createPlanningCalculation({ horizon: { start: '2024-01-02', end: '2024-01-05' }, balances: [ { id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }, { id: 'flexible', type: 'rtt', quantity: 1, availableFrom: '2024-01-03', expiryDate: '2024-01-04', }, { id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' }, ], rules: [loss, personal], }).outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.metrics.unplacedAtRisk).toBe(0); expect(outcome.placements.map(({ date, balanceId }) => ({ date, balanceId }))).toEqual([ { date: '2024-01-02', balanceId: 'scarce' }, { date: '2024-01-03', balanceId: 'cp' }, { date: '2024-01-04', balanceId: 'flexible' }, ]); }); it.each(['2024-01-23', '2024-01-24'])( 'finishes oversubscribed balances without an eligible free endpoint through %s', async (end) => { const outcome = await createPlanningCalculation({ deadlineMs: 500, horizon: { start: '2024-01-02', end }, balances: [ { id: 'cp', type: 'cp', quantity: 7, expiryDate: '2024-01-22' }, { id: 'rtt', type: 'rtt', quantity: 8, expiryDate: '2024-01-22' }, { id: 'other', type: 'other', quantity: 1, expiryDate: '2024-01-22' }, ], rules: [loss], }).outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a completed valid plan'); expect(outcome.placements).toHaveLength(15); expect(outcome.metrics.unplacedAtRisk).toBe(1); }, ); it('relocates repeated balance tokens across mandatory windows as a complete chain', async () => { const windows = [ ['cp-first', 'cp', '2024-01-02', '2024-01-03'], ['rtt-middle', 'rtt', '2024-01-03', '2024-01-04'], ['cp-last', 'cp', '2024-01-04', '2024-01-05'], ].map(([id, placeType, startDate, endDate]) => ({ id, type: 'time_window' as const, force: 'obligation' as const, placeType, placeDays: 1, startDate, endDate, })); const outcome = await createPlanningCalculation({ horizon: { start: '2024-01-02', end: '2024-01-06' }, balances: [ { id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-05' }, { id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-05' }, { id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' }, ], rules: [...windows, loss], }).outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.metrics.unplacedAtRisk).toBe(0); expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([ { date: '2024-01-02', type: 'other' }, { date: '2024-01-03', type: 'cp' }, { date: '2024-01-04', type: 'rtt' }, { date: '2024-01-05', type: 'cp' }, ]); }); it('relocates same-balance tokens with different mandatory provenance', async () => { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-02', end: '2024-01-05' }, balances: [ { id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-04' }, { id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' }, ], rules: [ { ...personal, id: 'first', force: 'obligation', endDate: '2024-01-03' }, { ...personal, id: 'second', force: 'obligation', startDate: '2024-01-03', endDate: '2024-01-04', }, loss, ], }); const outcome = await calculation.outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.metrics.unplacedAtRisk).toBe(0); }); it('satisfies a lower IF_THEN pattern by reassigning its complete typed bundle', async () => { const outcome = await createPlanningCalculation({ horizon: { start: '2027-12-20', end: '2027-12-28' }, balances: [ { id: 'A-cp', type: 'cp', quantity: 2, expiryDate: '2027-12-27' }, { id: 'B-rtt', type: 'rtt', quantity: 4, expiryDate: '2027-12-27' }, ], rules: [ loss, { id: 'samedi', name: 'Samedi Malin', type: 'if_then', dayOfWeek: 6, placeDays: 2, placeType: 'cp', placement: 'auto', rewardDays: 1, rewardType: 'other', }, ], }).outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.metrics.unplacedAtRisk).toBe(0); expect(outcome.preferences[0].satisfied).toBe(1); expect( outcome.placements.filter(({ type }) => type === 'cp').map(({ date }) => date), ).toEqual(['2027-12-23', '2027-12-24']); }); it('does not classify a period extension without a calendar benefit as supporting', async () => { const base: PlanningRequest = { horizon: { start: '2026-12-28', end: '2027-01-09' }, balances: [ { id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2027-01-08' }, { id: 'cp', type: 'cp', quantity: 1, availableFrom: '2027-01-05', expiryDate: '2027-02-01', }, ], rules: [ { ...personal, force: 'obligation', startDate: '2027-01-04', endDate: '2027-01-04', placeType: 'rtt', }, ], }; const before = await createPlanningCalculation(base).outcome; const after = await createPlanningCalculation({ ...base, rules: [ ...base.rules, { ...loss, configuration: { ...loss.configuration, conditions: [] } }, ], }).outcome; if (before.kind !== 'Plan' || after.kind !== 'Plan') throw new Error('Expected valid plans'); expect(after.placements.find(({ balanceId }) => balanceId === 'cp')).toMatchObject({ date: '2027-01-05', supporting: false, }); for (const key of [ 'holidayWeekValue', 'nonWorkingDayGain', 'longestWorkSequence', 'shortSequenceDeficit', ] as const) expect(after.metrics[key]).toBe(before.metrics[key]); expect(after.metrics.supportingPlacements).toBe(0); }); it('keeps joint window obligations and active priority when large previews are sampled', async () => { const obligations = [ ['2024-01-02', '2024-01-15'], ['2024-01-16', '2024-01-29'], ['2024-02-01', '2024-02-14'], ].map(([startDate, endDate], index) => ({ id: `mandatory-${index}`, type: 'time_window' as const, force: 'obligation' as const, startDate, endDate, placeDays: 2, placeType: 'any' as const, })); const request: PlanningRequest = { horizon: { start: '2024-01-02', end: '2024-02-16' }, balances: [ { id: 'cp', type: 'cp', quantity: 5, expiryDate: '2024-02-15' }, { id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' }, { id: 'deferred', type: 'cp', quantity: 1, expiryDate: '2024-05-01' }, ], rules: [...obligations, loss, personal], }; const calculation = createPlanningCalculation(request); const outcome = await calculation.outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.metrics.unplacedAtRisk).toBe(0); expect(outcome.preferences[0].satisfied).toBe(0); for (const rule of obligations) expect( outcome.placements.filter(({ ruleIds }) => ruleIds.includes(rule.id)), ).toHaveLength(2); const reversed = await createPlanningCalculation({ ...request, rules: [...obligations, personal, loss], }).outcome; if (reversed.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(reversed.preferences[0].satisfied).toBe(1); expect(reversed.metrics.unplacedAtRisk).toBe(1); }, 20_000); it('allows a lower typed preference to choose the balance when loss quantities tie', async () => { const outcome = await createPlanningCalculation({ ...concurrent, balances: concurrent.balances.map((balance) => ({ ...balance, expiryDate: '2024-01-02', })), rules: [loss, personal], }).outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan'); expect(outcome.placements[0].type).toBe('cp'); expect(outcome.metrics.unplacedAtRisk).toBe(1); expect(outcome.preferences[0].satisfied).toBe(1); }); it('makes a disabled optimization equivalent to its absence, including all-disabled requests', async () => { for (const otherRules of [[], [personal]]) { const absent = await createPlanningCalculation({ ...concurrent, rules: otherRules }) .outcome; const disabled = await createPlanningCalculation({ ...concurrent, rules: [{ ...loss, enabled: false }, ...otherRules], }).outcome; expect(disabled).toEqual(absent); } }); it.each([ { ...OPTIMIZATION_PRESETS.unplacedAtRisk, aggregation: 'average' }, { ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: ['complete'] }, null, ])( 'rejects incompatible or malformed active configurations without an obligation conflict: %s', async (configuration) => { const outcome = calculatePlanningInternal({ ...concurrent, rules: [{ ...loss, configuration }], }); expect(outcome.kind).toBe('InvalidInput'); if (outcome.kind !== 'InvalidInput') return; expect(outcome.reasons[0].ruleIds).toEqual(['loss']); expect(outcome.reasons[0].code).toMatch( /^(invalid|unsupported)-optimization-configuration$/, ); }, ); it('rejects obligation mode for an optimization and ignores a malformed disabled rule', async () => { const invalid = calculatePlanningInternal({ ...concurrent, rules: [{ ...loss, force: 'obligation' }], }); expect(invalid).toMatchObject({ kind: 'InvalidInput', reasons: [expect.objectContaining({ code: 'rule-force', ruleIds: ['loss'] })], }); const disabled = calculatePlanningInternal({ ...concurrent, rules: [{ ...loss, enabled: false, configuration: null }], }); expect(disabled).toMatchObject({ kind: 'Plan', placements: [] }); }); it('keeps direct snapshots and every explanation/comparison in the submitted active order', async () => { const configuration: OptimizationConfiguration = structuredClone( OPTIMIZATION_PRESETS.unplacedAtRisk, ); const rule = { ...loss, configuration }; const calculation = createPlanningCalculation({ ...concurrent, rules: [rule, personal] }); configuration.conditions = []; configuration.aggregation = 'count'; configuration.direction = 'maximize'; rule.name = 'Edited later'; const outcome = await calculation.outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a plan'); expect(outcome.placements[0].balanceId).toBe('risk-rtt'); expect(calculation.snapshot.rules[0]).toMatchObject({ name: 'Prevent losses', configuration: OPTIMIZATION_PRESETS.unplacedAtRisk, }); expect(Object.isFrozen(calculation.snapshot.rules[0])).toBe(true); const explanation = await calculation.explanation; if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation'); const ids = [ 'obligation-validity', 'optimization:loss', 'preference:personal', 'balance-expiry', 'placement-date', ]; expect( explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id), ).toEqual(ids); expect(explanation.placements[0].objectiveVector.objectives[1].label).toBe( 'Prevent losses', ); const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]); if (comparison.kind !== 'Counterfactual') throw new Error('Expected comparison'); expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual(ids); }); });