feat: add PlanningDurationService for estimating planning duration

- Implemented PlanningDurationService to provide an indicative wait time before calculation based on user input and calendar settings.
- Introduced duration estimation logic that considers horizon dates, balances, and rules without triggering a calculation.
- Enhanced user input component to display estimated duration using the new service.
- Updated translation files to include new duration estimate strings in multiple languages.
- Added tests for PlanningDurationService to ensure accurate duration estimation and calibration.
- Refactored leave planner service to remove hardcoded deadlines and improve worker responsiveness.
This commit is contained in:
nessar committed 2026-10-05 17:06:17 +02:00
1 parent 2edf2004f5
commit bc8570d8ca
29 files changed
+1064 -123

No files matched your search

+153 -38
View File
@@ -1079,6 +1079,63 @@ describe('planLeave', () => {
}
});
it.each([
['2026-01-01', undefined],
['2027-01-01', undefined],
['2027-01-01', 25_000],
] as const)(
'completes every objective on a horizon ending %s after more than ten seconds (budget %s)',
async (end, deadlineMs) => {
const request: PlanningRequest = {
deadlineMs,
horizon: { start: '2024-01-01', end },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 3,
availableFrom: '2024-05-06',
expiryDate: '2024-05-10',
},
],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-06',
endDate: '2024-05-06',
placeDays: 1,
placeType: 'cp',
},
],
};
const expected = isPlan(await runInNonBrowser(request));
let elapsed = 0;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
try {
const outcome = await runInNonBrowser({
...request,
progress: (stage) => {
if (stage === 'at-risk') queueMicrotask(() => (elapsed = 20_000));
},
});
expect(elapsed).toBeGreaterThan(10_000);
expect(outcome).toEqual(expected);
expect(expected.lastCompletedStage).toBe('dates');
expect(expected.placements).toContainEqual({
date: '2024-05-06',
balanceId: 'cp',
type: 'cp',
ruleIds: ['fixed'],
supporting: false,
});
} finally {
clock.mockRestore();
}
},
);
it('returns an incomplete outcome instead of a false conflict at an expired deadline', async () => {
const result = await planLeave({
horizon,
@@ -1134,6 +1191,53 @@ describe('planLeave', () => {
});
describe('planLeave transport', () => {
it('keeps a responsive phase active beyond repeated liveness windows and delivers its trace later', async () => {
const request = { horizon, balances: [], rules: [] };
const expected = isPlan(await runInNonBrowser(request));
const workers: ResponsiveWorker[] = [];
class ResponsiveWorker {
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);
}
}
vi.stubGlobal('Worker', ResponsiveWorker);
vi.useFakeTimers();
try {
const calculation = createPlanningCalculation(request);
const worker = workers[0];
let outcomeReady = false;
let traceReady = false;
void calculation.outcome.then(() => (outcomeReady = true));
void calculation.trace.then(() => (traceReady = true));
for (let tick = 0; tick < 10; tick++) {
await vi.advanceTimersByTimeAsync(10_000);
worker.emit({ type: 'heartbeat' });
}
expect(outcomeReady).toBe(false);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit({ type: 'result', outcome: expected });
expect(await calculation.outcome).toEqual(expected);
await vi.advanceTimersByTimeAsync(60_000);
expect(traceReady).toBe(false);
expect(worker.terminate).not.toHaveBeenCalled();
worker.emit({ type: 'artifacts', artifacts: {} });
await calculation.trace;
expect(await calculation.outcome).toEqual(expected);
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.terminate).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
it('terminates an isolated worker that misses the liveness deadline with its incumbent', async () => {
const incumbent = isPlan(
await runInNonBrowser({
@@ -1400,45 +1504,52 @@ describe('planLeave transport', () => {
}
});
it('sends a detached, callback-free request to the worker transport', async () => {
const sourceDate = new Date('2024-01-01T00:00:00Z');
const request = {
horizon: { start: sourceDate, end: '2025-01-01' },
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 0,
availableFrom: sourceDate,
expiryDate: '2025-01-01',
},
],
rules: [],
};
const expected = isPlan(await runInNonBrowser(request));
let posted: { type: string; request: PlanningRequest } | undefined;
class InspectingWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
it.each([undefined, 25_000])(
'sends a detached, callback-free request with budget %s',
async (deadlineMs) => {
const sourceDate = new Date('2024-01-01T00:00:00Z');
const request = {
deadlineMs,
horizon: { start: sourceDate, end: '2025-01-01' },
balances: [
{
id: 'cp',
type: 'cp' as const,
quantity: 0,
availableFrom: sourceDate,
expiryDate: '2025-01-01',
},
],
rules: [],
};
const expected = isPlan(await runInNonBrowser(request));
let posted: { type: string; request: PlanningRequest } | undefined;
class InspectingWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage(message: { type: string; request: PlanningRequest }): void {
posted = structuredClone(message);
this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent);
postMessage(message: { type: string; request: PlanningRequest }): void {
posted = structuredClone(message);
this.onmessage?.({
data: { type: 'result', outcome: expected },
} as MessageEvent);
}
terminate(): void {}
}
terminate(): void {}
}
vi.stubGlobal('Worker', InspectingWorker);
try {
const result = await planLeave(request);
expect(result.kind).toBe('Plan');
expect(posted?.request).not.toBe(request);
expect(posted?.request.progress).toBeUndefined();
expect(request.horizon.start).toBe(sourceDate);
} finally {
vi.unstubAllGlobals();
}
});
vi.stubGlobal('Worker', InspectingWorker);
try {
const result = await planLeave(request);
expect(result.kind).toBe('Plan');
expect(posted?.request).not.toBe(request);
expect(posted?.request.deadlineMs).toBe(deadlineMs);
expect(posted?.request.progress).toBeUndefined();
expect(request.horizon.start).toBe(sourceDate);
} finally {
vi.unstubAllGlobals();
}
},
);
it('strips legacy aliases at the worker boundary', async () => {
const request = {
@@ -3186,6 +3297,7 @@ describe('planning calculation context', () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', SlowTraceWorker);
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] });
try {
const calculation = createPlanningCalculation(request);
let traceReady = false;
@@ -3197,15 +3309,18 @@ describe('planning calculation context', () => {
expect((await calculation.explanation)?.kind).toBe('PlanExplanation');
expect(traceReady).toBe(false);
const traceStarted = performance.now();
await vi.advanceTimersByTimeAsync(10_001);
await vi.runAllTimersAsync();
const loadedTrace = await tracePromise;
expect(performance.now() - traceStarted).toBeGreaterThanOrEqual(10_000);
expect(loadedTrace?.phases[0].decisions[0].candidates).toHaveLength(candidateCount);
expect(loadedTrace?.status).toBe('ready');
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.stubGlobal('window', browserWindow);
}
}, 15_000);
});
it('drains queued trace packets in batches of at most sixteen', async () => {
const request = {