500 lines
15 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|