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>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { provideZonelessChangeDetection } from '@angular/core';
|
|
import { TestBed } from '@angular/core/testing';
|
|
import { vi } from '../../testing/vitest-compat';
|
|
import { CalendarService } from './calendar-service';
|
|
import { CalendarSettingsService } from './calendar-settings-service';
|
|
import type {
|
|
PlanOutcome,
|
|
PlanningCalculationContext,
|
|
PlanningExplanation,
|
|
PlanningOutcome,
|
|
PlanningReason,
|
|
} from './leave-planner.service';
|
|
import { PlanningCalculationService } from './planning-calculation.service';
|
|
import { UserInputService } from './user-input-service';
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function planFor(start: string, end: string): PlanOutcome {
|
|
return {
|
|
kind: 'Plan',
|
|
horizon: { start, end },
|
|
declaredDays: [],
|
|
placements: [],
|
|
timeOffPeriods: [],
|
|
preferences: [],
|
|
remainingBalances: [],
|
|
completedStages: [],
|
|
} as unknown as PlanOutcome;
|
|
}
|
|
|
|
const explained = { kind: 'PlanExplanation', placements: [] } as unknown as PlanningExplanation;
|
|
|
|
function fakeCalculation(id: string) {
|
|
const outcome = deferred<PlanningOutcome>();
|
|
const explanation = deferred<PlanningExplanation | undefined>();
|
|
const explanationError = deferred<PlanningReason | undefined>();
|
|
const cancel = vi.fn();
|
|
const context = {
|
|
id,
|
|
cancel,
|
|
snapshot: { rules: [], labels: {}, declaredDays: [] },
|
|
outcome: outcome.promise,
|
|
explanation: explanation.promise,
|
|
explanationError: explanationError.promise,
|
|
analyzeCounterfactual: vi.fn(),
|
|
} as unknown as PlanningCalculationContext;
|
|
return { context, outcome, explanation, explanationError, cancel };
|
|
}
|
|
|
|
type Fake = ReturnType<typeof fakeCalculation>;
|
|
|
|
const flush = () => new Promise<void>((resolve) => setTimeout(resolve));
|
|
|
|
describe('PlanningCalculationService', () => {
|
|
let create: ReturnType<typeof vi.fn>;
|
|
let service: PlanningCalculationService;
|
|
|
|
function queue(...fakes: Fake[]): void {
|
|
for (const fake of fakes) create.mockReturnValueOnce(fake.context);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
create = vi.fn();
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
provideZonelessChangeDetection(),
|
|
{ provide: CalendarService, useValue: { createPlanningCalculation: create } },
|
|
],
|
|
});
|
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
|
|
service = TestBed.inject(PlanningCalculationService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
TestBed.resetTestingModule();
|
|
localStorage.clear();
|
|
});
|
|
|
|
it('does not calculate until started and ignores input edits afterwards', async () => {
|
|
const first = fakeCalculation('first');
|
|
queue(first);
|
|
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-02', 'cp');
|
|
expect(create).not.toHaveBeenCalled();
|
|
expect(service.accepted()).toBeNull();
|
|
service.start();
|
|
expect(create).toHaveBeenCalledTimes(1);
|
|
expect(service.isCalculating()).toBe(true);
|
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
await flush();
|
|
const accepted = service.accepted()!;
|
|
expect(accepted.successful).toBe(true);
|
|
expect(service.isCalculating()).toBe(false);
|
|
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-03', 'cp');
|
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
|
|
await flush();
|
|
expect(create).toHaveBeenCalledTimes(1);
|
|
expect(service.accepted()).toBe(accepted);
|
|
expect(service.displayedHorizon()).toEqual({ start: '2024-01-01', end: '2025-01-01' });
|
|
});
|
|
|
|
it('hands the submission translated labels and leaves declaration entry', () => {
|
|
queue(fakeCalculation('first'));
|
|
const input = TestBed.inject(UserInputService);
|
|
input.declarationEntrySignal.set(true);
|
|
service.start();
|
|
expect(input.declarationEntrySignal()).toBe(false);
|
|
const submission = create.mock.calls[0][0];
|
|
expect(submission.horizon).toEqual(service.request()!.horizon);
|
|
expect(submission.balances.every((b: { label: string }) => !!b.label)).toBe(true);
|
|
expect(submission.labels).toBeDefined();
|
|
});
|
|
|
|
it('reports the stage and submission of the latest calculation only, until Cancel', () => {
|
|
queue(fakeCalculation('old'), fakeCalculation('latest'));
|
|
service.start();
|
|
const oldProgress = create.mock.calls[0][0].progress;
|
|
service.start();
|
|
const latestProgress = create.mock.calls[1][0].progress;
|
|
const submitted = service.request();
|
|
expect(service.pending()).toMatchObject({ stage: 'preparation', request: submitted });
|
|
oldProgress('obligations');
|
|
expect(service.pending()!.stage).toBe('preparation');
|
|
latestProgress('preference:spring');
|
|
expect(service.pending()!.stage).toBe('preference:spring');
|
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
|
|
expect(service.pending()!.request).toBe(submitted);
|
|
service.cancel();
|
|
latestProgress('complete');
|
|
expect(service.pending()).toMatchObject({ stage: 'preference:spring', cancelling: true });
|
|
});
|
|
|
|
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
|
|
const first = fakeCalculation('first');
|
|
const stale = fakeCalculation('stale');
|
|
const latest = fakeCalculation('latest');
|
|
queue(first, stale, latest);
|
|
service.start();
|
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
await flush();
|
|
const previous = service.accepted()!;
|
|
service.start();
|
|
service.start();
|
|
expect(service.accepted()).toBe(previous);
|
|
expect(service.displayedPlan()).toBe(previous.outcome);
|
|
stale.outcome.resolve(planFor('2030-01-01', '2031-01-01'));
|
|
await flush();
|
|
expect(service.accepted()).toBe(previous);
|
|
expect(service.isCalculating()).toBe(true);
|
|
latest.outcome.resolve(planFor('2026-01-01', '2027-01-01'));
|
|
await flush();
|
|
expect(service.accepted()!.calculation).toBe(latest.context);
|
|
expect(service.displayedHorizon()).toEqual({ start: '2026-01-01', end: '2027-01-01' });
|
|
expect(service.isCalculating()).toBe(false);
|
|
});
|
|
|
|
it('makes the outcome usable before its explanation and ignores stale explanations', async () => {
|
|
const first = fakeCalculation('first');
|
|
const second = fakeCalculation('second');
|
|
queue(first, second);
|
|
service.start();
|
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
await flush();
|
|
expect(service.outcome()?.kind).toBe('Plan');
|
|
expect(service.explanation()).toBeNull();
|
|
service.start();
|
|
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
await flush();
|
|
first.explanation.resolve(explained);
|
|
await flush();
|
|
expect(service.explanation()).toBeNull();
|
|
second.explanation.resolve(undefined);
|
|
second.explanationError.resolve(undefined);
|
|
await flush();
|
|
expect(service.outcome()?.kind).toBe('Plan');
|
|
});
|
|
|
|
it('ignores an explanation that arrives after a replacement was started and accepted', async () => {
|
|
const first = fakeCalculation('first');
|
|
const second = fakeCalculation('second');
|
|
queue(first, second);
|
|
service.start();
|
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
await flush();
|
|
service.start();
|
|
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
await flush();
|
|
first.explanationError.resolve({ code: 'old', message: 'old' });
|
|
await flush();
|
|
expect(service.explanationError()).toBeNull();
|
|
});
|
|
|
|
it('keeps the outcome when the explanation fails', async () => {
|
|
const first = fakeCalculation('first');
|
|
queue(first);
|
|
service.start();
|
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
first.explanation.reject(new Error('no trace'));
|
|
first.explanationError.resolve({ code: 'worker-error', message: 'broken' });
|
|
await flush();
|
|
expect(service.outcome()?.kind).toBe('Plan');
|
|
expect(service.explanationError()?.message).toBeTruthy();
|
|
expect(service.explanation()).toBeNull();
|
|
});
|
|
|
|
it('keeps the outcome when the explanation error promise rejects', async () => {
|
|
const first = fakeCalculation('first');
|
|
queue(first);
|
|
service.start();
|
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
first.explanationError.reject(new Error('lost'));
|
|
await flush();
|
|
expect(service.outcome()?.kind).toBe('Plan');
|
|
expect(service.explanationError()).toEqual({ code: 'explanation-error', message: 'lost' });
|
|
});
|
|
|
|
it('turns an outcome rejection into an accepted planner error', async () => {
|
|
const first = fakeCalculation('first');
|
|
queue(first);
|
|
service.start();
|
|
first.outcome.reject(new Error('worker died'));
|
|
await flush();
|
|
const accepted = service.accepted()!;
|
|
expect(accepted.successful).toBe(false);
|
|
expect(accepted.outcome).toEqual({
|
|
kind: 'PlanningIncomplete',
|
|
completedStages: [],
|
|
reasons: [{ code: 'planner-error', message: 'worker died' }],
|
|
});
|
|
expect(service.isCalculating()).toBe(false);
|
|
});
|
|
|
|
it('turns a construction failure into an accepted planner error with the requested horizon', () => {
|
|
create.mockImplementation(() => {
|
|
throw new Error('cannot start');
|
|
});
|
|
service.start();
|
|
const reason = { code: 'planner-error', message: 'cannot start' };
|
|
expect(service.outcome()).toEqual({
|
|
kind: 'PlanningIncomplete',
|
|
completedStages: [],
|
|
reasons: [reason],
|
|
});
|
|
expect(service.explanationError()).toEqual(reason);
|
|
expect(service.accepted()!.calculation).toBeNull();
|
|
expect(service.displayedHorizon()).toEqual(service.request()!.horizon);
|
|
expect(service.isCalculating()).toBe(false);
|
|
});
|
|
|
|
it('cancels cooperatively once, and not when nothing is running', async () => {
|
|
const first = fakeCalculation('first');
|
|
queue(first);
|
|
service.cancel();
|
|
expect(first.cancel).not.toHaveBeenCalled();
|
|
service.start();
|
|
service.cancel();
|
|
service.cancel();
|
|
expect(first.cancel).toHaveBeenCalledTimes(1);
|
|
expect(service.isCancelling()).toBe(true);
|
|
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
|
|
await flush();
|
|
expect(service.isCancelling()).toBe(false);
|
|
expect(service.accepted()!.successful).toBe(false);
|
|
});
|
|
|
|
it('accepts a cancelled calculation without an incumbent as incomplete, without inventing a conflict', async () => {
|
|
const first = fakeCalculation('first');
|
|
queue(first);
|
|
service.start();
|
|
service.cancel();
|
|
first.outcome.resolve({
|
|
kind: 'PlanningIncomplete',
|
|
completedStages: [],
|
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
|
});
|
|
await flush();
|
|
expect(service.outcome()?.kind).toBe('PlanningIncomplete');
|
|
expect(service.displayedPlan()).toBeUndefined();
|
|
expect(service.accepted()!.successful).toBe(false);
|
|
});
|
|
|
|
it('shows the incumbent of a cancelled calculation', async () => {
|
|
const first = fakeCalculation('first');
|
|
queue(first);
|
|
const incumbent = planFor('2024-01-01', '2025-01-01') as PlanOutcome;
|
|
service.start();
|
|
service.cancel();
|
|
first.outcome.resolve({
|
|
kind: 'PlanningIncomplete',
|
|
incumbent,
|
|
completedStages: [],
|
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
|
});
|
|
await flush();
|
|
expect(service.displayedPlan()).toBe(incumbent);
|
|
expect(service.accepted()!.successful).toBe(false);
|
|
});
|
|
});
|