349 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|