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(); 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 => 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'); }); });