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
226 lines
7.8 KiB
TypeScript
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 }));
|
|
},
|
|
);
|
|
});
|