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
@@ -11,6 +11,9 @@ describe('real planning worker cancellation', () => {
|
||||
let directory: string;
|
||||
let bundle: string;
|
||||
let afterCheckpoint: (() => void) | undefined;
|
||||
let heartbeats = 0;
|
||||
let simulateLongCalculation = false;
|
||||
let simulatedElapsed = 0;
|
||||
const threads = new Set<NodeWorker>();
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -19,8 +22,16 @@ describe('real planning worker cancellation', () => {
|
||||
const entry = join(directory, 'entry.ts');
|
||||
writeFileSync(
|
||||
entry,
|
||||
`import { parentPort } from 'node:worker_threads';
|
||||
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
|
||||
`import { parentPort, workerData } from 'node:worker_threads';
|
||||
let elapsed = 0;
|
||||
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 === 'at-risk')
|
||||
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
|
||||
parentPort.postMessage(message);
|
||||
} };
|
||||
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
`,
|
||||
@@ -30,13 +41,21 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
|
||||
beforeEach(() => {
|
||||
afterCheckpoint = undefined;
|
||||
heartbeats = 0;
|
||||
simulateLongCalculation = false;
|
||||
simulatedElapsed = 0;
|
||||
class RealWorker {
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
private thread = new NodeWorker(bundle);
|
||||
private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } });
|
||||
constructor() {
|
||||
threads.add(this.thread);
|
||||
this.thread.on('message', (data) => {
|
||||
if (data.type === 'test-clock') {
|
||||
simulatedElapsed = data.elapsed;
|
||||
return;
|
||||
}
|
||||
if (data.type === 'heartbeat') heartbeats++;
|
||||
this.onmessage?.({ data } as MessageEvent);
|
||||
if (data.type === 'checkpoint') afterCheckpoint?.();
|
||||
});
|
||||
@@ -63,7 +82,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
const stages: string[] = [];
|
||||
let preparationUpdates = 0;
|
||||
const calculation = createPlanningCalculation({
|
||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||
balances: [],
|
||||
rules: [],
|
||||
progress: (stage) => {
|
||||
@@ -82,6 +101,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
expect(stages).not.toContain('obligations');
|
||||
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||
expect(await calculation.explanationError).toBeUndefined();
|
||||
expect(heartbeats).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('cancels before a displayed valid plan without reporting a queued conflict', async () => {
|
||||
@@ -219,16 +239,27 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
expect(await calculation.outcome).toEqual(frozenOutcome);
|
||||
});
|
||||
|
||||
it('completes the same deterministic plan as the synchronous adapter when not cancelled', async () => {
|
||||
it('completes a three-year calculation after twenty simulated seconds with the same deterministic plan', async () => {
|
||||
const submitted = {
|
||||
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
|
||||
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: [],
|
||||
};
|
||||
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.trace)?.status).toBe('ready');
|
||||
expect(await calculation.explanationError).toBeUndefined();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user