Files
vac-optimizer/src/app/services/leave-planner-cancellation.spec.ts
T
nessar d9d9befa12
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
refactor(planning): remove the original decision trace
2026-10-15 20:42:33 +02:00

420 lines
15 KiB
TypeScript

import { createOptimizationPresetRules } from './rule-service';
import { vi } from 'vitest';
import { createPlanningCalculation, type PlanningIncompleteOutcome } 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' }],
});
});
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.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 current.outcome).toMatchObject({ kind: 'Plan' });
});
it('retains a plan at least as good as every valid checkpoint before cancellation', async () => {
const core = await import('./leave-planner.core');
let cancelled = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const checkpoints: (readonly import('./leave-planner.core').PlanningObjective[])[] = [];
const submitted = {
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;
},
};
const outcome = await core.calculatePlanningAsyncInternal(
submitted,
(plan, state) => {
if (!state) return;
const explained = core.explainIncompleteCheckpoint(submitted, plan, state, {
code: 'user-cancelled',
message: 'Cancelled',
}).explanation;
if (explained?.kind === 'PlanExplanation' && explained.placements.length)
checkpoints.push(explained.placements[0].objectiveVector.objectives);
},
(value) => (artifacts = value),
undefined,
undefined,
() => cancelled,
);
expect(outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (artifacts.explanation?.kind !== 'PlanExplanation')
throw new Error('Expected a retained plan explanation.');
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
expect(checkpoints.length).toBeGreaterThan(0);
for (const candidate of checkpoints) {
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
const first = retained.findIndex(
(objective, index) => objective.value !== candidate[index].value,
);
if (first < 0) continue;
expect(
retained[first].direction === 'minimize'
? retained[first].value < candidate[first].value
: retained[first].value > candidate[first].value,
).toBe(true);
}
});
it('requests interruption before a valid plan and terminates after required 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: {} });
expect(currentWorker().terminate).toHaveBeenCalledOnce();
});
it('keeps a cancelled partial plan after explanation delivery fails', async () => {
const core = await import('./leave-planner.core');
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,
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)
throw new Error('Expected a cancelled fixture with a valid plan.');
const calculation = createPlanningCalculation(submitted);
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: 'artifacts',
artifacts: {},
});
expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' });
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
expect(currentWorker().terminate).toHaveBeenCalledOnce();
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
expect(artifacts.finalState?.placements).toEqual(cancelled.incumbent.placements);
});
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' }],
});
});
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(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' });
const sent = currentWorker().postMessage.mock.calls.length;
newer.cancel();
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
});
});