Files
vac-optimizer/src/app/services/leave-planner.worker.spec.ts
T
nessarandClaude Opus 5.5 dfe6d28d2b test(planning): make the checkpoint cancellation test deterministic
The cancelled stage takes about a millisecond, so under load the worker
could finish it before the cancel message arrived and report it as
completed. The real-worker harness now holds the worker after reporting
that stage until the cancel is delivered, keeping every assertion.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:57:33 +02:00

770 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '../../testing/vitest-compat';
import {
createPlanningCalculation,
type PlanningCalculationContext,
} from './leave-planner.service';
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { CalendarYear } from '../components/calendar-year/calendar-year';
import { CalendarService, SelectableDayType } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import { UserInputService } from './user-input-service';
import { RuleService, RuleType, createOptimizationPresetRules } from './rule-service';
import { OPTIMIZATION_PRESETS, type OptimizationConfiguration } from './planning-objectives';
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
let afterCheckpoint: (() => void) | undefined;
let heartbeats = 0;
let holdArtifacts = false;
const heldArtifactMessages: (() => void)[] = [];
let simulateLongCalculation = false;
let holdForCancelAtStage: string | undefined;
let simulatedElapsed = 0;
const threads = new Set<NodeWorker>();
const sentTypes: string[] = [];
const receivedTypes: string[] = [];
let terminationCount = 0;
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-'));
bundle = join(directory, 'worker.mjs');
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort, receiveMessageOnPort, workerData } from 'node:worker_threads';
let elapsed = 0;
const pause = new Int32Array(new SharedArrayBuffer(4));
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 === 'optimization:default-unplaced-at-risk')
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
parentPort.postMessage(message);
// The worker reads its queue only between event-loop turns, so a stage that is shorter than the
// host round trip finishes first. Hold the stage until the host's cancel arrives, then deliver it.
if (message.type === 'progress' && message.stage === workerData.holdForCancelAtStage) {
let next;
for (let waits = 0; !(next = receiveMessageOnPort(parentPort)); waits++) {
if (waits > 5000) throw new Error('The host never sent the cancel request.');
Atomics.wait(pause, 0, 0, 1);
}
globalThis.self.onmessage({ data: next.message });
}
} };
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
`,
);
await promisify(execFile)(process.execPath, [
'build',
entry,
'--target=node',
'--outfile',
bundle,
]);
});
beforeEach(() => {
afterCheckpoint = undefined;
holdArtifacts = false;
heldArtifactMessages.length = 0;
localStorage.clear();
heartbeats = 0;
simulateLongCalculation = false;
holdForCancelAtStage = undefined;
simulatedElapsed = 0;
sentTypes.length = 0;
receivedTypes.length = 0;
terminationCount = 0;
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private thread = new NodeWorker(bundle, {
workerData: { simulateLongCalculation, holdForCancelAtStage },
});
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => {
if (data.type === 'test-clock') {
simulatedElapsed = data.elapsed;
return;
}
receivedTypes.push(data.type);
if (holdArtifacts && ['explanation', 'artifacts'].includes(data.type)) {
heldArtifactMessages.push(() => this.onmessage?.({ data } as MessageEvent));
return;
}
if (data.type === 'heartbeat') heartbeats++;
this.onmessage?.({ data } as MessageEvent);
if (data.type === 'checkpoint') afterCheckpoint?.();
});
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: { type: string }) {
sentTypes.push(message.type);
this.thread.postMessage(message);
}
terminate() {
terminationCount++;
void this.thread.terminate();
}
}
vi.stubGlobal('Worker', RealWorker);
});
afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear();
localStorage.clear();
vi.unstubAllGlobals();
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it('delivers the outcome and explanation and releases the worker without acknowledgements', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
rules: [presets[0]],
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'Plan',
placements: [{ date: '2024-01-02', balanceId: 'cp', type: 'cp' }],
lastCompletedStage: 'dates',
});
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
placements: [{ placement: { date: '2024-01-02', balanceId: 'cp' } }],
});
expect(await calculation.explanationError).toBeUndefined();
expect(await calculation.timings).toEqual({ planningWorkMs: expect.any(Number) });
expect(sentTypes).toEqual(['plan']);
expect(receivedTypes.slice(-3)).toEqual(['result', 'explanation', 'artifacts']);
expect(new Set(receivedTypes)).toEqual(
new Set(['heartbeat', 'progress', 'checkpoint', 'result', 'explanation', 'artifacts']),
);
expect(terminationCount).toBe(1);
});
it('ignores a replaced real worker’s late explanation without losing either outcome', async () => {
const request = {
horizon: { start: '2024-01-02', end: '2024-01-04' },
balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2024-01-03' }],
rules: [presets[0]],
};
holdArtifacts = true;
const first = createPlanningCalculation(request);
const original = await first.outcome;
expect(original.kind).toBe('Plan');
await vi.waitFor(() => expect(heldArtifactMessages).toHaveLength(2));
holdArtifacts = false;
const latest = createPlanningCalculation({ ...request, rules: [] });
const outcome = await latest.outcome;
expect(outcome).toMatchObject({ kind: 'Plan', placements: [] });
const explanation = await latest.explanation;
expect(explanation).toMatchObject({ kind: 'PlanExplanation', placements: [] });
await latest.timings;
for (const deliver of heldArtifactMessages) deliver();
expect(await first.outcome).toEqual(original);
expect(await first.explanation).toBeUndefined();
expect(await latest.outcome).toEqual(outcome);
expect(await latest.explanation).toEqual(explanation);
expect(await latest.explanationError).toBeUndefined();
expect(terminationCount).toBe(2);
});
it('preserves a complete optimization configuration and active order in the real worker snapshot', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.unplacedAtRisk,
);
configuration.aggregation = 'maximum';
const submitted = structuredClone(configuration);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-02', end: '2024-01-05' },
balances: [{ id: 'rtt', type: 'rtt', quantity: 1, expiryDate: '2024-01-04' }],
rules: [
{
id: 'personal-loss',
name: 'Captured loss rule',
type: 'optimization',
force: 'preference',
configuration,
},
],
});
configuration.conditions = [];
configuration.direction = 'maximize';
configuration.aggregation = 'count';
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements).toHaveLength(1);
expect(outcome.metrics.unplacedAtRisk).toBe(0);
expect(calculation.snapshot.rules[0]).toMatchObject({
name: 'Captured loss rule',
configuration: submitted,
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected worker explanation');
expect(
explanation.placements[0].objectiveVector.objectives.map((objective) => objective.id),
).toEqual([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
const comparison = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (comparison.kind !== 'Counterfactual') throw new Error('Expected worker comparison');
expect(comparison.baseline.objectives.map((objective) => objective.id)).toEqual([
'obligation-validity',
'optimization:personal-loss',
'balance-expiry',
'placement-date',
]);
});
it('keeps the submitted work-sequence threshold in the real worker and date comparisons', async () => {
const configuration: OptimizationConfiguration = structuredClone(
OPTIMIZATION_PRESETS.shortSequenceDeficit,
);
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-02-01' }],
declaredDays: ['2024-01-08', '2024-01-12'].map((date) => ({ date, type: 'other' })),
rules: [
{
id: 'deficit',
name: 'Captured five-day preference',
type: 'optimization',
configuration,
},
],
});
configuration.thresholdDays = 3;
configuration.conditions = [];
const outcome = await calculation.outcome;
if (outcome.kind !== 'Plan') throw new Error('Expected a worker plan');
expect(outcome.placements.map(({ date }) => date)).toEqual([
'2024-01-09',
'2024-01-10',
'2024-01-11',
]);
expect(calculation.snapshot.rules[0]).toMatchObject({
configuration: { thresholdDays: 5, conditions: ['internal', 'non-empty'] },
});
const explanation = await calculation.explanation;
if (explanation?.kind !== 'PlanExplanation') throw new Error('Expected an explanation');
expect(
explanation.placements[0].comparison.before.objectives.find(
({ id }) => id === 'optimization:deficit',
)?.value,
).toBe(4);
const counterfactual = await calculation.analyzeCounterfactual(outcome.placements[0]);
if (counterfactual.kind !== 'Counterfactual') throw new Error('Expected a counterfactual');
expect(
counterfactual.baseline.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(4);
expect(
counterfactual.results
.find(({ date }) => date === '2024-01-09')
?.comparison?.after.objectives.find(({ id }) => id === 'optimization:deficit')
?.value,
).toBe(0);
});
it.each([
[false, false],
[true, false],
[false, true],
[true, true],
])(
'submits declared earned chains from the calendar and retains actual artifacts after edits (worker: %s, cancelled: %s)',
async (worker, cancelled) => {
localStorage.clear();
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '{"version":1,"rules":[]}');
vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-29', '2024-05-07');
const input = TestBed.inject(UserInputService);
for (const date of ['2024-04-29', '2024-04-30']) input.toggleDeclaredDay(date, 'cp');
input.toggleDeclaredDay('2024-05-06', 'other');
const rules = TestBed.inject(RuleService);
rules.addRule(presets[0]);
rules.addRule({
id: 'producer',
name: 'CP autour du férié',
enabled: true,
force: 'preference',
type: RuleType.IF_THEN,
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: SelectableDayType.CP,
rewardDays: 1,
rewardType: SelectableDayType.OTHER,
});
rules.addRule({
id: 'consumer',
name: 'Période avec droit futur',
enabled: true,
force: 'obligation',
type: RuleType.TIME_WINDOW,
startDate: new Date('2024-05-03T00:00:00'),
endDate: new Date('2024-05-06T00:00:00'),
placeDays: 2,
placeType: SelectableDayType.OTHER,
});
for (const preset of presets.slice(1)) rules.addRule(preset);
const calendar = TestBed.inject(CalendarService);
const original = calendar.createPlanningCalculation.bind(calendar);
let calculation!: PlanningCalculationContext;
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
.mockImplementation((request) => {
const browserWindow = globalThis.window;
if (!worker) vi.stubGlobal('window', undefined);
try {
calculation = original({
...request,
progress: (stage) => {
request.progress?.(stage);
if (
cancelled &&
stage === 'optimization:default-non-working-day-gain'
) {
fixture.detectChanges();
fixture.nativeElement
.querySelector('button.cancel-calculation')
.click();
}
},
});
return calculation;
} finally {
if (!worker) vi.stubGlobal('window', browserWindow);
}
});
holdArtifacts = worker;
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
expect(calculate).toHaveBeenCalledOnce();
expect(calculation.snapshot.calculationDate).toBe('2024-05-01');
vi.setSystemTime(new Date('2024-05-01T22:00:01Z'));
const outcome = await calculation.outcome;
expect(outcome.kind).toBe(cancelled ? 'PlanningIncomplete' : 'Plan');
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
if (!plan) throw new Error('Expected a valid earned-chain plan.');
expect(plan.calculationDate).toBe('2024-05-01');
expect(plan.metrics).toMatchObject({
nonWorkingDayGain: 5,
averageWorkSequence: 1,
longestWorkSequence: 1,
averageTimeOffPeriod: 4.5,
longestTimeOffPeriod: 5,
});
expect(plan.placements).toMatchObject([
{
date: '2024-05-03',
balanceId: 'earned:producer:2024-05-01',
type: 'other',
ruleIds: ['consumer'],
},
]);
if (outcome.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(plan.remainingBalances).toEqual([]);
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
await fixture.whenStable();
const dialog = document.querySelector('[role="dialog"]')!;
const connectedHolidays = () =>
Array.from(document.querySelectorAll('[role="dialog"] .statistics-summary dt'))
.find((label) =>
label.textContent?.includes('Jours fériés reliés à un week-end'),
)
?.nextElementSibling?.textContent?.trim();
expect(connectedHolidays()).toBe('1');
const earnedStatistics = () => [
dialog
.querySelector('.statistics-summary > div:last-child dt')
?.textContent?.replace(/\s+/g, ' ')
.trim(),
dialog
.querySelector('.statistics-summary > div:last-child dd')
?.textContent?.trim(),
...Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
label.textContent?.trim(),
label.nextElementSibling?.textContent?.trim(),
]),
];
expect(earnedStatistics()).toEqual([
'Congés acquis avant consommation (jours)',
'1',
['CP autour du férié', '1'],
]);
expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled);
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
input.toggleDeclaredDay('2024-04-30', 'rtt');
rules.updateRule({
...rules.rules.find(({ id }) => id === 'producer')!,
name: 'Règle modifiée après calcul',
});
rules.deleteRule('producer');
input.updateBalances(() => []);
if (worker) {
await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0));
let explained = false;
void calculation.explanation.then(() => (explained = true));
await Promise.resolve();
expect(explained).toBe(false);
holdArtifacts = false;
for (const deliver of heldArtifactMessages.splice(0)) deliver();
}
const explanation = await calculation.explanation;
await fixture.whenStable();
fixture.detectChanges();
expect(connectedHolidays()).toBe('1');
expect(earnedStatistics()).toEqual([
'Congés acquis avant consommation (jours)',
'1',
['CP autour du férié', '1'],
]);
dialog.querySelector<HTMLButtonElement>('button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(explanation).toMatchObject({
kind: 'PlanExplanation',
calculationDate: '2024-05-01',
declaredDays: originalDeclarations,
ifThenContributions: [
{
label: 'CP autour du férié',
earnedEntitlement: {
availableFrom: '2024-05-02',
expiryDate: '2025-05-02',
added: true,
},
},
],
});
const core = await import('./leave-planner.core');
const direct = core.calculatePlanningInternal(calculation.snapshot);
if (direct.kind !== 'Plan')
throw new Error(`Expected the same direct plan: ${JSON.stringify(direct)}`);
expect(plan.placements).toEqual(direct.placements);
expect(plan.ifThenContributions).toEqual(direct.ifThenContributions);
expect(plan.metrics).toEqual(direct.metrics);
expect(plan.timeOffPeriods).toEqual(direct.timeOffPeriods);
expect(root.textContent).toContain('Droit futur généré');
expect(root.textContent).toContain('CP autour du férié');
expect(root.textContent).not.toContain('Règle modifiée après calcul');
expect(
root.querySelector('[data-date="2024-04-30"]')?.getAttribute('aria-label'),
).toContain('CP');
root.querySelector<HTMLButtonElement>('button.compare-dates-button')!.click();
await vi.waitFor(() =>
expect(fixture.componentInstance['counterfactualLoading']()).toBe(false),
);
const comparison = fixture.componentInstance['counterfactual']();
expect(comparison?.kind).toBe('Counterfactual');
expect(
comparison?.results.find(({ date }) => date === '2024-04-30')?.reason?.code,
).toBe('declared-day');
expect(comparison?.results.find(({ date }) => date === '2024-05-02')?.status).toBe(
'ineligible',
);
expect(comparison?.results.find(({ date }) => date === '2024-05-03')?.status).toBe(
'eligible',
);
root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
await fixture.whenStable();
expect(connectedHolidays()).toBe('1');
fixture.destroy();
},
);
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: presets,
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 === 'optimization:default-non-working-day-gain')
interruption = true;
},
},
undefined,
undefined,
undefined,
undefined,
() => interruption,
)
: calculatePlanningInternal(submitted);
const calculation = createPlanningCalculation({
...submitted,
progress: (stage) => {
if (cancelled && stage === 'optimization:default-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,
});
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.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();
});
it('stops an active obligation search and retains a jointly valid incumbent', async () => {
let cancelledAt = 0;
afterCheckpoint = () => {
afterCheckpoint = undefined;
holdArtifacts = false;
heldArtifactMessages.length = 0;
localStorage.clear();
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');
}
});
it('preserves balances and provenance after a valid checkpoint', async () => {
const cancelStage = 'optimization:default-non-working-day-gain';
holdForCancelAtStage = cancelStage;
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
rules: [
presets[0],
...[
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
],
...presets.slice(1),
],
progress: (stage) => {
if (stage === cancelStage) 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('optimization:default-holiday-week-value');
expect(outcome.completedStages).not.toContain(cancelStage);
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 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: presets,
};
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.explanationError).toBeUndefined();
});
});