217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
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,
|
|
[],
|
|
{ ...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);
|
|
});
|
|
});
|