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() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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(); const explanation = deferred(); const explanationError = deferred(); 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; const flush = () => new Promise((resolve) => setTimeout(resolve)); describe('PlanningCalculationService', () => { let create: ReturnType; 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); }); });