Files
vac-optimizer/src/app/services/calendar-objectives.worker.spec.ts
T
nessar 6811142299 feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
2026-10-10 16:07:34 +02:00

226 lines
7.8 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 { promisify } from 'node:util';
import { Worker as NodeWorker } from 'node:worker_threads';
import { vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
import type { OptimizationPlanningRule } from './leave-planner.core';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('calendar objectives in the real worker', () => {
let directory: string;
let bundle: string;
const threads = new Set<NodeWorker>();
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-calendar-worker-'));
bundle = join(directory, 'worker.mjs');
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort } from 'node:worker_threads';
globalThis.self = { postMessage: (message) => 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(() => {
vi.setSystemTime(new Date('2024-04-01T12:00:00Z'));
class BrowserWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private readonly thread = new NodeWorker(bundle);
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => this.onmessage?.({ data } as MessageEvent));
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
this.thread.postMessage(message);
}
terminate() {
void this.thread.terminate();
}
}
vi.stubGlobal('Worker', BrowserWorker);
});
afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear();
vi.useRealTimers();
vi.unstubAllGlobals();
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it.each([false, true])(
'retains the submitted configuration, plan and artifact order (gain first: %s)',
async (gainFirst) => {
const holiday: OptimizationPlanningRule = {
id: 'holiday',
type: 'optimization' as const,
name: 'Captured holiday rule',
configuration: structuredClone(OPTIMIZATION_PRESETS.holidayWeekValue),
};
const gain: OptimizationPlanningRule = {
id: 'gain',
type: 'optimization' as const,
name: 'Captured gain rule',
configuration: structuredClone(OPTIMIZATION_PRESETS.nonWorkingDayGain),
};
const rules = gainFirst ? [gain, holiday] : [holiday, gain];
const calculation = createPlanningCalculation({
horizon: { start: '2024-04-29', end: '2024-05-18' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-04-29',
expiryDate: '2025-01-01',
},
],
rules,
});
holiday.configuration = {
...holiday.configuration,
conditions: [],
direction: 'minimize',
};
const plan = await calculation.outcome;
if (plan.kind !== 'Plan')
throw new Error(`Expected a worker plan, received ${plan.kind}`);
expect(plan.metrics).toMatchObject(
gainFirst
? { holidayWeekValue: 0, nonWorkingDayGain: 9 }
: { holidayWeekValue: 5, nonWorkingDayGain: 6 },
);
expect(calculation.snapshot.rules.find((rule) => rule.id === 'holiday')).toMatchObject({
name: 'Captured holiday rule',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
});
const first = `optimization:${rules[0].id}`;
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a worker explanation');
expect(explanation.placements[0].firstDecisiveObjective).toBe(first);
expect((await calculation.trace)?.phases.map((phase) => phase.name)).toEqual([
'validation',
'obligations',
first,
`optimization:${rules[1].id}`,
'expiry',
'dates',
]);
const comparison = await calculation.analyzeCounterfactual(plan.placements[0]);
if (comparison.kind !== 'Counterfactual')
throw new Error('Expected a worker comparison');
expect(
comparison.results.find((result) => result.date === plan.placements[0].date)
?.comparison?.firstDecisiveObjective,
).toBe(first);
},
);
it.each([false, true])(
'lets economy block or permit the complete holiday bundle in a real worker (economy first: %s)',
async (economyFirst) => {
const economy: OptimizationPlanningRule = {
id: 'economy',
name: 'Captured supporting economy',
type: 'optimization',
configuration: structuredClone(OPTIMIZATION_PRESETS.supportingPlacements),
};
const holiday: OptimizationPlanningRule = {
id: 'holiday',
type: 'optimization',
configuration: OPTIMIZATION_PRESETS.holidayWeekValue,
};
const rules = economyFirst ? [economy, holiday] : [holiday, economy];
const calculation = createPlanningCalculation({
horizon: { start: '2026-11-09', end: '2026-11-14' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 2,
availableFrom: '2026-11-09',
expiryDate: '2027-01-01',
},
],
declaredDays: ['2026-11-09', '2026-11-10'].map((date) => ({ date, type: 'other' })),
rules,
});
economy.name = 'Edited later';
economy.configuration.conditions = [];
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.metrics).toMatchObject(
economyFirst
? { holidayWeekValue: 0, supportingPlacements: 0 }
: { holidayWeekValue: 3, supportingPlacements: 2 },
);
expect(calculation.snapshot.rules.find(({ id }) => id === 'economy')).toMatchObject({
name: 'Captured supporting economy',
configuration: OPTIMIZATION_PRESETS.supportingPlacements,
});
const trace = await calculation.trace;
expect(trace?.phases.map(({ name }) => name)).toEqual([
'validation',
'obligations',
`optimization:${rules[0].id}`,
`optimization:${rules[1].id}`,
'expiry',
'dates',
]);
const candidates = trace?.phases.flatMap(({ decisions }) =>
decisions.flatMap(({ candidates }) => candidates),
);
expect(candidates).toContainEqual(
expect.objectContaining({
status: economyFirst ? 'evaluated-rejected' : 'chosen',
dates: ['2026-11-12', '2026-11-13'],
comparison: expect.objectContaining({
firstDecisiveObjective: economyFirst
? 'optimization:economy'
: 'optimization:holiday',
}),
}),
);
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
if (economyFirst) {
expect(outcome.placements).toEqual([]);
expect(outcome.remainingBalances[0]).toMatchObject({
id: 'cp',
quantity: 2,
classification: 'deferred',
});
expect(explanation.placements).toEqual([]);
return;
}
expect(outcome.remainingBalances).toEqual([]);
expect(explanation.placements.every(({ supporting }) => supporting)).toBe(true);
expect(explanation.placements[0].firstDecisiveObjective).toBe('optimization:holiday');
expect(explanation.placements[0].objectiveVector.objectives).toContainEqual(
expect.objectContaining({
id: 'optimization:economy',
value: 2,
label: 'Captured supporting economy',
}),
);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (analysis.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
analysis.results.find(({ date }) => date === outcome.placements[0].date)?.comparison
?.after.objectives,
).toContainEqual(expect.objectContaining({ id: 'optimization:economy', value: 2 }));
},
);
});