import { TestBed } from '@angular/core/testing'; import { vi } from '../../testing/vitest-compat'; import { CalendarSettingsService } from './calendar-settings-service'; import { CalendarService, SelectableDayType } from './calendar-service'; import { RuleService, RuleType } from './rule-service'; import { UserInputService } from './user-input-service'; import { createPlanningCalculation, type PlanningOutcome } from './leave-planner.service'; import { PlanningDurationService } from './planning-duration.service'; import { PlanningCalculationService } from './planning-calculation.service'; import { OPTIMIZATION_PRESETS } from './planning-objectives'; describe('Planning duration estimate', () => { beforeEach(() => { localStorage.clear(); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31'); TestBed.inject(UserInputService).updateBalances(() => [ { id: 'cp', type: SelectableDayType.CP, numberOfDays: 30, availableDate: new Date('2024-01-01T00:00:00'), expiryDate: new Date('2024-12-31T00:00:00'), }, ]); }); afterEach(() => { TestBed.resetTestingModule(); localStorage.clear(); }); it('offers a coarse seconds range on the first launch without calculating', () => { const duration = TestBed.inject(PlanningDurationService); expect(duration.estimate()).toEqual({ minimum: 1, maximum: 2, unit: 'seconds' }); expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false); expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull(); expect(TestBed.inject(RuleService).rules).toEqual([]); }); it('estimates and calibrates optimization rules without a requested leave quantity', () => { const rules = TestBed.inject(RuleService); rules.addRule({ id: 'loss', name: 'Loss', type: RuleType.OPTIMIZATION, enabled: true, force: 'preference', configuration: structuredClone(OPTIMIZATION_PRESETS.unplacedAtRisk), }); const duration = TestBed.inject(PlanningDurationService); expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true); duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); }); it('updates for dates, balances and active rules without requesting a calculation', () => { const duration = TestBed.inject(PlanningDurationService); const settings = TestBed.inject(CalendarSettingsService); const input = TestBed.inject(UserInputService); const initial = duration.estimate()!; settings.setPlanningDates('2024-01-01', '2026-12-31'); const longer = duration.estimate()!; expect(longer.maximum).toBeGreaterThan(initial.maximum); input.updateBalances((balances) => balances.map((balance) => ({ ...balance, numberOfDays: 120 })), ); const moreLeave = duration.estimate()!; expect(moreLeave.maximum).toBeGreaterThan(longer.maximum); const rules = TestBed.inject(RuleService); rules.addRule({ id: 'window', name: 'Spring', type: RuleType.TIME_WINDOW, enabled: true, force: 'obligation', startDate: new Date('2024-04-01T00:00:00'), endDate: new Date('2024-04-30T00:00:00'), placeDays: 15, placeType: SelectableDayType.CP, }); expect(duration.estimate()!.maximum).toBeGreaterThan(moreLeave.maximum); rules.toggleRule('window'); expect(duration.estimate()).toEqual(moreLeave); settings.setPlanningDates('2026-12-31', '2024-01-01'); expect(duration.estimate()).toBeNull(); expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false); expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull(); }); it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => { const duration = TestBed.inject(PlanningDurationService); const submitted = TestBed.inject(PlanningCalculationService).request()!; duration.recordCompletion(submitted, 120_000); expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' }); }); it('calibrates against the submitted snapshot even when the form has since changed', () => { const duration = TestBed.inject(PlanningDurationService); const submitted = TestBed.inject(PlanningCalculationService).request()!; TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31'); duration.recordCompletion(submitted, 20_000); expect(duration.estimate()!.maximum).toBeGreaterThan(60); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31'); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); }); it('calibrates the same configuration after Date rules become ISO strings in the immutable snapshot', () => { TestBed.inject(RuleService).addRule({ id: 'future-rule', name: 'Outside the historical horizon', type: RuleType.TIME_WINDOW, force: 'preference', enabled: true, startDate: new Date(2026, 3, 1), endDate: new Date(2026, 3, 30), placeDays: 60, placeType: SelectableDayType.CP, }); const duration = TestBed.inject(PlanningDurationService); const calculation = createPlanningCalculation( TestBed.inject(PlanningCalculationService).request()!, ); duration.recordCompletion(calculation.snapshot, 20_000); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); }); it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => { const duration = TestBed.inject(PlanningDurationService); const submitted = TestBed.inject(PlanningCalculationService).request()!; for (const elapsed of [20_000, 25_000, 900_000]) duration.recordCompletion(submitted, elapsed); expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' }); TestBed.resetTestingModule(); expect(TestBed.inject(PlanningDurationService).estimate()).toEqual({ minimum: 1, maximum: 2, unit: 'seconds', }); }); it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => { const duration = TestBed.inject(PlanningDurationService); const initial = duration.estimate(); duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed); expect(duration.estimate()).toEqual(initial); }); }); describe('Indicative wait progress', () => { let now: number; let startedAt: number; let progress: (stage: string) => void; let resolve: (outcome: PlanningOutcome) => void; let reject: (error: Error) => void; let duration: PlanningDurationService; let calculations: PlanningCalculationService; /** Starts a calculation whose expected wait is exactly `expectedMs`. */ function start(expectedMs: number): void { duration['calibration'].set([]); duration.recordCompletion(calculations.request()!, expectedMs); startedAt = now; calculations.start(); TestBed.tick(); } function at(elapsedMs: number) { now = startedAt + elapsedMs; vi.advanceTimersByTime(250); return duration.wait(); } beforeEach(() => { localStorage.clear(); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); vi.useFakeTimers(); now = 1_000; vi.spyOn(performance, 'now').mockImplementation(() => now); TestBed.configureTestingModule({ providers: [ { provide: CalendarService, useValue: { createPlanningCalculation: (request: { progress: typeof progress }) => { progress = request.progress; return { cancel: () => undefined, snapshot: { rules: [], labels: {}, declaredDays: [] }, outcome: new Promise((res, rej) => { resolve = res; reject = rej; }), explanation: new Promise(() => undefined), explanationError: new Promise(() => undefined), }; }, }, }, ], }); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31'); duration = TestBed.inject(PlanningDurationService); calculations = TestBed.inject(PlanningCalculationService); }); afterEach(() => { TestBed.resetTestingModule(); vi.useRealTimers(); vi.restoreAllMocks(); localStorage.clear(); }); it('climbs linearly to 95% at the expected wait, holds, then shows 100% on completion', () => { start(20_000); expect(at(0)).toEqual({ percent: 0, remaining: { value: 20, unit: 'seconds' }, overrun: false, }); expect(at(10_000)).toMatchObject({ percent: 47, overrun: false }); expect(at(19_999)).toMatchObject({ percent: 94, overrun: false }); expect(at(20_000)).toEqual({ percent: 95, remaining: null, overrun: false }); expect(at(600_000)).toEqual({ percent: 95, remaining: null, overrun: true }); progress('complete'); expect(duration.wait()).toEqual({ percent: 100, remaining: null, overrun: false }); }); it('shows the remaining time only for expected waits of at least 3 seconds', () => { start(2_999); expect(at(1_000)).toEqual({ percent: 31, remaining: null, overrun: false }); expect(at(2_999)).toEqual({ percent: 95, remaining: null, overrun: false }); resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] }); }); it.each([ [2_999, { minimum: 1, maximum: 9, unit: 'seconds' }, 9_000], [20_000, { minimum: 10, maximum: 60, unit: 'seconds' }, 60_000], [300_000, { minimum: 2, maximum: 15, unit: 'minutes' }, 900_000], ])( 'warns of an overrun of a %i ms expected wait only past the top of the range shown before Calculate', (expectedMs, shown, topMs) => { start(expectedMs); expect(duration.estimate()).toEqual(shown); expect(at(expectedMs)).toEqual({ percent: 95, remaining: null, overrun: false }); expect(at(topMs - 1)!.overrun).toBe(false); expect(at(topMs)!.overrun).toBe(true); }, ); it('rounds the remaining time to seconds, then 5-second steps, then minutes', () => { start(3_000); expect(at(0)!.remaining).toEqual({ value: 3, unit: 'seconds' }); expect(at(2_100)!.remaining).toEqual({ value: 1, unit: 'seconds' }); start(300_000); const left = (seconds: number) => at(300_000 - seconds * 1_000)!.remaining; expect(left(9.2)).toEqual({ value: 10, unit: 'seconds' }); expect(left(12.4)).toEqual({ value: 10, unit: 'seconds' }); expect(left(12.6)).toEqual({ value: 15, unit: 'seconds' }); expect(left(57.4)).toEqual({ value: 55, unit: 'seconds' }); expect(left(57.6)).toEqual({ value: 1, unit: 'minutes' }); expect(left(149)).toEqual({ value: 2, unit: 'minutes' }); expect(left(300)).toEqual({ value: 5, unit: 'minutes' }); }); it('keeps the expected wait frozen when the form or calibration change mid-calculation', () => { start(20_000); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2028-12-31'); duration.recordCompletion(calculations.request()!, 900_000); expect(at(10_000)).toMatchObject({ percent: 47, remaining: { value: 10 } }); }); it('never moves backwards when the planner repeats stages', () => { start(20_000); const percents = ['obligations', 'preference:a', 'dates', 'preference:a', 'dates'].map( (stage, index) => { progress(stage); return at(index * 5_000)!.percent; }, ); expect(percents).toEqual([...percents].sort((a, b) => a - b)); expect(percents.at(-1)).toBe(95); }); it('freezes on Cancel and never shows 100% after cancellation, incomplete outcomes or errors', async () => { start(20_000); at(10_000); calculations.cancel(); TestBed.tick(); progress('complete'); expect(at(30_000)).toMatchObject({ percent: 47 }); resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] }); await Promise.resolve(); expect(duration.wait()).toBeNull(); start(20_000); at(25_000); reject(new Error('Worker failed')); await Promise.resolve(); await Promise.resolve(); expect(duration.wait()).toBeNull(); }); });