Files
vac-optimizer/src/app/services/leave-planner-explanation.spec.ts
T
nessar 01c87d1e44 feat(planning): complete verified explanations and trace delivery (#46)
Implement plan explanations, original decision traces, calendar replay, and worker counterfactual analysis. Preserve incomplete checkpoints and conflict reasons, with accessible navigation and localized comparison details.

Keep planning outcomes independent of tracing and bound worker delivery with receiver acknowledgements. Retain complete candidate history while releasing temporary buffers and benchmark references.

Validated with 201 tests, app/spec type checks, lint, production build, baseline outcome comparisons, and repeated loaded browser measurements. Document performance and remaining limits.
2026-10-03 16:26:56 +02:00

1041 lines
32 KiB
TypeScript

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());
describe('planner explanation and trace seam', () => {
it('records only actual evaluations and publishes non-working dates as metadata', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-07' },
balances: [],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
expect(trace?.status).toBe('ready');
expect(trace?.nonWorkingDates?.map(({ date }) => date)).toEqual([
'2024-01-01',
'2024-01-06',
]);
expect(
trace?.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.candidates),
).toEqual([]);
expect(trace?.phases.every((phase) => phase.status === 'completed')).toBe(true);
});
it('keeps joint obligation branches atomic and associates the selected 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;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const obligations = trace.phases.find((phase) => phase.name === 'obligations');
const branch = obligations?.decisions
.flatMap((decision) => decision.candidates)
.find((candidate) => candidate.status === 'chosen' && candidate.dates?.length === 2);
expect(branch?.dates).toHaveLength(2);
expect(
branch?.dates?.every((date) => outcome.placements.some((item) => item.date === date)),
).toBe(true);
const sharedPlacement = outcome.placements.find(
(placement) =>
placement.ruleIds.includes('joint') && placement.ruleIds.includes('shared'),
);
expect(sharedPlacement).toBeDefined();
const acceptedMutation = obligations?.decisions.find((decision) =>
decision.deltas.some(
(delta) =>
(delta.kind === 'placement-added' || delta.kind === 'placement-updated') &&
delta.placement !== undefined &&
delta.placement.date === sharedPlacement?.date &&
delta.placement.ruleIds.includes('joint') &&
delta.placement.ruleIds.includes('shared'),
),
);
expect(acceptedMutation).toBeDefined();
expect(acceptedMutation?.branch?.deltas).toContainEqual(
expect.objectContaining({
kind: 'placement-updated',
placement: expect.objectContaining({
date: sharedPlacement?.date,
ruleIds: ['joint', 'shared'],
}),
}),
);
const sameDatesOnRejectedParent = obligations?.decisions.filter(
(decision) =>
decision.id !== acceptedMutation?.id &&
decision.candidates.some(
(candidate) =>
candidate.status === 'evaluated-rejected' &&
candidate.dates?.includes(sharedPlacement?.date ?? ''),
),
);
expect(
sameDatesOnRejectedParent?.some((decision) => decision.branch?.parentDecisionId),
).toBe(true);
});
it('records at-risk assignments as sequential decisions with exact prior-state deltas', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const atRisk = trace.phases.find((phase) => phase.name === 'at-risk');
const jan2 = atRisk?.decisions.find((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-02',
),
);
const jan3 = atRisk?.decisions.find((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
),
);
expect(jan2?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan2?.candidates[0]).toMatchObject({
technicalComparison: [
{
name: 'preference-date-conflict',
direction: 'minimize',
before: 0,
after: 0,
},
{
name: 'placement-date',
direction: 'minimize',
before: '2024-01-02',
after: '2024-01-03',
},
],
firstDecisiveComparator: 'placement-date',
});
expect(jan2?.deltas).toContainEqual(
expect.objectContaining({
kind: 'balance-changed',
remainingBefore: 2,
remainingAfter: 1,
}),
);
expect(jan3?.candidates.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(jan3?.candidates[0]).toMatchObject({
technicalComparison: [
{
name: 'preference-date-conflict',
direction: 'minimize',
before: 0,
after: 0,
},
{
name: 'placement-date',
direction: 'minimize',
before: '2024-01-03',
after: '2024-01-04',
},
],
firstDecisiveComparator: 'placement-date',
});
expect(jan3?.deltas).toContainEqual(
expect.objectContaining({
kind: 'balance-changed',
remainingBefore: 1,
remainingAfter: 0,
}),
);
for (const [date, decision] of [
['2024-01-02', jan2],
['2024-01-03', jan3],
] as const) {
expect(
decision?.candidates.filter(
(candidate) => candidate.status === 'chosen' && candidate.date === date,
),
).toHaveLength(1);
expect(
decision?.deltas.filter(
(delta) => delta.kind === 'placement-added' && delta.date === date,
),
).toHaveLength(1);
}
const selected = jan3?.deltas.find(
(delta) => delta.kind === 'placement-added' && delta.date === '2024-01-03',
)?.placement;
expect(selected).toBeDefined();
if (!jan3 || !selected) return;
const analysis = await calculation.analyzeCounterfactual({
decisionId: jan3.id,
placement: selected,
});
expect(analysis.kind).toBe('Counterfactual');
if (analysis.kind === 'Counterfactual')
expect(analysis.results.find((item) => item.date === '2024-01-02')).toMatchObject({
status: 'ineligible',
reason: { code: 'occupied' },
});
});
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');
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('records the balance order used for a shared ANY preference placement', 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;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
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'],
});
const candidate = trace.phases
.find((phase) => phase.name === 'preference:any-preference')
?.decisions.flatMap((decision) => decision.candidates)
.find((item) => item.status === 'chosen' && item.date === '2024-01-03');
expect(candidate).toMatchObject({
technicalComparison: [
{
name: 'shared-placement-type',
direction: 'maximize',
before: 0,
after: 0,
},
{
name: 'protected-preference-type',
direction: 'maximize',
before: 1,
after: 1,
},
{
name: 'balance-expiry',
direction: 'minimize',
before: '2025-01-05',
after: '2025-01-06',
},
],
firstDecisiveComparator: 'balance-expiry',
placement: expect.objectContaining({ balanceId: 'rtt-first' }),
});
});
it('records actual IfThen leaf comparisons and selected branch identity', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [balance('cp', 'cp', 2, '2024-12-31')],
rules: [
{
id: 'if-then-preference',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 1,
placeType: 'cp',
placement: 'auto',
rewardDays: 0,
},
],
});
const outcome = await calculation.outcome;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const candidates = trace.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) =>
decision.candidates
.filter((candidate) =>
candidate.technicalComparison?.some((item) =>
item.name.startsWith('complete-occurrences:if-then-preference'),
),
)
.map((candidate) => ({ candidate, decision })),
);
const chosen = candidates.find(({ candidate }) => candidate.status === 'chosen');
const preferenceLeaves =
trace.phases
.find((phase) => phase.name === 'preference:if-then-preference')
?.decisions.flatMap((decision) => decision.candidates)
.filter((candidate) =>
candidate.technicalComparison?.some((item) =>
item.name.startsWith('complete-occurrences:if-then-preference'),
),
) ?? [];
expect(preferenceLeaves.length).toBeGreaterThan(1);
expect(preferenceLeaves.some((candidate) => candidate.status === 'chosen')).toBe(true);
expect(
preferenceLeaves.some((candidate) => candidate.status === 'evaluated-rejected'),
).toBe(true);
expect(candidates.some(({ candidate }) => candidate.status === 'evaluated-rejected')).toBe(
true,
);
expect(chosen?.candidate.technicalComparison).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'complete-occurrences:if-then-preference',
direction: 'maximize',
}),
expect.objectContaining({
name: 'future-pattern-conflict-score:if-then-preference',
direction: 'minimize',
}),
]),
);
expect(chosen?.decision.branch?.deltas.length).toBeGreaterThan(0);
expect(
chosen?.candidate.dates?.every((date) =>
outcome.placements.some((p) => p.date === date),
),
).toBe(true);
});
it('records earned obligation branches with the activated entitlement and its consumer', 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;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const earned = outcome.placements.find((placement) =>
placement.balanceId.startsWith('earned:'),
);
expect(earned?.ruleIds).toContain('consumer');
const decisions = trace.phases.flatMap((phase) => phase.decisions);
const activation = decisions
.flatMap((decision) => decision.deltas)
.find(
(delta) =>
delta.kind === 'earned-activated' && delta.balanceId === earned?.balanceId,
);
expect(activation?.activation).toMatchObject({ ruleId: 'producer', dates: ['2024-05-02'] });
const branch = decisions
.flatMap((decision) => decision.candidates)
.find(
(candidate) =>
candidate.status === 'chosen' && candidate.dates?.includes(earned?.date ?? ''),
);
expect(branch).toBeDefined();
});
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') return;
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'),
balance('rtt', 'rtt', 0, '2026-01-01'),
],
rules: [
{
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',
},
],
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan') return;
const atRisk = outcome.placements.find(
(placement) => placement.balanceId === 'cp2' && !placement.ruleIds.length,
);
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('records earned balance availability metadata changes 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;
const trace = await calculation.trace;
expect(outcome.kind).toBe('Plan');
if (outcome.kind !== 'Plan' || !trace) return;
const earnedBalanceId = outcome.placements.find((placement) =>
placement.ruleIds.includes('rtt-preference'),
)?.balanceId;
expect(earnedBalanceId).toBeDefined();
const changes = trace.phases
.flatMap((phase) => phase.decisions)
.flatMap((decision) => decision.deltas)
.filter(
(delta) => delta.kind === 'balance-changed' && delta.balanceId === earnedBalanceId,
);
expect(changes).toContainEqual(
expect.objectContaining({
remainingBefore: 1,
remainingAfter: 1,
balance: expect.objectContaining({
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
}),
}),
);
});
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') return;
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 trace = await calculation.trace;
expect(trace?.phases.some((phase) => phase.name === 'obligations')).toBe(true);
expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false);
if (index === 1)
expect(
trace?.phases
.find((phase) => phase.name === 'obligations')
?.decisions.flatMap((decision) => decision.candidates).length,
).toBeGreaterThan(0);
if (index === 2)
expect(
trace?.phases
.find((phase) => phase.name === 'obligations')
?.decisions.some((decision) =>
decision.candidates.some((candidate) => candidate.dates?.length),
),
).toBe(true);
}
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();
expect(await invalid.trace).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').PlanningTraceState | undefined;
const request = {
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 2, '2024-01-31')],
rules: [],
progress: (stage: string) => {
if (stage === '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').PlanningTraceState | 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('does not charge a deliberately slow incremental trace consumer to planning work', async () => {
const core = await import('./leave-planner.core');
let clock = 0;
vi.stubGlobal('performance', { now: () => ++clock });
const request = {
horizon: { start: '2024-01-01', end: '2024-01-15' },
balances: [balance('cp', 'cp', 3, '2024-01-31')],
rules: [],
deadlineMs: 1_000,
};
const plain = core.calculatePlanningInternal(request);
clock = 0;
let callbackCount = 0;
let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
const traced = core.calculatePlanningInternal(
{
...request,
traceProgress: (
_totalMs: number,
update: import('./leave-planner.core').PlanningTraceUpdate,
) => {
callbackCount++;
clock += 10_000;
operations.push(...update.operations);
},
},
undefined,
(value) => (artifacts = value),
);
expect(traced).toEqual(plain);
expect(callbackCount).toBeGreaterThan(0);
expect(artifacts?.trace?.status).toBe('ready');
expect(artifacts?.timings?.planningWorkMs).toBeLessThan(request.deadlineMs);
expect(artifacts?.timings?.traceTransferMs).toBeGreaterThanOrEqual(callbackCount * 10_000);
const decisions = artifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidateUpdates = operations.filter((operation) => operation.kind === 'candidate');
expect(candidateUpdates.some((operation) => operation.mode === 'replace')).toBe(true);
const streamedCandidates = new Map<string, unknown>();
for (const operation of candidateUpdates)
if (operation.kind === 'candidate')
streamedCandidates.set(
`${operation.phaseId}/${operation.decisionId}/${operation.index}`,
operation.candidate,
);
expect(streamedCandidates.size).toBe(
decisions.flatMap((decision) => decision.candidates).length,
);
expect(operations.filter((operation) => operation.kind === 'delta')).toHaveLength(
decisions.flatMap((decision) => decision.deltas).length,
);
});
it('coalesces pending candidate snapshots without losing later flushed replacements', async () => {
const core = await import('./leave-planner.core');
vi.stubGlobal('performance', { now: () => 0 });
const request = {
horizon: { start: '2024-01-01', end: '2024-01-08' },
balances: [balance('cp', 'cp', 2, '2024-01-07')],
rules: [],
};
let plainArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const plain = core.calculatePlanningInternal(request, undefined, (value) => {
plainArtifacts = value;
});
const operations: import('./leave-planner.core').PlanningTraceOperation[] = [];
let streamedArtifacts: import('./leave-planner.core').PlanningArtifacts | undefined;
const streamed = core.calculatePlanningInternal(
{
...request,
traceProgress: (
_totalMs: number,
update: import('./leave-planner.core').PlanningTraceUpdate,
) => operations.push(...update.operations),
},
undefined,
(value) => (streamedArtifacts = value),
);
expect(streamed).toEqual(plain);
expect(streamedArtifacts?.trace).toEqual(plainArtifacts?.trace);
const decisions =
streamedArtifacts?.trace?.phases.flatMap((phase) => phase.decisions) ?? [];
const candidates = decisions.flatMap((decision) =>
decision.candidates.map((candidate, index) => ({
key: `${decision.id}/${index}`,
candidate,
})),
);
const candidateOperations = operations.filter(
(operation): operation is Extract<typeof operation, { kind: 'candidate' }> =>
operation.kind === 'candidate',
);
expect(candidateOperations).toHaveLength(candidates.length);
const finalStreamedCandidates = new Map(
candidateOperations.map((operation) => [
`${operation.decisionId}/${operation.index}`,
operation,
]),
);
for (const { key, candidate } of candidates) {
expect(finalStreamedCandidates.get(key)?.candidate).toEqual(candidate);
if (candidate.status === 'chosen')
expect(finalStreamedCandidates.get(key)?.mode).toBe('append');
}
});
it('reports matching completed, interrupted, and not-run phases at both interruption boundaries', async () => {
const early = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-10' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: [],
deadlineMs: 0,
});
const earlyOutcome = await early.outcome;
const earlyTrace = await early.trace;
expect(earlyOutcome.kind).toBe('PlanningIncomplete');
if (earlyOutcome.kind === 'PlanningIncomplete')
expect(earlyOutcome.completedStages).toEqual(['validation']);
expect(earlyTrace?.completedStages).toEqual(['validation']);
expect(earlyTrace?.phases.find((phase) => phase.name === 'validation')?.status).toBe(
'completed',
);
expect(earlyTrace?.phases.find((phase) => phase.name === 'obligations')?.status).toBe(
'interrupted',
);
expect(earlyTrace?.phases.find((phase) => phase.name === 'at-risk')?.status).toBe(
'not-run',
);
const quality = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [balance('cp', 'cp', 1, '2024-01-31')],
rules: [],
progress: (stage) => {
if (stage === 'holiday-week-value') {
throw new Error('deliberate interruption in quality phase');
}
},
});
const qualityOutcome = await quality.outcome;
const qualityTrace = await quality.trace;
expect(qualityOutcome.kind).toBe('PlanningIncomplete');
if (qualityOutcome.kind !== 'PlanningIncomplete') return;
expect(qualityOutcome.reasons[0].code).toBe('planner-error');
expect(qualityOutcome.completedStages).toEqual(['validation', 'obligations', 'at-risk']);
expect(qualityTrace?.completedStages).toEqual(qualityOutcome.completedStages);
expect(
qualityTrace?.phases.find((phase) => phase.name === 'holiday-week-value')?.status,
).toBe('interrupted');
expect(
qualityTrace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status,
).toBe('not-run');
});
});