CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 22s
CI / benchmark (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 34s
CI / security (pull_request) Successful in 55s
CI / test (pull_request) Successful in 1m45s
CI / images (pull_request) Successful in 53s
CI / required (pull_request) Successful in 2s
CI / policy (push) Successful in 12s
CI / validate (push) Successful in 31s
CI / build (push) Successful in 29s
CI / benchmark (push) Successful in 1m53s
CI / security (push) Successful in 1m31s
CI / test (push) Successful in 2m12s
CI / images (push) Successful in 1m15s
CI / required (push) Successful in 3s
The expected wait sits near the middle of recent waits, so about half of all runs pass it. Between the expected wait and the top of the range shown before Calculate, the percentage now holds at 95% with no remaining-time line; "Taking longer than expected" appears and is announced only past that top. Refs #300 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
12 KiB
TypeScript
303 lines
12 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: 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();
|
|
});
|
|
});
|