The overlay percentage now rises linearly to 95% at the expected wait captured at Calculate, holds there, and shows 100% only when the planner completes. Beneath the status, one line names the current planning stage (with the rule name for rule stages) and another shows the approximate remaining time, or "Taking longer than expected" once the wait is exceeded, announced once through the live status. Cancel freezes the percentage and hides both lines. PlanningCalculationService reports the stage and submitted request instead of fixed per-stage percentages; PlanningDurationService owns the wait progress on a 250 ms tick. Closes #300 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
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<PlanningOutcome>((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: true });
|
|
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: true });
|
|
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|