import { describe, expect, it } from 'bun:test'; import { compareObjectiveLists, compareObjectiveValues, evaluateOptimization, firstDifferentObjective, OPTIMIZATION_PRESETS, optimizationUnit, validateOptimizationConfiguration, type OptimizationObservations, } from './planning-objectives'; const empty: OptimizationObservations = { leaveBalances: [], publicHolidayWeeks: [], timeOffPeriods: [], workSequences: [], leavePlacements: [], }; describe('composable planning objectives', () => { it('evaluates each internal non-empty sequence with its own working-day threshold', () => { const observations: OptimizationObservations = { ...empty, workSequences: [ { length: 1, internal: false }, { length: 0, internal: true }, { length: 1, internal: true }, { length: 4, internal: true }, { length: 6, internal: false }, ], }; expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe( 5, ); expect( evaluateOptimization( { ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 }, observations, ), ).toBe(2); expect(evaluateOptimization(OPTIMIZATION_PRESETS.longestWorkSequence, observations)).toBe( 6, ); }); it('evaluates all six recipes from the same concrete observations', () => { const observations: OptimizationObservations = { leaveBalances: [ { unplacedQuantity: 3, atRisk: true }, { unplacedQuantity: 8, atRisk: false }, ], publicHolidayWeeks: [ { value: 3, complete: true }, { value: 1, complete: false }, { value: 4, complete: true }, ], timeOffPeriods: [ { nonWorkingDates: ['2024-01-06', '2024-01-07'] }, { nonWorkingDates: ['2024-05-08', '2024-05-09', '2024-05-09'] }, ], workSequences: [ { length: 1, internal: false }, { length: 0, internal: true }, { length: 1, internal: true }, { length: 4, internal: true }, { length: 6, internal: false }, ], leavePlacements: [{ supporting: true }, { supporting: false }, { supporting: true }], }; expect( Object.values(OPTIMIZATION_PRESETS).map((configuration) => evaluateOptimization(configuration, observations), ), ).toEqual([3, 7, 4, 6, 5, 2]); }); it.each(['sum', 'count', 'maximum'] as const)( 'keeps %s distinct-date scores current with frozen and mutable period dates', (aggregation) => { const mutableDates = ['2024-05-08']; const observations: OptimizationObservations = { ...empty, timeOffPeriods: Object.freeze([ Object.freeze({ nonWorkingDates: Object.freeze(['2024-01-06', '2024-01-07', '2024-01-07']), }), Object.freeze({ nonWorkingDates: mutableDates }), ]), }; const configuration = { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, aggregation }; const initial = { sum: 3, count: 2, maximum: 2 }; expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]); expect(evaluateOptimization(configuration, observations)).toBe(initial[aggregation]); mutableDates.push('2024-05-09', '2024-05-09', '2024-05-11'); const updated = { sum: 5, count: 2, maximum: 3 }; expect(evaluateOptimization(configuration, observations)).toBe(updated[aggregation]); }, ); it.each(['sum', 'count', 'maximum'] as const)( 'defines empty %s as zero for every measure', (aggregation) => { for (const configuration of Object.values(OPTIMIZATION_PRESETS)) { expect(evaluateOptimization({ ...configuration, aggregation }, empty)).toBe(0); } }, ); it('composes conditions, aggregation and direction independently of a rule name or identity', () => { const configuration = { ...OPTIMIZATION_PRESETS.longestWorkSequence, conditions: ['internal', 'non-empty'] as const, aggregation: 'sum' as const, direction: 'maximize' as const, }; const observations: OptimizationObservations = { ...empty, workSequences: [ { length: 20, internal: false }, { length: 0, internal: true }, { length: 2, internal: true }, { length: 4, internal: true }, ], }; const rules = [ { id: 'provided', name: 'Original name', configuration }, { id: 'personal', name: 'Renamed rule', configuration: structuredClone(configuration) }, ]; expect(rules.map((rule) => evaluateOptimization(rule.configuration, observations))).toEqual( [6, 6], ); expect(evaluateOptimization({ ...configuration, aggregation: 'count' }, observations)).toBe( 2, ); expect( evaluateOptimization({ ...configuration, aggregation: 'maximum' }, observations), ).toBe(4); expect(optimizationUnit(configuration)).toBe('working-days'); expect(optimizationUnit({ ...configuration, aggregation: 'count' })).toBe('count'); expect(optimizationUnit(OPTIMIZATION_PRESETS.nonWorkingDayGain)).toBe('calendar-days'); }); it.each([ 0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, undefined, '3', Number.MAX_SAFE_INTEGER + 1, ])('rejects the invalid working-day deficit threshold %s', (thresholdDays) => { expect( validateOptimizationConfiguration({ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays, }), ).toMatchObject({ valid: false, code: 'invalid-optimization-configuration' }); }); it.each([ { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' }, { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, conditions: ['at-risk'] }, { ...OPTIMIZATION_PRESETS.longestWorkSequence, aggregation: 'average' }, { ...OPTIMIZATION_PRESETS.longestWorkSequence, thresholdDays: 3 }, { ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdUnit: 'calendar-days' }, ])('identifies an unsupported composition', (configuration) => { expect(validateOptimizationConfiguration(configuration)).toMatchObject({ valid: false, code: 'unsupported-optimization-configuration', }); }); it.each([ null, [undefined], [[]], { ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] }, { ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' }, { ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) }, ])('rejects malformed configurations', (configuration) => { expect(validateOptimizationConfiguration(configuration)).toMatchObject({ valid: false, code: 'invalid-optimization-configuration', }); }); it('never evaluates an unsupported configuration as a preset', () => { expect(() => evaluateOptimization( { ...OPTIMIZATION_PRESETS.nonWorkingDayGain, elements: 'work-sequences' }, empty, ), ).toThrow('unsupported-optimization-configuration'); }); it('compares ordered objectives lexicographically with the configured directions', () => { const before = [ { id: 'first', direction: 'minimize' as const, value: 3 }, { id: 'second', direction: 'maximize' as const, value: 100 }, { id: 'date', direction: 'minimize' as const, value: '2024-01-10' }, ]; const earlierPriority = [{ ...before[0], value: 2 }, { ...before[1], value: 0 }, before[2]]; expect(compareObjectiveLists(earlierPriority, before)).toBeLessThan(0); expect(firstDifferentObjective(earlierPriority, before)).toBe(0); const laterPriority = [before[0], { ...before[1], value: 101 }, before[2]]; expect(compareObjectiveLists(laterPriority, before)).toBeLessThan(0); expect(firstDifferentObjective(laterPriority, before)).toBe(1); const earlierDate = [before[0], before[1], { ...before[2], value: '2024-01-09' }]; expect(compareObjectiveLists(earlierDate, before)).toBeLessThan(0); expect(firstDifferentObjective(earlierDate, before)).toBe(2); expect(compareObjectiveLists(before, structuredClone(before))).toBe(0); expect(firstDifferentObjective(before, structuredClone(before))).toBe(-1); expect(compareObjectiveValues('maximize', 0, 0)).toBe(0); }); });