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

Refs #291

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

4191 lines
122 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
});
});