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
420 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|