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>
4191 lines
122 KiB
TypeScript
4191 lines
122 KiB
TypeScript
import { createOptimizationPresetRules } from './rule-service';
|
||
import { vi } from '../../testing/vitest-compat';
|
||
import {
|
||
PlanningOutcome,
|
||
PlanningRequest,
|
||
createPlanningCalculation,
|
||
planLeave,
|
||
} from './leave-planner.service';
|
||
import type {
|
||
CounterfactualAnalysis,
|
||
PlanOutcome,
|
||
PlanningReason,
|
||
PlanningStateSnapshot,
|
||
} from './leave-planner.service';
|
||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||
|
||
const supportingEconomy = {
|
||
id: 'economy',
|
||
type: 'optimization' as const,
|
||
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
|
||
};
|
||
const nonWorkingGain = {
|
||
id: 'gain',
|
||
type: 'optimization' as const,
|
||
configuration: OPTIMIZATION_PRESETS.nonWorkingDayGain,
|
||
};
|
||
|
||
const horizon = { start: '2024-01-01', end: '2025-01-01' };
|
||
const balance = (
|
||
id: string,
|
||
type: 'cp' | 'rtt' | 'other',
|
||
quantity: number,
|
||
expiryDate = '2024-12-31',
|
||
) => ({
|
||
id,
|
||
type,
|
||
quantity,
|
||
availableFrom: '2024-01-01',
|
||
expiryDate,
|
||
});
|
||
|
||
function isPlan(outcome: PlanningOutcome) {
|
||
if (outcome.kind !== 'Plan') throw new Error(`Expected plan, got ${outcome.kind}`);
|
||
return outcome;
|
||
}
|
||
|
||
async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
return await planLeave(request);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
}
|
||
|
||
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
|
||
afterEach(() => vi.useRealTimers());
|
||
|
||
const presets = createOptimizationPresetRules();
|
||
|
||
describe('calculated work-sequence and time-off-period statistics', () => {
|
||
it('keeps fractional work means and merges consecutive declared days across a weekend', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-11' },
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: ['2024-01-05', '2024-01-08'].map((date) => ({
|
||
date,
|
||
type: 'other',
|
||
})),
|
||
}),
|
||
);
|
||
expect(result.metrics).toMatchObject({
|
||
nonWorkingDayGain: 2,
|
||
averageWorkSequence: 2.5,
|
||
longestWorkSequence: 3,
|
||
averageTimeOffPeriod: 4,
|
||
longestTimeOffPeriod: 4,
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
{
|
||
scenario: 'averages the 10, 5 and 15 working days including both horizon edges',
|
||
horizon: { start: '2024-01-08', end: '2024-02-21' },
|
||
declaredDates: ['2024-01-22', '2024-01-30'],
|
||
metrics: {
|
||
averageWorkSequence: 10,
|
||
longestWorkSequence: 15,
|
||
averageTimeOffPeriod: 2,
|
||
longestTimeOffPeriod: 3,
|
||
nonWorkingDayGain: 2,
|
||
},
|
||
},
|
||
{
|
||
scenario: 'keeps the unrounded mean of work sequences of 3, 1 and 6 days',
|
||
horizon: { start: '2024-01-02', end: '2024-01-18' },
|
||
declaredDates: ['2024-01-05', '2024-01-09'],
|
||
metrics: {
|
||
averageWorkSequence: 10 / 3,
|
||
longestWorkSequence: 6,
|
||
averageTimeOffPeriod: 2,
|
||
longestTimeOffPeriod: 3,
|
||
nonWorkingDayGain: 2,
|
||
},
|
||
},
|
||
{
|
||
scenario: 'averages one-day and two-day breaks without adding zero-length work samples',
|
||
horizon: { start: '2024-01-02', end: '2024-01-12' },
|
||
declaredDates: ['2024-01-03', '2024-01-09', '2024-01-10'],
|
||
metrics: {
|
||
averageWorkSequence: 5 / 3,
|
||
longestWorkSequence: 3,
|
||
averageTimeOffPeriod: 1.5,
|
||
longestTimeOffPeriod: 2,
|
||
nonWorkingDayGain: 0,
|
||
},
|
||
},
|
||
{
|
||
scenario: 'does not count or split work on intervening holidays and weekends',
|
||
horizon: { start: '2024-05-06', end: '2024-05-15' },
|
||
declaredDates: [],
|
||
metrics: {
|
||
averageWorkSequence: 5,
|
||
longestWorkSequence: 5,
|
||
averageTimeOffPeriod: null,
|
||
longestTimeOffPeriod: null,
|
||
nonWorkingDayGain: 0,
|
||
},
|
||
},
|
||
{
|
||
scenario:
|
||
'retains an empty work average and a measured zero when every workday is declared',
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
declaredDates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11', '2024-01-12'],
|
||
metrics: {
|
||
averageWorkSequence: null,
|
||
longestWorkSequence: 0,
|
||
averageTimeOffPeriod: 9,
|
||
longestTimeOffPeriod: 9,
|
||
nonWorkingDayGain: 4,
|
||
},
|
||
},
|
||
{
|
||
scenario: 'distinguishes no samples from zero gain in a weekend-only horizon',
|
||
horizon: { start: '2024-01-06', end: '2024-01-08' },
|
||
declaredDates: [],
|
||
metrics: {
|
||
averageWorkSequence: null,
|
||
longestWorkSequence: 0,
|
||
averageTimeOffPeriod: null,
|
||
longestTimeOffPeriod: null,
|
||
nonWorkingDayGain: 0,
|
||
},
|
||
},
|
||
{
|
||
scenario:
|
||
'counts Christmas on a Saturday once alongside its Sunday in a declared break',
|
||
horizon: { start: '2021-12-20', end: '2021-12-28' },
|
||
declaredDates: ['2021-12-24'],
|
||
metrics: {
|
||
averageWorkSequence: 2.5,
|
||
longestWorkSequence: 4,
|
||
averageTimeOffPeriod: 3,
|
||
longestTimeOffPeriod: 3,
|
||
nonWorkingDayGain: 2,
|
||
},
|
||
},
|
||
])('$scenario', async ({ horizon, declaredDates, metrics }) => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon,
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: declaredDates.map((date) => ({ date, type: 'other' })),
|
||
}),
|
||
);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.metrics).toMatchObject(metrics);
|
||
});
|
||
});
|
||
|
||
describe('declared holiday patterns and captured entitlement date', () => {
|
||
const producer = {
|
||
id: 'producer',
|
||
type: 'if_then' as const,
|
||
force: 'obligation' as const,
|
||
dayOfWeek: 3,
|
||
placement: 'before' as const,
|
||
placeDays: 2,
|
||
placeType: 'cp' as const,
|
||
rewardDays: 1,
|
||
rewardType: 'other' as const,
|
||
};
|
||
const fixed = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' as const }));
|
||
const request = {
|
||
horizon: { start: '2024-04-29', end: '2024-05-08' },
|
||
balances: [],
|
||
rules: [producer],
|
||
declaredDays: fixed,
|
||
};
|
||
|
||
it.each([
|
||
['before', ['2024-04-29', '2024-04-30']],
|
||
['after', ['2024-05-02', '2024-05-03']],
|
||
['around', ['2024-04-30', '2024-05-02']],
|
||
['auto', ['2024-04-29', '2024-04-30']],
|
||
] as const)(
|
||
'credits the normal %s pattern, fully declared or mixed, without duplicate TIME_WINDOW debits',
|
||
async (placement, dates) => {
|
||
for (const mixed of [false, true]) {
|
||
const declaredDays = (mixed ? dates.slice(0, 1) : dates).map((date) => ({
|
||
date,
|
||
type: 'cp' as const,
|
||
}));
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
declaredDays,
|
||
balances: mixed ? [balance('cp', 'cp', 1, '2025-12-31')] : [],
|
||
rules: [
|
||
{ ...producer, placement, rewardDays: 0 },
|
||
{
|
||
id: 'shared',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: dates[0],
|
||
endDate: dates[1],
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements.map(({ date }) => date)).toEqual(mixed ? [dates[1]] : []);
|
||
expect(
|
||
result.placements.every(
|
||
({ ruleIds }) => ruleIds.includes('producer') && ruleIds.includes('shared'),
|
||
),
|
||
).toBe(true);
|
||
expect(result.ifThenContributions).toMatchObject([
|
||
{
|
||
occurrence: '2024-05-01',
|
||
declaredDays,
|
||
placementDates: mixed ? [dates[1]] : [],
|
||
},
|
||
]);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
}
|
||
},
|
||
);
|
||
|
||
it.each(['cp', 'rtt', 'other', 'any'] as const)(
|
||
'accepts compatible %s declarations in an entirely fixed preference',
|
||
async (placeType) => {
|
||
const declaredDays = fixed.map((day, index) => ({
|
||
...day,
|
||
type:
|
||
placeType === 'any'
|
||
? index
|
||
? ('other' as const)
|
||
: ('rtt' as const)
|
||
: placeType,
|
||
}));
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
declaredDays,
|
||
rules: [{ ...producer, force: 'preference', placeType }],
|
||
}),
|
||
);
|
||
expect(result.preferences).toMatchObject([
|
||
{ ruleId: 'producer', satisfied: 1, completeOccurrences: 1 },
|
||
]);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.remainingBalances).toMatchObject([
|
||
{
|
||
id: 'earned:producer:2024-05-01',
|
||
quantity: 1,
|
||
availableFrom: '2024-05-02',
|
||
expiryDate: '2025-05-02',
|
||
},
|
||
]);
|
||
},
|
||
);
|
||
|
||
it('does not skip an incompatible normal position, complete a partial preference, or invent a holiday', async () => {
|
||
const declaredDays = [fixed[0], { date: '2024-04-30', type: 'rtt' as const }];
|
||
const conflict = await runInNonBrowser({
|
||
...request,
|
||
declaredDays,
|
||
balances: [balance('cp', 'cp', 3)],
|
||
});
|
||
expect(conflict.kind).toBe('PlanningConflict');
|
||
if (conflict.kind === 'PlanningConflict')
|
||
expect(conflict.conflictingRuleIds).toContain('producer');
|
||
const preference = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
declaredDays,
|
||
rules: [{ ...producer, force: 'preference' }],
|
||
}),
|
||
);
|
||
expect(preference.preferences[0].satisfied).toBe(0);
|
||
expect(preference.remainingBalances).toEqual([]);
|
||
const noHoliday = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
horizon: { start: '2024-05-13', end: '2024-05-17' },
|
||
declaredDays: fixed.map(({ type }, index) => ({
|
||
type,
|
||
date: `2024-05-${13 + index}`,
|
||
})),
|
||
}),
|
||
);
|
||
expect(noHoliday.ifThenContributions).toBeUndefined();
|
||
expect(noHoliday.remainingBalances).toEqual([]);
|
||
expect(
|
||
(
|
||
await runInNonBrowser({
|
||
...request,
|
||
horizon: { start: '2024-04-30', end: '2024-05-08' },
|
||
})
|
||
).kind,
|
||
).toBe('PlanningConflict');
|
||
});
|
||
|
||
it('reserves only the unfilled positions of future IF_THEN preferences', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
balances: [
|
||
balance('cp', 'cp', 1, '2025-12-31'),
|
||
balance('rtt', 'rtt', 1, '2025-06-01'),
|
||
],
|
||
rules: [
|
||
{
|
||
id: 'any-first',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-05-06',
|
||
endDate: '2024-05-06',
|
||
placeDays: 1,
|
||
placeType: 'any',
|
||
},
|
||
{ ...producer, force: 'preference', rewardDays: 0 },
|
||
{
|
||
id: 'rtt-last',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-05-07',
|
||
endDate: '2024-05-07',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1, 1]);
|
||
expect(result.placements).toMatchObject([
|
||
{ date: '2024-05-06', type: 'cp' },
|
||
{ date: '2024-05-07', type: 'rtt' },
|
||
]);
|
||
});
|
||
|
||
it('requires every real occurrence and creates each rule/occurrence entitlement only once', async () => {
|
||
const submitted = {
|
||
...request,
|
||
horizon: { start: '2024-04-29', end: '2024-05-10' },
|
||
rules: [
|
||
{ ...producer, placeDays: 1 },
|
||
{ ...producer, id: 'second', placeDays: 1 },
|
||
],
|
||
declaredDays: [
|
||
{ date: '2024-04-30', type: 'cp' as const },
|
||
{ date: '2024-05-07', type: 'cp' as const },
|
||
],
|
||
};
|
||
const result = isPlan(await runInNonBrowser(submitted));
|
||
expect(result.remainingBalances.map(({ id }) => id).sort()).toEqual([
|
||
'earned:producer:2024-05-01',
|
||
'earned:producer:2024-05-08',
|
||
'earned:second:2024-05-01',
|
||
'earned:second:2024-05-08',
|
||
]);
|
||
expect(result.remainingBalances.every(({ quantity }) => quantity === 1)).toBe(true);
|
||
expect(
|
||
(
|
||
await runInNonBrowser({
|
||
...submitted,
|
||
declaredDays: submitted.declaredDays.slice(0, 1),
|
||
})
|
||
).kind,
|
||
).toBe('PlanningConflict');
|
||
});
|
||
|
||
it.each([
|
||
['2024-05-01', true],
|
||
['2024-05-02', false],
|
||
['2024-05-03', false],
|
||
] as const)(
|
||
'adds only strictly future rights on %s, also for newly placed historical patterns',
|
||
async (today, added) => {
|
||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||
for (const declared of [true, false]) {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
declaredDays: declared ? fixed : [],
|
||
balances: declared ? [] : [balance('cp', 'cp', 2, '2025-12-31')],
|
||
}),
|
||
);
|
||
expect(result.calculationDate).toBe(today);
|
||
expect(result.remainingBalances.filter(({ earned }) => earned)).toHaveLength(
|
||
added ? 1 : 0,
|
||
);
|
||
expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
||
added,
|
||
availableFrom: '2024-05-02',
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
['2024-01-01', '2024-05-03'],
|
||
['2024-05-03', '2024-05-03'],
|
||
['2024-05-03', '2024-04-29'],
|
||
])(
|
||
'reconciles an externally funded extension on %s with consumer starting %s and rejects a self-financing extension',
|
||
async (today, consumerStart) => {
|
||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||
const extender = {
|
||
id: 'extend',
|
||
type: 'time_window' as const,
|
||
force: 'obligation' as const,
|
||
startDate: '2024-05-02',
|
||
endDate: '2024-05-02',
|
||
placeDays: 1,
|
||
placeType: 'cp' as const,
|
||
};
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }],
|
||
balances: [balance('cp', 'cp', 1, '2025-12-31')],
|
||
rules: [
|
||
producer,
|
||
extender,
|
||
{
|
||
id: 'later',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: consumerStart,
|
||
endDate: '2024-05-07',
|
||
placeDays: 2,
|
||
placeType: 'other',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
||
availableFrom: '2024-05-06',
|
||
expiryDate: '2025-05-06',
|
||
});
|
||
expect(
|
||
result.placements.find(({ balanceId }) => balanceId.startsWith('earned:'))?.date,
|
||
).toBe('2024-05-07');
|
||
const impossible = await runInNonBrowser({
|
||
...request,
|
||
declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }],
|
||
rules: [producer, { ...extender, placeType: 'other' }],
|
||
});
|
||
expect(impossible.kind).toBe('PlanningConflict');
|
||
},
|
||
);
|
||
|
||
it.each(['2024-04-29', '2025-05-07'])(
|
||
'reconciles a future entitlement expiry before its consumer starting %s',
|
||
async (consumerStart) => {
|
||
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
|
||
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
|
||
const { addPlanningDays } = await import('./planning-date');
|
||
const declaredDays: import('./leave-planner.core').DeclaredNonWorkingDay[] = [...fixed];
|
||
for (let date = '2024-05-03'; date <= '2025-05-05'; date = addPlanningDays(date, 1)) {
|
||
if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'rtt' });
|
||
}
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
horizon: { start: '2024-04-29', end: '2025-05-08' },
|
||
declaredDays,
|
||
balances: [balance('cp', 'cp', 1, '2025-12-31')],
|
||
rules: [
|
||
{ ...producer, force: 'preference' },
|
||
{
|
||
id: 'consumer',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: consumerStart,
|
||
endDate: '2025-05-07',
|
||
placeDays: 1,
|
||
placeType: 'other',
|
||
},
|
||
{
|
||
id: 'extend',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-02',
|
||
endDate: '2024-05-02',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements).toMatchObject([
|
||
{ date: '2024-05-02', balanceId: 'cp' },
|
||
{
|
||
date: '2025-05-07',
|
||
balanceId: 'earned:producer:2024-05-01',
|
||
ruleIds: ['consumer'],
|
||
},
|
||
]);
|
||
expect(
|
||
result.ifThenContributions?.find(
|
||
({ ruleId, occurrence }) =>
|
||
ruleId === 'producer' && occurrence === '2024-05-01',
|
||
)?.earnedEntitlement,
|
||
).toMatchObject({ availableFrom: '2025-05-06', expiryDate: '2026-05-06', added: true });
|
||
},
|
||
);
|
||
|
||
it('funds a later IF_THEN obligation after an obligatory window extends a reported generator', async () => {
|
||
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
horizon: { start: '2024-04-29', end: '2024-05-11' },
|
||
declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }],
|
||
balances: [balance('cp', 'cp', 1, '2025-12-31')],
|
||
rules: [
|
||
{ ...producer, force: 'preference' },
|
||
{
|
||
id: 'extend',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-02',
|
||
endDate: '2024-05-02',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'consumer',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 4,
|
||
placement: 'after',
|
||
placeDays: 1,
|
||
placeType: 'other',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements).toMatchObject([
|
||
{ date: '2024-05-02', balanceId: 'cp', ruleIds: ['extend'] },
|
||
{ date: '2024-05-10', balanceId: 'earned:producer:2024-05-01', ruleIds: ['consumer'] },
|
||
]);
|
||
expect(
|
||
result.ifThenContributions?.find(
|
||
({ ruleId, occurrence }) => ruleId === 'producer' && occurrence === '2024-05-01',
|
||
)?.earnedEntitlement,
|
||
).toMatchObject({ availableFrom: '2024-05-06', added: true });
|
||
});
|
||
|
||
it('compares a generating preference with an ordinary obligation solution before spending higher-priority leave', async () => {
|
||
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-29', end: '2024-05-07' },
|
||
declaredDays: [{ date: '2024-04-30', type: 'rtt' }],
|
||
balances: [
|
||
balance('cp', 'cp', 1, '2025-12-31'),
|
||
balance('rtt', 'rtt', 1, '2025-12-31'),
|
||
],
|
||
rules: [
|
||
{
|
||
id: 'mandatory',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-06',
|
||
endDate: '2024-05-06',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'first',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-04-29',
|
||
endDate: '2024-04-29',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
...producer,
|
||
force: 'preference',
|
||
placement: 'around',
|
||
placeType: 'rtt',
|
||
rewardType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1]);
|
||
expect(result.placements.find(({ date }) => date === '2024-04-29')?.balanceId).toBe('cp');
|
||
expect(result.placements.find(({ date }) => date === '2024-05-06')?.balanceId).toBe(
|
||
'earned:producer:2024-05-01',
|
||
);
|
||
expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
|
||
availableFrom: '2024-05-03',
|
||
added: true,
|
||
});
|
||
});
|
||
|
||
it.each([false, true])(
|
||
'chooses between competing generators in the original preference order (reversed: %s)',
|
||
async (reversed) => {
|
||
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
|
||
const producers = [
|
||
{ ...producer, id: 'before', force: 'preference' as const },
|
||
{
|
||
...producer,
|
||
id: 'after',
|
||
force: 'preference' as const,
|
||
placement: 'after' as const,
|
||
},
|
||
];
|
||
if (reversed) producers.reverse();
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-29', end: '2024-05-11' },
|
||
declaredDays: [
|
||
{ date: '2024-04-29', type: 'cp' },
|
||
{ date: '2024-05-03', type: 'cp' },
|
||
{ date: '2024-05-06', type: 'other' },
|
||
],
|
||
balances: [balance('cp', 'cp', 1, '2025-12-31')],
|
||
rules: [
|
||
{
|
||
id: 'consumer',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-10',
|
||
endDate: '2024-05-10',
|
||
placeDays: 1,
|
||
placeType: 'other',
|
||
},
|
||
...producers,
|
||
],
|
||
}),
|
||
);
|
||
expect(
|
||
result.preferences.map(({ ruleId, satisfied }) => ({ ruleId, satisfied })),
|
||
).toEqual([
|
||
{ ruleId: producers[0].id, satisfied: 1 },
|
||
{ ruleId: producers[1].id, satisfied: 0 },
|
||
]);
|
||
expect(result.placements.find(({ type }) => type === 'cp')?.date).toBe(
|
||
reversed ? '2024-05-02' : '2024-04-30',
|
||
);
|
||
expect(result.placements.find(({ date }) => date === '2024-05-10')?.balanceId).toBe(
|
||
`earned:${producers[0].id}:2024-05-01`,
|
||
);
|
||
expect(
|
||
result.ifThenContributions?.filter(({ earnedEntitlement }) => earnedEntitlement),
|
||
).toHaveLength(1);
|
||
},
|
||
);
|
||
|
||
it('keeps a future right usable on its expiry date and excludes comparisons before availability or after expiry', async () => {
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
...request,
|
||
horizon: { start: '2024-04-29', end: '2025-05-07' },
|
||
declaredDays: [fixed[1]],
|
||
rules: [
|
||
{ ...producer, force: 'preference', placeDays: 1 },
|
||
{
|
||
id: 'expiry-consumer',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2025-05-02',
|
||
endDate: '2025-05-02',
|
||
placeDays: 1,
|
||
placeType: 'other',
|
||
},
|
||
],
|
||
});
|
||
const result = isPlan(await calculation.outcome);
|
||
const placement = result.placements.find(
|
||
({ balanceId }) => balanceId === 'earned:producer:2024-05-01',
|
||
);
|
||
expect(placement?.date).toBe('2025-05-02');
|
||
if (!placement)
|
||
throw new Error('Expected the earned entitlement to fund its last valid date.');
|
||
const comparison = await calculation.analyzeCounterfactual(placement);
|
||
if (comparison.kind !== 'Counterfactual') throw new Error('Expected date comparisons.');
|
||
expect(comparison.results.find(({ date }) => date === '2024-04-29')?.reason?.code).toBe(
|
||
'unavailable',
|
||
);
|
||
expect(comparison.results.find(({ date }) => date === '2025-05-02')?.status).toBe(
|
||
'eligible',
|
||
);
|
||
expect(comparison.results.find(({ date }) => date === '2025-05-05')?.reason?.code).toBe(
|
||
'expired',
|
||
);
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
|
||
it.each(['worker-unavailable', 'deadline', 'validation'])(
|
||
'keeps the launch date in %s outcomes after midnight',
|
||
async (failure) => {
|
||
vi.stubGlobal('Worker', undefined);
|
||
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
|
||
const calculation = createPlanningCalculation({
|
||
...request,
|
||
...(failure === 'deadline' ? { deadlineMs: -1 } : {}),
|
||
...(failure === 'validation' ? { balances: [balance('invalid', 'cp', -1)] } : {}),
|
||
});
|
||
vi.setSystemTime(new Date('2024-05-01T22:00:01Z'));
|
||
expect(calculation.snapshot.calculationDate).toBe('2024-05-01');
|
||
expect(await calculation.outcome).toMatchObject({
|
||
calculationDate: '2024-05-01',
|
||
kind: failure === 'worker-unavailable' ? 'PlanningIncomplete' : 'InvalidInput',
|
||
});
|
||
vi.unstubAllGlobals();
|
||
},
|
||
);
|
||
it('includes declared context beyond the horizon in the complete generator period', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
horizon: { start: '2024-04-29', end: '2024-05-02' },
|
||
declaredDays: [
|
||
...fixed,
|
||
{ date: '2024-05-02', type: 'rtt' },
|
||
{ date: '2024-05-03', type: 'other' },
|
||
],
|
||
}),
|
||
);
|
||
expect(result.remainingBalances).toMatchObject([
|
||
{ availableFrom: '2024-05-06', expiryDate: '2025-05-06' },
|
||
]);
|
||
expect(result.timeOffPeriods).toMatchObject([
|
||
{
|
||
end: '2024-05-05',
|
||
declaredDates: ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'],
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('captures Paris before the direct import wait and keeps snapshot, late artifacts and comparisons on that date', async () => {
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
|
||
const calculation = createPlanningCalculation({
|
||
...request,
|
||
rules: [
|
||
producer,
|
||
{
|
||
id: 'later',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-03',
|
||
endDate: '2024-05-03',
|
||
placeDays: 1,
|
||
placeType: 'other',
|
||
},
|
||
],
|
||
});
|
||
expect(calculation.snapshot.calculationDate).toBe('2024-05-01');
|
||
vi.setSystemTime(new Date('2024-05-01T22:00:01Z'));
|
||
const result = isPlan(await calculation.outcome);
|
||
expect(result.calculationDate).toBe('2024-05-01');
|
||
expect(await calculation.explanation).toMatchObject({
|
||
calculationDate: '2024-05-01',
|
||
declaredDays: fixed,
|
||
ifThenContributions: result.ifThenContributions,
|
||
});
|
||
const comparison = await calculation.analyzeCounterfactual(result.placements[0]);
|
||
expect(comparison.kind).toBe('Counterfactual');
|
||
if (comparison.kind === 'Counterfactual') {
|
||
expect(
|
||
comparison.results.find(({ date }) => date === '2024-04-30')?.reason?.code,
|
||
).toBe('declared-day');
|
||
expect(comparison.results.find(({ date }) => date === '2024-05-02')?.status).toBe(
|
||
'ineligible',
|
||
);
|
||
expect(comparison.results.find(({ date }) => date === '2024-05-03')?.status).toBe(
|
||
'eligible',
|
||
);
|
||
}
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('planLeave', () => {
|
||
it.each([
|
||
['obligation', false],
|
||
['obligation', true],
|
||
['preference', false],
|
||
['preference', true],
|
||
] as const)(
|
||
'uses declared holiday-pattern CP to earn future Other leave for the remaining TIME_WINDOW day (producer: %s, mixed: %s)',
|
||
async (force, mixed) => {
|
||
vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
|
||
try {
|
||
const declaredDays = [
|
||
...(mixed ? [] : [{ date: '2024-04-29', type: 'cp' as const }]),
|
||
{ date: '2024-04-30', type: 'cp' as const },
|
||
{ date: '2024-05-06', type: 'other' as const },
|
||
];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-29', end: '2024-05-07' },
|
||
balances: mixed ? [balance('cp', 'cp', 1, '2025-12-31')] : [],
|
||
declaredDays,
|
||
rules: [
|
||
{
|
||
id: 'producer',
|
||
type: 'if_then',
|
||
force,
|
||
dayOfWeek: 3,
|
||
placement: 'before',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
rewardDays: 1,
|
||
rewardType: 'other',
|
||
},
|
||
{
|
||
id: 'consumer',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-03',
|
||
endDate: '2024-05-06',
|
||
placeDays: 2,
|
||
placeType: 'other',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.placements.filter(({ type }) => type === 'other')).toEqual([
|
||
{
|
||
date: '2024-05-03',
|
||
type: 'other',
|
||
balanceId: 'earned:producer:2024-05-01',
|
||
ruleIds: ['consumer'],
|
||
supporting: false,
|
||
},
|
||
]);
|
||
expect(result.placements.filter(({ type }) => type === 'cp')).toHaveLength(
|
||
mixed ? 1 : 0,
|
||
);
|
||
expect(result.remainingBalances).toEqual([]);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
},
|
||
);
|
||
|
||
it.each(['obligation', 'preference'] as const)(
|
||
'completes five CP days with four remaining CP and a fixed Wednesday CP (%s)',
|
||
async (force) => {
|
||
const declaredDays = [{ date: '2024-01-10', type: 'cp' as const }];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [balance('cp', 'cp', 4, '2024-01-12')],
|
||
declaredDays,
|
||
rules: [
|
||
{
|
||
id: 'week',
|
||
type: 'time_window',
|
||
force,
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-12',
|
||
placeDays: 5,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.placements.map(({ date }) => date)).toEqual([
|
||
'2024-01-08',
|
||
'2024-01-09',
|
||
'2024-01-11',
|
||
'2024-01-12',
|
||
]);
|
||
expect(result.placements.every(({ ruleIds }) => ruleIds.includes('week'))).toBe(true);
|
||
expect(result.remainingBalances).toEqual([]);
|
||
expect(result.timeWindowContributions).toEqual([
|
||
{
|
||
ruleId: 'week',
|
||
label: 'week',
|
||
requested: 5,
|
||
declaredDays,
|
||
placementDates: result.placements.map(({ date }) => date),
|
||
},
|
||
]);
|
||
if (force === 'preference')
|
||
expect(result.preferences).toMatchObject([{ requested: 5, satisfied: 5 }]);
|
||
},
|
||
);
|
||
|
||
it.each(['cp', 'rtt', 'other'] as const)(
|
||
'shares a fixed %s day between typed and ANY obligations and preferences without a balance',
|
||
async (type) => {
|
||
const declaredDays = [{ date: '2024-01-10', type }];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [],
|
||
declaredDays,
|
||
rules: (['obligation', 'preference'] as const).flatMap((force) =>
|
||
([type, 'any'] as const).map((placeType) => ({
|
||
id: `${force}-${placeType}`,
|
||
type: 'time_window',
|
||
force,
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-12',
|
||
placeDays: 1,
|
||
placeType,
|
||
})),
|
||
),
|
||
}),
|
||
);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.remainingBalances).toEqual([]);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1]);
|
||
expect(result.metrics.preferenceSatisfaction).toEqual([1, 1]);
|
||
expect(result.timeWindowContributions).toHaveLength(4);
|
||
for (const contribution of result.timeWindowContributions ?? [])
|
||
expect(contribution).toMatchObject({
|
||
requested: 1,
|
||
declaredDays,
|
||
placementDates: [],
|
||
});
|
||
},
|
||
);
|
||
|
||
it('caps each rule contribution at its quantity while preserving every surplus declaration', async () => {
|
||
const declaredDays = [
|
||
{ date: '2024-01-10', type: 'cp' as const },
|
||
{ date: '2024-01-08', type: 'cp' as const },
|
||
{ date: '2024-01-09', type: 'cp' as const },
|
||
];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [],
|
||
declaredDays,
|
||
rules: [
|
||
{ id: 'exact', force: 'obligation', placeDays: 2, placeType: 'cp' },
|
||
{ id: 'preferred', force: 'preference', placeDays: 1, placeType: 'any' },
|
||
].map((rule) => ({
|
||
...rule,
|
||
type: 'time_window',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-12',
|
||
})),
|
||
}),
|
||
);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.preferences).toMatchObject([{ requested: 1, satisfied: 1 }]);
|
||
expect(result.timeWindowContributions).toMatchObject([
|
||
{
|
||
ruleId: 'exact',
|
||
requested: 2,
|
||
declaredDays: [declaredDays[1], declaredDays[2]],
|
||
placementDates: [],
|
||
},
|
||
{
|
||
ruleId: 'preferred',
|
||
requested: 1,
|
||
declaredDays: [declaredDays[1]],
|
||
placementDates: [],
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('credits inclusive window endpoints only inside the submitted half-open horizon', async () => {
|
||
const declaredDays = ['08', '09', '10', '11', '12'].map((day) => ({
|
||
date: `2024-01-${day}`,
|
||
type: 'cp' as const,
|
||
}));
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-09', end: '2024-01-12' },
|
||
balances: [],
|
||
declaredDays,
|
||
rules: [
|
||
{
|
||
id: 'left',
|
||
force: 'obligation',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-10',
|
||
placeDays: 2,
|
||
},
|
||
{
|
||
id: 'right',
|
||
force: 'obligation',
|
||
startDate: '2024-01-10',
|
||
endDate: '2024-01-12',
|
||
placeDays: 2,
|
||
},
|
||
{
|
||
id: 'before',
|
||
force: 'preference',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-08',
|
||
placeDays: 1,
|
||
},
|
||
{
|
||
id: 'after',
|
||
force: 'preference',
|
||
startDate: '2024-01-12',
|
||
endDate: '2024-01-12',
|
||
placeDays: 1,
|
||
},
|
||
].map((rule) => ({ ...rule, type: 'time_window', placeType: 'cp' })),
|
||
}),
|
||
);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([0, 0]);
|
||
expect(
|
||
result.timeWindowContributions?.map(({ declaredDays }) =>
|
||
declaredDays.map(({ date }) => date),
|
||
),
|
||
).toEqual([
|
||
['2024-01-09', '2024-01-10'],
|
||
['2024-01-10', '2024-01-11'],
|
||
]);
|
||
});
|
||
|
||
it('keeps an incompatible fixed RTT while placing additional CP on other dates', async () => {
|
||
const declaredDays = [{ date: '2024-01-10', type: 'rtt' as const }];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [balance('cp', 'cp', 3, '2024-01-12')],
|
||
declaredDays,
|
||
rules: [
|
||
{
|
||
id: 'cp-only',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-12',
|
||
placeDays: 3,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.placements).toHaveLength(3);
|
||
expect(
|
||
result.placements.every(({ date, type }) => date !== '2024-01-10' && type === 'cp'),
|
||
).toBe(true);
|
||
expect(result.remainingBalances).toEqual([]);
|
||
expect(result.timeWindowContributions).toBeUndefined();
|
||
});
|
||
|
||
it('preserves preference order when a partially declared higher preference needs the shared date', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [
|
||
balance('cp', 'cp', 1, '2025-01-01'),
|
||
balance('rtt', 'rtt', 1, '2025-01-01'),
|
||
],
|
||
declaredDays: [{ date: '2024-01-10', type: 'cp' }],
|
||
rules: [
|
||
{
|
||
id: 'higher-cp',
|
||
startDate: '2024-01-10',
|
||
endDate: '2024-01-11',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'lower-rtt',
|
||
startDate: '2024-01-11',
|
||
endDate: '2024-01-11',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
].map((rule) => ({ ...rule, type: 'time_window', force: 'preference' })),
|
||
}),
|
||
);
|
||
expect(result.preferences).toMatchObject([
|
||
{ ruleId: 'higher-cp', requested: 2, satisfied: 2 },
|
||
{
|
||
ruleId: 'lower-rtt',
|
||
requested: 1,
|
||
satisfied: 0,
|
||
reason: { code: 'higher-priority' },
|
||
},
|
||
]);
|
||
expect(result.placements).toContainEqual(
|
||
expect.objectContaining({ date: '2024-01-11', type: 'cp', ruleIds: ['higher-cp'] }),
|
||
);
|
||
expect(result.declaredDays).toEqual([{ date: '2024-01-10', type: 'cp' }]);
|
||
});
|
||
|
||
it('keeps the joint search exhaustive when fixed credits leave a small number of new placements', async () => {
|
||
const core = await import('./leave-planner.core');
|
||
const result = isPlan(
|
||
core.calculatePlanningInternal({
|
||
horizon: { start: '2024-01-08', end: '2024-04-17' },
|
||
balances: [
|
||
balance('cp', 'cp', 1, '2025-01-01'),
|
||
balance('rtt', 'rtt', 1, '2025-01-01'),
|
||
],
|
||
declaredDays: [
|
||
{ date: '2024-01-08', type: 'cp' },
|
||
{ date: '2024-01-09', type: 'rtt' },
|
||
],
|
||
rules: ['cp', 'rtt'].map((placeType) => ({
|
||
id: placeType,
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-04-16',
|
||
placeDays: 2,
|
||
placeType,
|
||
})),
|
||
}),
|
||
);
|
||
expect(result.placements).toHaveLength(2);
|
||
expect(result.placements.map(({ type }) => type).sort()).toEqual(['cp', 'rtt']);
|
||
expect(
|
||
result.timeWindowContributions?.map(({ declaredDays }) => declaredDays.length),
|
||
).toEqual([1, 1]);
|
||
});
|
||
|
||
it('does not explain a fixed contribution as a free date when a higher typed preference occupies the rest of the window', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [
|
||
balance('cp', 'cp', 1, '2025-01-01'),
|
||
balance('rtt', 'rtt', 1, '2025-01-01'),
|
||
],
|
||
declaredDays: [{ date: '2024-01-10', type: 'cp' }],
|
||
rules: [
|
||
{
|
||
id: 'higher-rtt',
|
||
startDate: '2024-01-11',
|
||
endDate: '2024-01-11',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
{
|
||
id: 'lower-cp',
|
||
startDate: '2024-01-10',
|
||
endDate: '2024-01-11',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
},
|
||
].map((rule) => ({ ...rule, type: 'time_window', force: 'preference' })),
|
||
}),
|
||
);
|
||
expect(result.preferences).toMatchObject([
|
||
{ ruleId: 'higher-rtt', satisfied: 1 },
|
||
{ ruleId: 'lower-cp', satisfied: 1, reason: { code: 'higher-priority' } },
|
||
]);
|
||
});
|
||
|
||
it('explains an impossible joint obligation after compatible declarations have been credited', async () => {
|
||
const core = await import('./leave-planner.core');
|
||
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||
const declaredDays = [{ date: '2024-01-08', type: 'cp' as const }];
|
||
const result = core.calculatePlanningInternal(
|
||
{
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 1)],
|
||
declaredDays,
|
||
labels: { 'three-cp': 'Three CP including Monday', 'one-rtt': 'Wednesday RTT' },
|
||
rules: [
|
||
{
|
||
id: 'three-cp',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-10',
|
||
placeDays: 3,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'one-rtt',
|
||
startDate: '2024-01-10',
|
||
endDate: '2024-01-10',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
].map((rule) => ({ ...rule, type: 'time_window', force: 'obligation' })),
|
||
},
|
||
undefined,
|
||
(value) => (artifacts = value),
|
||
);
|
||
expect(result).toMatchObject({
|
||
kind: 'PlanningConflict',
|
||
conflictingRuleIds: ['three-cp', 'one-rtt'],
|
||
reasons: [{ code: 'joint-date-conflict' }],
|
||
});
|
||
expect(artifacts.explanation).toMatchObject({
|
||
kind: 'ConflictExplanation',
|
||
conflictingRules: [
|
||
{ id: 'three-cp', label: 'Three CP including Monday' },
|
||
{ id: 'one-rtt', label: 'Wednesday RTT' },
|
||
],
|
||
bottleneck: { code: 'joint-date-conflict' },
|
||
});
|
||
});
|
||
|
||
it('distinguishes insufficient compatible leave from malformed rules without converting a fixed RTT', async () => {
|
||
const submitted = {
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [balance('cp', 'cp', 4)],
|
||
declaredDays: [{ date: '2024-01-10', type: 'rtt' as const }],
|
||
rules: [
|
||
{
|
||
id: 'cp-only',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-12',
|
||
placeDays: 5,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
};
|
||
expect(await runInNonBrowser(submitted)).toMatchObject({
|
||
kind: 'PlanningConflict',
|
||
reasons: [{ code: 'typed-capacity' }],
|
||
});
|
||
submitted.rules[0].placeDays = -1;
|
||
expect(await runInNonBrowser(submitted)).toMatchObject({ kind: 'InvalidInput' });
|
||
expect(submitted.declaredDays).toEqual([{ date: '2024-01-10', type: 'rtt' }]);
|
||
});
|
||
|
||
it('keeps declared non-working days fixed without consuming their remaining balances', async () => {
|
||
const declaredDays = [
|
||
{ date: '2024-01-02', type: 'cp' as const },
|
||
{ date: '2024-01-03', type: 'rtt' as const },
|
||
{ date: '2024-01-05', type: 'other' as const },
|
||
{ date: '2026-10-06', type: 'other' as const },
|
||
];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-06' },
|
||
balances: [balance('remaining-cp', 'cp', 4, '2024-01-05')],
|
||
rules: [presets[0]],
|
||
declaredDays,
|
||
}),
|
||
);
|
||
expect(result).toMatchObject({ declaredDays });
|
||
expect(result.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
|
||
expect(result.remainingBalances).toMatchObject([{ id: 'remaining-cp', quantity: 3 }]);
|
||
});
|
||
|
||
it('accepts declarations without a source balance or a new placement', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon,
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: [{ date: '2024-01-02', type: 'other' }],
|
||
}),
|
||
);
|
||
expect(result.declaredDays).toEqual([{ date: '2024-01-02', type: 'other' }]);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.remainingBalances).toEqual([]);
|
||
});
|
||
|
||
it('forms Friday–Sunday from an ordinary declared Other day without a balance debit', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-09' },
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: [{ date: '2024-01-05', type: 'other' }],
|
||
}),
|
||
);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.remainingBalances).toEqual([]);
|
||
expect(result.timeOffPeriods).toEqual([
|
||
{
|
||
start: '2024-01-05',
|
||
end: '2024-01-07',
|
||
duration: 3,
|
||
placementDates: [],
|
||
declaredDates: ['2024-01-05'],
|
||
nonWorkingDates: ['2024-01-06', '2024-01-07'],
|
||
nonWorkingDayGain: 2,
|
||
},
|
||
]);
|
||
expect(result.metrics).toMatchObject({
|
||
nonWorkingDayGain: 2,
|
||
averageWorkSequence: 2,
|
||
longestWorkSequence: 3,
|
||
averageTimeOffPeriod: 3,
|
||
longestTimeOffPeriod: 3,
|
||
});
|
||
});
|
||
|
||
it('merges declarations, placements, weekends and national holidays without counting dates twice', async () => {
|
||
const declaredDays = [
|
||
{ date: '2024-05-06', type: 'cp' as const },
|
||
{ date: '2024-05-07', type: 'rtt' as const },
|
||
{ date: '2024-05-10', type: 'other' as const },
|
||
];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-05-06', end: '2024-05-14' },
|
||
balances: [
|
||
{ ...balance('cp', 'cp', 1, '2024-05-13'), availableFrom: '2024-05-13' },
|
||
],
|
||
rules: [presets[0]],
|
||
declaredDays,
|
||
}),
|
||
);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.placements.map(({ date }) => date)).toEqual(['2024-05-13']);
|
||
expect(result.remainingBalances).toEqual([]);
|
||
expect(result.timeOffPeriods).toEqual([
|
||
{
|
||
start: '2024-05-04',
|
||
end: '2024-05-13',
|
||
duration: 10,
|
||
placementDates: ['2024-05-13'],
|
||
declaredDates: declaredDays.map(({ date }) => date),
|
||
nonWorkingDates: [
|
||
'2024-05-04',
|
||
'2024-05-05',
|
||
'2024-05-08',
|
||
'2024-05-09',
|
||
'2024-05-11',
|
||
'2024-05-12',
|
||
],
|
||
nonWorkingDayGain: 6,
|
||
},
|
||
]);
|
||
expect(result.metrics).toMatchObject({
|
||
nonWorkingDayGain: 6,
|
||
holidayWeekValue: 5,
|
||
averageWorkSequence: null,
|
||
longestWorkSequence: 0,
|
||
averageTimeOffPeriod: 10,
|
||
longestTimeOffPeriod: 10,
|
||
});
|
||
});
|
||
|
||
it('uses declared days to split work sequences while weekends and holidays alone never split them', async () => {
|
||
const withDeclarations = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-20' },
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: [
|
||
{ date: '2024-01-05', type: 'other' },
|
||
{ date: '2024-01-09', type: 'cp' },
|
||
],
|
||
}),
|
||
);
|
||
expect(withDeclarations.metrics).toMatchObject({
|
||
longestWorkSequence: 8,
|
||
shortSequenceDeficit: 4,
|
||
});
|
||
const withoutDeclarations = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-05-06', end: '2024-05-15' },
|
||
balances: [],
|
||
rules: [],
|
||
}),
|
||
);
|
||
expect(withoutDeclarations.timeOffPeriods).toEqual([]);
|
||
expect(withoutDeclarations.metrics).toMatchObject({
|
||
longestWorkSequence: 5,
|
||
shortSequenceDeficit: 0,
|
||
nonWorkingDayGain: 0,
|
||
});
|
||
});
|
||
|
||
it('extends only anchored periods with adjacent outside declarations and keeps placements inside the submitted horizon', async () => {
|
||
const selectedHorizon = { start: '2024-01-03', end: '2024-01-05' };
|
||
const declaredDays = [
|
||
{ date: '2024-01-02', type: 'cp' as const },
|
||
{ date: '2024-01-03', type: 'other' as const },
|
||
{ date: '2024-01-05', type: 'rtt' as const },
|
||
{ date: '2024-01-10', type: 'other' as const },
|
||
];
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: selectedHorizon,
|
||
balances: [
|
||
{ ...balance('cp', 'cp', 2, '2024-01-04'), availableFrom: '2024-01-04' },
|
||
],
|
||
rules: [presets[0]],
|
||
declaredDays,
|
||
}),
|
||
);
|
||
expect(result.horizon).toEqual(selectedHorizon);
|
||
expect(result.declaredDays).toEqual(declaredDays);
|
||
expect(result.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
|
||
expect(result.remainingBalances[0].quantity).toBe(1);
|
||
expect(result.timeOffPeriods).toHaveLength(1);
|
||
expect(result.timeOffPeriods[0]).toMatchObject({
|
||
start: '2023-12-30',
|
||
end: '2024-01-07',
|
||
duration: 9,
|
||
placementDates: ['2024-01-04'],
|
||
declaredDates: ['2024-01-02', '2024-01-03', '2024-01-05'],
|
||
nonWorkingDayGain: 5,
|
||
});
|
||
expect(result.metrics).toMatchObject({
|
||
nonWorkingDayGain: 5,
|
||
averageWorkSequence: null,
|
||
longestWorkSequence: 0,
|
||
averageTimeOffPeriod: 9,
|
||
longestTimeOffPeriod: 9,
|
||
});
|
||
const unanchored = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: selectedHorizon,
|
||
balances: [],
|
||
rules: [presets[0]],
|
||
declaredDays: declaredDays.filter(({ date }) => date !== '2024-01-03'),
|
||
}),
|
||
);
|
||
expect(unanchored.timeOffPeriods).toEqual([]);
|
||
expect(unanchored.metrics).toMatchObject({
|
||
nonWorkingDayGain: 0,
|
||
averageWorkSequence: 2,
|
||
longestWorkSequence: 2,
|
||
averageTimeOffPeriod: null,
|
||
longestTimeOffPeriod: null,
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
['2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
|
||
['2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'],
|
||
])(
|
||
'keeps explicit historical and multi-year bounds from %s to %s around declarations',
|
||
async (start, end, date, periodStart, periodEnd) => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start, end },
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: [{ date, type: 'other' }],
|
||
}),
|
||
);
|
||
expect(result.horizon).toEqual({ start, end });
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.timeOffPeriods).toHaveLength(1);
|
||
expect(result.timeOffPeriods[0]).toMatchObject({
|
||
start: periodStart,
|
||
end: periodEnd,
|
||
declaredDates: [date],
|
||
});
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
[false, '2024-05-06', '2024-05-13'],
|
||
[true, '2024-05-06', '2024-05-13'],
|
||
[false, '2024-05-06', '2024-05-10'],
|
||
[false, '2024-05-06', '2024-05-09'],
|
||
[false, '2024-05-10', '2024-05-11'],
|
||
[true, '2024-05-10', '2024-05-11'],
|
||
] as const)(
|
||
'completes a genuine public-holiday week with fixed declarations and additional placement: %s',
|
||
async (mixed, start, end) => {
|
||
const declaredDays = ['2024-05-06', '2024-05-07', ...(mixed ? [] : ['2024-05-10'])].map(
|
||
(date) => ({ date, type: 'other' as const }),
|
||
);
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start, end },
|
||
rules: [presets[0]],
|
||
declaredDays,
|
||
balances: mixed
|
||
? [{ ...balance('cp', 'cp', 1, '2024-05-10'), availableFrom: '2024-05-10' }]
|
||
: [],
|
||
}),
|
||
);
|
||
expect(result.metrics.holidayWeekValue).toBe(5);
|
||
expect(result.placements.map(({ date }) => date)).toEqual(mixed ? ['2024-05-10'] : []);
|
||
expect(result.timeOffPeriods[0].nonWorkingDates).not.toContain('2024-05-06');
|
||
},
|
||
);
|
||
|
||
it('does not treat a fully declared ordinary week as a national public-holiday week', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-13' },
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays: [
|
||
'2024-01-08',
|
||
'2024-01-09',
|
||
'2024-01-10',
|
||
'2024-01-11',
|
||
'2024-01-12',
|
||
].map((date) => ({ date, type: 'other' })),
|
||
}),
|
||
);
|
||
expect(result.metrics).toMatchObject({
|
||
holidayWeekValue: 0,
|
||
nonWorkingDayGain: 4,
|
||
longestWorkSequence: 0,
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
{
|
||
declaredStart: '2024-01-02',
|
||
declaredEnd: '2025-04-30',
|
||
horizon: { start: '2024-01-02', end: '2024-01-03' },
|
||
period: { start: '2023-12-30', end: '2025-04-29' },
|
||
},
|
||
{
|
||
declaredStart: '2024-12-30',
|
||
declaredEnd: '2026-01-06',
|
||
horizon: { start: '2024-12-30', end: '2024-12-31' },
|
||
period: { start: '2024-12-28', end: '2026-01-05' },
|
||
},
|
||
{
|
||
declaredStart: '2023-11-29',
|
||
declaredEnd: '2025-01-03',
|
||
horizon: { start: '2025-01-02', end: '2025-01-03' },
|
||
period: { start: '2023-11-29', end: '2025-01-02' },
|
||
},
|
||
])(
|
||
'keeps a contiguous outside declaration chain $declaredStart–$declaredEnd across the prepared calendar buffer',
|
||
async ({ declaredStart, declaredEnd, horizon, period }) => {
|
||
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
|
||
const { addPlanningDays } = await import('./planning-date');
|
||
const declaredDays: NonNullable<PlanningRequest['declaredDays']>[number][] = [];
|
||
for (
|
||
let date: string = declaredStart;
|
||
date < declaredEnd;
|
||
date = addPlanningDays(date, 1)
|
||
)
|
||
if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' });
|
||
const result = isPlan(
|
||
await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }),
|
||
);
|
||
expect(result.timeOffPeriods).toHaveLength(1);
|
||
expect(result.timeOffPeriods[0]).toMatchObject({
|
||
...period,
|
||
declaredDates: declaredDays.map(({ date }) => date),
|
||
});
|
||
expect(result.timeOffPeriods[0].duration).toBe(
|
||
result.timeOffPeriods[0].nonWorkingDates.length + declaredDays.length,
|
||
);
|
||
expect(result.placements).toEqual([]);
|
||
},
|
||
);
|
||
|
||
it('preserves holiday-week priority over a larger natural-day gain around declarations', async () => {
|
||
const request: PlanningRequest = {
|
||
horizon: { start: '2026-11-01', end: '2026-12-01' },
|
||
balances: [balance('cp', 'cp', 2, '2026-11-30')],
|
||
rules: presets,
|
||
declaredDays: [
|
||
{ date: '2026-11-09', type: 'other' },
|
||
{ date: '2026-11-10', type: 'rtt' },
|
||
],
|
||
};
|
||
const result = isPlan(await runInNonBrowser(request));
|
||
expect(result.placements.map(({ date }) => date)).toEqual(['2026-11-12', '2026-11-13']);
|
||
expect(result.metrics).toMatchObject({
|
||
unplacedAtRisk: 0,
|
||
holidayWeekValue: 3,
|
||
nonWorkingDayGain: 5,
|
||
});
|
||
const withPreference = isPlan(
|
||
await runInNonBrowser({
|
||
...request,
|
||
rules: [
|
||
presets[0],
|
||
...[
|
||
{
|
||
id: 'preferred-friday',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2026-11-20',
|
||
endDate: '2026-11-20',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
...presets.slice(1),
|
||
],
|
||
}),
|
||
);
|
||
expect(withPreference.preferences[0].satisfied).toBe(1);
|
||
expect(withPreference.metrics).toMatchObject({ unplacedAtRisk: 0, holidayWeekValue: 0 });
|
||
const supporting = isPlan(
|
||
await runInNonBrowser({ ...request, balances: [balance('cp', 'cp', 2, '2027-01-01')] }),
|
||
);
|
||
expect(supporting.placements.map(({ date }) => date)).toEqual(['2026-11-12', '2026-11-13']);
|
||
expect(supporting.placements.every(({ supporting }) => supporting)).toBe(true);
|
||
expect(supporting.metrics).toMatchObject({ holidayWeekValue: 3, supportingPlacements: 2 });
|
||
});
|
||
|
||
it.each([
|
||
null,
|
||
{},
|
||
[null],
|
||
[{ date: '2024-02-30', type: 'cp' }],
|
||
[{ date: '2024-01-06', type: 'cp' }],
|
||
[{ date: '2024-05-08', type: 'cp' }],
|
||
[{ date: '2024-01-02', type: 'any' }],
|
||
[
|
||
{ date: '2024-01-02', type: 'cp' },
|
||
{ date: '2024-01-02', type: 'rtt' },
|
||
],
|
||
])('reports malformed declarations as invalid planning input: %j', async (declaredDays) => {
|
||
const outcome = await runInNonBrowser({
|
||
horizon,
|
||
balances: [],
|
||
rules: [],
|
||
declaredDays,
|
||
} as unknown as PlanningRequest);
|
||
expect(outcome).toMatchObject({
|
||
kind: 'InvalidInput',
|
||
reasons: [{ code: 'declared-days' }],
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'],
|
||
['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
|
||
['two years', '2024-01-01', '2026-01-01', '2025-12-24', '2025-12-24', '2025-12-25'],
|
||
['three years', '2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'],
|
||
])(
|
||
'plans a light %s horizon with dated provenance and French holidays',
|
||
async (_, start, end, placementDate, periodStart, periodEnd) => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start, end },
|
||
balances: [
|
||
{
|
||
id: 'expired',
|
||
type: 'rtt',
|
||
quantity: 2,
|
||
availableFrom: '2020-01-01',
|
||
expiryDate: '2020-12-31',
|
||
},
|
||
{
|
||
id: 'dated-cp',
|
||
type: 'cp',
|
||
quantity: 1,
|
||
availableFrom: placementDate,
|
||
expiryDate: placementDate,
|
||
},
|
||
],
|
||
rules: [
|
||
{
|
||
id: 'chosen-date',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: placementDate,
|
||
endDate: placementDate,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.horizon).toEqual({ start, end });
|
||
expect(result.placements).toEqual([
|
||
{
|
||
date: placementDate,
|
||
balanceId: 'dated-cp',
|
||
type: 'cp',
|
||
ruleIds: ['chosen-date'],
|
||
supporting: false,
|
||
},
|
||
]);
|
||
expect(result.timeOffPeriods).toHaveLength(1);
|
||
expect(result.timeOffPeriods[0]).toMatchObject({ start: periodStart, end: periodEnd });
|
||
expect(result.remainingBalances.find((item) => item.id === 'expired')).toMatchObject({
|
||
quantity: 2,
|
||
type: 'rtt',
|
||
availableFrom: '2020-01-01',
|
||
expiryDate: '2020-12-31',
|
||
classification: 'unavoidable-expiry',
|
||
});
|
||
},
|
||
);
|
||
|
||
it('rejects malformed horizons before calculation', async () => {
|
||
const result = await planLeave({
|
||
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
||
balances: [],
|
||
rules: [],
|
||
});
|
||
|
||
expect(result.kind).toBe('InvalidInput');
|
||
if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order');
|
||
});
|
||
|
||
it.each([
|
||
['2024-02-30', '2025-01-01', 'horizon-start'],
|
||
['2024-13-01', '2025-01-01', 'horizon-start'],
|
||
['2024-01-01', '2024-00-01', 'horizon-end'],
|
||
['not-a-date', '2025-01-01', 'horizon-start'],
|
||
])('reports an invalid horizon date without throwing: %s to %s', async (start, end, code) => {
|
||
const outcome = await planLeave({ horizon: { start, end }, balances: [], rules: [] });
|
||
expect(outcome.kind).toBe('InvalidInput');
|
||
if (outcome.kind === 'InvalidInput')
|
||
expect(outcome.reasons.map((item) => item.code)).toContain(code);
|
||
});
|
||
|
||
it.each([
|
||
[
|
||
'invalid balance quantity',
|
||
{
|
||
balances: [{ id: 'cp', type: 'cp', quantity: -1, expiryDate: '2025-01-01' }],
|
||
rules: [],
|
||
},
|
||
'balance-quantity',
|
||
],
|
||
[
|
||
'invalid balance expiry',
|
||
{
|
||
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: 'not-a-date' }],
|
||
rules: [],
|
||
},
|
||
'balance-expiry',
|
||
],
|
||
[
|
||
'invalid rule selector',
|
||
{
|
||
balances: [],
|
||
rules: [
|
||
{
|
||
id: 'rule',
|
||
type: 'time_window',
|
||
startDate: '2024-01-01',
|
||
endDate: '2024-01-02',
|
||
placeDays: 1,
|
||
placeType: 'holiday',
|
||
},
|
||
],
|
||
},
|
||
'rule-selector',
|
||
],
|
||
] as const)('returns a table-driven validation reason for %s', async (_, request, code) => {
|
||
const result = await runInNonBrowser({ horizon, ...request });
|
||
expect(result.kind).toBe('InvalidInput');
|
||
if (result.kind === 'InvalidInput')
|
||
expect(result.reasons.some((candidate) => candidate.code === code)).toBe(true);
|
||
});
|
||
|
||
it('plans dated balances without mutating the request and classifies the remainder', async () => {
|
||
const request = {
|
||
horizon,
|
||
balances: [
|
||
{ ...balance('expired', 'cp', 1, '2023-12-31'), availableFrom: '2023-01-01' },
|
||
balance('future', 'rtt', 1, '2026-01-01'),
|
||
],
|
||
rules: [],
|
||
};
|
||
const before = JSON.stringify(request);
|
||
const result = isPlan(await runInNonBrowser(request));
|
||
|
||
expect(JSON.stringify(request)).toBe(before);
|
||
expect(result.placements.length).toBeLessThanOrEqual(1);
|
||
expect(result.remainingBalances[0].classification).toBe('unavoidable-expiry');
|
||
});
|
||
|
||
it('migrates a legacy balance without availability to the horizon start', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-06-01', end: '2024-06-02' },
|
||
balances: [{ id: 'legacy', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }],
|
||
rules: [],
|
||
}),
|
||
);
|
||
expect(result.remainingBalances[0].availableFrom).toBe('2024-06-01');
|
||
});
|
||
|
||
it.each([
|
||
['unavoidable-expiry', '2024-05-31', '2024-01-01', 'unavoidable-expiry'],
|
||
['at-risk', '2024-06-10', '2024-06-10', 'available-after-horizon'],
|
||
['deferred', '2024-07-01', '2024-06-11', 'available-after-horizon'],
|
||
] as const)(
|
||
'classifies a remaining balance as %s',
|
||
async (classification, expiryDate, availableFrom, reasonCode) => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-06-01', end: '2024-06-10' },
|
||
balances: [
|
||
{
|
||
id: classification,
|
||
type: 'cp',
|
||
quantity: 1,
|
||
availableFrom,
|
||
expiryDate,
|
||
},
|
||
],
|
||
rules: [],
|
||
}),
|
||
);
|
||
expect(result.remainingBalances[0].classification).toBe(classification);
|
||
expect(result.remainingBalances[0].reason.code).toBe(reasonCode);
|
||
},
|
||
);
|
||
|
||
it.each([
|
||
['no-working-date', '2024-06-08', '2024-06-10', []],
|
||
['all-dates-occupied', '2024-06-03', '2024-06-04', [{ date: '2024-06-03', type: 'cp' }]],
|
||
['preserved', '2024-06-04', '2024-06-13', [{ date: '2024-06-07', type: 'cp' }]],
|
||
] as const)(
|
||
'explains a remaining deferred balance as %s without treating it as unavailable',
|
||
async (code, start, end, declaredDays) => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start, end },
|
||
balances: [
|
||
{ ...balance('other', 'other', 1, '2025-05-10'), availableFrom: start },
|
||
],
|
||
declaredDays,
|
||
rules: [],
|
||
}),
|
||
);
|
||
expect(result.placements).toEqual([]);
|
||
expect(result.remainingBalances).toMatchObject([
|
||
{
|
||
id: 'other',
|
||
quantity: 1,
|
||
classification: 'deferred',
|
||
reason: { code },
|
||
},
|
||
]);
|
||
},
|
||
);
|
||
|
||
it('satisfies exact TIME_WINDOW obligations and reports typed conflicts', async () => {
|
||
const rule = {
|
||
id: 'cp-window',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-03-01',
|
||
endDate: '2024-03-05',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
};
|
||
const plan = isPlan(
|
||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 2)], rules: [rule] }),
|
||
);
|
||
expect(plan.placements).toHaveLength(2);
|
||
expect(plan.placements.every((placement) => placement.ruleIds).valueOf()).toBe(true);
|
||
|
||
const conflict = await runInNonBrowser({
|
||
horizon,
|
||
balances: [balance('rtt', 'rtt', 2)],
|
||
rules: [rule],
|
||
});
|
||
expect(conflict.kind).toBe('PlanningConflict');
|
||
});
|
||
|
||
it('lets one ANY obligation preserve a typed balance for another obligation', async () => {
|
||
const rules = [
|
||
{
|
||
id: 'any',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-04-01',
|
||
endDate: '2024-04-02',
|
||
placeDays: 1,
|
||
placeType: 'any',
|
||
},
|
||
{
|
||
id: 'cp',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-04-01',
|
||
endDate: '2024-02-02',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
];
|
||
// The second window is intentionally corrected below to keep this table readable.
|
||
rules[1].endDate = '2024-04-02';
|
||
const plan = isPlan(
|
||
await runInNonBrowser({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 1)],
|
||
rules: [presets[0], ...rules],
|
||
}),
|
||
);
|
||
|
||
expect(plan.placements).toHaveLength(2);
|
||
expect(plan.placements.find((placement) => placement.ruleIds.includes('cp'))?.type).toBe(
|
||
'cp',
|
||
);
|
||
expect(
|
||
plan.placements.some(
|
||
(placement) =>
|
||
placement.ruleIds.includes('any') && placement.ruleIds.includes('cp'),
|
||
),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('supports IF_THEN patterns, merged periods, and deterministic results', async () => {
|
||
const rule = {
|
||
id: 'holiday',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
placement: 'auto',
|
||
rewardDays: 1,
|
||
rewardType: 'rtt',
|
||
};
|
||
const request = {
|
||
horizon: { start: '2024-04-01', end: '2024-05-10' },
|
||
balances: [balance('cp', 'cp', 4), balance('rtt', 'rtt', 1, '2026-01-01')],
|
||
rules: [rule],
|
||
};
|
||
const first = isPlan(await runInNonBrowser(request));
|
||
const second = isPlan(await runInNonBrowser(request));
|
||
expect(first).toEqual(second);
|
||
expect(first.placements.length).toBeGreaterThanOrEqual(4);
|
||
expect(first.timeOffPeriods[0].duration).toBeGreaterThan(2);
|
||
});
|
||
|
||
it('selects a joint obligation assignment by later preference quality', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-02-01', end: '2024-02-10' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [
|
||
{
|
||
id: 'obligation',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-02-01',
|
||
endDate: '2024-02-05',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'preference',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-02-05',
|
||
endDate: '2024-02-05',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(
|
||
result.placements.find((placement) => placement.ruleIds.includes('obligation'))?.date,
|
||
).toBe('2024-02-05');
|
||
expect(result.preferences[0].satisfied).toBe(1);
|
||
});
|
||
|
||
it('preserves an at-risk balance while satisfying a deferred obligation', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-04' },
|
||
balances: [
|
||
{
|
||
...balance('cp', 'cp', 1, '2024-01-02'),
|
||
availableFrom: '2024-01-02',
|
||
},
|
||
{
|
||
...balance('rtt', 'rtt', 1, '2025-01-01'),
|
||
availableFrom: '2024-01-02',
|
||
},
|
||
],
|
||
rules: [
|
||
presets[0],
|
||
...[
|
||
{
|
||
id: 'rtt-obligation',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-03',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
],
|
||
],
|
||
}),
|
||
);
|
||
expect(
|
||
result.placements.find((placement) => placement.ruleIds.includes('rtt-obligation'))
|
||
?.date,
|
||
).toBe('2024-01-03');
|
||
expect(result.placements.find((placement) => placement.balanceId === 'cp')?.date).toBe(
|
||
'2024-01-02',
|
||
);
|
||
});
|
||
|
||
it('does not let a generating period reward finance an earlier merged period placement', async () => {
|
||
const result = await runInNonBrowser({
|
||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 3, '2026-01-01')],
|
||
rules: [
|
||
{
|
||
id: 'producer',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'before',
|
||
rewardDays: 1,
|
||
rewardType: 'rtt',
|
||
},
|
||
{
|
||
id: 'consumer',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 4,
|
||
placeDays: 4,
|
||
placeType: 'rtt',
|
||
placement: 'before',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
});
|
||
expect(result.kind).toBe('PlanningConflict');
|
||
});
|
||
|
||
it('chains an earned balance into a later obligation with provenance', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
||
balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')],
|
||
rules: [
|
||
{
|
||
id: 'producer',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'after',
|
||
rewardDays: 1,
|
||
rewardType: 'rtt',
|
||
},
|
||
{
|
||
id: 'consumer',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-07',
|
||
endDate: '2024-05-07',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
const earned = result.placements.find((placement) =>
|
||
placement.ruleIds.includes('consumer'),
|
||
);
|
||
expect(earned?.balanceId).toBe('earned:producer:2024-05-01');
|
||
expect(earned?.ruleIds).toEqual(['consumer']);
|
||
});
|
||
|
||
it('allows a later preference producer to fund an earlier consumer chronologically', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
||
balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')],
|
||
rules: [
|
||
{
|
||
id: 'consumer',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-05-07',
|
||
endDate: '2024-05-07',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
{
|
||
id: 'producer',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'after',
|
||
rewardDays: 1,
|
||
rewardType: 'rtt',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1]);
|
||
expect(result.placements).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
date: '2024-05-02',
|
||
balanceId: 'cp',
|
||
ruleIds: ['producer'],
|
||
}),
|
||
expect.objectContaining({
|
||
date: '2024-05-07',
|
||
balanceId: 'earned:producer:2024-05-01',
|
||
ruleIds: ['consumer'],
|
||
}),
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('preserves preference priority when one later producer earns only one day', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-25', end: '2024-05-08' },
|
||
balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')],
|
||
rules: [
|
||
{
|
||
id: 'first',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-05-06',
|
||
endDate: '2024-05-06',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
{
|
||
id: 'second',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-05-07',
|
||
endDate: '2024-05-07',
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
},
|
||
{
|
||
id: 'producer',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'after',
|
||
rewardDays: 1,
|
||
rewardType: 'rtt',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 0, 1]);
|
||
expect(result.placements).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ date: '2024-05-06', ruleIds: ['first'] }),
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('replays the current preference when an earlier retry creates its entitlement', async () => {
|
||
vi.setSystemTime(new Date('2021-01-01T12:00:00Z'));
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2021-05-20', end: '2021-11-03' },
|
||
balances: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 0,
|
||
availableFrom: '2021-05-20',
|
||
expiryDate: '2022-01-01',
|
||
},
|
||
{
|
||
id: 'rtt',
|
||
type: 'rtt',
|
||
quantity: 1,
|
||
availableFrom: '2021-05-20',
|
||
expiryDate: '2021-08-01',
|
||
},
|
||
],
|
||
rules: [
|
||
{
|
||
id: 'wednesday-producer',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'after',
|
||
rewardDays: 1,
|
||
rewardType: 'rtt',
|
||
},
|
||
{
|
||
id: 'monday-chain',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 1,
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
placement: 'after',
|
||
rewardDays: 1,
|
||
rewardType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 2]);
|
||
expect(result.placements).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ date: '2021-07-15', ruleIds: ['wednesday-producer'] }),
|
||
expect.objectContaining({ date: '2021-11-02', ruleIds: ['monday-chain'] }),
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('retains a useful supporting placement while preserving its provenance', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
||
rules: [nonWorkingGain, supportingEconomy],
|
||
}),
|
||
);
|
||
const supporting = result.placements.find((placement) => placement.supporting);
|
||
expect(supporting?.ruleIds).toEqual(['gain']);
|
||
expect(supporting?.balanceId).toBe('cp');
|
||
});
|
||
|
||
it('does not trade an earlier short-sequence deficit for supporting placement gain', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||
balances: [balance('cp-four', 'cp', 4, '2025-01-01')],
|
||
rules: [
|
||
{
|
||
id: 'deficit',
|
||
type: 'optimization',
|
||
configuration: OPTIMIZATION_PRESETS.shortSequenceDeficit,
|
||
},
|
||
nonWorkingGain,
|
||
supportingEconomy,
|
||
],
|
||
}),
|
||
);
|
||
expect(result.metrics.shortSequenceDeficit).toBe(0);
|
||
});
|
||
|
||
it('retains supporting provenance when a placement is moved', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-01', end: '2024-01-29' },
|
||
balances: [balance('cp-six', 'cp', 6, '2025-01-01')],
|
||
rules: [
|
||
nonWorkingGain,
|
||
{
|
||
id: 'longest',
|
||
type: 'optimization',
|
||
configuration: OPTIMIZATION_PRESETS.longestWorkSequence,
|
||
},
|
||
supportingEconomy,
|
||
],
|
||
}),
|
||
);
|
||
expect(result.metrics.supportingPlacements).toBe(
|
||
result.placements.filter((placement) => placement.supporting).length,
|
||
);
|
||
expect(result.placements.length).toBeGreaterThan(0);
|
||
expect(result.placements.every((placement) => placement.supporting)).toBe(true);
|
||
});
|
||
|
||
it('orders non-working gain, longest sequence, and final dates lexicographically', async () => {
|
||
const twoDayPlan = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-02-01', end: '2024-03-01' },
|
||
balances: [balance('cp', 'cp', 2, '2024-02-29')],
|
||
rules: presets,
|
||
}),
|
||
);
|
||
const fourDayPlan = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-02-01', end: '2024-03-01' },
|
||
balances: [balance('cp', 'cp', 4, '2024-02-29')],
|
||
rules: presets,
|
||
}),
|
||
);
|
||
expect(twoDayPlan.metrics).toMatchObject({
|
||
nonWorkingDayGain: 4,
|
||
longestWorkSequence: 9,
|
||
shortSequenceDeficit: 0,
|
||
});
|
||
expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([
|
||
'2024-02-02',
|
||
'2024-02-16',
|
||
]);
|
||
expect(fourDayPlan.placements.map((placement) => placement.date)).toEqual([
|
||
'2024-02-02',
|
||
'2024-02-09',
|
||
'2024-02-16',
|
||
'2024-02-23',
|
||
]);
|
||
});
|
||
|
||
it.each([
|
||
[
|
||
'11 November',
|
||
{ start: '2026-11-01', end: '2026-12-01' },
|
||
'2026-11-30',
|
||
['2026-11-09', '2026-11-10', '2026-11-12', '2026-11-13'],
|
||
],
|
||
[
|
||
'14 July',
|
||
{ start: '2027-07-01', end: '2027-08-01' },
|
||
'2027-07-31',
|
||
['2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16'],
|
||
],
|
||
] as const)('takes the full week around %s', async (_, holidayHorizon, expiryDate, dates) => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: holidayHorizon,
|
||
balances: [balance('cp', 'cp', 4, expiryDate)],
|
||
rules: presets,
|
||
}),
|
||
);
|
||
|
||
expect(result.placements.map((placement) => placement.date)).toEqual(dates);
|
||
expect(result.timeOffPeriods).toHaveLength(1);
|
||
expect(result.timeOffPeriods[0].duration).toBe(9);
|
||
});
|
||
|
||
// This full configured year is measured separately; allow instrumentation overhead without a planning deadline.
|
||
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2026-08-29', end: '2027-08-29' },
|
||
balances: [
|
||
{
|
||
...balance('cp', 'cp', 10, '2027-08-29'),
|
||
availableFrom: '2026-08-29',
|
||
},
|
||
{
|
||
...balance('rtt', 'rtt', 10, '2027-08-29'),
|
||
availableFrom: '2026-08-29',
|
||
},
|
||
{
|
||
...balance('other', 'other', 4, '2027-08-29'),
|
||
availableFrom: '2026-08-29',
|
||
},
|
||
],
|
||
rules: [
|
||
presets[0],
|
||
...[
|
||
{
|
||
id: 'default-samedi-malin',
|
||
name: 'Samedi Malin',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 6,
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
placement: 'auto',
|
||
rewardDays: 1,
|
||
rewardType: 'other',
|
||
},
|
||
{
|
||
id: 'default-christmas',
|
||
name: 'Christmas holidays',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2026-12-20',
|
||
endDate: '2027-01-03',
|
||
placeDays: 5,
|
||
placeType: 'any',
|
||
},
|
||
],
|
||
...presets.slice(1),
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(result.placements.map((placement) => placement.date)).toEqual(
|
||
expect.arrayContaining([
|
||
'2026-11-09',
|
||
'2026-11-10',
|
||
'2026-11-12',
|
||
'2026-11-13',
|
||
'2027-07-12',
|
||
'2027-07-13',
|
||
'2027-07-15',
|
||
'2027-07-16',
|
||
]),
|
||
);
|
||
}, 120_000);
|
||
|
||
it('moves a satisfied time-window preference to complete a holiday week', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2027-07-01', end: '2027-08-01' },
|
||
balances: [balance('cp', 'cp', 4, '2027-07-31')],
|
||
rules: [
|
||
presets[0],
|
||
...[
|
||
{
|
||
id: 'july',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2027-07-01',
|
||
endDate: '2027-07-31',
|
||
placeDays: 4,
|
||
placeType: 'any',
|
||
},
|
||
],
|
||
...presets.slice(1),
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(result.preferences[0].satisfied).toBe(4);
|
||
expect(result.placements.map((placement) => placement.date)).toEqual([
|
||
'2027-07-12',
|
||
'2027-07-13',
|
||
'2027-07-15',
|
||
'2027-07-16',
|
||
]);
|
||
});
|
||
|
||
it('uses deferred leave as supporting placements to complete a holiday week', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2027-07-01', end: '2027-08-01' },
|
||
balances: [balance('cp', 'cp', 4, '2028-07-31')],
|
||
rules: [
|
||
{
|
||
id: 'holiday',
|
||
type: 'optimization',
|
||
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
|
||
},
|
||
supportingEconomy,
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(result.placements.map((placement) => placement.date)).toEqual([
|
||
'2027-07-12',
|
||
'2027-07-13',
|
||
'2027-07-15',
|
||
'2027-07-16',
|
||
]);
|
||
expect(result.placements.every((placement) => placement.supporting)).toBe(true);
|
||
});
|
||
|
||
it('moves a time-window obligation to improve a later quality stage', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-08', end: '2024-01-15' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [
|
||
presets[0],
|
||
...[
|
||
{
|
||
id: 'window',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-09',
|
||
endDate: '2024-01-12',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
...presets.slice(1),
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements[0].date).toBe('2024-01-12');
|
||
});
|
||
|
||
it('shares future ANY preference demand and preserves preference order', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-02-01', end: '2024-02-12' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [
|
||
{
|
||
id: 'first',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-02-05',
|
||
endDate: '2024-02-08',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'any',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-02-07',
|
||
endDate: '2024-02-09',
|
||
placeDays: 1,
|
||
placeType: 'any',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements[0].date).toBe('2024-02-07');
|
||
expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1]);
|
||
});
|
||
|
||
it.each(['cp', 'any'] as const)(
|
||
'shares an existing compatible placement before consuming a day for a %s preference',
|
||
async (lastSelector) => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-15' },
|
||
balances: [balance('cp', 'cp', 3)],
|
||
rules: [
|
||
{
|
||
id: 'high',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-05',
|
||
endDate: '2024-01-08',
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'middle',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-03',
|
||
endDate: '2024-01-10',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'low',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-12',
|
||
endDate: '2024-01-12',
|
||
placeDays: 1,
|
||
placeType: lastSelector,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.preferences.map((preference) => preference.satisfied)).toEqual([2, 1, 1]);
|
||
},
|
||
);
|
||
|
||
it('uses the earliest unsatisfied preference slot as a cardinality look-ahead', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-08' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [
|
||
{
|
||
id: 'flexible',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-05',
|
||
placeDays: 1,
|
||
placeType: 'any',
|
||
},
|
||
{
|
||
id: 'second',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-05',
|
||
endDate: '2024-01-05',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'third',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-02',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements[0].date).toBe('2024-01-05');
|
||
expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1, 0]);
|
||
});
|
||
|
||
it('classifies a later preference blocked by consumed higher-priority capacity', async () => {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-02', end: '2024-01-08' },
|
||
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
||
rules: [
|
||
{
|
||
id: 'high',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-02',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'low',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-05',
|
||
endDate: '2024-01-05',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.preferences[0].satisfied).toBe(1);
|
||
expect(result.preferences[1].reason?.code).toBe('higher-priority');
|
||
});
|
||
|
||
it('keeps holiday occurrences date-only across host timezones', async () => {
|
||
const previousTimezone = process.env['TZ'];
|
||
process.env['TZ'] = 'Asia/Tokyo';
|
||
try {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2023-12-29', end: '2024-01-05' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [
|
||
{
|
||
id: 'new-year',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 1,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'after',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements[0].date).toBe('2024-01-02');
|
||
} finally {
|
||
if (previousTimezone === undefined) delete process.env['TZ'];
|
||
else process.env['TZ'] = previousTimezone;
|
||
}
|
||
});
|
||
|
||
it('rejects around N=1 while supporting valid around splits', async () => {
|
||
const aroundOne = await runInNonBrowser({
|
||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||
balances: [balance('cp-one', 'cp', 2)],
|
||
rules: [
|
||
{
|
||
id: 'around-one',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'around',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
});
|
||
expect(aroundOne.kind).toBe('PlanningConflict');
|
||
|
||
const aroundPreference = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||
balances: [balance('cp-pref', 'cp', 1)],
|
||
rules: [
|
||
{
|
||
id: 'around-preference',
|
||
type: 'if_then',
|
||
force: 'preference',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'around',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(aroundPreference.preferences[0].satisfied).toBe(0);
|
||
|
||
const aroundTwo = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-29', end: '2024-05-04' },
|
||
balances: [balance('cp-two', 'cp', 2)],
|
||
rules: [
|
||
{
|
||
id: 'around-two',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 2,
|
||
placeType: 'cp',
|
||
placement: 'around',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(aroundTwo.placements).toHaveLength(2);
|
||
|
||
for (const mode of ['before', 'after', 'auto'] as const) {
|
||
const result = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||
balances: [balance(`cp-${mode}`, 'cp', 2)],
|
||
rules: [
|
||
{
|
||
id: `rule-${mode}`,
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: mode,
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.placements).toHaveLength(2);
|
||
expect(
|
||
result.placements.every((placement) => placement.ruleIds.includes(`rule-${mode}`)),
|
||
).toBe(true);
|
||
}
|
||
});
|
||
|
||
it.each([
|
||
['2026-01-01', undefined],
|
||
['2027-01-01', undefined],
|
||
['2027-01-01', 25_000],
|
||
] as const)(
|
||
'completes every objective on a horizon ending %s after more than ten seconds (budget %s)',
|
||
async (end, deadlineMs) => {
|
||
const request: PlanningRequest = {
|
||
deadlineMs,
|
||
horizon: { start: '2024-01-01', end },
|
||
balances: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 3,
|
||
availableFrom: '2024-05-06',
|
||
expiryDate: '2024-05-10',
|
||
},
|
||
],
|
||
rules: [
|
||
presets[0],
|
||
...[
|
||
{
|
||
id: 'fixed',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-05-06',
|
||
endDate: '2024-05-06',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
...presets.slice(1),
|
||
],
|
||
};
|
||
const expected = isPlan(await runInNonBrowser(request));
|
||
let elapsed = 0;
|
||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
|
||
try {
|
||
const outcome = await runInNonBrowser({
|
||
...request,
|
||
progress: (stage) => {
|
||
if (stage === 'optimization:default-unplaced-at-risk')
|
||
queueMicrotask(() => (elapsed = 20_000));
|
||
},
|
||
});
|
||
expect(elapsed).toBeGreaterThan(10_000);
|
||
expect(outcome).toEqual(expected);
|
||
expect(expected.lastCompletedStage).toBe('dates');
|
||
expect(expected.placements).toContainEqual({
|
||
date: '2024-05-06',
|
||
balanceId: 'cp',
|
||
type: 'cp',
|
||
ruleIds: ['fixed'],
|
||
supporting: false,
|
||
});
|
||
} finally {
|
||
clock.mockRestore();
|
||
}
|
||
},
|
||
);
|
||
|
||
it('returns an incomplete outcome instead of a false conflict at an expired deadline', async () => {
|
||
const result = await planLeave({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 5)],
|
||
rules: [],
|
||
deadlineMs: 0,
|
||
});
|
||
expect(result.kind).toBe('PlanningIncomplete');
|
||
});
|
||
|
||
it('reports worker-unavailable in a browser instead of running the core synchronously', async () => {
|
||
const result = await planLeave({ horizon, balances: [], rules: [] });
|
||
expect(result.kind).toBe('PlanningIncomplete');
|
||
if (result.kind === 'PlanningIncomplete') {
|
||
expect(result.reasons[0].code).toBe('worker-unavailable');
|
||
expect(result.incumbent).toBeUndefined();
|
||
expect(result.lastCompletedStage).toBeUndefined();
|
||
expect(result.completedStages).toEqual([]);
|
||
}
|
||
});
|
||
|
||
it('keeps an expired browser request off the UI thread when a worker exists', async () => {
|
||
let started = false;
|
||
let terminated = false;
|
||
class ZeroBudgetWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
|
||
constructor() {
|
||
started = true;
|
||
}
|
||
|
||
postMessage(): void {}
|
||
|
||
terminate(): void {
|
||
terminated = true;
|
||
}
|
||
}
|
||
vi.stubGlobal('Worker', ZeroBudgetWorker);
|
||
vi.useFakeTimers();
|
||
try {
|
||
const resultPromise = planLeave({ horizon, balances: [], rules: [], deadlineMs: 0 });
|
||
await vi.advanceTimersByTimeAsync(30_000);
|
||
const result = await resultPromise;
|
||
expect(result.kind).toBe('PlanningIncomplete');
|
||
expect(started).toBe(true);
|
||
expect(terminated).toBe(true);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('planLeave transport', () => {
|
||
it('keeps a responsive phase active beyond repeated liveness windows', async () => {
|
||
const request = { horizon, balances: [], rules: [] };
|
||
const expected = isPlan(await runInNonBrowser(request));
|
||
const workers: ResponsiveWorker[] = [];
|
||
class ResponsiveWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
postMessage = vi.fn();
|
||
terminate = vi.fn();
|
||
constructor() {
|
||
workers.push(this);
|
||
}
|
||
emit(data: unknown) {
|
||
this.onmessage?.({ data } as MessageEvent);
|
||
}
|
||
}
|
||
vi.stubGlobal('Worker', ResponsiveWorker);
|
||
vi.useFakeTimers();
|
||
try {
|
||
const calculation = createPlanningCalculation(request);
|
||
const worker = workers[0];
|
||
let outcomeReady = false;
|
||
void calculation.outcome.then(() => (outcomeReady = true));
|
||
for (let tick = 0; tick < 10; tick++) {
|
||
await vi.advanceTimersByTimeAsync(10_000);
|
||
worker.emit({ type: 'heartbeat' });
|
||
}
|
||
expect(outcomeReady).toBe(false);
|
||
expect(worker.terminate).not.toHaveBeenCalled();
|
||
worker.emit({ type: 'result', outcome: expected });
|
||
expect(await calculation.outcome).toEqual(expected);
|
||
await vi.advanceTimersByTimeAsync(60_000);
|
||
expect(worker.terminate).not.toHaveBeenCalled();
|
||
worker.emit({ type: 'artifacts', artifacts: {} });
|
||
expect(await calculation.outcome).toEqual(expected);
|
||
expect(worker.postMessage).toHaveBeenCalledOnce();
|
||
expect(worker.terminate).toHaveBeenCalledOnce();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
|
||
it('terminates an isolated worker that misses the liveness deadline with its incumbent', async () => {
|
||
const incumbent = isPlan(
|
||
await runInNonBrowser({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 2)],
|
||
rules: [],
|
||
}),
|
||
);
|
||
class HangingWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
|
||
postMessage(): void {
|
||
this.onmessage?.({
|
||
data: { type: 'checkpoint', plan: incumbent },
|
||
} as MessageEvent);
|
||
}
|
||
|
||
terminate(): void {
|
||
terminated = true;
|
||
}
|
||
}
|
||
let terminated = false;
|
||
vi.stubGlobal('Worker', HangingWorker);
|
||
vi.useFakeTimers();
|
||
|
||
try {
|
||
const resultPromise = planLeave({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 2)],
|
||
rules: [],
|
||
deadlineMs: 10,
|
||
});
|
||
await vi.advanceTimersByTimeAsync(30_000);
|
||
const result = await resultPromise;
|
||
expect(result.kind).toBe('PlanningIncomplete');
|
||
if (result.kind === 'PlanningIncomplete') {
|
||
expect(result.incumbent?.kind).toBe('Plan');
|
||
expect(result.lastCompletedStage).toBe('dates');
|
||
expect(result.reasons[0].code).toBe('worker-error');
|
||
}
|
||
expect(terminated).toBe(true);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
|
||
it('keeps the delivered checkpoint and recovers its explanation after watchdog failure', async () => {
|
||
const core = await import('./leave-planner.core');
|
||
const request = {
|
||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
};
|
||
let checkpoint: PlanOutcome | undefined;
|
||
let checkpointState: PlanningStateSnapshot | undefined;
|
||
const expected = core.calculatePlanningInternal(request, (plan, state) => {
|
||
checkpoint = plan;
|
||
checkpointState = state;
|
||
});
|
||
expect(expected.kind).toBe('Plan');
|
||
if (expected.kind !== 'Plan' || !checkpointState) return;
|
||
checkpoint = {
|
||
...expected,
|
||
completedStages: ['validation', 'obligations', 'optimization:default-unplaced-at-risk'],
|
||
lastCompletedStage: 'optimization:default-unplaced-at-risk',
|
||
};
|
||
const workers: Array<{ terminated: boolean }> = [];
|
||
class RecoveringWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
terminated = false;
|
||
|
||
constructor() {
|
||
workers.push(this);
|
||
}
|
||
|
||
postMessage(
|
||
message:
|
||
| { type: 'plan' }
|
||
| {
|
||
type: 'explain-checkpoint';
|
||
request: unknown;
|
||
plan: PlanOutcome;
|
||
state: PlanningStateSnapshot;
|
||
interruption: PlanningReason;
|
||
},
|
||
): void {
|
||
if (message.type === 'plan') {
|
||
queueMicrotask(() =>
|
||
this.onmessage?.({
|
||
data: { type: 'checkpoint', plan: checkpoint, state: checkpointState },
|
||
} as MessageEvent),
|
||
);
|
||
return;
|
||
}
|
||
if (message.type === 'explain-checkpoint')
|
||
queueMicrotask(() => {
|
||
const recovered = core.explainIncompleteCheckpoint(
|
||
message.request,
|
||
message.plan,
|
||
message.state,
|
||
message.interruption,
|
||
);
|
||
this.onmessage?.({
|
||
data: { type: 'explanation', ...recovered },
|
||
} as MessageEvent);
|
||
});
|
||
}
|
||
|
||
terminate(): void {
|
||
this.terminated = true;
|
||
}
|
||
}
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', {});
|
||
vi.stubGlobal('Worker', RecoveringWorker);
|
||
vi.useFakeTimers();
|
||
try {
|
||
const calculation = createPlanningCalculation({ ...request, deadlineMs: 20 });
|
||
await vi.runAllTicks();
|
||
await vi.advanceTimersByTimeAsync(30_000);
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||
if (outcome.kind === 'PlanningIncomplete')
|
||
expect(outcome.incumbent).toEqual(checkpoint);
|
||
const explanation = await calculation.explanation;
|
||
expect(explanation?.kind).toBe('PlanExplanation');
|
||
if (explanation?.kind === 'PlanExplanation')
|
||
expect(explanation.incomplete?.reason.code).toBe('worker-error');
|
||
expect((await calculation.explanationError)?.code).toBe('worker-error');
|
||
expect(workers).toHaveLength(2);
|
||
expect(workers.every((worker) => worker.terminated)).toBe(true);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('reports no completed stage when an isolated worker never checkpoints', async () => {
|
||
class SilentWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
|
||
postMessage(): void {
|
||
// Deliberately never checkpoint or finish.
|
||
}
|
||
|
||
terminate(): void {}
|
||
}
|
||
vi.stubGlobal('Worker', SilentWorker);
|
||
vi.useFakeTimers();
|
||
try {
|
||
const resultPromise = planLeave({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 2)],
|
||
rules: [],
|
||
deadlineMs: 10,
|
||
});
|
||
await vi.advanceTimersByTimeAsync(30_000);
|
||
const result = await resultPromise;
|
||
expect(result.kind).toBe('PlanningIncomplete');
|
||
if (result.kind === 'PlanningIncomplete') {
|
||
expect(result.incumbent).toBeUndefined();
|
||
expect(result.completedStages).toEqual([]);
|
||
expect(result.lastCompletedStage).toBeUndefined();
|
||
expect(result.reasons[0].code).toBe('worker-error');
|
||
}
|
||
} finally {
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
|
||
it.each([undefined, 25_000])(
|
||
'sends a detached, callback-free request with budget %s',
|
||
async (deadlineMs) => {
|
||
const sourceDate = new Date('2024-01-01T00:00:00Z');
|
||
const request = {
|
||
deadlineMs,
|
||
horizon: { start: sourceDate, end: '2025-01-01' },
|
||
balances: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp' as const,
|
||
quantity: 0,
|
||
availableFrom: sourceDate,
|
||
expiryDate: '2025-01-01',
|
||
},
|
||
],
|
||
rules: [],
|
||
};
|
||
const expected = isPlan(await runInNonBrowser(request));
|
||
let posted: { type: string; request: PlanningRequest } | undefined;
|
||
class InspectingWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
|
||
postMessage(message: { type: string; request: PlanningRequest }): void {
|
||
posted = structuredClone(message);
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: expected },
|
||
} as MessageEvent);
|
||
}
|
||
|
||
terminate(): void {}
|
||
}
|
||
vi.stubGlobal('Worker', InspectingWorker);
|
||
try {
|
||
const result = await planLeave(request);
|
||
expect(result.kind).toBe('Plan');
|
||
expect(posted?.request).not.toBe(request);
|
||
expect(posted?.request.deadlineMs).toBe(deadlineMs);
|
||
expect(posted?.request.progress).toBeUndefined();
|
||
expect(request.horizon.start).toBe(sourceDate);
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
}
|
||
},
|
||
);
|
||
|
||
it('strips legacy aliases at the worker boundary', async () => {
|
||
const request = {
|
||
horizon,
|
||
balances: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 1,
|
||
expiryDate: '2025-01-01',
|
||
numberOfDays: 99,
|
||
availabilityDate: '2024-01-01',
|
||
},
|
||
],
|
||
rules: [],
|
||
} as unknown as PlanningRequest;
|
||
let posted: { type: string; request: PlanningRequest } | undefined;
|
||
class InspectingWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
|
||
postMessage(message: { type: string; request: PlanningRequest }): void {
|
||
posted = structuredClone(message);
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: { kind: 'Plan' } },
|
||
} as MessageEvent);
|
||
}
|
||
|
||
terminate(): void {}
|
||
}
|
||
vi.stubGlobal('Worker', InspectingWorker);
|
||
try {
|
||
await planLeave(request);
|
||
expect(posted?.request.balances[0]).toEqual({
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 1,
|
||
availableFrom: undefined,
|
||
expiryDate: '2025-01-01',
|
||
});
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('planning calculation context', () => {
|
||
it('explains fixed rule credits without debit and keeps them in comparisons after the inputs change', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const submitted: PlanningRequest = {
|
||
horizon: { start: '2024-01-08', end: '2024-01-20' },
|
||
balances: [balance('remaining-cp', 'cp', 1, '2025-01-01')],
|
||
declaredDays: [{ date: '2024-01-10', type: 'cp' }],
|
||
labels: { exact: 'Two CP including Wednesday', preferred: 'Two days this week' },
|
||
rules: [
|
||
{ id: 'exact', force: 'obligation', placeType: 'cp' },
|
||
{ id: 'preferred', force: 'preference', placeType: 'any' },
|
||
].map((rule) => ({
|
||
...rule,
|
||
type: 'time_window',
|
||
startDate: '2024-01-08',
|
||
endDate: '2024-01-12',
|
||
placeDays: 2,
|
||
})),
|
||
};
|
||
const originalDeclarations = structuredClone(submitted.declaredDays);
|
||
const calculation = createPlanningCalculation(submitted);
|
||
submitted.declaredDays![0].date = '2024-01-11';
|
||
submitted.declaredDays![0].type = 'rtt';
|
||
if ('placeDays' in submitted.rules[0]) submitted.rules[0].placeDays = 4;
|
||
if ('endDate' in submitted.rules[0]) submitted.rules[0].endDate = '2024-01-19';
|
||
submitted.horizon.end = '2024-01-11';
|
||
submitted.labels = { exact: 'Edited later' };
|
||
const outcome = isPlan(await calculation.outcome);
|
||
expect(outcome.placements).toHaveLength(1);
|
||
expect(outcome.remainingBalances).toEqual([]);
|
||
expect(outcome.preferences).toMatchObject([{ requested: 2, satisfied: 2 }]);
|
||
expect(outcome.timeWindowContributions).toEqual([
|
||
{
|
||
ruleId: 'exact',
|
||
label: 'Two CP including Wednesday',
|
||
requested: 2,
|
||
declaredDays: originalDeclarations,
|
||
placementDates: [outcome.placements[0].date],
|
||
},
|
||
{
|
||
ruleId: 'preferred',
|
||
label: 'Two days this week',
|
||
requested: 2,
|
||
declaredDays: originalDeclarations,
|
||
placementDates: [outcome.placements[0].date],
|
||
},
|
||
]);
|
||
const explanation = await calculation.explanation;
|
||
if (explanation?.kind !== 'PlanExplanation')
|
||
throw new Error('Expected a stable explanation.');
|
||
expect(explanation.timeWindowContributions).toEqual(outcome.timeWindowContributions);
|
||
expect(explanation.placements).toHaveLength(1);
|
||
expect(explanation.placements[0].comparison).toMatchObject({
|
||
before: {
|
||
objectives: expect.arrayContaining([
|
||
expect.objectContaining({ id: 'obligation-validity', value: 1 }),
|
||
expect.objectContaining({ id: 'preference:preferred', value: 1 }),
|
||
]),
|
||
},
|
||
after: {
|
||
objectives: expect.arrayContaining([
|
||
expect.objectContaining({ id: 'obligation-validity', value: 0 }),
|
||
expect.objectContaining({ id: 'preference:preferred', value: 2 }),
|
||
]),
|
||
},
|
||
});
|
||
const before = structuredClone(outcome);
|
||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||
expect(analysis.results.find(({ date }) => date === '2024-01-10')).toMatchObject({
|
||
status: 'ineligible',
|
||
reason: { code: 'declared-day' },
|
||
});
|
||
expect(analysis.results.find(({ date }) => date === '2024-01-15')).toMatchObject({
|
||
status: 'ineligible',
|
||
reason: { code: 'rule-invalid' },
|
||
});
|
||
expect(analysis.results.find(({ date }) => date === '2024-01-11')).toMatchObject({
|
||
status: 'eligible',
|
||
comparison: {
|
||
after: {
|
||
objectives: expect.arrayContaining([
|
||
expect.objectContaining({ id: 'obligation-validity', value: 0 }),
|
||
expect.objectContaining({ id: 'preference:preferred', value: 2 }),
|
||
]),
|
||
},
|
||
},
|
||
});
|
||
expect(
|
||
analysis.results.find(({ date }) => date === outcome.placements[0].date)?.comparison
|
||
?.after,
|
||
).toEqual(explanation.placements[0].objectiveVector);
|
||
expect(outcome).toEqual(before);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('keeps a cross-year declared extension in stable explanations and restored date comparisons', async () => {
|
||
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
|
||
const { addPlanningDays } = await import('./planning-date');
|
||
const declaredDays: NonNullable<PlanningRequest['declaredDays']>[number][] = [];
|
||
for (let date = '2024-12-31'; date < '2026-01-06'; date = addPlanningDays(date, 1))
|
||
if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' });
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-12-30', end: '2024-12-31' },
|
||
balances: [
|
||
{ ...balance('cp', 'cp', 1, '2024-12-30'), availableFrom: '2024-12-30' },
|
||
],
|
||
rules: [presets[0]],
|
||
declaredDays,
|
||
});
|
||
const outcome = isPlan(await calculation.outcome);
|
||
expect(outcome.timeOffPeriods[0]).toMatchObject({
|
||
start: '2024-12-28',
|
||
end: '2026-01-05',
|
||
});
|
||
expect(outcome.timeOffPeriods[0].nonWorkingDates).toContain('2026-01-01');
|
||
const explanation = await calculation.explanation;
|
||
if (explanation?.kind !== 'PlanExplanation')
|
||
throw new Error('Expected a stable plan explanation.');
|
||
expect(explanation.timeOffPeriods).toEqual(outcome.timeOffPeriods);
|
||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||
expect(analysis.results[0].comparison?.after).toEqual(
|
||
explanation.placements[0].objectiveVector,
|
||
);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('keeps declared periods and objective comparisons tied to the same snapshot after current inputs change', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const submitted: PlanningRequest = {
|
||
horizon: { start: '2026-11-01', end: '2026-12-01' },
|
||
balances: [balance('cp', 'cp', 2, '2026-11-30')],
|
||
rules: presets,
|
||
declaredDays: [
|
||
{ date: '2026-11-09', type: 'other' },
|
||
{ date: '2026-11-10', type: 'rtt' },
|
||
],
|
||
};
|
||
const original = structuredClone(submitted.declaredDays);
|
||
const calculation = createPlanningCalculation(submitted);
|
||
submitted.declaredDays![0].date = '2026-11-20';
|
||
submitted.declaredDays![0].type = 'cp';
|
||
submitted.horizon.end = '2026-11-12';
|
||
const outcome = isPlan(await calculation.outcome);
|
||
const explanation = await calculation.explanation;
|
||
if (explanation?.kind !== 'PlanExplanation')
|
||
throw new Error('Expected a stable plan explanation.');
|
||
expect(explanation.declaredDays).toEqual(original);
|
||
expect(explanation.timeOffPeriods).toEqual(outcome.timeOffPeriods);
|
||
for (const placement of explanation.placements) {
|
||
expect(placement.timeOffPeriod).toEqual(outcome.timeOffPeriods[0]);
|
||
expect(placement.finalMetrics).toEqual(outcome.metrics);
|
||
expect(placement.firstDecisiveObjective).toBe(
|
||
'optimization:default-unplaced-at-risk',
|
||
);
|
||
}
|
||
const before = structuredClone(outcome);
|
||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||
const restored = analysis.results.find(
|
||
({ date }) => date === outcome.placements[0].date,
|
||
);
|
||
expect(restored?.comparison?.after).toEqual(explanation.placements[0].objectiveVector);
|
||
const alternative = analysis.results.find(({ date }) => date === '2026-11-20');
|
||
expect(alternative?.status).toBe('eligible');
|
||
expect(alternative?.comparison?.after.objectives).toContainEqual(
|
||
expect.objectContaining({
|
||
id: 'optimization:default-holiday-week-value',
|
||
value: 0,
|
||
}),
|
||
);
|
||
expect(alternative?.comparison?.after.objectives).toContainEqual(
|
||
expect.objectContaining({
|
||
id: 'optimization:default-non-working-day-gain',
|
||
value: 7,
|
||
}),
|
||
);
|
||
expect(outcome).toEqual(before);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('detaches declared dates and types across results, explanations and comparisons', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const submitted: PlanningRequest = {
|
||
horizon: { start: '2024-01-02', end: '2024-02-01' },
|
||
balances: [balance('cp', 'cp', 4, '2024-01-05')],
|
||
rules: [presets[0]],
|
||
declaredDays: [
|
||
{ date: '2024-01-02', type: 'cp' },
|
||
{ date: '2024-01-03', type: 'rtt' },
|
||
{ date: '2024-01-05', type: 'other' },
|
||
],
|
||
};
|
||
const original = structuredClone(submitted.declaredDays);
|
||
const calculation = createPlanningCalculation(submitted);
|
||
submitted.declaredDays![0].type = 'other';
|
||
submitted.horizon.end = '2024-01-03';
|
||
expect(calculation.snapshot.declaredDays).toEqual(original);
|
||
expect(Object.isFrozen(calculation.snapshot.declaredDays![0])).toBe(true);
|
||
const outcome = isPlan(await calculation.outcome);
|
||
expect(outcome.declaredDays).toEqual(original);
|
||
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
|
||
expect(await calculation.explanation).toMatchObject({
|
||
kind: 'PlanExplanation',
|
||
declaredDays: original,
|
||
placements: [{ placement: { date: '2024-01-04' } }],
|
||
});
|
||
const frozenOutcome = structuredClone(outcome);
|
||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||
for (const { date } of original!)
|
||
expect(analysis.results.find((result) => result.date === date)).toMatchObject({
|
||
status: 'ineligible',
|
||
reason: { code: 'declared-day' },
|
||
});
|
||
expect(outcome).toEqual(frozenOutcome);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('keeps a multi-year horizon attached to its immutable outcome and explanation', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const request = {
|
||
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||
balances: [
|
||
{
|
||
id: 'cp',
|
||
type: 'cp',
|
||
quantity: 1,
|
||
availableFrom: '2026-12-24',
|
||
expiryDate: '2026-12-24',
|
||
},
|
||
],
|
||
rules: [
|
||
{
|
||
id: 'final-year',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2026-12-24',
|
||
endDate: '2026-12-24',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
};
|
||
const calculation = createPlanningCalculation(request);
|
||
request.horizon.end = '2025-01-01';
|
||
request.balances[0].expiryDate = '2024-12-31';
|
||
expect(calculation.snapshot.horizon).toEqual({
|
||
start: '2024-01-01',
|
||
end: '2027-01-01',
|
||
});
|
||
expect(isPlan(await calculation.outcome).horizon).toEqual(calculation.snapshot.horizon);
|
||
const explanation = await calculation.explanation;
|
||
expect(explanation?.kind).toBe('PlanExplanation');
|
||
if (explanation?.kind === 'PlanExplanation')
|
||
expect(explanation.placements[0]).toMatchObject({
|
||
placement: { date: '2026-12-24', balanceId: 'cp', ruleIds: ['final-year'] },
|
||
balance: { availableFrom: '2026-12-24', expiryDate: '2026-12-24' },
|
||
});
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('keeps an immutable submitted snapshot beside the outcome', async () => {
|
||
const request = {
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
labels: { cp: 'Paid leave' },
|
||
};
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation(request);
|
||
request.balances[0].quantity = 99;
|
||
request.labels.cp = 'Changed later';
|
||
|
||
expect(calculation.snapshot.balances[0].quantity).toBe(1);
|
||
expect(calculation.snapshot.labels['cp']).toBe('Paid leave');
|
||
expect((await calculation.outcome).kind).toBe('Plan');
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('lets validation reject an invalid Date in the submitted horizon', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: new Date(Number.NaN), end: '2025-01-01' },
|
||
balances: [],
|
||
rules: [],
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('InvalidInput');
|
||
if (outcome.kind === 'InvalidInput')
|
||
expect(outcome.reasons[0].code).toBe('horizon-start');
|
||
expect(calculation.snapshot.horizon.start).toBe('Invalid Date');
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('materializes a chronological explanation after the unchanged outcome', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-01-01', end: '2024-01-15' },
|
||
balances: [
|
||
{
|
||
...balance('cp', 'cp', 1, '2026-01-01'),
|
||
name: 'Paid leave entitlement',
|
||
},
|
||
],
|
||
rules: [
|
||
{
|
||
id: 'winter',
|
||
name: 'Winter break',
|
||
type: 'time_window',
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-12',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
labels: { winter: 'Winter break' },
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('Plan');
|
||
if (outcome.kind !== 'Plan') return;
|
||
expect('explanation' in outcome).toBe(false);
|
||
const explanation = await calculation.explanation;
|
||
expect(explanation?.kind).toBe('PlanExplanation');
|
||
if (explanation?.kind !== 'PlanExplanation') return;
|
||
expect(explanation.placements).toHaveLength(outcome.placements.length);
|
||
expect(explanation.placements[0].balance?.name).toBe('Paid leave entitlement');
|
||
expect(explanation.placements[0].ruleIds).toContain('winter');
|
||
expect(explanation.placements[0].objectiveVector.objectives.length).toBeGreaterThan(1);
|
||
expect('score' in explanation.placements[0].objectiveVector).toBe(false);
|
||
expect(explanation.placements[0].firstDecisiveObjective).toBe(
|
||
explanation.placements[0].comparison.firstDecisiveObjective,
|
||
);
|
||
expect(explanation.placements[0].firstDecisiveObjective).toBe('preference:winter');
|
||
const alternatives = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||
expect(alternatives.kind).toBe('Counterfactual');
|
||
if (alternatives.kind === 'Counterfactual')
|
||
expect(
|
||
alternatives.results.find(
|
||
(result) => result.date === outcome.placements[0].date,
|
||
)?.comparison?.firstDecisiveObjective,
|
||
).toBe('preference:winter');
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('reports every rule contribution when one placement is shared', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-01-01', end: '2024-01-06' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [
|
||
{
|
||
id: 'wide',
|
||
name: 'Wide window',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-05',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
{
|
||
id: 'exact',
|
||
name: 'Exact day',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-01-05',
|
||
endDate: '2024-01-05',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('Plan');
|
||
const explanation = await calculation.explanation;
|
||
expect(explanation?.kind).toBe('PlanExplanation');
|
||
if (explanation?.kind !== 'PlanExplanation') return;
|
||
expect(
|
||
explanation.placements[0].contributions
|
||
.filter((contribution) => contribution.kind === 'rule')
|
||
.map((contribution) => contribution.ruleId),
|
||
).toEqual(['exact', 'wide']);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('compares the selected placement across the half-open horizon without changing the plan', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1, '2024-12-31')],
|
||
rules: [presets[3]],
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('Plan');
|
||
if (outcome.kind !== 'Plan') return;
|
||
const before = structuredClone(outcome);
|
||
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
|
||
expect(analysis.kind).toBe('Counterfactual');
|
||
if (analysis.kind !== 'Counterfactual') return;
|
||
expect(analysis.results).toHaveLength(9);
|
||
expect(analysis.results.some((result) => result.reason?.code === 'non-working')).toBe(
|
||
true,
|
||
);
|
||
expect(
|
||
analysis.results.some(
|
||
(result) => result.status === 'eligible' && result.comparison,
|
||
),
|
||
).toBe(true);
|
||
expect(outcome).toEqual(before);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('keeps every mandatory IF_THEN occurrence satisfied in counterfactuals', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||
balances: [balance('cp', 'cp', 2, '2024-12-31')],
|
||
rules: [
|
||
{
|
||
id: 'wednesday-holidays',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'auto',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('Plan');
|
||
if (outcome.kind !== 'Plan') return;
|
||
const firstOccurrence = outcome.placements.find(
|
||
(placement) => placement.date < '2024-05-05',
|
||
);
|
||
const secondOccurrence = outcome.placements.find(
|
||
(placement) => placement.date > '2024-05-05',
|
||
);
|
||
expect(firstOccurrence).toBeDefined();
|
||
expect(secondOccurrence).toBeDefined();
|
||
if (!firstOccurrence || !secondOccurrence) return;
|
||
|
||
const alternateSecondDate =
|
||
secondOccurrence.date === '2024-05-07' ? '2024-05-10' : '2024-05-07';
|
||
const analysis = await calculation.analyzeCounterfactual(firstOccurrence);
|
||
expect(analysis.kind).toBe('Counterfactual');
|
||
if (analysis.kind !== 'Counterfactual') return;
|
||
expect(
|
||
analysis.results.find((result) => result.date === alternateSecondDate),
|
||
).toMatchObject({
|
||
status: 'ineligible',
|
||
reason: { code: 'rule-invalid' },
|
||
});
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('preserves supporting provenance in counterfactual placements', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-01-05', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1, '2025-01-01')],
|
||
rules: [nonWorkingGain, supportingEconomy],
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('Plan');
|
||
if (outcome.kind !== 'Plan') return;
|
||
const selected = outcome.placements.find((placement) => placement.supporting);
|
||
expect(selected).toBeDefined();
|
||
if (!selected) return;
|
||
|
||
const analysis = await calculation.analyzeCounterfactual(selected);
|
||
expect(analysis.kind).toBe('Counterfactual');
|
||
if (analysis.kind !== 'Counterfactual') return;
|
||
const restored = analysis.results.find((result) => result.date === selected.date);
|
||
expect(
|
||
restored?.comparison?.after.objectives.find(
|
||
(objective) => objective.id === 'optimization:economy',
|
||
)?.value,
|
||
).toBe(1);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('cancels superseded planning workers without changing the latest result', async () => {
|
||
const browserWindow = globalThis.window;
|
||
const expected = isPlan(
|
||
await runInNonBrowser({
|
||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
}),
|
||
);
|
||
const workers: Array<{ terminated: boolean }> = [];
|
||
class SupersededWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
terminated = false;
|
||
|
||
constructor() {
|
||
workers.push(this);
|
||
}
|
||
|
||
postMessage(): void {
|
||
if (workers.length === 2) {
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: expected },
|
||
} as MessageEvent);
|
||
return;
|
||
}
|
||
if (workers.length !== 3) return;
|
||
queueMicrotask(() => {
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: expected },
|
||
} as MessageEvent);
|
||
this.onmessage?.({
|
||
data: { type: 'artifacts', artifacts: {} },
|
||
} as MessageEvent);
|
||
});
|
||
}
|
||
|
||
terminate(): void {
|
||
this.terminated = true;
|
||
}
|
||
}
|
||
vi.stubGlobal('window', {});
|
||
vi.stubGlobal('Worker', SupersededWorker);
|
||
try {
|
||
const pending = createPlanningCalculation({
|
||
horizon: expected.horizon,
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
});
|
||
const materializing = createPlanningCalculation({
|
||
horizon: expected.horizon,
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
});
|
||
const latest = createPlanningCalculation({
|
||
horizon: expected.horizon,
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
});
|
||
|
||
expect(workers).toHaveLength(3);
|
||
expect(workers[0].terminated).toBe(true);
|
||
expect(workers[1].terminated).toBe(true);
|
||
const pendingOutcome = await pending.outcome;
|
||
expect(pendingOutcome.kind).toBe('PlanningIncomplete');
|
||
if (pendingOutcome.kind === 'PlanningIncomplete')
|
||
expect(pendingOutcome.reasons[0].code).toBe('stale-calculation');
|
||
expect(await materializing.outcome).toEqual(expected);
|
||
expect(await materializing.explanationError).toBeUndefined();
|
||
expect(await latest.outcome).toEqual(expected);
|
||
expect(workers[2].terminated).toBe(true);
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('discards counterfactual work from an older calculation', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const older = createPlanningCalculation({
|
||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [presets[3]],
|
||
});
|
||
const olderOutcome = await older.outcome;
|
||
expect(olderOutcome.kind).toBe('Plan');
|
||
if (olderOutcome.kind !== 'Plan') return;
|
||
createPlanningCalculation({ horizon, balances: [], rules: [] });
|
||
|
||
const analysis = await older.analyzeCounterfactual(olderOutcome.placements[0]);
|
||
expect(analysis.kind).toBe('CounterfactualStale');
|
||
expect(analysis.results).toEqual([]);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('keeps counterfactual worker failures local and allows a clean retry', async () => {
|
||
const request = {
|
||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [presets[3]],
|
||
};
|
||
const expected = isPlan(await runInNonBrowser(request));
|
||
const selected = expected.placements[0];
|
||
const response: CounterfactualAnalysis = {
|
||
kind: 'Counterfactual',
|
||
calculationId: 'transport-test',
|
||
placement: selected,
|
||
hypothetical: true,
|
||
baseline: { objectives: [] },
|
||
results: [],
|
||
};
|
||
let counterfactualCalls = 0;
|
||
class RetriableWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
terminated = false;
|
||
|
||
postMessage(message: { type: string }): void {
|
||
if (message.type === 'plan') {
|
||
queueMicrotask(() => {
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: expected },
|
||
} as MessageEvent);
|
||
this.onmessage?.({
|
||
data: { type: 'artifacts', artifacts: {} },
|
||
} as MessageEvent);
|
||
});
|
||
return;
|
||
}
|
||
counterfactualCalls++;
|
||
queueMicrotask(() => {
|
||
if (counterfactualCalls === 1)
|
||
this.onmessage?.({
|
||
data: {
|
||
type: 'error',
|
||
code: 'counterfactual-error',
|
||
message: 'Counterfactual analysis failed locally.',
|
||
},
|
||
} as MessageEvent);
|
||
else
|
||
this.onmessage?.({
|
||
data: { type: 'counterfactual-result', analysis: response },
|
||
} as MessageEvent);
|
||
});
|
||
}
|
||
|
||
terminate(): void {
|
||
this.terminated = true;
|
||
}
|
||
}
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', {});
|
||
vi.stubGlobal('Worker', RetriableWorker);
|
||
try {
|
||
const calculation = createPlanningCalculation(request);
|
||
expect(await calculation.outcome).toEqual(expected);
|
||
await expect(calculation.analyzeCounterfactual(selected)).rejects.toThrow(
|
||
'Counterfactual analysis failed locally.',
|
||
);
|
||
const analysis = await calculation.analyzeCounterfactual(selected);
|
||
expect(analysis.kind).toBe('Counterfactual');
|
||
expect(analysis.calculationId).toBe(calculation.id);
|
||
expect(counterfactualCalls).toBe(2);
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('terminates an active counterfactual worker when a newer calculation starts', async () => {
|
||
const request = {
|
||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [presets[3]],
|
||
};
|
||
const expected = isPlan(await runInNonBrowser(request));
|
||
const workers: Array<{ terminated: boolean }> = [];
|
||
class SupersededCounterfactualWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
terminated = false;
|
||
|
||
constructor() {
|
||
workers.push(this);
|
||
}
|
||
|
||
postMessage(message: { type: string }): void {
|
||
if (message.type !== 'plan') return;
|
||
queueMicrotask(() => {
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: expected },
|
||
} as MessageEvent);
|
||
this.onmessage?.({
|
||
data: { type: 'artifacts', artifacts: {} },
|
||
} as MessageEvent);
|
||
});
|
||
}
|
||
|
||
terminate(): void {
|
||
this.terminated = true;
|
||
}
|
||
}
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', {});
|
||
vi.stubGlobal('Worker', SupersededCounterfactualWorker);
|
||
try {
|
||
const previous = createPlanningCalculation(request);
|
||
await previous.outcome;
|
||
const pendingAnalysis = previous.analyzeCounterfactual(expected.placements[0]);
|
||
await vi.waitFor(() => expect(workers).toHaveLength(2));
|
||
const latest = createPlanningCalculation(request);
|
||
const stale = await pendingAnalysis;
|
||
expect(stale.kind).toBe('CounterfactualStale');
|
||
expect(workers[1].terminated).toBe(true);
|
||
expect((await latest.outcome).kind).toBe('Plan');
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('keeps earned-entitlement availability and provenance in counterfactuals', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-04-25', end: '2024-05-15' },
|
||
balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')],
|
||
rules: [
|
||
{
|
||
id: 'wednesday-holidays',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 3,
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
placement: 'after',
|
||
rewardDays: 1,
|
||
rewardType: 'rtt',
|
||
},
|
||
{
|
||
id: 'thursday-consumer',
|
||
type: 'if_then',
|
||
force: 'obligation',
|
||
dayOfWeek: 4,
|
||
placeDays: 1,
|
||
placeType: 'rtt',
|
||
placement: 'before',
|
||
rewardDays: 0,
|
||
},
|
||
],
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('Plan');
|
||
if (outcome.kind !== 'Plan') return;
|
||
const earned = outcome.placements.find((placement) =>
|
||
placement.balanceId.startsWith('earned:'),
|
||
);
|
||
expect(earned).toBeDefined();
|
||
if (!earned) return;
|
||
const analysis = await calculation.analyzeCounterfactual(earned);
|
||
expect(analysis.kind).toBe('Counterfactual');
|
||
if (analysis.kind !== 'Counterfactual') return;
|
||
expect(analysis.placement).toEqual(earned);
|
||
expect(
|
||
analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code,
|
||
).toBe('unavailable');
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('evaluates every eligible balance and retains the earlier expiry for an equal preference', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon: { start: '2024-01-01', end: '2024-01-10' },
|
||
balances: [
|
||
balance('later', 'cp', 1, '2026-01-01'),
|
||
balance('earlier', 'cp', 1, '2025-01-01'),
|
||
],
|
||
rules: [
|
||
{
|
||
id: 'window',
|
||
type: 'time_window',
|
||
force: 'preference',
|
||
startDate: '2024-01-02',
|
||
endDate: '2024-01-02',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('Plan');
|
||
if (outcome.kind !== 'Plan') return;
|
||
expect(outcome.placements[0].balanceId).toBe('earlier');
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('omits a plan explanation for invalid input and explains conflicts', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const invalidCalculation = createPlanningCalculation({
|
||
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
||
balances: [],
|
||
rules: [],
|
||
});
|
||
expect((await invalidCalculation.outcome).kind).toBe('InvalidInput');
|
||
expect(await invalidCalculation.explanation).toBeUndefined();
|
||
|
||
const conflictCalculation = createPlanningCalculation({
|
||
horizon,
|
||
balances: [balance('rtt', 'rtt', 1)],
|
||
rules: [
|
||
{
|
||
id: 'cp-only',
|
||
name: 'Paid leave only',
|
||
type: 'time_window',
|
||
force: 'obligation',
|
||
startDate: '2024-04-01',
|
||
endDate: '2024-04-05',
|
||
placeDays: 1,
|
||
placeType: 'cp',
|
||
},
|
||
],
|
||
});
|
||
expect((await conflictCalculation.outcome).kind).toBe('PlanningConflict');
|
||
const explanation = await conflictCalculation.explanation;
|
||
expect(explanation?.kind).toBe('ConflictExplanation');
|
||
if (explanation?.kind === 'ConflictExplanation')
|
||
expect(explanation.conflictingRules).toEqual([
|
||
{ id: 'cp-only', label: 'Paid leave only' },
|
||
]);
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('marks interrupted and unexecuted phases when no incumbent exists', async () => {
|
||
const browserWindow = globalThis.window;
|
||
vi.stubGlobal('window', undefined);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 5)],
|
||
rules: [],
|
||
deadlineMs: 0,
|
||
});
|
||
const outcome = await calculation.outcome;
|
||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||
const explanation = await calculation.explanation;
|
||
expect(explanation?.kind).toBe('IncompleteExplanation');
|
||
} finally {
|
||
vi.stubGlobal('window', browserWindow);
|
||
}
|
||
});
|
||
|
||
it('explains an incomplete incumbent without reusing the expired planning deadline', async () => {
|
||
const core = await import('./leave-planner.core');
|
||
let clock = 0;
|
||
let expired = false;
|
||
const clockSpy = vi.spyOn(performance, 'now').mockImplementation(() => {
|
||
if (expired) clock += 100;
|
||
return clock;
|
||
});
|
||
try {
|
||
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
|
||
const outcome = core.calculatePlanningInternal(
|
||
{
|
||
horizon,
|
||
balances: Array.from({ length: 12 }, (_, index) =>
|
||
balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12),
|
||
),
|
||
rules: presets,
|
||
deadlineMs: 20,
|
||
progress: (stage: string) => {
|
||
if (stage === `optimization:${presets[0].id}`) expired = true;
|
||
},
|
||
},
|
||
undefined,
|
||
(value) => (artifacts = value),
|
||
);
|
||
expect(outcome.kind).toBe('PlanningIncomplete');
|
||
if (outcome.kind !== 'PlanningIncomplete') return;
|
||
expect(outcome.incumbent).toBeDefined();
|
||
expect(artifacts?.explanation?.kind).toBe('PlanExplanation');
|
||
if (artifacts?.explanation?.kind !== 'PlanExplanation') return;
|
||
expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline');
|
||
expect(artifacts.explanation.placements).toHaveLength(
|
||
outcome.incumbent?.placements.length ?? 0,
|
||
);
|
||
} finally {
|
||
clockSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it('publishes the worker outcome before delayed explanation artifacts', async () => {
|
||
const expected = isPlan(
|
||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||
);
|
||
class DelayedArtifactsWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
|
||
postMessage(): void {
|
||
queueMicrotask(() =>
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: expected },
|
||
} as MessageEvent),
|
||
);
|
||
}
|
||
|
||
terminate(): void {}
|
||
}
|
||
vi.stubGlobal('Worker', DelayedArtifactsWorker);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
});
|
||
expect((await calculation.outcome).kind).toBe('Plan');
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
|
||
it('keeps a valid outcome when explanation delivery fails afterward', async () => {
|
||
const expected = isPlan(
|
||
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
|
||
);
|
||
class FailingArtifactsWorker {
|
||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||
onerror: (() => void) | null = null;
|
||
|
||
postMessage(): void {
|
||
queueMicrotask(() => {
|
||
this.onmessage?.({
|
||
data: { type: 'result', outcome: expected },
|
||
} as MessageEvent);
|
||
queueMicrotask(() => this.onerror?.());
|
||
});
|
||
}
|
||
|
||
terminate(): void {}
|
||
}
|
||
vi.stubGlobal('Worker', FailingArtifactsWorker);
|
||
try {
|
||
const calculation = createPlanningCalculation({
|
||
horizon,
|
||
balances: [balance('cp', 'cp', 1)],
|
||
rules: [],
|
||
});
|
||
expect(await calculation.outcome).toEqual(expected);
|
||
expect((await calculation.explanationError)?.code).toBe('worker-error');
|
||
} finally {
|
||
vi.unstubAllGlobals();
|
||
}
|
||
});
|
||
});
|