Files
vac-optimizer/src/app/services/leave-planner-explanation.spec.ts
T
nessar d9d9befa12
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
refactor(planning): remove the original decision trace
2026-10-15 20:42:33 +02:00

879 lines
26 KiB
TypeScript

import { createOptimizationPresetRules } from './rule-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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('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',
]);
});
});