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>
770 lines
28 KiB
TypeScript
770 lines
28 KiB
TypeScript
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();
|
||
});
|
||
});
|