Files
vac-optimizer/src/app/services/leave-planner-calendar-objectives.spec.ts
T

500 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
type OptimizationPlanningRule,
type PlanningArtifacts,
type PlanningRequest,
} from './leave-planner.core';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
const holiday: OptimizationPlanningRule = {
id: 'holiday',
name: 'Complete public-holiday weeks',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
};
const gain: OptimizationPlanningRule = {
id: 'gain',
name: 'Enjoy weekends and public holidays',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
};
const samedi: PlanningRequest['rules'][number] = {
id: 'samedi',
type: 'if_then',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
};
const concurrent: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules: [],
};
function calculate(request: PlanningRequest, calculationDate = '2024-04-01') {
let artifacts: PlanningArtifacts = {};
const plan = calculatePlanningInternal(
{ ...request, calculationDate },
undefined,
(value) => (artifacts = value),
);
if (plan.kind !== 'Plan') throw new Error(`Expected a plan, received ${plan.kind}`);
return { plan, artifacts };
}
describe('composed calendar objectives', () => {
it('follows either displayed order when a completed holiday week competes with greater natural gain', () => {
const holidayFirst = calculate({ ...concurrent, rules: [holiday, gain] }).plan;
const gainFirst = calculate({ ...concurrent, rules: [gain, holiday] }).plan;
expect(holidayFirst.placements.map((placement) => placement.date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(holidayFirst.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
expect(gainFirst.placements.map((placement) => placement.date)).toEqual([
'2024-04-29',
'2024-05-10',
'2024-05-17',
]);
expect(gainFirst.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 9 });
});
it('regroups existing placements as a complete week without leaving higher-priority at-risk leave unused', () => {
const loss: OptimizationPlanningRule = {
id: 'loss',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
};
const { plan } = calculate({
...concurrent,
balances: [{ ...concurrent.balances[0], expiryDate: '2024-05-17' }],
rules: [loss, holiday, gain],
});
expect(plan.metrics).toMatchObject({
unplacedAtRisk: 0,
holidayWeekValue: 5,
nonWorkingDayGain: 6,
});
expect(plan.placements.map((placement) => placement.date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
});
it('preserves an earlier preference that minimizes supporting placements', () => {
const { plan } = calculate({
...concurrent,
rules: [
{
id: 'economy',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
holiday,
],
});
expect(plan.placements).toEqual([]);
});
it('uses edited holiday conditions and aggregation without dispatching by a preset identity', () => {
const counted: OptimizationPlanningRule = {
...holiday,
id: 'my-complete-weeks',
configuration: { ...holiday.configuration, aggregation: 'count' },
};
const { plan, artifacts } = calculate({ ...concurrent, rules: [counted] });
expect(plan.metrics.holidayWeekValue).toBe(5);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
expect(artifacts.explanation.placements[0].objectiveVector.objectives[1]).toMatchObject({
id: 'optimization:my-complete-weeks',
value: 1,
});
const allWeeks = calculate({
...concurrent,
rules: [{ ...counted, configuration: { ...counted.configuration, conditions: [] } }],
}).plan;
expect(allWeeks.placements).toEqual([]);
});
it('attributes a coordinated holiday addition and its explanation to the active rule', () => {
const { plan, artifacts } = calculate(
{
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [
{
id: 'deferred-cp',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-01',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules: [holiday],
},
'2026-11-01',
);
expect(plan.placements).toEqual([
expect.objectContaining({ date: '2026-11-12', ruleIds: ['holiday'], supporting: true }),
expect.objectContaining({ date: '2026-11-13', ruleIds: ['holiday'], supporting: true }),
]);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
for (const placement of artifacts.explanation.placements) {
expect(placement.firstDecisiveObjective).toBe('optimization:holiday');
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'holiday' }),
);
}
});
it.each<OptimizationConfiguration>([
{ ...gain.configuration, aggregation: 'maximum' },
{ ...gain.configuration, aggregation: 'count', direction: 'minimize' },
])(
'bridges fixed periods for the personal configuration $aggregation/$direction',
(configuration) => {
const { plan, artifacts } = calculate({
horizon: { start: '2024-01-05', end: '2024-01-13' },
balances: [
{
id: 'rtt',
type: 'rtt',
quantity: 4,
availableFrom: '2024-01-05',
expiryDate: '2025-01-01',
},
],
declaredDays: ['2024-01-05', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [
{
...gain,
id: 'personal-maximum',
configuration,
},
],
});
expect(plan.placements.map((placement) => placement.date)).toEqual([
'2024-01-08',
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(plan.timeOffPeriods).toHaveLength(1);
expect(plan.timeOffPeriods[0].nonWorkingDates).toEqual([
'2024-01-06',
'2024-01-07',
'2024-01-13',
'2024-01-14',
]);
expect(plan.metrics.nonWorkingDayGain).toBe(4);
expect(
plan.placements.every(({ ruleIds }) => ruleIds.includes('personal-maximum')),
).toBe(true);
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected an explanation');
for (const placement of artifacts.explanation.placements)
expect(placement.contributions).toContainEqual(
expect.objectContaining({ kind: 'rule', ruleId: 'personal-maximum' }),
);
},
);
it.each([false, true])(
'finishes a rejected bridge with an irrelevant typed rule: %s',
(withSamedi) => {
const { plan } = calculate({
horizon: { start: '2024-01-05', end: '2024-02-06' },
balances: (['cp', 'rtt', 'other'] as const).map((type) => ({
id: type,
type,
quantity: 20,
availableFrom: '2024-01-05',
expiryDate: '2025-01-01',
})),
declaredDays: ['2024-01-05', '2024-02-05'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'economy',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
},
{
...gain,
configuration: {
...gain.configuration,
aggregation: 'count',
direction: 'minimize',
},
},
...(withSamedi ? [samedi] : []),
],
deadlineMs: 2_000,
});
expect(plan.placements).toEqual([]);
expect(plan.timeOffPeriods).toHaveLength(2);
},
);
it('does not label a period-count bridge supporting when it adds no domain calendar benefit', () => {
const { plan } = calculate({
horizon: { start: '2024-01-02', end: '2024-01-31' },
balances: [{ ...concurrent.balances[0], quantity: 5, availableFrom: '2024-01-02' }],
declaredDays: ['2024-01-05', '2024-01-15'].map((date) => ({ date, type: 'other' })),
rules: [
{
...gain,
configuration: {
...gain.configuration,
aggregation: 'count',
direction: 'minimize',
},
},
],
});
expect(plan.timeOffPeriods).toHaveLength(1);
expect(plan.placements).toHaveLength(5);
expect(plan.placements.every((placement) => !placement.supporting)).toBe(true);
expect(plan.metrics.nonWorkingDayGain).toBe(4);
});
it('can place the holiday preference before or after Samedi Malin', () => {
const request: PlanningRequest = {
horizon: { start: '2021-11-01', end: '2021-12-29' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 4,
availableFrom: '2021-11-01',
expiryDate: '2022-01-01',
},
],
rules: [],
};
const first = calculate({ ...request, rules: [holiday, samedi] }, '2021-10-01').plan;
const second = calculate({ ...request, rules: [samedi, holiday] }, '2021-10-01').plan;
expect(first.placements.map((placement) => placement.date)).toEqual([
'2021-11-08',
'2021-11-09',
'2021-11-10',
'2021-11-12',
]);
expect(first.metrics.holidayWeekValue).toBe(2);
expect(first.preferences[0].satisfied).toBe(0);
expect(second.placements.map((placement) => placement.date)).toEqual([
'2021-12-23',
'2021-12-24',
]);
expect(second.metrics.holidayWeekValue).toBe(0);
expect(second.preferences[0].satisfied).toBe(1);
expect(second.remainingBalances.find((balance) => balance.earned)).toMatchObject({
id: 'earned:samedi:2021-12-25',
quantity: 1,
});
});
it.each(['obligation', 'preference'] as const)(
'preserves a higher %s when completing a week would take its last balance',
(force) => {
const { plan } = calculate({
...concurrent,
rules: [
{
id: 'monday',
type: 'time_window',
force,
startDate: '2024-04-29',
endDate: '2024-04-29',
placeDays: 1,
placeType: 'cp',
},
holiday,
],
});
expect(plan.placements).toMatchObject([{ date: '2024-04-29', ruleIds: ['monday'] }]);
expect(plan.metrics.holidayWeekValue).toBe(0);
},
);
it.each(['obligation', 'preference'] as const)(
'keeps a lower %s only when required while completing a holiday week',
(force) => {
const { plan } = calculate({
...concurrent,
rules: [
holiday,
{
id: 'monday',
type: 'time_window',
force,
startDate: '2024-04-29',
endDate: '2024-04-29',
placeDays: 1,
placeType: 'cp',
},
],
});
if (force === 'obligation') {
expect(plan.placements).toMatchObject([
{ date: '2024-04-29', type: 'cp', ruleIds: ['monday'] },
]);
expect(plan.metrics.holidayWeekValue).toBe(0);
} else {
expect(plan.placements.map(({ date }) => date)).toEqual([
'2024-05-06',
'2024-05-07',
'2024-05-10',
]);
expect(plan.metrics.holidayWeekValue).toBe(5);
expect(plan.placements.every(({ ruleIds }) => !ruleIds.includes('monday'))).toBe(
true,
);
}
},
);
it('adds no residual loss-prevention or calendar placements when their rules are disabled', () => {
const request: PlanningRequest = {
horizon: { start: '2024-04-29', end: '2024-05-04' },
balances: [{ ...concurrent.balances[0], quantity: 5, expiryDate: '2024-05-03' }],
rules: [holiday],
};
const absent = calculate(request).plan;
const disabled = calculate({
...request,
rules: [
holiday,
{
id: 'loss',
type: 'optimization',
enabled: false,
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
},
{ ...gain, enabled: false },
],
}).plan;
expect(disabled).toEqual(absent);
expect(absent.metrics.unplacedAtRisk).toBe(1);
expect(absent.metrics.holidayWeekValue).toBe(3);
expect(absent.placements).toHaveLength(4);
expect(
calculate({ ...request, rules: [{ ...holiday, enabled: false }] }).plan.placements,
).toEqual([]);
});
it.each([
{ start: '2024-05-06', end: '2024-05-11' },
{ start: '2024-05-10', end: '2024-05-11' },
])(
'uses declarations outside the horizon to complete a genuine holiday week ($start)',
(horizon) => {
const { plan } = calculate({
horizon,
balances: [{ ...concurrent.balances[0], quantity: 1, availableFrom: '2024-05-06' }],
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
rules: [holiday],
});
expect(plan.placements).toMatchObject([{ date: '2024-05-10', supporting: true }]);
expect(plan.metrics).toMatchObject({ holidayWeekValue: 5, nonWorkingDayGain: 6 });
},
);
it('cannot fill missing weekdays outside the horizon or with unavailable balances', () => {
const request: PlanningRequest = {
horizon: { start: '2024-05-10', end: '2024-05-11' },
balances: [{ ...concurrent.balances[0], quantity: 1 }],
rules: [holiday],
};
expect(calculate(request).plan.placements).toEqual([]);
const unavailable = calculate({
...request,
balances: [{ ...request.balances[0], availableFrom: '2024-05-11' }],
declaredDays: ['2024-05-06', '2024-05-07'].map((date) => ({ date, type: 'other' })),
}).plan;
expect(unavailable.placements).toEqual([]);
expect(unavailable.metrics.holidayWeekValue).toBe(0);
});
it('measures only distinct natural dates and leaves ordinary declared weeks ordinary', () => {
const christmas = calculate({
horizon: { start: '2021-12-24', end: '2021-12-25' },
balances: [],
declaredDays: [{ date: '2021-12-24', type: 'other' }],
rules: [holiday, gain],
}).plan;
expect(christmas.metrics).toMatchObject({ holidayWeekValue: 0, nonWorkingDayGain: 2 });
expect(christmas.timeOffPeriods[0].nonWorkingDates).toEqual(['2021-12-25', '2021-12-26']);
const ordinary = calculate({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [],
declaredDays: ['08', '09', '10', '11', '12'].map((day) => ({
date: `2024-01-${day}`,
type: 'other',
})),
rules: [holiday],
}).plan;
expect(ordinary.metrics.holidayWeekValue).toBe(0);
});
it('uses the same first calendar objective in the explanation and date comparison', () => {
for (const rules of [
[holiday, gain],
[gain, holiday],
]) {
const request = { ...concurrent, rules };
const { plan, artifacts } = calculate(request);
const explanation = artifacts.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
const first = `optimization:${rules[0].id}`;
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
expect(
explanation.placements[0].objectiveVector.objectives.map(
(objective) => objective.id,
),
).toEqual([
'obligation-validity',
first,
`optimization:${rules[1].id}`,
'balance-expiry',
'placement-date',
]);
const placement = rules[0].id === 'holiday' ? plan.placements[1] : plan.placements[0];
const alternative = rules[0].id === 'holiday' ? '2024-04-30' : '2024-05-02';
const comparison = analyzeCounterfactualInternal(
request,
plan,
placement,
artifacts.finalState,
);
const original = comparison.results.find((result) => result.date === placement.date)
?.comparison?.after;
const alternativeVector = comparison.results.find(
(result) => result.date === alternative,
)?.comparison?.after;
expect(
alternativeVector?.objectives.find(
(objective, index) => objective.value !== original?.objectives[index].value,
)?.id,
).toBe(first);
}
});
});