Files
vac-optimizer/src/app/services/leave-planner.worker.spec.ts
T
2026-10-07 00:28:29 +02:00

349 lines
12 KiB
TypeScript

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