Files
vac-optimizer/src/app/services/leave-planner.service.spec.ts
T

5539 lines
162 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 { vi } from 'vitest';
import {
applyReplayDeltas,
PlanningOutcome,
PlanningRequest,
createPlanningCalculation,
planLeave,
} from './leave-planner.service';
import type {
CounterfactualAnalysis,
HistoricalEligibilityReason,
PlanOutcome,
PlanningReason,
PlanningTraceState,
PlanningTraceUpdate,
} from './leave-planner.service';
import type { CounterfactualBaselineOptions } from './leave-planner.core';
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());
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 trace = await calculation.trace;
expect(trace?.initialState.calculationDate).toBe('2024-05-01');
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',
);
}
const consumerDecision = trace?.phases
.flatMap(({ decisions }) => decisions)
.find(({ deltas }) =>
deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')),
);
expect(consumerDecision).toBeDefined();
if (consumerDecision)
expect(
(
await calculation.analyzeCounterfactual({
decisionId: consumerDecision.id,
placement: result.placements[0],
})
).kind,
).toBe('Counterfactual');
} 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');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
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,
})),
},
undefined,
(value) => (artifacts = value),
),
);
expect(result.placements).toHaveLength(2);
expect(result.placements.map(({ type }) => type).sort()).toEqual(['cp', 'rtt']);
const evaluated =
artifacts.trace?.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.candidates),
) ?? [];
expect(evaluated.some(({ reason }) => reason?.code === 'obligation-solution-limit')).toBe(
false,
);
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' },
});
expect(artifacts.trace?.initialState.declaredDays).toEqual(declaredDays);
});
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: [],
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: [],
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: [],
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: [],
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: [],
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 = 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: [],
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: [
{
id: 'preferred-friday',
type: 'time_window',
force: 'preference',
startDate: '2026-11-20',
endDate: '2026-11-20',
placeDays: 1,
placeType: 'cp',
},
],
}),
);
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,
}),
);
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: [
{
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: [],
}),
);
const supporting = result.placements.find((placement) => placement.supporting);
expect(supporting?.ruleIds).toEqual([]);
expect(supporting?.balanceId).toBe('cp');
});
it('does not trade a 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: [],
}),
);
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: [],
}),
);
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: [],
}),
);
const fourDayPlan = isPlan(
await runInNonBrowser({
horizon: { start: '2024-02-01', end: '2024-03-01' },
balances: [balance('cp', 'cp', 4, '2024-02-29')],
rules: [],
}),
);
expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([
'2024-02-09',
'2024-02-19',
]);
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: [],
}),
);
expect(result.placements.map((placement) => placement.date)).toEqual(dates);
expect(result.timeOffPeriods).toHaveLength(1);
expect(result.timeOffPeriods[0].duration).toBe(9);
});
// Async calculation yields to the test timer; allow the planner's ten-second budget.
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: [
{
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',
},
],
}),
);
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',
]),
);
}, 15_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: [
{
id: 'july',
type: 'time_window',
force: 'preference',
startDate: '2027-07-01',
endDate: '2027-07-31',
placeDays: 4,
placeType: 'any',
},
],
}),
);
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: [],
}),
);
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: [
{
id: 'window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-09',
endDate: '2024-01-12',
placeDays: 1,
placeType: 'cp',
},
],
}),
);
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: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
},
],
};
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 === '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 and delivers its trace later', 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;
let traceReady = false;
void calculation.outcome.then(() => (outcomeReady = true));
void calculation.trace.then(() => (traceReady = 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(traceReady).toBe(false);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit({ type: 'artifacts', artifacts: {} });
await calculation.trace;
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 prefix 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: PlanningTraceState | 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', 'at-risk'],
lastCompletedStage: 'at-risk',
};
const initialState: PlanningTraceState = {
horizon: request.horizon,
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
remaining: 1,
availableFrom: '2024-01-01',
expiryDate: '2024-12-31',
earned: false,
},
],
placements: [],
earnedActivations: [],
};
const prefix: PlanningTraceUpdate = {
initialState,
operations: [
{
kind: 'phase',
phase: {
id: 'phase:1',
name: 'dates',
completed: false,
status: 'interrupted',
},
},
{
kind: 'decision',
phaseId: 'phase:1',
decision: { id: 'decision:1', action: 'choose', dates: ['2024-01-02'] },
},
{
kind: 'candidate',
phaseId: 'phase:1',
decisionId: 'decision:1',
index: 0,
mode: 'append',
candidate: { status: 'evaluated-rejected', date: '2024-01-02' },
},
],
};
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: PlanningTraceState;
interruption: PlanningReason;
},
): void {
if (message.type === 'plan') {
queueMicrotask(() => {
this.onmessage?.({
data: { type: 'checkpoint', plan: checkpoint, state: checkpointState },
} as MessageEvent);
this.onmessage?.({
data: { type: 'trace-update', totalMs: 0, update: prefix },
} 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 trace = await calculation.trace;
expect(trace?.status).toBe('failed');
expect(trace?.error?.code).toBe('worker-error');
expect(trace?.phases[0].decisions[0].candidates).toHaveLength(1);
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('does not let excluded trace transfer time consume worker liveness before the outcome', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
class TraceAwareWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 0 } } as MessageEvent);
setTimeout(() => {
this.onmessage?.({
data: { type: 'trace-overhead', totalMs: 300 },
} as MessageEvent);
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts', artifacts: {} },
} as MessageEvent);
}, 300);
}
terminate(): void {}
}
vi.stubGlobal('Worker', TraceAwareWorker);
try {
expect(
await planLeave({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
deadlineMs: 100,
}),
).toEqual(expected);
} finally {
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';
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 trace = await calculation.trace;
expect(trace?.initialState.declaredDays).toEqual(originalDeclarations);
const deltas =
trace?.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.deltas),
) ?? [];
const placements = deltas.filter((delta) => delta.kind === 'placement-added');
expect(placements.length).toBeGreaterThan(0);
for (const delta of placements) {
expect(delta.placement?.date).not.toBe('2024-01-10');
}
const debits = deltas.filter(
(delta) =>
delta.kind === 'balance-changed' &&
delta.remainingBefore! > delta.remainingAfter!,
);
expect(debits.length).toBeGreaterThan(0);
for (const debit of debits)
expect(debit.remainingBefore! - debit.remainingAfter!).toBe(1);
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: [],
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: [],
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('unplaced-at-risk');
}
const trace = await calculation.trace;
expect(trace?.initialState.declaredDays).toEqual(original);
const evaluated = trace?.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.candidates),
);
const fixedDates = new Set(original?.map(({ date }) => date));
for (const candidate of evaluated?.filter(({ status }) => status === 'chosen') ?? [])
expect(
(candidate.dates ?? [candidate.date]).some(
(date) => date && fixedDates.has(date),
),
).toBe(false);
expect(
evaluated?.some(
(candidate) =>
candidate.comparison?.after.objectives.some(
(objective) =>
objective.id === 'holiday-week-value' && objective.value === 3,
) &&
candidate.comparison.after.objectives.some(
(objective) =>
objective.id === 'non-working-day-gain' && objective.value === 5,
),
),
).toBe(true);
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: 'holiday-week-value', value: 0 }),
);
expect(alternative?.comparison?.after.objectives).toContainEqual(
expect.objectContaining({ id: 'non-working-day-gain', value: 7 }),
);
expect(outcome).toEqual(before);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('detaches declared dates and types across results, explanations, trace 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: [],
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 trace = await calculation.trace;
expect(trace?.initialState.declaredDays).toEqual(original);
expect(trace?.nonWorkingDates).toContainEqual({
date: '2024-01-02',
reason: expect.objectContaining({ code: 'declared-day' }),
});
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' },
historicalStatus: 'ineligible',
});
expect(outcome).toEqual(frozenOutcome);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', 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' },
});
expect((await calculation.trace)?.initialState.horizon).toEqual(
calculation.snapshot.horizon,
);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('replays full balance metadata when an existing balance changes', () => {
const state = {
balances: [
{
id: 'earned:producer:2024-01-04',
type: 'rtt' as const,
quantity: 0,
remaining: 0,
availableFrom: '2024-01-01',
expiryDate: '2024-12-31',
earned: false,
},
],
placements: new Map(),
earnedActivations: [],
};
applyReplayDeltas(state, [
{
kind: 'balance-changed',
balanceId: 'earned:producer:2024-01-04',
remainingBefore: 0,
remainingAfter: 1,
balance: {
id: 'earned:producer:2024-01-04',
type: 'rtt',
quantity: 1,
remaining: 1,
availableFrom: '2024-01-05',
expiryDate: '2024-12-31',
earned: true,
sourceRuleId: 'producer',
},
},
]);
expect(state.balances[0]).toEqual({
id: 'earned:producer:2024-01-04',
type: 'rtt',
quantity: 1,
remaining: 1,
availableFrom: '2024-01-05',
expiryDate: '2024-12-31',
earned: true,
sourceRuleId: 'producer',
});
});
it('applies placement delta balance changes to the replayed balance', () => {
const state = {
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 1,
remaining: 1,
availableFrom: '2024-01-01',
expiryDate: '2024-12-31',
earned: false,
},
],
placements: new Map(),
earnedActivations: [],
};
applyReplayDeltas(state, [
{
kind: 'placement-added',
balanceId: 'cp',
remainingBefore: 1,
remainingAfter: 0,
placement: {
date: '2024-01-04',
balanceId: 'cp',
type: 'cp',
ruleIds: [],
supporting: false,
},
},
]);
expect(state.balances[0]).toEqual({
id: 'cp',
type: 'cp',
quantity: 1,
remaining: 0,
availableFrom: '2024-01-01',
expiryDate: '2024-12-31',
earned: false,
});
});
it('keeps the planning outcome identical when trace instrumentation is enabled', async () => {
const core = await import('./leave-planner.core');
const request = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 3, '2024-01-31')],
rules: [],
};
const withoutTrace = core.calculatePlanningInternal(request);
let traceStatus: string | undefined;
const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => {
traceStatus = artifacts.trace?.status;
});
expect(withTrace).toEqual(withoutTrace);
expect(traceStatus).toBe('ready');
});
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: [],
});
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-09' : '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('allows trace counterfactuals while later obligations are still pending', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-05-01', end: '2024-05-10' },
balances: [balance('cp', 'cp', 2, '2024-12-31')],
rules: [
{
id: 'early-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-02',
endDate: '2024-05-03',
placeDays: 1,
placeType: 'cp',
},
{
id: 'late-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-07',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const earlyPlacement = outcome.placements.find((placement) =>
placement.ruleIds.includes('early-window'),
);
expect(earlyPlacement).toBeDefined();
if (!earlyPlacement) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some(
(delta) =>
delta.kind === 'placement-added' &&
delta.placement?.ruleIds.includes('early-window'),
),
);
expect(decision).toBeDefined();
if (!decision) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: decision.id,
placement: earlyPlacement,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
['2024-05-02', '2024-05-03'].map(
(date) => analysis.results.find((result) => result.date === date)?.status,
),
).toEqual(['eligible', 'eligible']);
expect(
analysis.results.find((result) => result.date === earlyPlacement.date)?.status,
).toBe('eligible');
} 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: [],
});
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 === 'supporting-placements',
)?.value,
).toBe(1);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('cancels superseded planning and trace 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 trace = {
status: 'ready' as const,
initialState: {
horizon: expected.horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
};
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: { trace } },
} 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 pending.trace)?.error?.code).toBe('stale-calculation');
expect(await materializing.outcome).toEqual(expected);
expect((await materializing.trace)?.error?.code).toBe('stale-calculation');
expect(await materializing.explanationError).toBeUndefined();
expect(await latest.outcome).toEqual(expected);
expect((await latest.trace)?.status).toBe('ready');
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: [],
});
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: [],
};
const expected = isPlan(await runInNonBrowser(request));
const selected = expected.placements[0];
const trace = {
status: 'ready' as const,
initialState: {
horizon: expected.horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: expected.completedStages,
};
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: 'explanation',
explanation: { kind: 'PlanExplanation', placements: [] },
},
} as MessageEvent);
this.onmessage?.({
data: {
type: 'artifacts-start',
artifacts: {},
trace,
},
} as MessageEvent);
this.onmessage?.({ data: { type: 'artifacts-complete' } } 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 calculation.trace;
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: [],
};
const expected = isPlan(await runInNonBrowser(request));
const trace = {
status: 'ready' as const,
initialState: {
horizon: expected.horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: expected.completedStages,
};
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-start', artifacts: {}, trace },
} as MessageEvent);
this.onmessage?.({ data: { type: 'artifacts-complete' } } 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;
await previous.trace;
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');
const trace = await calculation.trace;
const earnedBalanceDelta = trace?.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.deltas)
.find((delta) => delta.balance?.id === earned.balanceId);
expect(earnedBalanceDelta?.balance).toMatchObject({
id: earned.balanceId,
type: earned.type,
earned: true,
});
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('replays a trace decision from its pre-decision state', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: [
{
id: 'wide',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
);
const placement = decision?.deltas.find(
(delta) => delta.kind === 'placement-added',
)?.placement;
expect(decision).toBeDefined();
expect(placement).toBeDefined();
if (!decision || !placement) return;
const laterPlacement = outcome.placements.find(
(candidate) => candidate.date !== placement.date,
);
expect(laterPlacement).toBeDefined();
if (!laterPlacement) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: decision.id,
placement,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
analysis.results.find((result) => result.date === laterPlacement.date)?.status,
).toBe('eligible');
expect(
analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
).toBe('ineligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('keeps source-state ineligibility separate from unexamined trace history', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-11' },
balances: [
{
...balance('cp', 'cp', 1, '2024-01-07'),
availableFrom: '2024-01-04',
},
],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some(
(delta) =>
delta.kind === 'placement-added' &&
delta.placement?.date === '2024-01-04',
),
);
const placement = decision?.deltas.find(
(delta) =>
delta.kind === 'placement-added' && delta.placement?.date === '2024-01-04',
)?.placement;
expect(placement).toBeDefined();
expect(decision).toBeDefined();
if (!placement || !decision) return;
const selection = { decisionId: decision.id, placement };
const historicalEligibility = await calculation.historicalEligibility(selection);
expect(historicalEligibility).toContainEqual({
date: '2024-01-02',
reason: expect.objectContaining({ code: 'unavailable' }),
});
expect(historicalEligibility).toContainEqual({
date: '2024-01-08',
reason: expect.objectContaining({ code: 'expired' }),
});
const analysis = await calculation.analyzeCounterfactual(selection);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.results.find((result) => result.date === '2024-01-02')).toMatchObject({
status: 'ineligible',
historicalStatus: 'ineligible',
reason: { code: 'unavailable' },
historicalReason: { code: 'unavailable' },
});
expect(analysis.results.find((result) => result.date === '2024-01-08')).toMatchObject({
status: 'ineligible',
historicalStatus: 'ineligible',
reason: { code: 'expired' },
historicalReason: { code: 'expired' },
});
expect(analysis.results.find((result) => result.date === '2024-01-04')).toMatchObject({
historicalStatus: 'chosen',
historicalReason: undefined,
});
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('replays branch-local ancestors without applying the selected decision twice', async () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1)],
rules: [],
};
const plan = isPlan(await runInNonBrowser(request));
const firstPlacement = plan.placements[0];
if (!firstPlacement) throw new Error('Expected the plan to contain a placement.');
const selected = { ...firstPlacement, date: '2024-01-03' };
const outcome = { ...plan, placements: [selected] };
const committedPlacement = { ...selected, date: '2024-01-01' };
const parentPlacement = { ...selected, date: '2024-01-02' };
const initialState: PlanningTraceState = {
horizon: request.horizon,
balances: [
{
id: 'cp',
type: 'cp',
quantity: 5,
remaining: 5,
availableFrom: '2024-01-01',
expiryDate: '2024-01-31',
earned: false,
},
],
placements: [],
earnedActivations: [],
};
const committedDeltas = [
{
kind: 'placement-added' as const,
date: committedPlacement.date,
balanceId: 'cp',
placement: committedPlacement,
},
{
kind: 'balance-changed' as const,
balanceId: 'cp',
remainingBefore: 5,
remainingAfter: 4,
balance: {
id: 'cp',
type: 'cp' as const,
quantity: 5,
remaining: 4,
availableFrom: '2024-01-02',
expiryDate: '2024-01-31',
earned: true,
sourceRuleId: 'producer',
},
},
];
const parentDeltas = [
{
kind: 'placement-added' as const,
date: parentPlacement.date,
balanceId: 'cp',
placement: parentPlacement,
},
{
kind: 'balance-changed' as const,
balanceId: 'cp',
remainingBefore: 4,
remainingAfter: 3,
},
];
const selectedTransition = [
{
kind: 'placement-moved' as const,
fromDate: parentPlacement.date,
toDate: selected.date,
date: selected.date,
balanceId: 'cp',
previousPlacement: parentPlacement,
placement: selected,
},
{
kind: 'balance-changed' as const,
balanceId: 'cp',
remainingBefore: 3,
remainingAfter: 2,
},
];
const update: PlanningTraceUpdate = {
initialState,
operations: [
{
kind: 'phase',
phase: {
id: 'obligations',
name: 'obligations',
completed: true,
status: 'completed',
},
},
{
kind: 'decision',
phaseId: 'obligations',
decision: { id: 'committed', action: 'choose', dates: ['2024-01-01'] },
},
...committedDeltas.map((delta, index) => ({
kind: 'delta' as const,
phaseId: 'obligations',
decisionId: 'committed',
index,
delta,
})),
{
kind: 'decision',
phaseId: 'obligations',
decision: {
id: 'branch-parent',
action: 'choose',
dates: [parentPlacement.date],
branch: { deltas: parentDeltas },
},
},
{
kind: 'decision',
phaseId: 'obligations',
decision: {
id: 'branch-selected',
action: 'choose',
dates: [selected.date],
branch: {
parentDecisionId: 'branch-parent',
deltas: selectedTransition,
},
},
},
...selectedTransition.map((delta, index) => ({
kind: 'delta' as const,
phaseId: 'obligations',
decisionId: 'branch-selected',
index,
delta,
})),
{
kind: 'decision',
phaseId: 'obligations',
decision: {
id: 'self-parent',
action: 'choose',
dates: [selected.date],
branch: { parentDecisionId: 'self-parent', deltas: [] },
},
},
],
};
const response: CounterfactualAnalysis = {
kind: 'Counterfactual',
calculationId: 'branch-transport-test',
placement: selected,
hypothetical: true,
baseline: { objectives: [] },
results: [],
};
let receivedState: PlanningTraceState | undefined;
let receivedOptions: CounterfactualBaselineOptions | undefined;
class BranchTraceWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(message: {
type: string;
state?: PlanningTraceState;
options?: CounterfactualBaselineOptions;
}): void {
if (message.type === 'plan') {
this.onmessage?.({ data: { type: 'result', outcome } } as MessageEvent);
this.onmessage?.({
data: {
type: 'artifacts-start',
artifacts: {},
trace: {
status: 'ready',
initialState,
phases: [],
completedStages: outcome.completedStages,
nonWorkingDates: [],
error: undefined,
},
},
} as MessageEvent);
this.onmessage?.({
data: { type: 'trace-update', totalMs: 0, update },
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts-complete', traceTransferMs: 0 },
} as MessageEvent);
return;
}
receivedState = message.state;
receivedOptions = message.options;
if (message.type === 'historical-eligibility') {
queueMicrotask(() =>
this.onmessage?.({
data: {
type: 'historical-eligibility-result',
entries: [
{
date: '2024-01-03',
reason: {
code: 'unavailable',
message:
'The selected entitlement is not available yet.',
},
} satisfies HistoricalEligibilityReason,
],
},
} as MessageEvent),
);
return;
}
queueMicrotask(() =>
this.onmessage?.({
data: { type: 'counterfactual-result', analysis: response },
} as MessageEvent),
);
}
terminate(): void {}
}
const browserWindow = globalThis.window;
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', BranchTraceWorker);
try {
const calculation = createPlanningCalculation(request);
await calculation.trace;
const historicalEligibility = await calculation.historicalEligibility({
decisionId: 'branch-selected',
placement: selected,
});
expect(historicalEligibility).toEqual([
{
date: '2024-01-03',
reason: {
code: 'unavailable',
message: 'The selected entitlement is not available yet.',
},
},
]);
expect(receivedState?.balances[0]).toMatchObject({
earned: true,
sourceRuleId: 'producer',
});
expect(receivedOptions).toEqual({
removeDate: '2024-01-02',
compareOriginalState: true,
});
const analysis = await calculation.analyzeCounterfactual({
decisionId: 'branch-selected',
placement: selected,
});
expect(analysis.kind).toBe('Counterfactual');
expect(receivedState?.placements.map((placement) => placement.date)).toEqual([
'2024-01-01',
'2024-01-02',
]);
expect(receivedState?.balances[0]?.remaining).toBe(3);
expect(receivedState?.balances[0]).toMatchObject({
availableFrom: '2024-01-02',
earned: true,
sourceRuleId: 'producer',
});
expect(receivedOptions).toEqual({
removeDate: '2024-01-02',
compareOriginalState: true,
});
await expect(
calculation.analyzeCounterfactual({
decisionId: 'self-parent',
placement: selected,
}),
).rejects.toThrow('The selected trace decision is unavailable.');
} finally {
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
});
it('does not inherit unrelated rule-invalid history into a trace decision', 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-01-31')],
rules: [
{
id: 'early-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-04',
placeDays: 1,
placeType: 'cp',
},
{
id: 'late-window',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-05',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const decision = trace.phases
.flatMap((phase) => phase.decisions)
.find((candidate) =>
candidate.deltas.some((delta) => delta.kind === 'placement-added'),
);
const placement = decision?.deltas.find(
(delta) => delta.kind === 'placement-added',
)?.placement;
expect(decision).toBeDefined();
expect(placement).toBeDefined();
if (!decision || !placement) return;
const candidates = trace.phases.flatMap((phase) =>
phase.decisions.flatMap((item) => item.candidates),
);
const decisionDates = new Set(
decision.candidates.flatMap(
(candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []),
),
);
const unrelatedIneligible = candidates.find(
(candidate) =>
candidate.status === 'ineligible' &&
candidate.reason?.code === 'rule-invalid' &&
candidate.date &&
!decisionDates.has(candidate.date),
);
expect(unrelatedIneligible).toBeDefined();
if (!unrelatedIneligible?.date) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: decision.id,
placement,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(
analysis.results.find((result) => result.date === unrelatedIneligible.date)
?.historicalStatus,
).toBe('not-examined');
expect(
analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
).toBe('ineligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('reconstructs the returned plan from the original trace deltas', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-31')],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
if (outcome.kind !== 'Plan' || !trace) return;
const placements = new Map(
trace.initialState.placements.map((placement) => [placement.date, placement]),
);
for (const delta of trace.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) => decision.deltas),
)) {
if (delta.previousPlacement) placements.delete(delta.previousPlacement.date);
if (delta.placement) placements.set(delta.placement.date, delta.placement);
}
expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual(
outcome.placements,
);
expect(trace.phases.map((phase) => phase.name)).toEqual(
expect.arrayContaining([
'obligations',
'at-risk',
'holiday-week-value',
'non-working-day-gain',
'longest-work-sequence',
'short-sequence-deficit',
'supporting-placements',
'expiry',
'dates',
]),
);
expect(
trace.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.candidates).length,
).toBeGreaterThan(20);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('records loaded-plan pruning and full quality comparisons without truncation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-04-01' },
balances: [balance('cp', 'cp', 25, '2024-03-31')],
rules: [],
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
const decisions = trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidates = decisions.flatMap((decision) => decision.candidates);
expect(
candidates.some(
(candidate) =>
candidate.status === 'not-examined' &&
candidate.reason?.code === 'loaded-plan-sampling',
),
).toBe(true);
const comparison = candidates.find((candidate) => candidate.comparison)?.comparison;
expect(comparison?.before.objectives.length).toBeGreaterThan(1);
expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length);
expect(
candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
).toBe(true);
const chosenComparisons = decisions.flatMap((decision) =>
decision.candidates
.filter((candidate) => candidate.status === 'chosen' && candidate.comparison)
.map((candidate) => ({ decision, candidate })),
);
expect(chosenComparisons.length).toBeGreaterThan(0);
for (const { decision, candidate: chosen } of chosenComparisons) {
expect(
decision.deltas.some(
(delta) =>
(delta.kind === 'placement-moved' &&
delta.toDate === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
(delta.kind === 'placement-added' &&
delta.date === chosen.date &&
delta.balanceId === chosen.placement?.balanceId) ||
(delta.kind === 'placement-removed' && delta.date === chosen.date),
),
).toBe(true);
}
const phasesWithMultipleAcceptedMutations = trace?.phases.filter((phase) => {
const acceptedMutations = phase.decisions.filter(
(decision) =>
decision.candidates.some(
(candidate) => candidate.status === 'chosen' && candidate.comparison,
) &&
decision.deltas.some(
(delta) =>
delta.kind === 'placement-moved' ||
delta.kind === 'placement-added' ||
delta.kind === 'placement-removed' ||
delta.kind === 'placement-updated',
),
);
return acceptedMutations.length > 1;
});
expect(phasesWithMultipleAcceptedMutations?.length).toBeGreaterThan(0);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('records every eligible balance when a preference keeps the first assignment', 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');
const trace = await calculation.trace;
const candidates =
trace?.phases
.find((phase) => phase.name === 'preference:window')
?.decisions.flatMap((decision) => decision.candidates)
.filter((candidate) => candidate.date === '2024-01-02' && candidate.placement)
.map((candidate) => ({
balanceId: candidate.placement!.balanceId,
status: candidate.status,
reason: candidate.reason?.code,
})) ?? [];
expect(candidates.slice(0, 2)).toEqual([
{ balanceId: 'earlier', status: 'chosen', reason: undefined },
{ balanceId: 'later', status: 'not-examined', reason: 'first-compatible-balance' },
]);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('offers no explanation trace 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();
expect(await invalidCalculation.trace).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' },
]);
const trace = await conflictCalculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
} 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');
const trace = await calculation.trace;
expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true);
expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true);
} 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 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: [],
deadlineMs: 20,
},
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,
);
});
it('publishes the worker outcome before delayed explanation artifacts', async () => {
const expected = isPlan(
await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }),
);
const trace = {
status: 'ready' as const,
initialState: {
horizon,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: [],
};
class DelayedArtifactsWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(): void {
queueMicrotask(() =>
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent),
);
setTimeout(
() =>
this.onmessage?.({
data: { type: 'artifacts', artifacts: { trace } },
} as MessageEvent),
20,
);
}
terminate(): void {}
}
vi.stubGlobal('Worker', DelayedArtifactsWorker);
try {
const calculation = createPlanningCalculation({
horizon,
balances: [balance('cp', 'cp', 1)],
rules: [],
});
let traceReady = false;
void calculation.trace.then(() => (traceReady = true));
expect((await calculation.outcome).kind).toBe('Plan');
expect(traceReady).toBe(false);
expect((await calculation.trace)?.status).toBe('ready');
} finally {
vi.unstubAllGlobals();
}
});
it('delivers every streamed evaluation after ten seconds without delaying the outcome', async () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-12-31' },
balances: [balance('cp', 'cp', 1)],
rules: [],
};
const expected = isPlan(await runInNonBrowser(request));
const candidateCount = 768;
const initialState: PlanningTraceState = {
horizon: request.horizon,
balances: [],
placements: [],
earnedActivations: [],
};
const operations: PlanningTraceUpdate['operations'][number][] = [
{
kind: 'phase',
phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' },
},
{
kind: 'decision',
phaseId: 'phase:1',
decision: { id: 'decision:1', action: 'choose', dates: ['2024-02-01'] },
},
...Array.from({ length: candidateCount }, (_, index) => ({
kind: 'candidate' as const,
phaseId: 'phase:1',
decisionId: 'decision:1',
index,
mode: 'append' as const,
candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' },
})),
];
const trace = {
status: 'ready' as const,
initialState,
completedStages: ['dates'],
};
class SlowTraceWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
terminated = false;
postMessage(): void {
queueMicrotask(() => {
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
this.onmessage?.({
data: {
type: 'explanation',
explanation: { kind: 'PlanExplanation', placements: [] },
finalState: initialState,
},
} as MessageEvent);
});
setTimeout(() => {
for (let offset = 0; offset < operations.length; offset += 64)
this.onmessage?.({
data: {
type: 'trace-update',
totalMs: 0,
update: {
initialState: offset === 0 ? initialState : undefined,
operations: operations.slice(offset, offset + 64),
},
},
} as MessageEvent);
this.onmessage?.({
data: {
type: 'trace-status-index',
statuses: new Map([['2024-02-01', 'evaluated-rejected']]),
nonWorkingDates: [],
},
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts-start', artifacts: {}, trace },
} as MessageEvent);
this.onmessage?.({ data: { type: 'artifacts-complete' } } as MessageEvent);
}, 10_001);
}
terminate(): void {
this.terminated = true;
}
}
const browserWindow = globalThis.window;
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', SlowTraceWorker);
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance', 'Date'] });
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
try {
const calculation = createPlanningCalculation(request);
let traceReady = false;
const tracePromise = calculation.trace.then((value) => {
traceReady = true;
return value;
});
expect(await calculation.outcome).toEqual(expected);
expect((await calculation.explanation)?.kind).toBe('PlanExplanation');
expect(traceReady).toBe(false);
const traceStarted = performance.now();
await vi.advanceTimersByTimeAsync(10_001);
await vi.runAllTimersAsync();
const loadedTrace = await tracePromise;
expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000);
expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount);
expect(loadedTrace?.status).toBe('ready');
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
});
it('drains queued trace packets in batches of at most sixteen', async () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-12-31' },
balances: [balance('cp', 'cp', 1)],
rules: [],
};
const expected = isPlan(await runInNonBrowser(request));
const packetCount = 17;
const initialState: PlanningTraceState = {
horizon: request.horizon,
balances: [],
placements: [],
earnedActivations: [],
};
const trace = {
status: 'ready' as const,
initialState,
completedStages: ['dates'],
};
const updates = Array.from(
{ length: packetCount },
(_, index): PlanningTraceUpdate => ({
initialState: index === 0 ? initialState : undefined,
operations:
index === 0
? [
{
kind: 'phase',
phase: {
id: 'phase:1',
name: 'search',
completed: true,
status: 'completed',
},
},
{
kind: 'decision',
phaseId: 'phase:1',
decision: {
id: 'decision:1',
action: 'choose',
dates: ['2024-02-01'],
},
},
{
kind: 'candidate',
phaseId: 'phase:1',
decisionId: 'decision:1',
index,
mode: 'append',
candidate: {
status: 'evaluated-rejected',
date: '2024-02-01',
},
},
]
: [
{
kind: 'candidate',
phaseId: 'phase:1',
decisionId: 'decision:1',
index,
mode: 'append',
candidate: {
status: 'evaluated-rejected',
date: '2024-02-01',
},
},
],
}),
);
let performanceNowCalls = 0;
let performanceNowCallsAtTermination = -1;
let terminationCount = 0;
let acknowledgementCount = 0;
class BatchedTraceWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(message?: { type?: string }): void {
if (message?.type === 'trace-ack') {
acknowledgementCount++;
return;
}
this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent);
this.onmessage?.({
data: {
type: 'explanation',
explanation: { kind: 'PlanExplanation', placements: [] },
finalState: initialState,
},
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts-start', artifacts: {}, trace },
} as MessageEvent);
for (const update of updates)
this.onmessage?.({
data: { type: 'trace-update', totalMs: 0, update },
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts-complete', traceTransferMs: 0 },
} as MessageEvent);
}
terminate(): void {
terminationCount++;
performanceNowCallsAtTermination = performanceNowCalls;
}
}
const browserWindow = globalThis.window;
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
vi.spyOn(performance, 'now').mockImplementation(() => {
performanceNowCalls++;
return 0;
});
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', BatchedTraceWorker);
try {
const calculation = createPlanningCalculation(request);
const loadedTrace = await calculation.trace;
expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(packetCount);
expect(timeoutSpy.mock.calls.filter(([, delay]) => delay === 0)).toHaveLength(2);
expect(terminationCount).toBe(1);
expect(performanceNowCallsAtTermination).toBe(2);
expect(acknowledgementCount).toBe(0);
} finally {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
});
it('acknowledges marked trace packets only after applying them and waits for the tail', async () => {
const request = {
horizon: { start: '2024-01-01', end: '2024-12-31' },
balances: [balance('cp', 'cp', 1)],
rules: [],
};
const expected = isPlan(await runInNonBrowser(request));
const initialState: PlanningTraceState = {
horizon: request.horizon,
balances: [],
placements: [],
earnedActivations: [],
};
const trace = {
status: 'ready' as const,
initialState,
completedStages: ['dates'],
};
const phase = {
kind: 'phase' as const,
phase: { id: 'phase:1', name: 'search', completed: true, status: 'completed' as const },
};
const decision = {
kind: 'decision' as const,
phaseId: 'phase:1',
decision: { id: 'decision:1', action: 'choose' as const, dates: ['2024-02-01'] },
};
const candidate = (index: number) => ({
kind: 'candidate' as const,
phaseId: 'phase:1',
decisionId: 'decision:1',
index,
mode: 'append' as const,
candidate: { status: 'evaluated-rejected' as const, date: '2024-02-01' },
});
const firstUpdate: PlanningTraceUpdate = {
initialState,
operations: [phase, decision, candidate(0)],
};
const tailUpdate: PlanningTraceUpdate = { operations: [candidate(1)] };
let acknowledgePackets = 0;
let tailSent = false;
let resolveFirstAcknowledgement!: () => void;
const firstAcknowledgement = new Promise<void>(
(resolve) => (resolveFirstAcknowledgement = resolve),
);
class AcknowledgedTraceWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(message: { type?: string; count?: number }): void {
if (message.type === 'trace-ack') {
acknowledgePackets += message.count ?? 0;
if (acknowledgePackets === 1) {
tailSent = true;
queueMicrotask(() =>
this.onmessage?.({
data: {
type: 'trace-update',
totalMs: 0,
update: tailUpdate,
acknowledge: true,
},
} as MessageEvent),
);
resolveFirstAcknowledgement();
} else if (acknowledgePackets === 2)
queueMicrotask(() =>
this.onmessage?.({
data: { type: 'artifacts-complete', traceTransferMs: 0 },
} as MessageEvent),
);
return;
}
this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent);
this.onmessage?.({
data: {
type: 'explanation',
explanation: { kind: 'PlanExplanation', placements: [] },
finalState: initialState,
},
} as MessageEvent);
this.onmessage?.({
data: { type: 'artifacts-start', artifacts: {}, trace },
} as MessageEvent);
this.onmessage?.({
data: {
type: 'trace-update',
totalMs: 0,
update: firstUpdate,
acknowledge: true,
},
} as MessageEvent);
}
terminate(): void {}
}
const browserWindow = globalThis.window;
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', AcknowledgedTraceWorker);
try {
const calculation = createPlanningCalculation(request);
let traceReady = false;
const tracePromise = calculation.trace.then((result) => {
traceReady = true;
return result;
});
await calculation.outcome;
expect(acknowledgePackets).toBe(0);
expect(traceReady).toBe(false);
await firstAcknowledgement;
expect(tailSent).toBe(true);
expect(traceReady).toBe(false);
const loadedTrace = await tracePromise;
expect(acknowledgePackets).toBe(2);
expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(2);
} finally {
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
});
it('keeps a valid outcome when trace 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.trace)?.status).toBe('failed');
expect((await calculation.explanationError)?.code).toBe('worker-error');
} finally {
vi.unstubAllGlobals();
}
});
});