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:
1 parent
2edf2004f5
commit
bc8570d8ca
29 files changed
+1064
-123
No files matched your search
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user