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 'vitest'; import { createPlanningCalculation } from './leave-planner.service'; describe('real planning worker cancellation', () => { let directory: string; let bundle: string; let afterCheckpoint: (() => void) | undefined; let heartbeats = 0; let simulateLongCalculation = false; let simulatedElapsed = 0; const threads = new Set(); beforeAll(async () => { directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-')); bundle = join(directory, 'worker.mjs'); const entry = join(directory, 'entry.ts'); writeFileSync( entry, `import { parentPort, workerData } from 'node:worker_threads'; let elapsed = 0; 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 === 'at-risk') setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0); parentPort.postMessage(message); } }; await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))}); parentPort.on('message', (data) => globalThis.self.onmessage({ data })); `, ); await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]); }); beforeEach(() => { afterCheckpoint = undefined; heartbeats = 0; simulateLongCalculation = false; simulatedElapsed = 0; class RealWorker { onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } }); constructor() { threads.add(this.thread); this.thread.on('message', (data) => { if (data.type === 'test-clock') { simulatedElapsed = data.elapsed; return; } if (data.type === 'heartbeat') heartbeats++; this.onmessage?.({ data } as MessageEvent); if (data.type === 'checkpoint') afterCheckpoint?.(); }); this.thread.on('error', () => this.onerror?.()); } postMessage(message: unknown) { this.thread.postMessage(message); } terminate() { void this.thread.terminate(); } } vi.stubGlobal('Worker', RealWorker); }); afterEach(async () => { await Promise.all([...threads].map((thread) => thread.terminate())); threads.clear(); vi.unstubAllGlobals(); }); afterAll(() => rmSync(directory, { recursive: true, force: true })); 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: [], 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 === 'non-working-day-gain') interruption = true; }, }, undefined, undefined, undefined, undefined, () => interruption, ) : calculatePlanningInternal(submitted); const calculation = createPlanningCalculation({ ...submitted, progress: (stage) => { if (cancelled && stage === '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, }); expect((await calculation.trace)?.initialState.declaredDays).toEqual( 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.trace).toMatchObject({ status: 'incomplete', initialState: { declaredDays: [{ date: '2024-01-02', type: 'other' }] }, }); 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(); expect((await calculation.trace)?.status).toBe('incomplete'); }); it('stops an active obligation search and retains a jointly valid incumbent and its history', async () => { let cancelledAt = 0; afterCheckpoint = () => { afterCheckpoint = undefined; 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'); } const trace = await calculation.trace; expect(trace?.status).toBe('incomplete'); expect( trace?.phases.find((phase) => phase.name === 'obligations')?.decisions.length, ).toBeGreaterThan(0); expect(trace?.phases.find((phase) => phase.name === 'dates')?.status).toBe('not-run'); }); it('preserves balances and provenance after a valid checkpoint and drains every produced trace packet', async () => { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }], rules: [ { id: 'fixed', type: 'time_window', force: 'obligation', startDate: '2024-01-02', endDate: '2024-01-02', placeDays: 1, placeType: 'cp', }, ], progress: (stage) => { if (stage === 'non-working-day-gain') 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('holiday-week-value'); expect(outcome.completedStages).not.toContain('non-working-day-gain'); 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 trace = await calculation.trace; expect(trace?.status).toBe('incomplete'); expect(trace?.error?.code).toBe('user-cancelled'); expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe( 'interrupted', ); 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: [], }; 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.trace)?.status).toBe('ready'); expect(await calculation.explanationError).toBeUndefined(); }); });