import { vi } from '../../testing/vitest-compat'; import { calculatePlanningAsyncInternal, calculatePlanningInternal, type OptimizationPlanningRule, type PlanOutcome, type PlanningArtifacts, type PlanningRequest, } from './leave-planner.core'; import { OPTIMIZATION_PRESETS } from './planning-objectives'; import { createPlanningCalculation } from './leave-planner.service'; const economy: OptimizationPlanningRule = { id: 'economy', name: 'Économiser les jours d’appoint', type: 'optimization', configuration: OPTIMIZATION_PRESETS.supportingPlacements, }; const gain: OptimizationPlanningRule = { id: 'gain', type: 'optimization', configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain, }; const requested: PlanningRequest['rules'][number] = { id: 'requested', type: 'time_window', startDate: '2024-01-05', endDate: '2024-01-05', placeDays: 1, placeType: 'cp', }; const weekend: PlanningRequest = { horizon: { start: '2024-01-05', end: '2024-01-10' }, balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }], rules: [], }; const bundles: { name: string; rule: OptimizationPlanningRule; request: PlanningRequest; dates: string[]; }[] = [ { name: 'complete public-holiday week', rule: { id: 'holiday', type: 'optimization', configuration: OPTIMIZATION_PRESETS.holidayWeekValue, }, request: { horizon: { start: '2026-11-09', end: '2026-11-14' }, balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2027-01-01' }], declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })), rules: [], }, dates: ['2026-11-12', '2026-11-13'], }, { name: 'non-working-day gain', rule: gain, request: weekend, dates: ['2024-01-05'], }, { name: 'maximum non-working gain of a joined period', rule: { ...gain, configuration: { ...gain.configuration, aggregation: 'maximum' }, }, request: { horizon: { start: '2024-01-05', end: '2024-01-13' }, balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }], declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })), rules: [], }, dates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11'], }, { name: 'complete short internal work sequence', rule: { id: 'deficit', type: 'optimization', configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit, }, request: { horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }], declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })), rules: [], }, dates: ['2024-01-09', '2024-01-10', '2024-01-11'], }, { name: 'tied longest work sequences', rule: { id: 'longest', type: 'optimization', configuration: OPTIMIZATION_PRESETS.longestWorkSequence, }, request: { horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-02-01' }], declaredDays: [{ date: '2024-01-10', type: 'other' }], rules: [], }, dates: ['2024-01-08', '2024-01-11'], }, ]; function plan(request: PlanningRequest): PlanOutcome { const outcome = calculatePlanningInternal(request); if (outcome.kind !== 'Plan') throw new Error(`Expected a plan, got ${outcome.kind}`); return outcome; } describe('supporting placement economy and provenance', () => { beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); afterEach(() => vi.useRealTimers()); it.each(bundles)( 'compares the whole $name bundle against economy in either order', ({ rule, request, dates }) => { const improve = plan({ ...request, rules: [rule, economy] }); const preserve = plan({ ...request, rules: [economy, rule] }); expect(improve.placements.map(({ date }) => date)).toEqual(dates); expect(improve.placements.every(({ supporting }) => supporting)).toBe(true); expect(improve.metrics.supportingPlacements).toBe(dates.length); expect(improve.remainingBalances).toEqual([]); expect(preserve.placements).toEqual([]); expect(preserve.metrics.supportingPlacements).toBe(0); expect(preserve.remainingBalances[0]).toMatchObject({ id: 'cp', quantity: request.balances[0].quantity, classification: 'deferred', reason: { code: 'preserved' }, }); }, ); it.each(bundles)( 'makes disabled economy identical to absence for the $name bundle', ({ rule, request }) => { const absent = plan({ ...request, rules: [rule] }); for (const rules of [ [{ ...economy, enabled: false }, rule], [rule, { ...economy, enabled: false }], ]) expect(plan({ ...request, rules })).toEqual(absent); }, ); it('does not add placements merely because economy is disabled or removed', () => { for (const rules of [[], [economy], [{ ...economy, enabled: false }]]) { const outcome = plan({ ...weekend, rules }); expect(outcome.placements).toEqual([]); expect(outcome.remainingBalances[0].quantity).toBe(1); } }); it('credits an earlier supporting placement to a later explicit window without counting an extra day', () => { const outcome = plan({ ...weekend, rules: [gain, economy, requested], }); expect(outcome.placements).toEqual([ expect.objectContaining({ date: '2024-01-05', balanceId: 'cp', ruleIds: ['gain', 'requested'], supporting: false, }), ]); expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 2, supportingPlacements: 0 }); expect(outcome.preferences[0].satisfied).toBe(1); }); it('exempts only the requested window quantity, including fixed declared days', () => { const outcome = plan({ ...weekend, horizon: { start: '2024-01-05', end: '2024-01-20' }, balances: [{ ...weekend.balances[0], quantity: 3 }], declaredDays: [{ date: '2024-01-09', type: 'cp' }], rules: [gain, economy, { ...requested, endDate: '2024-01-19', placeDays: 2 }], }); expect(outcome.placements.map(({ date, supporting }) => ({ date, supporting }))).toEqual([ { date: '2024-01-05', supporting: false }, { date: '2024-01-12', supporting: true }, { date: '2024-01-19', supporting: true }, ]); expect(outcome.metrics.supportingPlacements).toBe(2); expect(outcome.timeWindowContributions?.[0]).toMatchObject({ requested: 2, declaredDays: [{ date: '2024-01-09', type: 'cp' }], placementDates: ['2024-01-05'], }); }); it.each([false, true])( 'bounds explicit calendar credit before accepting further window improvements (declared: %s)', (declared) => { const windowRule = { ...requested, endDate: '2024-01-19' }; const request: PlanningRequest = { ...weekend, horizon: { start: '2024-01-05', end: '2024-01-20' }, balances: [{ ...weekend.balances[0], quantity: 4 }], declaredDays: declared ? [{ date: '2024-01-09', type: 'cp' }] : [], rules: [economy, gain, windowRule], }; const preserve = plan(request); expect(preserve.placements).toEqual( declared ? [] : [ expect.objectContaining({ date: '2024-01-05', ruleIds: ['requested'], supporting: false, }), ], ); expect(preserve.metrics.supportingPlacements).toBe(0); expect(preserve.preferences[0].satisfied).toBe(1); expect(preserve.remainingBalances[0]).toMatchObject({ id: 'cp', quantity: declared ? 4 : 3, }); const improve = plan({ ...request, rules: [gain, economy, windowRule] }); expect(improve.placements.map(({ date }) => date)).toEqual([ '2024-01-05', '2024-01-12', '2024-01-19', ]); expect( improve.placements.filter(({ ruleIds }) => ruleIds.includes('requested')), ).toHaveLength(declared ? 0 : 1); expect(improve.metrics.supportingPlacements).toBe(declared ? 3 : 2); expect( plan({ ...request, rules: [{ ...economy, enabled: false }, gain, windowRule] }), ).toEqual(plan({ ...request, rules: [gain, windowRule] })); }, ); it('restores supporting classification when a moved placement loses its explicit calendar request', () => { const outcome = plan({ ...weekend, horizon: { start: '2024-01-05', end: '2024-01-20' }, balances: [{ ...weekend.balances[0], quantity: 4 }], rules: [ gain, { ...requested, startDate: '2024-01-08', endDate: '2024-01-09' }, bundles[4].rule, economy, ], }); expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 6, longestWorkSequence: 3, supportingPlacements: 3, }); expect(outcome.placements.filter(({ supporting }) => !supporting)).toEqual([ expect.objectContaining({ date: '2024-01-08', ruleIds: ['gain', 'requested'], }), ]); }); it.each(['preference', 'obligation'] as const)( 'keeps explicitly requested deferred days outside economy (%s)', (force) => { const outcome = plan({ ...weekend, rules: [economy, { ...requested, force }, gain] }); expect(outcome.placements).toEqual([ expect.objectContaining({ date: '2024-01-05', ruleIds: ['requested'], supporting: false, }), ]); expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 2, supportingPlacements: 0, }); }, ); it('credits the complete explicit IF_THEN pattern without treating its deferred day as support', () => { const outcome = plan({ horizon: { start: '2024-05-06', end: '2024-05-11' }, balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }], rules: [ gain, economy, { id: 'thursday', type: 'if_then', dayOfWeek: 4, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 0, }, ], }); expect(outcome.placements).toEqual([ expect.objectContaining({ date: '2024-05-10', ruleIds: ['gain', 'thursday'], supporting: false, }), ]); expect(outcome.metrics.supportingPlacements).toBe(0); expect(outcome.preferences[0].satisfied).toBe(1); }); it.each(['preference', 'obligation'] as const)( 'limits IF_THEN credit to its declared producer pattern when another complete pattern improves rest (%s)', (force) => { const request: PlanningRequest = { horizon: { start: '2024-05-06', end: '2024-05-11' }, balances: [{ id: 'cp', type: 'cp', quantity: 4, expiryDate: '2025-01-01' }], declaredDays: [{ date: '2024-05-07', type: 'cp' }], rules: [ economy, gain, { id: 'producer', type: 'if_then', force, dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'auto', rewardDays: 1, rewardType: 'rtt', }, ], }; const preserve = plan(request); expect(preserve.placements).toEqual([]); expect(preserve.metrics.supportingPlacements).toBe(0); expect(preserve.remainingBalances).toEqual([ expect.objectContaining({ id: 'cp', quantity: 4 }), expect.objectContaining({ id: 'earned:producer:2024-05-08', quantity: 1, availableFrom: '2024-05-10', }), ]); expect(preserve.ifThenContributions?.[0]).toMatchObject({ declaredDays: [{ date: '2024-05-07', type: 'cp' }], placementDates: [], earnedEntitlement: { availableFrom: '2024-05-10', added: true }, }); const improve = plan({ ...request, rules: [gain, economy, request.rules[2]] }); expect(improve.placements).toEqual([ expect.objectContaining({ date: '2024-05-06', ruleIds: ['gain'], supporting: true, }), expect.objectContaining({ date: '2024-05-10', ruleIds: ['gain'], supporting: true, }), ]); expect(improve.metrics).toMatchObject({ nonWorkingDayGain: 6, supportingPlacements: 2, }); expect(improve.remainingBalances).toEqual([ expect.objectContaining({ id: 'cp', quantity: 2 }), expect.objectContaining({ id: 'earned:producer:2024-05-08', quantity: 1, availableFrom: '2024-05-13', }), ]); expect(improve.ifThenContributions?.[0]).toMatchObject({ declaredDays: [{ date: '2024-05-07', type: 'cp' }], placementDates: [], earnedEntitlement: { availableFrom: '2024-05-13', added: true }, }); expect( plan({ ...request, rules: [{ ...economy, enabled: false }, gain, request.rules[2]], }), ).toEqual(plan({ ...request, rules: [gain, request.rules[2]] })); }, ); it('uses expiry then dates when active rules tie, without restoring a removed gain objective', () => { const request: PlanningRequest = { horizon: { start: '2024-01-09', end: '2024-01-13' }, balances: [ { id: 'later', type: 'cp', quantity: 1, expiryDate: '2024-03-01' }, { id: 'earlier', type: 'cp', quantity: 1, expiryDate: '2024-02-01' }, ], rules: [economy, { ...requested, startDate: '2024-01-09', endDate: '2024-01-12' }], }; const outcome = plan(request); expect(outcome.placements).toEqual([ expect.objectContaining({ date: '2024-01-09', balanceId: 'earlier', supporting: false, }), ]); expect(outcome.metrics.nonWorkingDayGain).toBe(0); expect( plan({ ...request, rules: [{ ...gain, enabled: false }, ...request.rules] }), ).toEqual(outcome); }); it('reclassifies a deferred support addition when an eligible earlier-expiring balance replaces it', () => { const outcome = calculatePlanningInternal({ horizon: { start: '2024-05-06', end: '2024-05-11' }, balances: [ { id: 'deferred', type: 'cp', quantity: 3, expiryDate: '2025-01-01' }, { id: 'at-risk', type: 'rtt', quantity: 1, availableFrom: '2024-05-07', expiryDate: '2024-05-07', }, ], rules: [bundles[0].rule, economy], }); if (outcome.kind !== 'Plan') throw new Error('Expected a plan'); expect( outcome.placements.map(({ date, balanceId, supporting }) => ({ date, balanceId, supporting, })), ).toEqual([ { date: '2024-05-06', balanceId: 'deferred', supporting: true }, { date: '2024-05-07', balanceId: 'at-risk', supporting: false }, { date: '2024-05-10', balanceId: 'deferred', supporting: true }, ]); expect(outcome.metrics.supportingPlacements).toBe(2); expect(outcome.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 1 }); }); it('compares new deferred support when at-risk holiday placements relocate into an explicit window', async () => { const holiday = bundles[0].rule; const early: PlanningRequest['rules'][number] = { id: 'early', type: 'time_window', startDate: '2026-11-02', endDate: '2026-11-03', placeDays: 2, placeType: 'cp', }; const request: PlanningRequest = { horizon: { start: '2026-11-02', end: '2026-11-14' }, balances: [ { id: 'at-risk', type: 'cp', quantity: 2, expiryDate: '2026-11-13' }, { id: 'deferred', type: 'cp', quantity: 2, availableFrom: '2026-11-12', expiryDate: '2027-01-01', }, ], declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })), rules: [], }; const economyFirst = plan({ ...request, rules: [holiday, economy, early] }); let artifacts: PlanningArtifacts | undefined; const windowFirst = calculatePlanningInternal( { ...request, rules: [holiday, early, economy], }, (checkpoint, state) => expect(state?.placements).toEqual(checkpoint.placements), (value) => (artifacts = value), ); if (windowFirst.kind !== 'Plan') throw new Error('Expected a plan'); expect(economyFirst.placements).toEqual([ expect.objectContaining({ date: '2026-11-12', balanceId: 'at-risk', supporting: false, }), expect.objectContaining({ date: '2026-11-13', balanceId: 'at-risk', supporting: false, }), ]); expect(economyFirst.metrics.supportingPlacements).toBe(0); expect(economyFirst.preferences[0].satisfied).toBe(0); expect(economyFirst.remainingBalances[0]).toMatchObject({ id: 'deferred', quantity: 2 }); expect(windowFirst.placements).toEqual([ expect.objectContaining({ date: '2026-11-02', balanceId: 'at-risk', ruleIds: ['early'], supporting: false, }), expect.objectContaining({ date: '2026-11-03', balanceId: 'at-risk', ruleIds: ['early'], supporting: false, }), expect.objectContaining({ date: '2026-11-12', balanceId: 'deferred', ruleIds: ['holiday'], supporting: true, }), expect.objectContaining({ date: '2026-11-13', balanceId: 'deferred', ruleIds: ['holiday'], supporting: true, }), ]); expect(windowFirst.metrics.supportingPlacements).toBe(2); expect(windowFirst.preferences[0].satisfied).toBe(2); expect(windowFirst.metrics.holidayWeekValue).toBe(3); if (artifacts?.explanation?.kind !== 'PlanExplanation' || !artifacts.finalState) throw new Error('Expected matching explanation and state snapshots'); expect(artifacts.finalState.placements).toEqual(windowFirst.placements); expect(artifacts.explanation.placements.map(({ placement }) => placement)).toEqual( windowFirst.placements, ); for (const placement of artifacts.explanation.placements) { const contribution = expect.objectContaining({ kind: 'rule', ruleId: 'holiday' }); if (placement.supporting) { expect(placement.ruleIds).toEqual(['holiday']); expect(placement.contributions).toContainEqual(contribution); expect(placement.firstDecisiveObjective).toBe('optimization:holiday'); } else { expect(placement.ruleIds).toEqual(['early']); expect(placement.contributions).not.toContainEqual(contribution); } } let cancelled = false; let cancelledArtifacts: PlanningArtifacts | undefined; let published: PlanOutcome | undefined; const partial = await calculatePlanningAsyncInternal( { ...request, rules: [holiday, early, economy], progress: (stage: string) => { if (stage === 'optimization:economy') cancelled = true; }, }, (checkpoint, state) => { expect(state?.placements).toEqual(checkpoint.placements); if (checkpoint.lastCompletedStage === 'preference:early') published = checkpoint; }, (value) => (cancelledArtifacts = value), undefined, undefined, () => cancelled, ); if ( partial.kind !== 'PlanningIncomplete' || !partial.incumbent || cancelledArtifacts?.explanation?.kind !== 'PlanExplanation' ) throw new Error('Expected an explained cancelled incumbent'); expect(partial.reasons[0].code).toBe('user-cancelled'); expect(partial.incumbent.placements).toEqual(windowFirst.placements); expect(partial.incumbent.placements).toEqual(published?.placements); expect(partial.incumbent.preferences).toMatchObject([{ ruleId: 'early', satisfied: 2 }]); expect(cancelledArtifacts.finalState?.placements).toEqual(partial.incumbent.placements); expect(cancelledArtifacts.explanation.placements.map(({ placement }) => placement)).toEqual( partial.incumbent.placements, ); for (const placement of cancelledArtifacts.explanation.placements.slice(0, 2)) { expect(placement.ruleIds).toEqual(['early']); expect(placement.contributions).toContainEqual( expect.objectContaining({ kind: 'rule', ruleId: 'early' }), ); } }); it('keeps established maximum credit when a later earned period masks its local contribution', () => { let artifacts: PlanningArtifacts | undefined; const outcome = calculatePlanningInternal( { horizon: { start: '2024-05-01', end: '2024-05-11' }, balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-05-03' }], rules: [ { ...gain, configuration: { ...gain.configuration, aggregation: 'maximum' } }, { id: 'producer', type: 'if_then', dayOfWeek: 3, placement: 'after', placeDays: 1, placeType: 'cp', rewardDays: 2, rewardType: 'rtt', }, ], }, undefined, (value) => (artifacts = value), ); if (outcome.kind !== 'Plan' || artifacts?.explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explained plan'); expect(outcome.placements).toMatchObject([ { date: '2024-05-02', ruleIds: ['gain', 'producer'], supporting: false }, { date: '2024-05-03', ruleIds: ['gain'], supporting: false }, { date: '2024-05-10', ruleIds: ['gain'], supporting: true }, ]); for (const placement of artifacts.explanation.placements.slice(0, 2)) { expect(placement.comparison.delta).toContainEqual( expect.objectContaining({ id: 'optimization:gain', value: 0 }), ); expect(placement.contributions).toContainEqual( expect.objectContaining({ kind: 'rule', ruleId: 'gain' }), ); } }); it('counts reverse deferred reassignment for an edited optimization without explicit calendar credit', () => { const request = bundles[0].request; const forgoAtRisk: OptimizationPlanningRule = { id: 'forgo', type: 'optimization', configuration: { ...OPTIMIZATION_PRESETS.unplacedAtRisk, direction: 'maximize' }, }; const outcome = plan({ ...request, balances: [ { ...request.balances[0], id: 'at-risk', expiryDate: '2026-11-13' }, { ...request.balances[0], id: 'deferred' }, ], rules: [bundles[0].rule, forgoAtRisk, economy], }); expect( outcome.placements.map(({ balanceId, supporting }) => ({ balanceId, supporting })), ).toEqual([ { balanceId: 'deferred', supporting: true }, { balanceId: 'deferred', supporting: true }, ]); expect(outcome.metrics).toMatchObject({ holidayWeekValue: 3, unplacedAtRisk: 2, supportingPlacements: 2, }); }); it('keeps earned support chronological and preserves the captured economy rule in explanations and counterfactuals', async () => { vi.stubGlobal('window', undefined); try { const capturedEconomy = structuredClone(economy); const request: PlanningRequest = { horizon: { start: '2024-04-29', end: '2024-05-08' }, balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }], rules: [ gain, capturedEconomy, { id: 'producer', type: 'if_then', force: 'obligation', dayOfWeek: 3, placeDays: 1, placeType: 'cp', placement: 'after', rewardDays: 1, rewardType: 'rtt', }, ], }; const calculation = createPlanningCalculation(request); capturedEconomy.configuration.conditions = []; capturedEconomy.name = 'Changed later'; const outcome = await calculation.outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a plan'); expect(outcome.placements).toEqual([ expect.objectContaining({ date: '2024-05-02', ruleIds: ['producer'], supporting: false, }), expect.objectContaining({ date: '2024-05-06', balanceId: 'earned:producer:2024-05-01', supporting: true, }), ]); expect(outcome.metrics).toMatchObject({ nonWorkingDayGain: 3, supportingPlacements: 1, }); expect(calculation.snapshot.rules[1]).toMatchObject({ name: 'Économiser les jours d’appoint', configuration: OPTIMIZATION_PRESETS.supportingPlacements, }); const explanation = await calculation.explanation; if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation'); const supporting = explanation.placements.find(({ supporting }) => supporting)!; expect(supporting.firstDecisiveObjective).toBe('optimization:gain'); expect(supporting.objectiveVector.objectives.map(({ id }) => id)).toEqual([ 'obligation-validity', 'optimization:gain', 'optimization:economy', 'balance-expiry', 'placement-date', ]); expect(supporting.contributions).toContainEqual( expect.objectContaining({ kind: 'supporting' }), ); const analysis = await calculation.analyzeCounterfactual(outcome.placements[1]); if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual'); expect(analysis.results.find(({ date }) => date === '2024-05-03')).toMatchObject({ status: 'ineligible', reason: { code: 'unavailable' }, }); expect( analysis.results.find(({ date }) => date === '2024-05-06')?.comparison?.after .objectives, ).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 })); expect( analysis.results.find(({ date }) => date === '2024-05-07')?.comparison?.after .objectives, ).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 1 })); expect(outcome.placements[1]).toMatchObject({ date: '2024-05-06', supporting: true }); } finally { vi.unstubAllGlobals(); } }); });