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
462 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|