Files
vac-optimizer/src/app/services/optimization-rule.spec.ts
T
nessarandClaude Opus 5.5 94d57add2c test: run the suite and benchmark with Bun's native test runner
Replace Vitest with bun test across the whole suite. A preload compiles
components through Angular's JIT transform with inlined templates, sets
up jsdom, TestBed and zoneless change detection, and a small shim covers
the Vitest APIs bun:test lacks. Files run isolated, real-worker specs
spawn the running Bun, and line coverage from lcov is converted to the
existing HTML and Cobertura reports. The CI benchmark replaces its heap
cap with a container memory limit. Vitest, Jest, Nx and their Vite
configuration are removed.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:40:42 +02:00

478 lines
16 KiB
TypeScript

import { vi } from '../../testing/vitest-compat';
import { calculatePlanningInternal } from './leave-planner.core';
import { createPlanningCalculation } from './leave-planner.service';
import type { PlanningRequest } from './leave-planner.service';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
const loss = {
id: 'loss',
name: 'Prevent losses',
type: 'optimization' as const,
force: 'preference' as const,
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
};
const personal = {
id: 'personal',
name: 'Personal CP day',
type: 'time_window' as const,
force: 'preference' as const,
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp' as const,
};
const concurrent: PlanningRequest = {
horizon: { start: '2024-01-02', end: '2024-01-03' },
balances: [
{ id: 'risk-rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
{ id: 'deferred-cp', type: 'cp', quantity: 1, expiryDate: '2024-02-01' },
],
rules: [],
};
describe('explicit active planning objectives', () => {
beforeEach(() => {
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
vi.stubGlobal('window', undefined);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('rejects a sparse selector list consistently at direct and calculation boundaries', async () => {
const request = {
...concurrent,
rules: [
{ ...loss, configuration: { ...loss.configuration, conditions: new Array(1) } },
],
};
const direct = calculatePlanningInternal(request);
const calculation = await createPlanningCalculation(request).outcome;
for (const result of [direct, calculation])
expect(result).toMatchObject({
kind: 'InvalidInput',
reasons: [{ code: 'invalid-optimization-configuration', ruleIds: ['loss'] }],
});
});
it('adds no leave without active rules and retains declared days', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'rtt', type: 'rtt', quantity: 2, expiryDate: '2024-01-04' }],
rules: [],
declaredDays: [{ date: '2024-01-03', type: 'other' }],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
expect(outcome.placements).toEqual([]);
expect(outcome.declaredDays).toEqual([{ date: '2024-01-03', type: 'other' }]);
expect(outcome.remainingBalances[0].quantity).toBe(2);
expect(outcome.remainingBalances[0]).toMatchObject({
classification: 'at-risk',
reason: { code: 'active-priorities' },
});
});
it.each([
['z-early', 'a-later'],
['a-early', 'z-later'],
])(
'compares equal expirations before dates without preferring balance identity %s',
(earlyId, laterId) => {
const result = calculatePlanningInternal({
horizon: { start: '2024-01-09', end: '2024-01-13' },
balances: [
{
id: earlyId,
type: 'cp',
quantity: 1,
availableFrom: '2024-01-09',
expiryDate: '2024-02-01',
},
{
id: laterId,
type: 'cp',
quantity: 1,
availableFrom: '2024-01-10',
expiryDate: '2024-02-01',
},
],
rules: [{ ...personal, startDate: '2024-01-09', endDate: '2024-01-12' }],
});
expect(result).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-09', balanceId: earlyId }],
});
},
);
it('follows the displayed order when loss prevention competes with a personal preference', async () => {
const first = await createPlanningCalculation({ ...concurrent, rules: [loss, personal] })
.outcome;
const second = await createPlanningCalculation({ ...concurrent, rules: [personal, loss] })
.outcome;
if (first.kind !== 'Plan' || second.kind !== 'Plan')
throw new Error('Expected valid plans');
expect(first.placements.map((placement) => placement.balanceId)).toEqual(['risk-rtt']);
expect(first.metrics.unplacedAtRisk).toBe(0);
expect(first.preferences[0].satisfied).toBe(0);
expect(second.placements.map((placement) => placement.balanceId)).toEqual(['deferred-cp']);
expect(second.metrics.unplacedAtRisk).toBe(1);
expect(second.preferences[0].satisfied).toBe(1);
});
it('keeps typed obligations valid when an optimization would prefer another balance', async () => {
const outcome = await createPlanningCalculation({
...concurrent,
rules: [{ ...personal, force: 'obligation' }, loss],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.placements[0]).toMatchObject({
type: 'cp',
balanceId: 'deferred-cp',
ruleIds: ['personal'],
});
expect(outcome.metrics.unplacedAtRisk).toBe(1);
});
it('preserves scarce expiry dates before a lower personal preference', async () => {
const outcome = await createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' },
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [loss, personal],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([
{ date: '2024-01-02', type: 'rtt' },
{ date: '2024-01-03', type: 'cp' },
]);
expect(outcome.preferences[0].satisfied).toBe(0);
});
it('can relocate a short chain before adding a balance with a scarce date', async () => {
const outcome = await createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [
{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' },
{
id: 'flexible',
type: 'rtt',
quantity: 1,
availableFrom: '2024-01-03',
expiryDate: '2024-01-04',
},
{ id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [loss, personal],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.placements.map(({ date, balanceId }) => ({ date, balanceId }))).toEqual([
{ date: '2024-01-02', balanceId: 'scarce' },
{ date: '2024-01-03', balanceId: 'cp' },
{ date: '2024-01-04', balanceId: 'flexible' },
]);
});
it.each(['2024-01-23', '2024-01-24'])(
'finishes oversubscribed balances without an eligible free endpoint through %s',
async (end) => {
const outcome = await createPlanningCalculation({
deadlineMs: 500,
horizon: { start: '2024-01-02', end },
balances: [
{ id: 'cp', type: 'cp', quantity: 7, expiryDate: '2024-01-22' },
{ id: 'rtt', type: 'rtt', quantity: 8, expiryDate: '2024-01-22' },
{ id: 'other', type: 'other', quantity: 1, expiryDate: '2024-01-22' },
],
rules: [loss],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a completed valid plan');
expect(outcome.placements).toHaveLength(15);
expect(outcome.metrics.unplacedAtRisk).toBe(1);
},
);
it('relocates repeated balance tokens across mandatory windows as a complete chain', async () => {
const windows = [
['cp-first', 'cp', '2024-01-02', '2024-01-03'],
['rtt-middle', 'rtt', '2024-01-03', '2024-01-04'],
['cp-last', 'cp', '2024-01-04', '2024-01-05'],
].map(([id, placeType, startDate, endDate]) => ({
id,
type: 'time_window' as const,
force: 'obligation' as const,
placeType,
placeDays: 1,
startDate,
endDate,
}));
const outcome = await createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-06' },
balances: [
{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-05' },
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-05' },
{ id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [...windows, loss],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.placements.map(({ date, type }) => ({ date, type }))).toEqual([
{ date: '2024-01-02', type: 'other' },
{ date: '2024-01-03', type: 'cp' },
{ date: '2024-01-04', type: 'rtt' },
{ date: '2024-01-05', type: 'cp' },
]);
});
it('relocates same-balance tokens with different mandatory provenance', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [
{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-01-04' },
{ id: 'scarce', type: 'other', quantity: 1, expiryDate: '2024-01-02' },
],
rules: [
{ ...personal, id: 'first', force: 'obligation', endDate: '2024-01-03' },
{
...personal,
id: 'second',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-04',
},
loss,
],
});
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
});
it('satisfies a lower IF_THEN pattern by reassigning its complete typed bundle', async () => {
const outcome = await createPlanningCalculation({
horizon: { start: '2027-12-20', end: '2027-12-28' },
balances: [
{ id: 'A-cp', type: 'cp', quantity: 2, expiryDate: '2027-12-27' },
{ id: 'B-rtt', type: 'rtt', quantity: 4, expiryDate: '2027-12-27' },
],
rules: [
loss,
{
id: 'samedi',
name: 'Samedi Malin',
type: 'if_then',
dayOfWeek: 6,
placeDays: 2,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'other',
},
],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.preferences[0].satisfied).toBe(1);
expect(
outcome.placements.filter(({ type }) => type === 'cp').map(({ date }) => date),
).toEqual(['2027-12-23', '2027-12-24']);
});
it('does not classify a period extension without a calendar benefit as supporting', async () => {
const base: PlanningRequest = {
horizon: { start: '2026-12-28', end: '2027-01-09' },
balances: [
{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2027-01-08' },
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2027-01-05',
expiryDate: '2027-02-01',
},
],
rules: [
{
...personal,
force: 'obligation',
startDate: '2027-01-04',
endDate: '2027-01-04',
placeType: 'rtt',
},
],
};
const before = await createPlanningCalculation(base).outcome;
const after = await createPlanningCalculation({
...base,
rules: [
...base.rules,
{ ...loss, configuration: { ...loss.configuration, conditions: [] } },
],
}).outcome;
if (before.kind !== 'Plan' || after.kind !== 'Plan')
throw new Error('Expected valid plans');
expect(after.placements.find(({ balanceId }) => balanceId === 'cp')).toMatchObject({
date: '2027-01-05',
supporting: false,
});
for (const key of [
'holidayWeekValue',
'nonWorkingDayGain',
'longestWorkSequence',
'shortSequenceDeficit',
] as const)
expect(after.metrics[key]).toBe(before.metrics[key]);
expect(after.metrics.supportingPlacements).toBe(0);
});
it('keeps joint window obligations and active priority when large previews are sampled', async () => {
const obligations = [
['2024-01-02', '2024-01-15'],
['2024-01-16', '2024-01-29'],
['2024-02-01', '2024-02-14'],
].map(([startDate, endDate], index) => ({
id: `mandatory-${index}`,
type: 'time_window' as const,
force: 'obligation' as const,
startDate,
endDate,
placeDays: 2,
placeType: 'any' as const,
}));
const request: PlanningRequest = {
horizon: { start: '2024-01-02', end: '2024-02-16' },
balances: [
{ id: 'cp', type: 'cp', quantity: 5, expiryDate: '2024-02-15' },
{ id: 'scarce', type: 'rtt', quantity: 1, expiryDate: '2024-01-02' },
{ id: 'deferred', type: 'cp', quantity: 1, expiryDate: '2024-05-01' },
],
rules: [...obligations, loss, personal],
};
const calculation = createPlanningCalculation(request);
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(outcome.preferences[0].satisfied).toBe(0);
for (const rule of obligations)
expect(
outcome.placements.filter(({ ruleIds }) => ruleIds.includes(rule.id)),
).toHaveLength(2);
const reversed = await createPlanningCalculation({
...request,
rules: [...obligations, personal, loss],
}).outcome;
if (reversed.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(reversed.preferences[0].satisfied).toBe(1);
expect(reversed.metrics.unplacedAtRisk).toBe(1);
}, 20_000);
it('allows a lower typed preference to choose the balance when loss quantities tie', async () => {
const outcome = await createPlanningCalculation({
...concurrent,
balances: concurrent.balances.map((balance) => ({
...balance,
expiryDate: '2024-01-02',
})),
rules: [loss, personal],
}).outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan');
expect(outcome.placements[0].type).toBe('cp');
expect(outcome.metrics.unplacedAtRisk).toBe(1);
expect(outcome.preferences[0].satisfied).toBe(1);
});
it('makes a disabled optimization equivalent to its absence, including all-disabled requests', async () => {
for (const otherRules of [[], [personal]]) {
const absent = await createPlanningCalculation({ ...concurrent, rules: otherRules })
.outcome;
const disabled = await createPlanningCalculation({
...concurrent,
rules: [{ ...loss, enabled: false }, ...otherRules],
}).outcome;
expect(disabled).toEqual(absent);
}
});
it.each([
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, aggregation: 'average' },
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: ['complete'] },
null,
])(
'rejects incompatible or malformed active configurations without an obligation conflict: %s',
async (configuration) => {
const outcome = calculatePlanningInternal({
...concurrent,
rules: [{ ...loss, configuration }],
});
expect(outcome.kind).toBe('InvalidInput');
if (outcome.kind !== 'InvalidInput') return;
expect(outcome.reasons[0].ruleIds).toEqual(['loss']);
expect(outcome.reasons[0].code).toMatch(
/^(invalid|unsupported)-optimization-configuration$/,
);
},
);
it('rejects obligation mode for an optimization and ignores a malformed disabled rule', async () => {
const invalid = calculatePlanningInternal({
...concurrent,
rules: [{ ...loss, force: 'obligation' }],
});
expect(invalid).toMatchObject({
kind: 'InvalidInput',
reasons: [expect.objectContaining({ code: 'rule-force', ruleIds: ['loss'] })],
});
const disabled = calculatePlanningInternal({
...concurrent,
rules: [{ ...loss, enabled: false, configuration: null }],
});
expect(disabled).toMatchObject({ kind: 'Plan', placements: [] });
});
it('keeps direct snapshots and every explanation/comparison in the submitted active order', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.unplacedAtRisk,
);
const rule = { ...loss, configuration };
const calculation = createPlanningCalculation({ ...concurrent, rules: [rule, personal] });
configuration.conditions = [];
configuration.aggregation = 'count';
configuration.direction = 'maximize';
rule.name = 'Edited later';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan');
expect(outcome.placements[0].balanceId).toBe('risk-rtt');
expect(calculation.snapshot.rules[0]).toMatchObject({
name: 'Prevent losses',
configuration: OPTIMIZATION_PRESETS.unplacedAtRisk,
});
expect(Object.isFrozen(calculation.snapshot.rules[0])).toBe(true);
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
const ids = [
'obligation-validity',
'optimization:loss',
'preference:personal',
'balance-expiry',
'placement-date',
];
expect(
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
).toEqual(ids);
expect(explanation.placements[0].objectiveVector.objectives[1].label).toBe(
'Prevent losses',
);
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (comparison.kind !== 'Counterfactual') throw new Error('Expected comparison');
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual(ids);
});
});