Files
vac-optimizer/src/app/services/planning-objectives.spec.ts
T

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);
});
});