Files
vac-optimizer/src/app/services/leave-planner-cancellation.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

462 lines
16 KiB
TypeScript

import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import {
createPlanningCalculation,
type PlanningIncompleteOutcome,
type PlanningTraceUpdate,
} from './leave-planner.service';
const request = {
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [],
rules: [],
};
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
const presets = createOptimizationPresetRules();
describe('user cancellation', () => {
const workers: CancellableWorker[] = [];
const currentWorker = () => workers[workers.length - 1];
class CancellableWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage = vi.fn();
terminate = vi.fn();
constructor() {
workers.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
}
beforeEach(() => {
workers.length = 0;
vi.stubGlobal('Worker', CancellableWorker);
});
afterEach(() => vi.unstubAllGlobals());
it('supports cancellation through the public non-browser calculation context', async () => {
vi.stubGlobal('window', undefined);
const calculation = createPlanningCalculation(request);
calculation.cancel();
expect(await calculation.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
expect((await calculation.trace)?.status).toBe('incomplete');
});
it('retains the same declared-only periods and metrics in a cancelled valid plan', async () => {
const core = await import('./leave-planner.core');
const submitted = {
horizon: { start: '2024-01-02', end: '2024-01-09' },
balances: [],
rules: [presets[2]],
declaredDays: [{ date: '2024-01-05', type: 'other' as const }],
};
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = await core.calculatePlanningAsyncInternal(
{
...submitted,
progress: (stage: string) => {
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => interrupted,
);
const completed = core.calculatePlanningInternal(submitted);
if (
outcome.kind !== 'PlanningIncomplete' ||
!outcome.incumbent ||
completed.kind !== 'Plan'
)
throw new Error('Expected an interrupted valid plan and its completed counterpart.');
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.incumbent.timeOffPeriods).toEqual(completed.timeOffPeriods);
expect(outcome.incumbent.metrics).toEqual(completed.metrics);
expect(outcome.incumbent.metrics).toMatchObject({
nonWorkingDayGain: 2,
averageWorkSequence: 2,
longestWorkSequence: 3,
averageTimeOffPeriod: 3,
longestTimeOffPeriod: 3,
});
expect(artifacts.explanation).toMatchObject({
kind: 'PlanExplanation',
declaredDays: submitted.declaredDays,
timeOffPeriods: completed.timeOffPeriods,
placements: [],
incomplete: { reason: { code: 'user-cancelled' } },
});
expect(artifacts.trace?.status).toBe('incomplete');
expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays);
});
it('retains compatible fixed rule credits and incompatible declarations in a cancelled valid plan', async () => {
const core = await import('./leave-planner.core');
const submitted = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 1,
availableFrom: '2024-01-12',
expiryDate: '2025-01-01',
},
],
declaredDays: [
{ date: '2024-01-10', type: 'cp' as const },
{ date: '2024-01-11', type: 'rtt' as const },
],
rules: [
presets[1],
...[
{ id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' },
{ id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' },
].map((rule) => ({
...rule,
type: 'time_window',
startDate: '2024-01-08',
endDate: '2024-01-12',
})),
],
};
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = await core.calculatePlanningAsyncInternal(
{
...submitted,
progress: (stage: string) => {
if (stage === 'optimization:default-holiday-week-value') interrupted = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => interrupted,
);
const completed = core.calculatePlanningInternal(submitted);
if (
outcome.kind !== 'PlanningIncomplete' ||
!outcome.incumbent ||
completed.kind !== 'Plan'
)
throw new Error('Expected an interrupted valid plan and a completed counterpart.');
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.incumbent.declaredDays).toEqual(submitted.declaredDays);
expect(outcome.incumbent.placements).toMatchObject([
{ date: '2024-01-12', type: 'cp', ruleIds: ['exact-cp'] },
]);
expect(outcome.incumbent.preferences).toMatchObject([{ requested: 1, satisfied: 1 }]);
expect(outcome.incumbent.timeWindowContributions).toEqual(
completed.timeWindowContributions,
);
expect(outcome.incumbent.timeWindowContributions).toMatchObject([
{
ruleId: 'exact-cp',
declaredDays: [submitted.declaredDays[0]],
placementDates: ['2024-01-12'],
},
{
ruleId: 'preferred-rtt',
declaredDays: [submitted.declaredDays[1]],
placementDates: [],
},
]);
expect(artifacts.explanation).toMatchObject({
kind: 'PlanExplanation',
timeWindowContributions: completed.timeWindowContributions,
incomplete: { reason: { code: 'user-cancelled' } },
});
expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays);
});
it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => {
vi.stubGlobal('window', undefined);
const replaced = createPlanningCalculation(request);
const current = createPlanningCalculation(request);
expect(await replaced.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'stale-calculation' }],
});
expect(await replaced.explanation).toMatchObject({
kind: 'IncompleteExplanation',
reason: { code: 'stale-calculation' },
});
expect((await replaced.trace)?.error).toMatchObject({ code: 'stale-calculation' });
expect(await current.outcome).toMatchObject({ kind: 'Plan' });
await current.trace;
});
it('retains a plan at least as good as every valid candidate actually evaluated before cancellation', async () => {
const core = await import('./leave-planner.core');
let cancelled = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = await core.calculatePlanningAsyncInternal(
{
horizon: request.horizon,
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
rules: [
presets[0],
...[
{
id: 'january',
type: 'time_window',
force: 'preference',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 2,
placeType: 'cp',
},
],
...presets.slice(1),
],
progress: (stage: string) => {
if (stage === 'optimization:default-longest-work-sequence') cancelled = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => cancelled,
);
expect(outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (artifacts.explanation?.kind !== 'PlanExplanation' || !artifacts.trace)
throw new Error('Expected a retained plan explanation and its original trace.');
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
const evaluated = artifacts.trace.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) =>
decision.candidates.flatMap((candidate) =>
candidate.comparison?.after.objectives[0].value === 0
? [candidate.comparison.after.objectives]
: [],
),
),
);
expect(evaluated.length).toBeGreaterThan(0);
for (const candidate of evaluated) {
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
const firstDifference = retained.findIndex(
(objective, index) => objective.value !== candidate[index].value,
);
if (firstDifference < 0) continue;
const chosen = retained[firstDifference];
const alternative = candidate[firstDifference];
expect(
chosen.direction === 'minimize'
? chosen.value < alternative.value
: chosen.value > alternative.value,
).toBe(true);
}
});
it('requests interruption before a valid plan without terminating trace delivery', async () => {
const calculation = createPlanningCalculation(request);
calculation.cancel();
calculation.cancel();
expect(currentWorker().postMessage).toHaveBeenLastCalledWith({ type: 'cancel' });
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
expect(currentWorker().terminate).not.toHaveBeenCalled();
const cancelled: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
completedStages: ['validation'],
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
};
currentWorker().emit({ type: 'result', outcome: cancelled });
currentWorker().emit({
type: 'result',
outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] },
});
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(currentWorker().terminate).not.toHaveBeenCalled();
currentWorker().emit({ type: 'artifacts', artifacts: {} });
await calculation.trace;
expect(currentWorker().terminate).toHaveBeenCalledOnce();
});
it('drains records queued before and after cancellation while keeping the partial plan on artifact error', async () => {
const core = await import('./leave-planner.core');
const updates: PlanningTraceUpdate[] = [];
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const submitted = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
rules: presets,
};
const cancelled = await core.calculatePlanningAsyncInternal(
{
...submitted,
traceProgress: (_: number, update: PlanningTraceUpdate) =>
updates.push(structuredClone(update)),
progress: (stage: string) => {
if (stage === 'optimization:default-non-working-day-gain') interrupted = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => interrupted,
);
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent || !artifacts.trace)
throw new Error('Expected a cancelled fixture with a valid plan and original trace.');
const calculation = createPlanningCalculation(submitted);
const operations = updates.flatMap(({ operations }) => operations);
const split = Math.floor(operations.length / 2);
currentWorker().emit({
type: 'trace-update',
totalMs: 0,
acknowledge: true,
update: {
...updates[0],
operations: operations.slice(0, split),
},
});
calculation.cancel();
currentWorker().emit({ type: 'result', outcome: cancelled });
currentWorker().emit({ type: 'result', outcome: cancelled.incumbent });
currentWorker().emit({
type: 'explanation',
explanationError: { code: 'explanation-error', message: 'Unavailable' },
});
currentWorker().emit({
type: 'trace-update',
totalMs: 0,
acknowledge: true,
update: {
operations: operations.slice(split),
},
});
const trace = artifacts.trace;
currentWorker().emit({
type: 'artifacts-start',
artifacts: {},
trace: {
status: trace.status,
initialState: trace.initialState,
completedStages: trace.completedStages,
nonWorkingDates: trace.nonWorkingDates,
error: trace.error,
},
});
currentWorker().emit({ type: 'artifacts-complete' });
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
expect(await calculation.trace).toEqual(trace);
});
it.each([false, true])(
'preserves calculated statistics from a retained checkpoint after worker failure (declared period: %s)',
async (withPeriod) => {
const core = await import('./leave-planner.core');
const submitted = {
horizon: { start: '2024-01-02', end: '2024-01-09' },
balances: [],
rules: [],
declaredDays: withPeriod ? [{ date: '2024-01-05', type: 'other' as const }] : [],
};
const checkpoint = core.calculatePlanningInternal(submitted);
if (checkpoint.kind !== 'Plan') throw new Error('Expected a valid checkpoint.');
const calculation = createPlanningCalculation(submitted);
currentWorker().emit({ type: 'checkpoint', plan: structuredClone(checkpoint) });
currentWorker().onerror?.();
const outcome = await calculation.outcome;
if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent)
throw new Error('Expected a retained checkpoint.');
expect(outcome.reasons[0].code).toBe('worker-error');
expect(outcome.incumbent.metrics).toEqual(checkpoint.metrics);
expect(outcome.incumbent.metrics).toMatchObject({
nonWorkingDayGain: withPeriod ? 2 : 0,
averageWorkSequence: withPeriod ? 2 : 5,
longestWorkSequence: withPeriod ? 3 : 5,
averageTimeOffPeriod: withPeriod ? 3 : null,
longestTimeOffPeriod: withPeriod ? 3 : null,
});
},
);
it('reports worker failure distinctly after a cancellation request', async () => {
const calculation = createPlanningCalculation(request);
calculation.cancel();
currentWorker().onerror?.();
expect(await calculation.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'worker-error' }],
});
expect((await calculation.trace)?.status).toBe('failed');
});
it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => {
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const plan = core.calculatePlanningInternal(
request,
undefined,
(value) => (artifacts = value),
);
expect(plan.kind).toBe('Plan');
const calculation = createPlanningCalculation(request);
calculation.cancel();
currentWorker().emit({ type: 'result', outcome: plan });
currentWorker().emit({ type: 'artifacts', artifacts });
expect(await calculation.outcome).toMatchObject({
kind: 'PlanningIncomplete',
incumbent: plan,
reasons: [{ code: 'user-cancelled' }],
});
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
incomplete: { reason: { code: 'user-cancelled' } },
});
expect((await calculation.trace)?.status).toBe('incomplete');
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
});
it('reports replacement distinctly and ignores late replies from the cancelled calculation', async () => {
const cancelled = createPlanningCalculation(request);
cancelled.cancel();
const previousWorker = currentWorker();
const lateReply = previousWorker.onmessage!;
const newer = createPlanningCalculation(request);
expect(previousWorker.terminate).toHaveBeenCalledOnce();
lateReply({
data: {
type: 'result',
outcome: { kind: 'PlanningConflict', reasons: [], conflictingRuleIds: [] },
},
} as MessageEvent);
expect(await cancelled.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'stale-calculation' }],
});
const outcome: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
};
currentWorker().emit({ type: 'result', outcome });
currentWorker().emit({ type: 'artifacts', artifacts: {} });
expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' });
await newer.trace;
const sent = currentWorker().postMessage.mock.calls.length;
newer.cancel();
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
});
});