import { vi } from '../../testing/vitest-compat'; import { createPlanningCalculation, type LeavePlacement, type PlanningCalculationContext, } from './leave-planner.service'; const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] }; const plan = { kind: 'Plan', placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement], completedStages: [], }; const state = { marker: 'state' }; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); class FakeWorker { static all: FakeWorker[] = []; static constructThrows = false; static postThrows = false; onmessage: ((event: MessageEvent) => void) | null = null; onerror: (() => void) | null = null; terminate = vi.fn(); postMessage = vi.fn(() => { if (this.throwOnPost) throw new Error('post failed'); }); private throwOnPost = FakeWorker.postThrows; constructor() { if (FakeWorker.constructThrows) throw new Error('no worker'); FakeWorker.all.push(this); } emit(data: unknown) { this.onmessage?.({ data } as MessageEvent); } } interface Started { worker?: FakeWorker; succeeded: Promise; deliver: () => void; late: () => void; } type Flags = { construct?: boolean; post?: boolean }; const arm = (flags: Flags) => { FakeWorker.constructThrows = !!flags.construct; FakeWorker.postThrows = !!flags.post; }; const jobs: Record Promise> = { plan: async (flags) => { arm(flags); const progress = vi.fn(); const calculation = createPlanningCalculation({ ...request, progress }); const worker = FakeWorker.all[0]; return { worker, succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'), deliver: () => { worker?.emit({ type: 'result', outcome: plan }); worker?.emit({ type: 'artifacts', artifacts: {} }); }, late: () => { worker?.emit({ type: 'progress', stage: 'late' }); expect(progress).not.toHaveBeenCalled(); }, }; }, counterfactual: async (flags) => { const calculation = createPlanningCalculation(request); FakeWorker.all[0].emit({ type: 'result', outcome: plan }); FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} }); await calculation.timings; arm(flags); const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual( plan.placements[0], ); const succeeded = analysis.then( (result) => result.kind !== 'CounterfactualStale', () => false, ); await flush(); const worker = FakeWorker.all[1]; return { worker, succeeded, deliver: () => worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }), late: () => worker?.emit({ type: 'error', message: 'late' }), }; }, 'explain-checkpoint': async (flags) => { const calculation = createPlanningCalculation(request); const first = FakeWorker.all[0]; first.emit({ type: 'checkpoint', plan, state }); arm(flags); first.emit({ type: 'error', message: 'planner crashed' }); const worker = FakeWorker.all[1]; return { worker, succeeded: calculation.explanation.then((explanation) => explanation !== undefined), deliver: () => worker?.emit({ type: 'explanation', explanation: { kind: 'IncompleteExplanation' }, }), late: () => worker?.emit({ type: 'error', message: 'late' }), }; }, }; describe('worker job lifetime', () => { beforeEach(() => { FakeWorker.all = []; arm({}); vi.stubGlobal('window', {}); vi.stubGlobal('Worker', FakeWorker); }); afterEach(() => { arm({}); vi.unstubAllGlobals(); }); describe.each(Object.keys(jobs))('%s job', (name) => { const start = jobs[name]; const released = (worker: FakeWorker) => { expect(worker.terminate).toHaveBeenCalledOnce(); expect(worker.onmessage).toBeNull(); expect(worker.onerror).toBeNull(); }; it('releases the worker once on success and leaves no handler for late messages', async () => { const job = await start({}); job.deliver(); expect(await job.succeeded).toBe(true); released(job.worker!); job.late(); job.deliver(); released(job.worker!); }); it('fails without a worker when construction throws', async () => { const job = await start({ construct: true }); expect(job.worker).toBeUndefined(); expect(await job.succeeded).toBe(false); }); it('releases the worker once when postMessage throws', async () => { const job = await start({ post: true }); expect(await job.succeeded).toBe(false); released(job.worker!); }); it('releases the worker once on a runtime error', async () => { const job = await start({}); job.worker!.onerror!(); expect(await job.succeeded).toBe(false); released(job.worker!); job.deliver(); expect(await job.succeeded).toBe(false); released(job.worker!); }); it('releases the worker once when a newer calculation supersedes it', async () => { const job = await start({}); createPlanningCalculation(request); expect(await job.succeeded).toBe(false); released(job.worker!); job.deliver(); released(job.worker!); }); }); });