import { execFile } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Worker as NodeWorker } from 'node:worker_threads'; import { promisify } from 'node:util'; import { vi } from '../../testing/vitest-compat'; import { createPlanningCalculation, type PlanningCalculationContext, } from './leave-planner.service'; import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { CalendarYear } from '../components/calendar-year/calendar-year'; import { CalendarService, SelectableDayType } from './calendar-service'; import { CalendarSettingsService } from './calendar-settings-service'; import { UserInputService } from './user-input-service'; import { RuleService, RuleType, createOptimizationPresetRules } from './rule-service'; import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives'; beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); afterEach(() => vi.useRealTimers()); const presets = createOptimizationPresetRules(); describe('real planning worker cancellation', () => { let directory: string; let bundle: string; let afterCheckpoint: (() => void) | undefined; let heartbeats = 0; let holdArtifacts = false; const heldArtifactMessages: (() => void)[] = []; let simulateLongCalculation = false; let holdForCancelAtStage: string | undefined; let simulatedElapsed = 0; const threads = new Set(); const sentTypes: string[] = []; const receivedTypes: string[] = []; let terminationCount = 0; beforeAll(async () => { directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-')); bundle = join(directory, 'worker.mjs'); const entry = join(directory, 'entry.ts'); writeFileSync( entry, `import { parentPort, receiveMessageOnPort, workerData } from 'node:worker_threads'; let elapsed = 0; const pause = new Int32Array(new SharedArrayBuffer(4)); const originalNow = performance.now.bind(performance); if (workerData.simulateLongCalculation) Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed }); globalThis.self = { postMessage: (message) => { if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-at-risk') setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0); parentPort.postMessage(message); // The worker reads its queue only between event-loop turns, so a stage that is shorter than the // host round trip finishes first. Hold the stage until the host's cancel arrives, then deliver it. if (message.type === 'progress' && message.stage === workerData.holdForCancelAtStage) { let next; for (let waits = 0; !(next = receiveMessageOnPort(parentPort)); waits++) { if (waits > 5000) throw new Error('The host never sent the cancel request.'); Atomics.wait(pause, 0, 0, 1); } globalThis.self.onmessage({ data: next.message }); } } }; await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))}); parentPort.on('message', (data) => globalThis.self.onmessage({ data })); `, ); await promisify(execFile)(process.execPath, [ 'build', entry, '--target=node', '--outfile', bundle, ]); }); beforeEach(() => { afterCheckpoint = undefined; holdArtifacts = false; heldArtifactMessages.length = 0; localStorage.clear(); heartbeats = 0; simulateLongCalculation = false; holdForCancelAtStage = undefined; simulatedElapsed = 0; sentTypes.length = 0; receivedTypes.length = 0; terminationCount = 0; class RealWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation, holdForCancelAtStage }, }); constructor() { threads.add(this.thread); this.thread.on('message', (data) => { if (data.type === 'test-clock') { simulatedElapsed = data.elapsed; return; } receivedTypes.push(data.type); if (holdArtifacts && ['explanation', 'artifacts'].includes(data.type)) { heldArtifactMessages.push(() => this.onmessage?.({ data } as MessageEvent)); return; } if (data.type === 'heartbeat') heartbeats++; this.onmessage?.({ data } as MessageEvent); if (data.type === 'checkpoint') afterCheckpoint?.(); }); this.thread.on('error', () => this.onerror?.()); } postMessage(message: { type: string }) { sentTypes.push(message.type); this.thread.postMessage(message); } terminate() { terminationCount++; void this.thread.terminate(); } } vi.stubGlobal('Worker', RealWorker); }); afterEach(async () => { await Promise.all([...threads].map((thread) => thread.terminate())); threads.clear(); localStorage.clear(); vi.unstubAllGlobals(); }); afterAll(() => rmSync(directory, { recursive: true, force: true })); it('delivers the outcome and explanation and releases the worker without acknowledgements', async () => { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-02', end: '2024-01-04' }, balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }], rules: [presets[0]], }); const outcome = await calculation.outcome; expect(outcome).toMatchObject({ kind: 'Plan', placements: [{ date: '2024-01-02', balanceId: 'cp', type: 'cp' }], lastCompletedStage: 'dates', }); expect(await calculation.explanation).toMatchObject({ kind: 'PlanExplanation', placements: [{ placement: { date: '2024-01-02', balanceId: 'cp' } }], }); expect(await calculation.explanationError).toBeUndefined(); expect(await calculation.timings).toEqual({ planningWorkMs: expect.any(Number) }); expect(sentTypes).toEqual(['plan']); expect(receivedTypes.slice(-3)).toEqual(['result', 'explanation', 'artifacts']); expect(new Set(receivedTypes)).toEqual( new Set(['heartbeat', 'progress', 'checkpoint', 'result', 'explanation', 'artifacts']), ); expect(terminationCount).toBe(1); }); it('ignores a replaced real worker’s late explanation without losing either outcome', async () => { const request = { horizon: { start: '2024-01-02', end: '2024-01-04' }, balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }], rules: [presets[0]], }; holdArtifacts = true; const first = createPlanningCalculation(request); const original = await first.outcome; expect(original.kind).toBe('Plan'); await vi.waitFor(() => expect(heldArtifactMessages).toHaveLength(2)); holdArtifacts = false; const latest = createPlanningCalculation({ ...request, rules: [] }); const outcome = await latest.outcome; expect(outcome).toMatchObject({ kind: 'Plan', placements: [] }); const explanation = await latest.explanation; expect(explanation).toMatchObject({ kind: 'PlanExplanation', placements: [] }); await latest.timings; for (const deliver of heldArtifactMessages) deliver(); expect(await first.outcome).toEqual(original); expect(await first.explanation).toBeUndefined(); expect(await latest.outcome).toEqual(outcome); expect(await latest.explanation).toEqual(explanation); expect(await latest.explanationError).toBeUndefined(); expect(terminationCount).toBe(2); }); it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => { const configuration: OptimizationConfiguration = structuredClone( OPTIMIZATION_PRESETS.unplacedAtRisk, ); configuration.aggregation = 'maximum'; const submitted = structuredClone(configuration); const calculation = createPlanningCalculation({ horizon: { start: '2024-01-02', end: '2024-01-05' }, balances: [{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-04' }], rules: [ { id: 'personal-loss', name: 'Captured loss rule', type: 'optimization', force: 'preference', configuration, }, ], }); configuration.conditions = []; configuration.direction = 'maximize'; configuration.aggregation = 'count'; const outcome = await calculation.outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan'); expect(outcome.placements).toHaveLength(1); expect(outcome.metrics.unplacedAtRisk).toBe(0); expect(calculation.snapshot.rules[0]).toMatchObject({ name: 'Captured loss rule', configuration: submitted, }); const explanation = await calculation.explanation; if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected worker explanation'); expect( explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id), ).toEqual([ 'obligation-validity', 'optimization:personal-loss', 'balance-expiry', 'placement-date', ]); const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]); if (comparison.kind !== 'Counterfactual') throw new Error('Expected worker comparison'); expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual([ 'obligation-validity', 'optimization:personal-loss', 'balance-expiry', 'placement-date', ]); }); it('keeps the submitted work-sequence threshold in the real worker and date comparisons', async () => { const configuration: OptimizationConfiguration = structuredClone( OPTIMIZATION_PRESETS.shortSequenceDeficit, ); const calculation = createPlanningCalculation({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }], declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })), rules: [ { id: 'deficit', name: 'Captured five-day preference', type: 'optimization', configuration, }, ], }); configuration.thresholdDays = 3; configuration.conditions = []; const outcome = await calculation.outcome; if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan'); expect(outcome.placements.map(({ date }) => date)).toEqual([ '2024-01-09', '2024-01-10', '2024-01-11', ]); expect(calculation.snapshot.rules[0]).toMatchObject({ configuration: { thresholdDays: 5, conditions: ['internal', 'non-empty'] }, }); const explanation = await calculation.explanation; if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation'); expect( explanation.placements[0].comparison.before.objectives.find( ({ id }) => id === 'optimization:deficit', )?.value, ).toBe(4); const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]); if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual'); expect( counterfactual.baseline.objectives.find(({ id }) => id === 'optimization:deficit') ?.value, ).toBe(4); expect( counterfactual.results .find(({ date }) => date === '2024-01-09') ?.comparison?.after.objectives.find(({ id }) => id === 'optimization:deficit') ?.value, ).toBe(0); }); it.each([ [false, false], [true, false], [false, true], [true, true], ])( 'submits declared earned chains from the calendar and retains actual artifacts after edits (worker: %s, cancelled: %s)', async (worker, cancelled) => { localStorage.clear(); localStorage.setItem('app.language', '"fr"'); localStorage.setItem('leaveBalances', '[]'); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-29', '2024-05-07'); const input = TestBed.inject(UserInputService); for (const date of ['2024-04-29', '2024-04-30']) input.toggleDeclaredDay(date, 'cp'); input.toggleDeclaredDay('2024-05-06', 'other'); const rules = TestBed.inject(RuleService); rules.addRule(presets[0]); rules.addRule({ id: 'producer', name: 'CP autour du férié', enabled: true, force: 'preference', type: RuleType.IF_THEN, dayOfWeek: 3, placement: 'before', placeDays: 2, placeType: SelectableDayType.CP, rewardDays: 1, rewardType: SelectableDayType.OTHER, }); rules.addRule({ id: 'consumer', name: 'Période avec droit futur', enabled: true, force: 'obligation', type: RuleType.TIME_WINDOW, startDate: new Date('2024-05-03T00:00:00'), endDate: new Date('2024-05-06T00:00:00'), placeDays: 2, placeType: SelectableDayType.OTHER, }); for (const preset of presets.slice(1)) rules.addRule(preset); const calendar = TestBed.inject(CalendarService); const original = calendar.createPlanningCalculation.bind(calendar); let calculation!: PlanningCalculationContext; const calculate = vi .spyOn(calendar, 'createPlanningCalculation') .mockImplementation((request) => { const browserWindow = globalThis.window; if (!worker) vi.stubGlobal('window', undefined); try { calculation = original({ ...request, progress: (stage) => { request.progress?.(stage); if ( cancelled && stage === 'optimization:default-non-working-day-gain' ) { fixture.detectChanges(); fixture.nativeElement .querySelector('button.cancel-calculation') .click(); } }, }); return calculation; } finally { if (!worker) vi.stubGlobal('window', browserWindow); } }); holdArtifacts = worker; const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); expect(calculate).toHaveBeenCalledOnce(); expect(calculation.snapshot.calculationDate).toBe('2024-05-01'); vi.setSystemTime(new Date('2024-05-01T22:00:01Z')); const outcome = await calculation.outcome; expect(outcome.kind).toBe(cancelled ? 'PlanningIncomplete' : 'Plan'); const plan = outcome.kind === 'Plan' ? outcome : outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined; if (!plan) throw new Error('Expected a valid earned-chain plan.'); expect(plan.calculationDate).toBe('2024-05-01'); expect(plan.metrics).toMatchObject({ nonWorkingDayGain: 5, averageWorkSequence: 1, longestWorkSequence: 1, averageTimeOffPeriod: 4.5, longestTimeOffPeriod: 5, }); expect(plan.placements).toMatchObject([ { date: '2024-05-03', balanceId: 'earned:producer:2024-05-01', type: 'other', ruleIds: ['consumer'], }, ]); if (outcome.kind === 'PlanningIncomplete') expect(outcome.reasons[0].code).toBe('user-cancelled'); expect(plan.remainingBalances).toEqual([]); await fixture.whenStable(); fixture.detectChanges(); const root: HTMLElement = fixture.nativeElement; root.querySelector('.statistics-button')!.click(); await fixture.whenStable(); const dialog = document.querySelector('[role="dialog"]')!; const connectedHolidays = () => Array.from(document.querySelectorAll('[role="dialog"] .statistics-summary dt')) .find((label) => label.textContent?.includes('Jours fériés reliés à un week-end'), ) ?.nextElementSibling?.textContent?.trim(); expect(connectedHolidays()).toBe('1'); const earnedStatistics = () => [ dialog .querySelector('.statistics-summary > div:last-child dt') ?.textContent?.replace(/\s+/g, ' ') .trim(), dialog .querySelector('.statistics-summary > div:last-child dd') ?.textContent?.trim(), ...Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ label.textContent?.trim(), label.nextElementSibling?.textContent?.trim(), ]), ]; expect(earnedStatistics()).toEqual([ 'Congés acquis avant consommation (jours)', '1', ['CP autour du férié', '1'], ]); expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled); const originalDeclarations = structuredClone(calculation.snapshot.declaredDays); input.toggleDeclaredDay('2024-04-30', 'rtt'); rules.updateRule({ ...rules.rules.find(({ id }) => id === 'producer')!, name: 'Règle modifiée après calcul', }); rules.deleteRule('producer'); input.updateBalances(() => []); if (worker) { await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0)); let explained = false; void calculation.explanation.then(() => (explained = true)); await Promise.resolve(); expect(explained).toBe(false); holdArtifacts = false; for (const deliver of heldArtifactMessages.splice(0)) deliver(); } const explanation = await calculation.explanation; await fixture.whenStable(); fixture.detectChanges(); expect(connectedHolidays()).toBe('1'); expect(earnedStatistics()).toEqual([ 'Congés acquis avant consommation (jours)', '1', ['CP autour du férié', '1'], ]); dialog.querySelector('button')!.click(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); expect(explanation).toMatchObject({ kind: 'PlanExplanation', calculationDate: '2024-05-01', declaredDays: originalDeclarations, ifThenContributions: [ { label: 'CP autour du férié', earnedEntitlement: { availableFrom: '2024-05-02', expiryDate: '2025-05-02', added: true, }, }, ], }); const core = await import('./leave-planner.core'); const direct = core.calculatePlanningInternal(calculation.snapshot); if (direct.kind !== 'Plan') throw new Error(`Expected the same direct plan: ${JSON.stringify(direct)}`); expect(plan.placements).toEqual(direct.placements); expect(plan.ifThenContributions).toEqual(direct.ifThenContributions); expect(plan.metrics).toEqual(direct.metrics); expect(plan.timeOffPeriods).toEqual(direct.timeOffPeriods); expect(root.textContent).toContain('Droit futur généré'); expect(root.textContent).toContain('CP autour du férié'); expect(root.textContent).not.toContain('Règle modifiée après calcul'); expect( root.querySelector('[data-date="2024-04-30"]')?.getAttribute('aria-label'), ).toContain('CP'); root.querySelector('button.compare-dates-button')!.click(); await vi.waitFor(() => expect(fixture.componentInstance['counterfactualLoading']()).toBe(false), ); const comparison = fixture.componentInstance['counterfactual'](); expect(comparison?.kind).toBe('Counterfactual'); expect( comparison?.results.find(({ date }) => date === '2024-04-30')?.reason?.code, ).toBe('declared-day'); expect(comparison?.results.find(({ date }) => date === '2024-05-02')?.status).toBe( 'ineligible', ); expect(comparison?.results.find(({ date }) => date === '2024-05-03')?.status).toBe( 'eligible', ); root.querySelector('.statistics-button')!.click(); await fixture.whenStable(); expect(connectedHolidays()).toBe('1'); fixture.destroy(); }, ); it.each([false, true])( 'keeps declarations fixed in direct and worker planning and date comparisons (cancelled: %s)', async (cancelled) => { const submitted = { horizon: { start: '2024-01-02', end: '2024-02-01' }, balances: [ { id: 'cp', type: 'cp' as const, quantity: 4, expiryDate: '2024-01-05' }, ], rules: presets, declaredDays: [ { date: '2024-01-02', type: 'cp' as const }, { date: '2024-01-03', type: 'rtt' as const }, { date: '2024-01-05', type: 'other' as const }, ], }; const { calculatePlanningInternal, calculatePlanningAsyncInternal } = await import('./leave-planner.core'); let interruption = false; const direct = cancelled ? await calculatePlanningAsyncInternal( { ...submitted, progress: (stage: string) => { if (stage === 'optimization:default-non-working-day-gain') interruption = true; }, }, undefined, undefined, undefined, undefined, () => interruption, ) : calculatePlanningInternal(submitted); const calculation = createPlanningCalculation({ ...submitted, progress: (stage) => { if (cancelled && stage === 'optimization:default-non-working-day-gain') calculation.cancel(); }, }); const outcome = await calculation.outcome; expect(outcome.kind).toBe(cancelled ? 'PlanningIncomplete' : 'Plan'); expect(direct.kind).toBe(outcome.kind); const plan = outcome.kind === 'Plan' ? outcome : outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined; const directPlan = direct.kind === 'Plan' ? direct : direct.kind === 'PlanningIncomplete' ? direct.incumbent : undefined; expect(plan).toBeDefined(); expect(plan?.declaredDays).toEqual(submitted.declaredDays); expect(plan?.placements.map(({ date }) => date)).toEqual(['2024-01-04']); expect(directPlan?.placements).toEqual(plan?.placements); expect(plan?.remainingBalances).toMatchObject([{ id: 'cp', quantity: 3 }]); if (!cancelled) expect(outcome).toEqual(direct); expect(await calculation.explanation).toMatchObject({ kind: 'PlanExplanation', declaredDays: submitted.declaredDays, }); const original = structuredClone(outcome); const comparison = await calculation.analyzeCounterfactual(plan!.placements[0]); for (const { date } of submitted.declaredDays) expect(comparison.results.find((result) => result.date === date)).toMatchObject({ status: 'ineligible', reason: { code: 'declared-day' }, }); expect(await calculation.outcome).toEqual(original); }, ); it('stops during calendar preparation without a plan or an obligation conflict', async () => { const stages: string[] = []; let preparationUpdates = 0; const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2027-01-01' }, balances: [], rules: [], declaredDays: [{ date: '2024-01-02', type: 'other' }], progress: (stage) => { stages.push(stage); if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel(); }, }); const outcome = await calculation.outcome; expect(outcome).toMatchObject({ kind: 'PlanningIncomplete', reasons: [{ code: 'user-cancelled' }], }); if (outcome.kind !== 'PlanningIncomplete') return; expect(outcome.incumbent).toBeUndefined(); expect(preparationUpdates).toBeGreaterThanOrEqual(2); expect(stages).not.toContain('obligations'); expect(await calculation.explanationError).toBeUndefined(); expect(heartbeats).toBeGreaterThan(0); }); it('cancels before a displayed valid plan without reporting a queued conflict', async () => { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [ { id: 'required', type: 'time_window', force: 'obligation', startDate: '2024-01-01', endDate: '2024-12-31', placeDays: 1, placeType: 'cp', }, ], progress: (stage) => { if (stage === 'obligations') calculation.cancel(); }, }); const outcome = await calculation.outcome; expect(outcome).toMatchObject({ kind: 'PlanningIncomplete', reasons: [{ code: 'user-cancelled' }], }); if (outcome.kind !== 'PlanningIncomplete') return; expect(outcome.incumbent).toBeUndefined(); }); it('stops an active obligation search and retains a jointly valid incumbent', async () => { let cancelledAt = 0; afterCheckpoint = () => { afterCheckpoint = undefined; holdArtifacts = false; heldArtifactMessages.length = 0; localStorage.clear(); setTimeout(() => { cancelledAt = performance.now(); calculation.cancel(); }, 10); }; const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [{ id: 'cp', type: 'cp', quantity: 6, expiryDate: '2024-12-31' }], rules: ['first', 'second'].map((id) => ({ id, type: 'time_window' as const, force: 'obligation' as const, startDate: '2024-01-01', endDate: '2024-12-31', placeDays: 3, placeType: 'cp' as const, })), }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('PlanningIncomplete'); if (outcome.kind !== 'PlanningIncomplete') return; expect(outcome.reasons[0].code).toBe('user-cancelled'); expect(performance.now() - cancelledAt).toBeLessThan(2000); expect(outcome.completedStages).not.toContain('obligations'); expect(outcome.incumbent).toBeDefined(); const incumbent = outcome.incumbent!; for (const id of ['first', 'second']) expect( incumbent.placements.filter((placement) => placement.ruleIds.includes(id)), ).toHaveLength(3); expect( incumbent.remainingBalances.reduce((sum, balance) => sum + balance.quantity, 0) + incumbent.placements.length, ).toBe(6); const explanation = await calculation.explanation; expect(explanation?.kind).toBe('PlanExplanation'); if (explanation?.kind === 'PlanExplanation') { expect(explanation.placements.map(({ placement }) => placement)).toEqual( incumbent.placements, ); expect(explanation.incomplete?.reason.code).toBe('user-cancelled'); } }); it('preserves balances and provenance after a valid checkpoint', async () => { const cancelStage = 'optimization:default-non-working-day-gain'; holdForCancelAtStage = cancelStage; const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }], rules: [ presets[0], ...[ { id: 'fixed', type: 'time_window', force: 'obligation', startDate: '2024-01-02', endDate: '2024-01-02', placeDays: 1, placeType: 'cp', }, ], ...presets.slice(1), ], progress: (stage) => { if (stage === cancelStage) calculation.cancel(); }, }); const outcome = await calculation.outcome; expect(outcome.kind).toBe('PlanningIncomplete'); if (outcome.kind !== 'PlanningIncomplete') return; expect(outcome.reasons[0].code).toBe('user-cancelled'); expect(outcome.completedStages).toContain('optimization:default-holiday-week-value'); expect(outcome.completedStages).not.toContain(cancelStage); const incumbent = outcome.incumbent!; expect(incumbent.placements).toHaveLength(3); expect(incumbent.placements).toContainEqual({ date: '2024-01-02', type: 'cp', balanceId: 'cp', ruleIds: ['fixed'], supporting: false, }); expect(incumbent.remainingBalances).toEqual([]); const explanation = await calculation.explanation; expect(explanation?.kind).toBe('PlanExplanation'); if (explanation?.kind === 'PlanExplanation') expect(explanation.placements.map(({ placement }) => placement)).toEqual( incumbent.placements, ); const frozenOutcome = structuredClone(outcome); calculation.cancel(); expect(await calculation.outcome).toEqual(frozenOutcome); }); it('completes a three-year calculation after twenty simulated seconds with the same deterministic plan', async () => { const submitted = { horizon: { start: '2024-01-01', end: '2027-01-01' }, balances: [ { id: 'cp', type: 'cp' as const, quantity: 1, availableFrom: '2026-12-24', expiryDate: '2026-12-24', }, ], rules: presets, }; const { calculatePlanningInternal } = await import('./leave-planner.core'); const expected = calculatePlanningInternal(submitted); expect(expected).toMatchObject({ kind: 'Plan', lastCompletedStage: 'dates' }); simulateLongCalculation = true; const calculation = createPlanningCalculation(submitted); expect(await calculation.outcome).toEqual(expected); expect(simulatedElapsed).toBe(20_000); expect(await calculation.explanationError).toBeUndefined(); }); });