Files
vac-optimizer/src/app/services/leave-planner-explanation.spec.ts
T
nessarandClaude Opus 5.5 fe705fe618
CI / policy (push) Successful in 28s
CI / validate (push) Successful in 46s
CI / build (push) Successful in 25s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 26s
CI / benchmark (push) Successful in 1m28s
CI / security (push) Successful in 1m11s
CI / build (pull_request) Successful in 23s
CI / test (push) Successful in 2m27s
CI / benchmark (pull_request) Successful in 1m33s
CI / security (pull_request) Successful in 1m0s
CI / images (push) Successful in 1m0s
CI / required (push) Successful in 1s
CI / test (pull_request) Successful in 2m6s
CI / images (pull_request) Successful in 54s
CI / required (pull_request) Successful in 1s
fix(planning): keep counterfactual moves off the shared final plan
Since assigning a date shares unchanged activations with the parent
state, counterfactual analysis edited an activation it shared with the
final plan, so a later alternative date could inherit an earlier one's
dates and be judged eligible. It now replaces the moved activation.
Every counterfactual over seeds 280000-289999 matches the engine before
the sharing step again.

When every one of several obligation solutions fails earned-entitlement
chronology, the planner again reports the obligation conflict, as when
every preview failed. No seeded request reaches that path, so the
frozen engine is unchanged.

Also marks the ranking's known limit as a shortcut and fixes stale
documentation found in review.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:49:55 +02:00

927 lines
27 KiB
TypeScript

