Files
vac-optimizer/src/app/services/leave-planner-objectives.spec.ts
T
nessar 6811142299 feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
2026-10-10 16:07:34 +02:00

187 lines
5.6 KiB
TypeScript

import { createOptimizationPresetRules } from './rule-service';
import { describe, expect, it } from 'vitest';
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
import {
evaluateOptimization,
OPTIMIZATION_PRESETS,
type OptimizationObservations,
} from './planning-objectives';
const presets = createOptimizationPresetRules();
describe('planning objective observations', () => {
it.each([
{ start: '2024-05-06', end: '2024-05-13' },
{ start: '2024-05-10', end: '2024-05-11' },
])(
'evaluates the same declared holiday week and supporting placement at horizon boundaries',
(horizon) => {
const result = calculatePlanningInternal({
horizon,
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
},
],
rules: presets,
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind !== 'Plan') return;
expect(result.placements).toEqual([
{
date: '2024-05-10',
balanceId: 'cp',
type: 'cp',
ruleIds: ['default-holiday-week-value'],
supporting: true,
},
]);
const observations: OptimizationObservations = {
leaveBalances: [{ unplacedQuantity: 0, atRisk: false }],
publicHolidayWeeks: [{ value: 5, complete: true }],
timeOffPeriods: result.timeOffPeriods,
workSequences: [
{ length: 0, internal: false },
{ length: 0, internal: false },
],
leavePlacements: result.placements,
};
expect(
Object.values(OPTIMIZATION_PRESETS).map((configuration) =>
evaluateOptimization(configuration, observations),
),
).toEqual([0, 5, 6, 0, 0, 1]);
expect(result.metrics).toMatchObject({
unplacedAtRisk: 0,
holidayWeekValue: 5,
nonWorkingDayGain: 6,
longestWorkSequence: 0,
shortSequenceDeficit: 0,
supportingPlacements: 1,
});
},
);
it('uses internal working-day deficits while retaining both horizon edges in the maximum', () => {
const result = calculatePlanningInternal({
horizon: { start: '2024-01-02', end: '2024-01-18' },
balances: [],
rules: [],
declaredDays: ['2024-01-05', '2024-01-09'].map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind !== 'Plan') return;
expect(result.metrics).toMatchObject({
longestWorkSequence: 6,
shortSequenceDeficit: 4,
nonWorkingDayGain: 2,
});
const observations: OptimizationObservations = {
leaveBalances: [],
publicHolidayWeeks: [],
timeOffPeriods: result.timeOffPeriods,
workSequences: [
{ length: 3, internal: false },
{ length: 1, internal: true },
{ length: 6, internal: false },
],
leavePlacements: result.placements,
};
expect(evaluateOptimization(OPTIMIZATION_PRESETS.shortSequenceDeficit, observations)).toBe(
4,
);
expect(
evaluateOptimization(
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, thresholdDays: 3 },
observations,
),
).toBe(2);
});
it.each([
['2024-01-01', '2024-01-06', ['2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05'], 1],
['2018-04-30', '2018-05-05', ['2018-04-30', '2018-05-02', '2018-05-03', '2018-05-04'], 2],
['2024-04-29', '2024-05-04', ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'], 3],
['2024-08-12', '2024-08-17', ['2024-08-12', '2024-08-13', '2024-08-14', '2024-08-16'], 2],
['2024-10-28', '2024-11-02', ['2024-10-28', '2024-10-29', '2024-10-30', '2024-10-31'], 1],
] as const)(
'retains the weekday holiday weight in the completed week starting %s',
(start, end, dates, value) => {
const result = calculatePlanningInternal({
horizon: { start, end },
balances: [],
rules: [],
declaredDays: dates.map((date) => ({ date, type: 'other' })),
});
expect(result.kind).toBe('Plan');
if (result.kind === 'Plan') expect(result.metrics.holidayWeekValue).toBe(value);
},
);
it('keeps the historical objective order in the explanation of a jointly valid plan', () => {
let artifacts: PlanningArtifacts | undefined;
const result = calculatePlanningInternal(
{
horizon: { start: '2024-01-02', end: '2024-01-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-01-02',
expiryDate: '2024-01-17',
},
],
rules: [
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-05',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
{
id: 'personal',
type: 'time_window',
force: 'preference',
startDate: '2024-01-09',
endDate: '2024-01-09',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
},
undefined,
(value) => (artifacts = value),
);
expect(result.kind).toBe('Plan');
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
const placement = artifacts.explanation.placements[0];
expect(placement.objectiveVector.objectives.map((objective) => objective.id)).toEqual([
'obligation-validity',
'optimization:default-unplaced-at-risk',
'preference:personal',
'optimization:default-holiday-week-value',
'optimization:default-non-working-day-gain',
'optimization:default-longest-work-sequence',
'optimization:default-short-sequence-deficit',
'optimization:default-supporting-placements',
'balance-expiry',
'placement-date',
]);
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
});
});