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>
478 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|