import { createOptimizationPresetRules } from './rule-service'; import { vi } from 'vitest'; import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service'; const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [], }; beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); afterEach(() => vi.useRealTimers()); const presets = createOptimizationPresetRules(); describe('user cancellation', () => { const workers: CancellableWorker[] = []; const currentWorker = () => workers[workers.length - 1]; class CancellableWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; postMessage = vi.fn(); terminate = vi.fn(); constructor() { workers.push(this); } emit(data: unknown) { this.onmessage?.({ data } as MessageEvent); } } beforeEach(() => { workers.length = 0; vi.stubGlobal('Worker', CancellableWorker); }); afterEach(() => vi.unstubAllGlobals()); it('supports cancellation through the public non-browser calculation context', async () => { vi.stubGlobal('window', undefined); const calculation = createPlanningCalculation(request); calculation.cancel(); expect(await calculation.outcome).toMatchObject({ kind: 'PlanningIncomplete', reasons: [{ code: 'user-cancelled' }], }); }); it('retains the same declared-only periods and metrics in a cancelled valid plan', async () => { const core = await import('./leave-planner.core'); const submitted = { horizon: { start: '2024-01-02', end: '2024-01-09' }, balances: [], rules: [presets[2]], declaredDays: [{ date: '2024-01-05', type: 'other' as const }], }; let interrupted = false; let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; const outcome = await core.calculatePlanningAsyncInternal( { ...submitted, progress: (stage: string) => { if (stage === 'optimization:default-non-working-day-gain') interrupted = true; }, }, undefined, (value) => (artifacts = value), undefined, undefined, () => interrupted, ); const completed = core.calculatePlanningInternal(submitted); if ( outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent || completed.kind !== 'Plan' ) throw new Error('Expected an interrupted valid plan and its completed counterpart.'); expect(outcome.reasons[0].code).toBe('user-cancelled'); expect(outcome.incumbent.timeOffPeriods).toEqual(completed.timeOffPeriods); expect(outcome.incumbent.metrics).toEqual(completed.metrics); expect(outcome.incumbent.metrics).toMatchObject({ nonWorkingDayGain: 2, averageWorkSequence: 2, longestWorkSequence: 3, averageTimeOffPeriod: 3, longestTimeOffPeriod: 3, }); expect(artifacts.explanation).toMatchObject({ kind: 'PlanExplanation', declaredDays: submitted.declaredDays, timeOffPeriods: completed.timeOffPeriods, placements: [], incomplete: { reason: { code: 'user-cancelled' } }, }); expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays); }); it('retains compatible fixed rule credits and incompatible declarations in a cancelled valid plan', async () => { const core = await import('./leave-planner.core'); const submitted = { horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [ { id: 'cp', type: 'cp' as const, quantity: 1, availableFrom: '2024-01-12', expiryDate: '2025-01-01', }, ], declaredDays: [ { date: '2024-01-10', type: 'cp' as const }, { date: '2024-01-11', type: 'rtt' as const }, ], rules: [ presets[1], ...[ { id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' }, { id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' }, ].map((rule) => ({ ...rule, type: 'time_window', startDate: '2024-01-08', endDate: '2024-01-12', })), ], }; let interrupted = false; let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; const outcome = await core.calculatePlanningAsyncInternal( { ...submitted, progress: (stage: string) => { if (stage === 'optimization:default-holiday-week-value') interrupted = true; }, }, undefined, (value) => (artifacts = value), undefined, undefined, () => interrupted, ); const completed = core.calculatePlanningInternal(submitted); if ( outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent || completed.kind !== 'Plan' ) throw new Error('Expected an interrupted valid plan and a completed counterpart.'); expect(outcome.reasons[0].code).toBe('user-cancelled'); expect(outcome.incumbent.declaredDays).toEqual(submitted.declaredDays); expect(outcome.incumbent.placements).toMatchObject([ { date: '2024-01-12', type: 'cp', ruleIds: ['exact-cp'] }, ]); expect(outcome.incumbent.preferences).toMatchObject([{ requested: 1, satisfied: 1 }]); expect(outcome.incumbent.timeWindowContributions).toEqual( completed.timeWindowContributions, ); expect(outcome.incumbent.timeWindowContributions).toMatchObject([ { ruleId: 'exact-cp', declaredDays: [submitted.declaredDays[0]], placementDates: ['2024-01-12'], }, { ruleId: 'preferred-rtt', declaredDays: [submitted.declaredDays[1]], placementDates: [], }, ]); expect(artifacts.explanation).toMatchObject({ kind: 'PlanExplanation', timeWindowContributions: completed.timeWindowContributions, incomplete: { reason: { code: 'user-cancelled' } }, }); expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays); }); it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => { vi.stubGlobal('window', undefined); const replaced = createPlanningCalculation(request); const current = createPlanningCalculation(request); expect(await replaced.outcome).toMatchObject({ kind: 'PlanningIncomplete', reasons: [{ code: 'stale-calculation' }], }); expect(await replaced.explanation).toMatchObject({ kind: 'IncompleteExplanation', reason: { code: 'stale-calculation' }, }); expect(await current.outcome).toMatchObject({ kind: 'Plan' }); }); it('retains a plan at least as good as every valid checkpoint before cancellation', async () => { const core = await import('./leave-planner.core'); let cancelled = false; let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; const checkpoints: (readonly import('./leave-planner.core').PlanningObjective[])[] = []; const submitted = { horizon: request.horizon, balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }], rules: [ presets[0], ...[ { id: 'january', type: 'time_window', force: 'preference', startDate: '2024-01-01', endDate: '2024-01-31', placeDays: 2, placeType: 'cp', }, ], ...presets.slice(1), ], progress: (stage: string) => { if (stage === 'optimization:default-longest-work-sequence') cancelled = true; }, }; const outcome = await core.calculatePlanningAsyncInternal( submitted, (plan, state) => { if (!state) return; const explained = core.explainIncompleteCheckpoint(submitted, plan, state, { code: 'user-cancelled', message: 'Cancelled', }).explanation; if (explained?.kind === 'PlanExplanation' && explained.placements.length) checkpoints.push(explained.placements[0].objectiveVector.objectives); }, (value) => (artifacts = value), undefined, undefined, () => cancelled, ); expect(outcome).toMatchObject({ kind: 'PlanningIncomplete', reasons: [{ code: 'user-cancelled' }], }); if (artifacts.explanation?.kind !== 'PlanExplanation') throw new Error('Expected a retained plan explanation.'); const retained = artifacts.explanation.placements[0].objectiveVector.objectives; expect(checkpoints.length).toBeGreaterThan(0); for (const candidate of checkpoints) { expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id)); const first = retained.findIndex( (objective, index) => objective.value !== candidate[index].value, ); if (first < 0) continue; expect( retained[first].direction === 'minimize' ? retained[first].value < candidate[first].value : retained[first].value > candidate[first].value, ).toBe(true); } }); it('requests interruption before a valid plan and terminates after required delivery', async () => { const calculation = createPlanningCalculation(request); calculation.cancel(); calculation.cancel(); expect(currentWorker().postMessage).toHaveBeenLastCalledWith({ type: 'cancel' }); expect(currentWorker().postMessage).toHaveBeenCalledTimes(2); expect(currentWorker().terminate).not.toHaveBeenCalled(); const cancelled: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', completedStages: ['validation'], reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }], }; currentWorker().emit({ type: 'result', outcome: cancelled }); currentWorker().emit({ type: 'result', outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] }, }); expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' }); expect(currentWorker().terminate).not.toHaveBeenCalled(); currentWorker().emit({ type: 'artifacts', artifacts: {} }); expect(currentWorker().terminate).toHaveBeenCalledOnce(); }); it('keeps a cancelled partial plan after explanation delivery fails', async () => { const core = await import('./leave-planner.core'); let interrupted = false; let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; const submitted = { horizon: { start: '2024-01-01', end: '2024-02-01' }, balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }], rules: presets, }; const cancelled = await core.calculatePlanningAsyncInternal( { ...submitted, progress: (stage: string) => { if (stage === 'optimization:default-non-working-day-gain') interrupted = true; }, }, undefined, (value) => (artifacts = value), undefined, undefined, () => interrupted, ); if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent) throw new Error('Expected a cancelled fixture with a valid plan.'); const calculation = createPlanningCalculation(submitted); calculation.cancel(); currentWorker().emit({ type: 'result', outcome: cancelled }); currentWorker().emit({ type: 'result', outcome: cancelled.incumbent }); currentWorker().emit({ type: 'explanation', explanationError: { code: 'explanation-error', message: 'Unavailable' }, }); currentWorker().emit({ type: 'artifacts', artifacts: {}, }); expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' }); expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' }); expect(currentWorker().terminate).toHaveBeenCalledOnce(); expect(currentWorker().postMessage).toHaveBeenCalledTimes(2); expect(artifacts.finalState?.placements).toEqual(cancelled.incumbent.placements); }); it.each([false, true])( 'preserves calculated statistics from a retained checkpoint after worker failure (declared period: %s)', async (withPeriod) => { const core = await import('./leave-planner.core'); const submitted = { horizon: { start: '2024-01-02', end: '2024-01-09' }, balances: [], rules: [], declaredDays: withPeriod ? [{ date: '2024-01-05', type: 'other' as const }] : [], }; const checkpoint = core.calculatePlanningInternal(submitted); if (checkpoint.kind !== 'Plan') throw new Error('Expected a valid checkpoint.'); const calculation = createPlanningCalculation(submitted); currentWorker().emit({ type: 'checkpoint', plan: structuredClone(checkpoint) }); currentWorker().onerror?.(); const outcome = await calculation.outcome; if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent) throw new Error('Expected a retained checkpoint.'); expect(outcome.reasons[0].code).toBe('worker-error'); expect(outcome.incumbent.metrics).toEqual(checkpoint.metrics); expect(outcome.incumbent.metrics).toMatchObject({ nonWorkingDayGain: withPeriod ? 2 : 0, averageWorkSequence: withPeriod ? 2 : 5, longestWorkSequence: withPeriod ? 3 : 5, averageTimeOffPeriod: withPeriod ? 3 : null, longestTimeOffPeriod: withPeriod ? 3 : null, }); }, ); it('reports worker failure distinctly after a cancellation request', async () => { const calculation = createPlanningCalculation(request); calculation.cancel(); currentWorker().onerror?.(); expect(await calculation.outcome).toMatchObject({ kind: 'PlanningIncomplete', reasons: [{ code: 'worker-error' }], }); }); it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => { const core = await import('./leave-planner.core'); let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; const plan = core.calculatePlanningInternal( request, undefined, (value) => (artifacts = value), ); expect(plan.kind).toBe('Plan'); const calculation = createPlanningCalculation(request); calculation.cancel(); currentWorker().emit({ type: 'result', outcome: plan }); currentWorker().emit({ type: 'artifacts', artifacts }); expect(await calculation.outcome).toMatchObject({ kind: 'PlanningIncomplete', incumbent: plan, reasons: [{ code: 'user-cancelled' }], }); expect(await calculation.explanation).toMatchObject({ kind: 'PlanExplanation', incomplete: { reason: { code: 'user-cancelled' } }, }); expect(currentWorker().postMessage).toHaveBeenCalledTimes(2); }); it('reports replacement distinctly and ignores late replies from the cancelled calculation', async () => { const cancelled = createPlanningCalculation(request); cancelled.cancel(); const previousWorker = currentWorker(); const lateReply = previousWorker.onmessage!; const newer = createPlanningCalculation(request); expect(previousWorker.terminate).toHaveBeenCalledOnce(); lateReply({ data: { type: 'result', outcome: { kind: 'PlanningConflict', reasons: [], conflictingRuleIds: [] }, }, } as MessageEvent); expect(await cancelled.outcome).toMatchObject({ kind: 'PlanningIncomplete', reasons: [{ code: 'stale-calculation' }], }); const outcome: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', completedStages: [], reasons: [{ code: 'user-cancelled', message: 'Cancelled' }], }; currentWorker().emit({ type: 'result', outcome }); currentWorker().emit({ type: 'artifacts', artifacts: {} }); expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' }); const sent = currentWorker().postMessage.mock.calls.length; newer.cancel(); expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent); }); });