import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { vi } from '../../testing/vitest-compat';
import { createPlanningCalculation } from './leave-planner.service';
const balance = (
id: string,
type: 'cp' | 'rtt',
quantity: number,
expiryDate: string,
availableFrom = '2024-01-01',
) => ({ id, type, quantity, expiryDate, availableFrom });
beforeEach(() => vi.stubGlobal('window', undefined));
afterEach(() => vi.unstubAllGlobals());
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('planner explanation and alternative dates', () => {
it('delivers only the displayed plan, explanation and state needed for alternative dates', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-09')],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
expect(Object.keys(calculation).sort()).toEqual([
'analyzeCounterfactual',
'cancel',
'explanation',
'explanationError',
'id',
'outcome',
'snapshot',
'timings',
]);
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
expect(
core.calculatePlanningInternal(
calculation.snapshot,
undefined,
(value) => (artifacts = value),
),
).toEqual(outcome);
expect(Object.keys(artifacts).sort()).toEqual([
'explanation',
'explanationError',
'finalState',
'timings',
]);
expect(artifacts.finalState?.placements).toEqual(outcome.placements);
expect(Object.keys(artifacts.timings ?? {})).toEqual(['planningWorkMs']);
const original = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
expect(analysis.results).toHaveLength(8);
for (const result of analysis.results) {
expect(result).not.toHaveProperty('historicalStatus');
expect(result).not.toHaveProperty('historicalReason');
}
expect(outcome).toEqual(original);
});
it('excludes observers from the technical budget and keeps the outcome after explanation delivery fails', async () => {
const core = await import('./leave-planner.core');
let elapsed = 0;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
let delivered = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
try {
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-01-02', end: '2024-01-10' },
balances: [],
rules: [],
deadlineMs: 10,
progress: () => (elapsed += 100),
},
() => (elapsed += 500),
(value) => (artifacts = value),
() => (delivered = true),
() => {
expect(delivered).toBe(true);
elapsed += 1000;
throw new Error('Explanation delivery failed.');
},
);
expect(outcome.kind).toBe('Plan');
expect(artifacts.explanationError).toMatchObject({
code: 'explanation-error',
message: 'Explanation delivery failed.',
});
expect(artifacts.finalState?.horizon).toEqual({
start: '2024-01-02',
end: '2024-01-10',
});
expect(artifacts.timings?.planningWorkMs).toBe(0);
} finally {
clock.mockRestore();
}
});
it('keeps earned availability after a reported occurrence is extended', async () => {
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-04-29', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
{ date: '2024-05-03', type: 'other' },
],
balances: [balance('cp', 'cp', 1, '2025-12-31')],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays: 1,
rewardType: 'other',
},
{
id: 'extend',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-02',
endDate: '2024-05-02',
placeDays: 1,
placeType: 'cp',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-29',
endDate: '2024-05-07',
placeDays: 2,
placeType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.finalState)
throw new Error('Expected the extended generator to finance a plan.');
const earnedId = 'earned:producer:2024-05-01';
expect(artifacts.finalState.balances).toContainEqual(
expect.objectContaining({
id: earnedId,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
remaining: 0,
}),
);
expect(outcome.placements.find(({ balanceId }) => balanceId === earnedId)?.date).toBe(
'2024-05-07',
);
expect(artifacts.finalState.placements).toEqual(outcome.placements);
});
it.each([5, 8])(
'keeps %s unrelated obligations valid without duplicating a reported entitlement',
async (count) => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
const dates = [
'2024-04-17',
'2024-04-18',
'2024-04-19',
'2024-04-22',
'2024-04-23',
'2024-04-24',
'2024-04-25',
'2024-04-26',
].slice(0, count);
for (const rewardDays of [0, 1]) {
const outcome = core.calculatePlanningInternal({
horizon: { start: '2024-04-17', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
],
balances: [
{
id: 'other',
type: 'other',
quantity: dates.length,
availableFrom: '2024-04-17',
expiryDate: '2025-12-31',
},
],
rules: [
...dates.map((date) => ({
id: date,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: date,
endDate: date,
placeDays: 1,
placeType: 'other' as const,
})),
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays,
rewardType: 'other',
},
],
});
if (outcome.kind !== 'Plan') throw new Error('Expected a completed plan.');
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
expect(outcome.remainingBalances).toEqual([]);
if (rewardDays)
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-02',
added: false,
});
}
},
);
it('keeps reported entitlements out of the final state under explicit priorities', async () => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-05-01', end: '2024-05-11' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2024-05-07',
},
],
declaredDays: [
{ date: '2024-05-02', type: 'cp' },
{ date: '2024-05-03', type: 'cp' },
],
rules: [
presets[0],
...[
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 2,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'other',
},
],
...presets.slice(1),
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.finalState)
throw new Error('Expected a completed plan.');
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
expect(outcome.remainingBalances).toEqual([]);
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-06',
added: false,
});
});
it('associates every joint obligation with its shared placement', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-12-31')],
rules: [
{
id: 'joint',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-05',
placeDays: 2,
placeType: 'cp',
},
{
id: 'shared',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const sharedPlacement = outcome.placements.find(
(placement) =>
placement.ruleIds.includes('joint') && placement.ruleIds.includes('shared'),
);
expect(sharedPlacement).toBeDefined();
});
it('puts actual obligation validity first in stable plan explanations', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 1, '2025-01-01')],
rules: [
{
id: 'mandatory',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-03',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
const explanation = await calculation.explanation;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind !== 'PlanExplanation') return;
const placement = explanation.placements[0];
expect(placement.objectiveVector.objectives[0]).toMatchObject({
id: 'obligation-validity',
direction: 'minimize',
value: 0,
});
expect(placement.comparison.before.objectives[0]).toMatchObject({
id: 'obligation-validity',
value: 1,
});
expect(placement.comparison.after.objectives[0]).toMatchObject({
id: 'obligation-validity',
value: 0,
});
expect(placement.firstDecisiveObjective).toBe('obligation-validity');
});
it('uses the earlier-expiring balance for shared ANY preferences', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [
balance('cp', 'cp', 1, '2025-01-04'),
balance('rtt-first', 'rtt', 1, '2025-01-05'),
balance('rtt-second', 'rtt', 1, '2025-01-06'),
],
rules: [
{
id: 'any-preference',
type: 'time_window',
force: 'preference',
startDate: '2024-01-03',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'any',
},
{
id: 'rtt-preference',
type: 'time_window',
force: 'preference',
startDate: '2024-01-03',
endDate: '2024-01-03',
placeDays: 1,
placeType: 'rtt',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const sharedPlacement = outcome.placements.find((placement) =>
placement.ruleIds.includes('any-preference'),
);
expect(sharedPlacement).toMatchObject({
date: '2024-01-03',
balanceId: 'rtt-first',
ruleIds: ['any-preference', 'rtt-preference'],
});
});
it('keeps an earned obligation entitlement and its consumer chronological', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-08' },
balances: [
balance('cp', 'cp', 1, '2024-12-31'),
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 outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const earned = outcome.placements.find((placement) =>
placement.balanceId.startsWith('earned:'),
);
expect(earned?.ruleIds).toContain('consumer');
});
it('rejects a producer move that delays its earned entitlement past the consumer', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-08' },
balances: [
balance('cp', 'cp', 1, '2025-12-31'),
balance('rtt', 'rtt', 0, '2026-01-01'),
],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-03',
endDate: '2024-05-03',
placeDays: 1,
placeType: 'rtt',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const producer = outcome.placements.find((placement) =>
placement.ruleIds.includes('producer'),
);
expect(producer?.date).toBe('2024-04-30');
if (!producer) return;
const analysis = await calculation.analyzeCounterfactual(producer);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.results.find((result) => result.date === '2024-05-02')).toMatchObject({
status: 'ineligible',
reason: { code: 'unavailable' },
});
expect(
analysis.results.find((result) => result.date === '2024-05-02')?.comparison,
).toBeUndefined();
});
it('judges each alternative producer date against the unchanged final plan', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2026-04-20', end: '2026-05-31' },
balances: [balance('cp', 'cp', 2, '2026-12-31', '2026-04-20')],
rules: [
{
id: 'friday',
type: 'if_then',
force: 'preference',
dayOfWeek: 5,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2026-05-12',
endDate: '2026-05-12',
placeDays: 1,
placeType: 'rtt',
},
],
});
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const producer = outcome.placements.find(
(placement) => placement.date === '2026-04-30' && placement.ruleIds.includes('friday'),
);
expect(producer).toBeDefined();
if (!producer) return;
const analysis = await calculation.analyzeCounterfactual(producer);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual.');
// 2026-05-04 is evaluated first; the move to 2026-05-11 must not inherit its dates.
expect(analysis.results.find((result) => result.date === '2026-05-04')?.status).toBe(
'eligible',
);
expect(analysis.results.find((result) => result.date === '2026-05-11')).toMatchObject({
status: 'ineligible',
reason: { code: 'unavailable' },
});
});
it('rejects an at-risk move that would delay already-earned leave past its consumer', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-08' },
balances: [
balance('cp1', 'cp', 1, '2025-12-31'),
{ ...balance('cp2', 'cp', 2, '2024-05-07'), availableFrom: '2024-04-26' },
balance('rtt', 'rtt', 0, '2026-01-01'),
],
rules: [
presets[0],
...[
{
id: 'anchor',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-25',
endDate: '2024-04-25',
placeDays: 1,
placeType: 'cp',
},
{
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-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'rtt',
},
],
...presets.slice(1),
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const atRisk = outcome.placements.find(
(placement) =>
placement.balanceId === 'cp2' &&
placement.ruleIds.every((id) => id.startsWith('default-')),
);
expect(atRisk?.date).toBeDefined();
if (!atRisk) return;
const analysis = await calculation.analyzeCounterfactual(atRisk);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.results.find((result) => result.date === '2024-05-03')).toMatchObject({
status: 'ineligible',
reason: { code: 'unavailable' },
});
expect(
analysis.results.find((result) => result.date === '2024-05-03')?.comparison,
).toBeUndefined();
});
it('keeps earned balance metadata consistent when a shared period extends', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-25', end: '2024-05-15' },
balances: [
balance('cp', 'cp', 3, '2024-12-31'),
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: 'cp-preference',
type: 'time_window',
force: 'preference',
startDate: '2024-05-03',
endDate: '2024-05-03',
placeDays: 1,
placeType: 'cp',
},
{
id: 'rtt-preference',
type: 'time_window',
force: 'preference',
startDate: '2024-05-07',
endDate: '2024-05-07',
placeDays: 1,
placeType: 'rtt',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const earnedBalanceId = outcome.placements.find((placement) =>
placement.ruleIds.includes('rtt-preference'),
)?.balanceId;
expect(earnedBalanceId).toBeDefined();
});
it('compares every horizon date while preserving the selected placement provenance', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-12-27', end: '2025-01-07' },
balances: [balance('cp', 'cp', 2, '2025-01-03', '2024-12-30')],
rules: [
{
id: 'exact-date',
type: 'time_window',
force: 'preference',
startDate: '2024-12-30',
endDate: '2024-12-30',
placeDays: 1,
placeType: 'cp',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') throw new Error('Expected a plan.');
const selected = outcome.placements.find((placement) =>
placement.ruleIds.includes('exact-date'),
);
expect(selected).toBeDefined();
if (!selected) return;
const before = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(selected);
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind !== 'Counterfactual') return;
expect(analysis.results.map(({ date }) => date)).toEqual([
'2024-12-27',
'2024-12-28',
'2024-12-29',
'2024-12-30',
'2024-12-31',
'2025-01-01',
'2025-01-02',
'2025-01-03',
'2025-01-04',
'2025-01-05',
'2025-01-06',
]);
expect(analysis.placement).toEqual(selected);
expect(analysis.results.find((item) => item.date === '2024-12-27')?.reason?.code).toBe(
'unavailable',
);
expect(analysis.results.find((item) => item.date === '2025-01-01')?.reason?.code).toBe(
'non-working',
);
expect(analysis.results.find((item) => item.date === '2025-01-06')?.reason?.code).toBe(
'expired',
);
for (const item of analysis.results.filter((result) => result.status === 'ineligible'))
expect(item.comparison).toBeUndefined();
expect(outcome).toEqual(before);
});
it('keeps typed, joint-date, and earned-order conflicts separate from invalid input', async () => {
const scenarios = [
{
horizon: { start: '2024-04-01', end: '2024-04-06' },
balances: [balance('rtt', 'rtt', 1, '2025-01-01')],
rules: [
{
id: 'cp-only',
type: 'time_window' as const,
force: 'obligation' as const,
startDate: '2024-04-02',
endDate: '2024-04-02',
placeDays: 1,
placeType: 'cp',
},
],
},
{
horizon: { start: '2024-04-01', end: '2024-04-06' },
balances: [
balance('cp', 'cp', 1, '2025-01-01'),
balance('rtt', 'rtt', 1, '2025-01-01'),
],
rules: ['cp', 'rtt'].map((type) => ({
id: `${type}-same-day`,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: '2024-04-02',
endDate: '2024-04-02',
placeDays: 1,
placeType: type,
})),
},
{
horizon: { start: '2024-04-25', end: '2024-05-15' },
balances: [
balance('cp', 'cp', 2, '2025-01-01'),
balance('rtt', 'rtt', 3, '2026-01-01'),
],
rules: [
{
id: 'producer',
type: 'if_then' as const,
force: 'obligation' as const,
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'before',
rewardDays: 1,
rewardType: 'rtt',
},
{
id: 'consumer',
type: 'if_then' as const,
force: 'obligation' as const,
dayOfWeek: 4,
placeDays: 4,
placeType: 'rtt',
placement: 'before',
rewardDays: 0,
},
],
},
];
const conflictCodes = [
'typed-capacity',
'joint-date-conflict',
'earned-entitlement-bottleneck',
];
for (const [index, scenario] of scenarios.entries()) {
const calculation = createPlanningCalculation(scenario);
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningConflict');
if (outcome.kind !== 'PlanningConflict') continue;
expect(outcome.reasons[0].code).toBe(conflictCodes[index]);
expect((await calculation.explanation)?.kind).toBe('ConflictExplanation');
}
const invalid = createPlanningCalculation({
horizon: { start: '2025-01-01', end: '2024-01-01' },
balances: [],
rules: [],
});
expect((await invalid.outcome).kind).toBe('InvalidInput');
expect(await invalid.explanation).toBeUndefined();
});
it('does not classify a joint-date conflict as earned when only a preference generates leave', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-01', end: '2024-04-06' },
balances: [
balance('cp', 'cp', 1, '2025-01-01'),
balance('rtt', 'rtt', 1, '2025-01-01'),
],
rules: [
...(['cp', 'rtt'] as const).map((type) => ({
id: `${type}-required`,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: '2024-04-02',
endDate: '2024-04-02',
placeDays: 1,
placeType: type,
})),
{
id: 'unrelated-reward-preference',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 1,
rewardType: 'rtt',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningConflict');
if (outcome.kind === 'PlanningConflict')
expect(outcome.reasons[0].code).toBe('joint-date-conflict');
});
it('recovers an incumbent explanation from the exact delivered checkpoint state', async () => {
const core = await import('./leave-planner.core');
let checkpointPlan: import('./leave-planner.core').PlanOutcome | undefined;
let checkpointState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: presets,
progress: (stage: string) => {
if (stage === 'optimization:default-holiday-week-value')
throw new Error('interrupted after checkpoint');
},
};
const outcome = core.calculatePlanningInternal(request, (plan, state) => {
checkpointPlan = plan;
checkpointState = state;
});
expect(outcome.kind).toBe('PlanningIncomplete');
expect(checkpointPlan).toBeDefined();
expect(checkpointState).toBeDefined();
if (!checkpointPlan || !checkpointState) return;
const recovered = core.explainIncompleteCheckpoint(
request,
checkpointPlan,
checkpointState,
{ code: 'worker-error', message: 'The worker stopped after its checkpoint.' },
);
expect(recovered.explanation?.kind).toBe('PlanExplanation');
if (recovered.explanation?.kind !== 'PlanExplanation') return;
expect(recovered.explanation.incomplete?.reason.code).toBe('worker-error');
expect(recovered.explanation.placements).toHaveLength(checkpointPlan.placements.length);
let completedState: import('./leave-planner.core').PlanningStateSnapshot | undefined;
const completed = core.calculatePlanningInternal(
{ ...request, progress: undefined },
(plan, state) => {
if (plan.completedStages.at(-1) === 'dates') completedState = state;
},
);
expect(completed.kind).toBe('Plan');
if (completed.kind !== 'Plan' || !completedState) return;
const completedRecovery = core.explainIncompleteCheckpoint(
{ ...request, progress: undefined },
completed,
completedState,
{ code: 'worker-error', message: 'The result arrived before the worker stopped.' },
);
expect(completedRecovery.explanation?.kind).toBe('PlanExplanation');
if (completedRecovery.explanation?.kind === 'PlanExplanation')
expect(completedRecovery.explanation.incomplete).toBeUndefined();
});
it('explains incomplete calculations before and after a valid plan', async () => {
const early = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: presets,
deadlineMs: 0,
});
const earlyOutcome = await early.outcome;
expect(earlyOutcome.kind).toBe('PlanningIncomplete');
if (earlyOutcome.kind === 'PlanningIncomplete')
expect(earlyOutcome.completedStages).toEqual(['validation']);
const quality = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: presets,
progress: (stage) => {
if (stage === 'optimization:default-holiday-week-value') {
throw new Error('deliberate interruption in quality phase');
}
},
});
const qualityOutcome = await quality.outcome;
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
expect(qualityOutcome.completedStages).toEqual([
'validation',
'obligations',
'optimization:default-unplaced-at-risk',
]);
});
});