import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { CalendarYear } from './calendar-year'; import { CalendarService, DayType, SelectedDate, SelectedDates, SelectableDayType, } from '../../services/calendar-service'; import { CounterfactualAnalysis, CounterfactualSelection, HistoricalEligibilityReason, LeavePlacement, PlanOutcome, PlanningCalculationContext, PlanningConflictOutcome, PlanningExplanation, PlanningIncompleteOutcome, InvalidInputOutcome, PlanningOutcome, PlanningObjectiveVector, PlanningTrace, } from '../../services/leave-planner.service'; import { UserInputService } from '../../services/user-input-service'; import { TranslationService } from '../../services/translation-service'; import { DateRange } from '@angular/material/datepicker'; const invalid: InvalidInputOutcome = { kind: 'InvalidInput', reasons: [], }; const conflict: PlanningConflictOutcome = { kind: 'PlanningConflict', reasons: [], conflictingRuleIds: ['rule'], }; const incomplete: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', completedStages: [], reasons: [], }; const plan: PlanOutcome = { kind: 'Plan', horizon: { start: '2024-01-01', end: '2025-01-01' }, placements: [ { date: '2024-05-03', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false, }, { date: '2024-05-06', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false, }, ], timeOffPeriods: [], preferences: [], remainingBalances: [], metrics: { unplacedAtRisk: 0, holidayWeekValue: 0, nonWorkingDayGain: 4, longestWorkSequence: 6, shortSequenceDeficit: 0, supportingPlacements: 0, preferenceSatisfaction: [], }, completedStages: ['dates'], lastCompletedStage: 'dates', }; const objectiveVector = { objectives: [{ id: 'non-working-day-gain' as const, direction: 'maximize' as const, value: 4 }], }; const explanation: PlanningExplanation = { kind: 'PlanExplanation', placements: plan.placements.map((placement, index) => ({ placement, balance: { id: 'cp', name: 'Paid leave', type: 'cp', quantity: 2, expiryDate: '2024-12-31', }, ruleIds: placement.ruleIds, supporting: false, remainingQuantity: 1 - index, contributions: [{ kind: 'entitlement', code: 'balance', label: 'Paid leave', value: 1 }], finalMetrics: plan.metrics, objectiveVector, comparison: { before: { objectives: [{ ...objectiveVector.objectives[0], value: 3 }] }, after: objectiveVector, delta: [ { id: 'non-working-day-gain', direction: 'maximize', value: 1, }, ], firstDecisiveObjective: 'non-working-day-gain', }, firstDecisiveObjective: 'non-working-day-gain', })), }; function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates { const selected = new SelectedDates(); if (includeHoliday) { const holiday = new Date('2024-05-08T00:00:00'); selected.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday))); } for (const placement of value.placements) { const date = new Date(`${placement.date}T00:00:00`); selected.push(new SelectedDate(DayType.CP, new DateRange(date, date))); } return selected; } function placementForSelection(selection: CounterfactualSelection): LeavePlacement { if (typeof selection === 'string') return ( plan.placements.find( (candidate) => candidate.date === selection || candidate.balanceId === selection, ) ?? plan.placements[0] ); return 'decisionId' in selection ? selection.placement : selection; } function calculationFor( outcome: PlanningOutcome, stableExplanation?: PlanningExplanation, trace?: PlanningTrace, tracePromise: Promise = Promise.resolve(trace), ): PlanningCalculationContext { const counterfactual: CounterfactualAnalysis = { kind: 'Counterfactual', calculationId: 'test', placement: plan.placements[0], hypothetical: true, baseline: objectiveVector, results: [ { date: '2024-05-03', status: 'eligible', comparison: stableExplanation?.kind === 'PlanExplanation' ? stableExplanation.placements[0].comparison : undefined, }, { date: '2024-05-04', status: 'ineligible', reason: { code: 'non-working', message: 'Weekend' }, historicalStatus: 'ineligible', }, { date: '2024-05-07', status: 'ineligible', reason: { code: 'rule-invalid', message: 'Hypothetical constraint' }, }, { date: '2024-05-08', status: 'ineligible', reason: { code: 'non-working', message: 'Public Holiday' }, historicalStatus: 'ineligible', }, ], }; return { id: 'test', snapshot: { horizon: plan.horizon, balances: [], rules: [], identities: {}, labels: {}, }, outcome: Promise.resolve(outcome), explanation: Promise.resolve(stableExplanation), explanationError: Promise.resolve(undefined), trace: tracePromise, analyzeCounterfactual: vi.fn(async (selection: CounterfactualSelection) => ({ ...counterfactual, placement: placementForSelection(selection), })), historicalEligibility: vi.fn(async () => [] as readonly HistoricalEligibilityReason[]), }; } function calendarFor( outcome: PlanningOutcome, stableExplanation?: PlanningExplanation, trace?: PlanningTrace, tracePromise?: Promise, ) { const calculation = calculationFor( outcome, stableExplanation, trace, tracePromise ?? Promise.resolve(trace), ); return { calculation, calendar: { getSelectedDatesForYear: vi.fn(() => new SelectedDates()), getDefaultPlanningHorizon: vi.fn(() => plan.horizon), createPlanningCalculation: vi.fn(() => calculation), selectedDatesForPlan: vi.fn(selectedDatesForPlan), }, }; } function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function activateWithKeyboard(element: HTMLElement, key: 'Enter' | ' ' = 'Enter'): void { element.focus(); element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); // jsdom does not implement native keyboard activation for buttons or summaries. element.click(); } function jointTrace(): PlanningTrace { const placementComparison = explanation.kind === 'PlanExplanation' ? explanation.placements[0].comparison : undefined; return { status: 'ready', initialState: { horizon: plan.horizon, balances: [ { id: 'cp', type: 'cp', quantity: 2, remaining: 2, availableFrom: '2024-01-01', expiryDate: '2024-12-31', earned: false, }, { id: 'earned:spring:2024', type: 'rtt', quantity: 2, remaining: 2, availableFrom: '2024-05-04', expiryDate: '2025-05-04', earned: true, sourceRuleId: 'spring', }, ], placements: [plan.placements[1]], earnedActivations: [ { id: 'earned:spring:2024', ruleId: 'spring', occurrence: '2024', dates: ['2024-05-03', '2024-05-06'], }, ], }, phases: [ { id: 'phase:obligations', name: 'obligations', completed: true, status: 'completed', decisions: [ { id: 'decision:prior', action: 'validate', dates: ['2024-05-01'], candidates: [], deltas: [ { kind: 'balance-changed', balanceId: 'cp', remainingBefore: 2, remainingAfter: 1, }, ], }, { id: 'decision:joint', action: 'choose', dates: ['2024-05-03', '2024-05-06'], branch: { parentDecisionId: 'decision:prior', deltas: [ { kind: 'placement-added', date: '2024-05-03', balanceId: 'cp', remainingBefore: 1, remainingAfter: 0, placement: plan.placements[0], }, { kind: 'balance-changed', balanceId: 'earned:spring:2024', remainingBefore: 2, remainingAfter: 2, balance: { id: 'earned:spring:2024', type: 'rtt', quantity: 2, remaining: 2, availableFrom: '2024-05-07', expiryDate: '2025-05-07', earned: true, sourceRuleId: 'spring', }, }, ], }, candidates: [ { status: 'evaluated-rejected', dates: ['2024-05-10', '2024-05-13'], reason: { code: 'joint-date-conflict', message: 'This branch cannot satisfy both required dates.', }, technicalComparison: [ { name: 'unplaced-at-risk', direction: 'minimize', before: 2, after: 2, }, { name: 'preference:spring', direction: 'maximize', before: 1, after: 0, }, ], firstDecisiveComparator: 'preference:spring', comparison: placementComparison, }, { status: 'chosen', dates: ['2024-05-03', '2024-05-06'], comparison: placementComparison, }, ], deltas: [ { kind: 'placement-added', date: '2024-05-03', balanceId: 'cp', remainingBefore: 1, remainingAfter: 0, placement: plan.placements[0], }, ], }, { id: 'decision:later', action: 'reject', dates: ['2024-05-20'], candidates: [], deltas: [], }, ], }, { id: 'phase:dates', name: 'dates', completed: true, status: 'completed', decisions: [], }, ], completedStages: ['obligations', 'dates'], }; } describe('CalendarYear planning outcome boundary', () => { it.each([ ['InvalidInput', invalid], ['PlanningConflict', conflict], ['PlanningIncomplete without incumbent', incomplete], ] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => { const { calendar } = calendarFor(outcome); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const userInput = TestBed.inject(UserInputService); userInput.remainingVacationDaysSignal.set([ { id: 'stale', type: SelectableDayType.CP, numberOfDays: 1, expiryDate: new Date('2025-01-01'), }, ]); const fixture: ComponentFixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); expect(userInput.remainingVacationDaysSignal()).toEqual([]); }); it('keeps and summarizes a returned result', async () => { const { calendar } = calendarFor(plan, explanation); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const userInput = TestBed.inject(UserInputService); userInput.remainingVacationDaysSignal.set([]); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(userInput.remainingVacationDaysSignal()).toEqual([]); const summaryValues = Array.from( fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf, ).map((element) => element.textContent?.trim()); expect(summaryValues).toEqual(['2', '4', '6']); }); it('shows muted, disabled context dates in an adjacent year', async () => { if (explanation.kind !== 'PlanExplanation') throw new Error('Expected plan explanation'); const boundaryPlan: PlanOutcome = { ...plan, horizon: { start: '2024-01-01', end: '2024-01-08' }, placements: [{ ...plan.placements[0], date: '2024-01-02' }], timeOffPeriods: [ { start: '2023-12-30', end: '2024-01-02', duration: 4, placementDates: ['2024-01-02'], nonWorkingDates: ['2023-12-30', '2023-12-31', '2024-01-01'], nonWorkingDayGain: 3, }, ], }; const boundaryExplanation: PlanningExplanation = { ...explanation, placements: [ { ...explanation.placements[0], placement: boundaryPlan.placements[0], timeOffPeriod: boundaryPlan.timeOffPeriods[0], }, ], }; const { calendar, calculation } = calendarFor(boundaryPlan, boundaryExplanation); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; expect(root.querySelector('[aria-label="December 2023"]')).not.toBeNull(); const contextDate = root.querySelector( 'button[data-date="2023-12-30"]', )!; expect(contextDate.classList).toContain('context-only'); expect(contextDate.disabled).toBe(true); expect(contextDate.classList).toContain('explained-period'); expect(contextDate.getAttribute('aria-label')).toContain('Explained time-off period'); expect(root.querySelectorAll('button.explained-period')).toHaveLength(4); expect(contextDate.getAttribute('aria-label')).toContain( 'Context date outside the planning horizon', ); expect( root.querySelector('button[data-date="2024-01-02"]')?.disabled, ).toBe(false); contextDate.click(); expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); }); it('keeps the final plan visible and navigates its accessible explanation without losing focus', async () => { const first = explanation.placements[0]; const explained: PlanningExplanation = { ...explanation, placements: [ { ...first, placement: { ...first.placement, ruleIds: ['spring'] }, ruleIds: ['spring'], supporting: true, timeOffPeriod: { start: '2024-05-03', end: '2024-05-06', duration: 4, placementDates: ['2024-05-03', '2024-05-06'], nonWorkingDates: ['2024-05-04', '2024-05-05'], nonWorkingDayGain: 2, }, remainingBalance: { id: 'cp', type: 'cp', quantity: 1, availableFrom: '2024-01-01', expiryDate: '2024-12-31', classification: 'at-risk', reason: { code: 'at-risk', message: 'Expires within the horizon.' }, earned: false, }, contributions: [ { kind: 'entitlement', code: 'balance', label: 'Paid leave' }, { kind: 'rule', code: 'rule', ruleId: 'spring', label: 'Spring rule' }, { kind: 'at-risk', code: 'at-risk', label: 'Protects leave at risk of expiry.', }, { kind: 'supporting', code: 'supporting', label: 'Supports the surrounding time-off plan.', }, { kind: 'time-off-period', code: 'time-off-period', label: 'Contributes to the time-off period.', }, { kind: 'metric', code: 'non-working-day-gain', label: 'Weekend and holiday days', value: 1, }, ], comparison: { ...first.comparison, firstDecisiveObjective: 'non-working-day-gain', }, }, ...explanation.placements.slice(1), ], }; const { calendar, calculation } = calendarFor(plan, explained); calculation.snapshot.rules = [ { id: 'spring', name: 'Spring rule', type: 'time_window', force: 'preference', startDate: '2024-05-01', endDate: '2024-05-06', placeDays: 2, placeType: 'cp', }, ]; calendar.selectedDatesForPlan.mockImplementation((value) => selectedDatesForPlan(value, true), ); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const planDetails = root.querySelector('.plan-details')!; const traceDetails = root.querySelector('.trace-explorer')!; expect(planDetails.open).toBe(true); expect(traceDetails.open).toBe(false); expect(root.querySelector('.plan-insights')!.firstElementChild).toBe(planDetails); expect(root.querySelector('.leave-story')?.textContent).toContain('Spring rule'); expect(root.querySelector('.leave-story')?.textContent).toContain('Protects leave at risk'); expect(root.querySelector('.leave-story')?.textContent).toContain('could be saved'); expect(root.querySelector('.break-equation span:last-child strong')?.textContent).toBe('4'); expect(root.querySelector('.break-story')?.textContent).toContain('days off in a row'); expect(root.querySelector('.decisive-summary')?.textContent).toContain( 'Weekend & holiday days in your breaks', ); expect(root.querySelectorAll('button[data-date="2024-05-03"].selected')).toHaveLength(1); expect(root.querySelectorAll('button[data-date="2024-05-06"].selected')).toHaveLength(1); expect( Array.from( root.querySelectorAll('button.explained-period'), (button) => button.dataset['date'], ), ).toEqual(['2024-05-03', '2024-05-04', '2024-05-05', '2024-05-06']); expect(root.querySelector('.technical-details')?.textContent).toContain( 'The planner’s priorities', ); const technicalSummary = root.querySelector('.technical-details summary')!; expect(technicalSummary.parentElement?.matches('details[open]')).toBe(false); activateWithKeyboard(technicalSummary, ' '); fixture.detectChanges(); expect(technicalSummary.parentElement?.matches('details[open]')).toBe(true); const next = root.querySelector('button[data-step="next"]')!; activateWithKeyboard(next); fixture.detectChanges(); expect(next.getAttribute('aria-disabled')).toBe('true'); expect(next.disabled).toBe(false); expect(document.activeElement).toBe(next); expect( root.querySelector('output[aria-current="step"]')?.getAttribute('aria-label'), ).toContain('6 May 2024'); await fixture.whenStable(); fixture.detectChanges(); expect(document.activeElement).toBe(next); expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); expect(root.querySelector('[role="dialog"]')).toBeNull(); expect(root.querySelectorAll('button.explained-period')).toHaveLength(0); const firstButton = root.querySelector('button[data-step="first"]')!; activateWithKeyboard(firstButton); fixture.detectChanges(); expect(document.activeElement).toBe(firstButton); const previous = root.querySelector('button[data-step="previous"]')!; activateWithKeyboard(previous); fixture.detectChanges(); expect(previous.getAttribute('aria-disabled')).toBe('true'); expect(previous.disabled).toBe(false); expect(document.activeElement).toBe(previous); const lastButton = root.querySelector('button[data-step="last"]')!; activateWithKeyboard(lastButton); fixture.detectChanges(); expect(lastButton.getAttribute('aria-disabled')).toBe('true'); expect(document.activeElement).toBe(lastButton); const date = root.querySelector('button[data-date="2024-05-06"]')!; activateWithKeyboard(date, ' '); fixture.detectChanges(); expect(document.activeElement).toBe(date); expect(date.getAttribute('aria-current')).toBe('date'); expect(date.getAttribute('aria-label')).toContain('Paid Leave'); }); it('keeps an incomplete incumbent visible and exposes its interrupted-stage details', async () => { const outcome: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', incumbent: plan, completedStages: ['at-risk'], lastCompletedStage: 'at-risk', reasons: [{ code: 'deadline', message: 'Planning stopped at the deadline.' }], }; const incumbentExplanation: PlanningExplanation = { ...explanation, incomplete: { kind: 'IncompleteExplanation', reason: outcome.reasons[0], completedStages: ['at-risk'], uncompletedStages: ['holiday-week-value'], }, }; const { calendar } = calendarFor(outcome, incumbentExplanation); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement; details.open = true; details.dispatchEvent(new Event('toggle')); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.incomplete-incumbent').textContent).toContain( 'holiday-week-value', ); expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); }); it('keeps explanation selection quiet and analyzes alternatives only from Compare dates', async () => { const { calendar, calculation } = calendarFor(plan, explanation); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const details = root.querySelector('.plan-details')!; expect(details.open).toBe(true); expect(root.querySelector('button.current')?.dataset['date']).toBe( '2024-05-03', ); details.open = false; details.dispatchEvent(new Event('toggle')); fixture.detectChanges(); expect(root.querySelector('button.current')).toBeNull(); details.open = true; details.dispatchEvent(new Event('toggle')); fixture.detectChanges(); root.querySelector('button[data-date="2024-05-06"]')!.click(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); expect(root.querySelector('button.current')?.dataset['date']).toBe( '2024-05-06', ); expect(root.querySelectorAll('.status-marker, .hypothetical-marker')).toHaveLength(0); root.querySelector('.compare-dates-button')!.click(); await fixture.whenStable(); fixture.detectChanges(); expect(calculation.analyzeCounterfactual).toHaveBeenCalledExactlyOnceWith( plan.placements[1], ); expect(document.querySelector('[role="dialog"]')).not.toBeNull(); expect(root.querySelectorAll('button.selected')).toHaveLength(2); expect(root.querySelectorAll('.status-marker, .hypothetical-marker')).toHaveLength(0); expect(root.querySelector('.calendar-legend')?.textContent).not.toContain('Eligible'); expect(root.querySelector('button.current')?.dataset['date']).toBe( '2024-05-06', ); const input = document.querySelector('.compare-date-field input')!; input.value = '04/05/2024'; input.dispatchEvent(new Event('input')); fixture.detectChanges(); expect(document.querySelector('.unavailable-date')?.textContent).toContain( 'No leave is needed.', ); expect(root.querySelector('button[data-date="2024-05-04"]')?.classList).not.toContain( 'ineligible', ); expect(root.querySelector('button.current')?.dataset['date']).toBe( '2024-05-06', ); document.querySelector('.comparison-close')!.click(); await fixture.whenStable(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); root.querySelector('button[data-date="2024-05-03"]')!.click(); fixture.detectChanges(); expect(root.querySelector('button.current')?.dataset['date']).toBe( '2024-05-03', ); expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1); }); it('explains a whole break and compares another date against the current plan', async () => { const before: PlanningObjectiveVector = { objectives: [ { id: 'unplaced-at-risk', direction: 'minimize', value: 1 }, { id: 'preference:spring', direction: 'maximize', value: 1 }, { id: 'non-working-day-gain', direction: 'maximize', value: 0 }, { id: 'longest-work-sequence', direction: 'minimize', value: 12 }, { id: 'placement-date', direction: 'minimize', value: 'removed' }, ], }; const current: PlanningObjectiveVector = { objectives: [ { id: 'unplaced-at-risk', direction: 'minimize', value: 0 }, { id: 'preference:spring', direction: 'maximize', value: 2 }, { id: 'non-working-day-gain', direction: 'maximize', value: 4 }, { id: 'longest-work-sequence', direction: 'minimize', value: 6 }, { id: 'placement-date', direction: 'minimize', value: '2024-05-03' }, ], }; const alternative: PlanningObjectiveVector = { objectives: [ { id: 'unplaced-at-risk', direction: 'minimize', value: 0 }, { id: 'preference:spring', direction: 'maximize', value: 1 }, { id: 'non-working-day-gain', direction: 'maximize', value: 2 }, { id: 'longest-work-sequence', direction: 'minimize', value: 8 }, { id: 'placement-date', direction: 'minimize', value: '2024-05-07' }, ], }; const readable: PlanningExplanation = { ...explanation, placements: explanation.placements.map((selected) => ({ ...selected, timeOffPeriod: { start: '2024-05-03', end: '2024-05-06', duration: 4, placementDates: ['2024-05-03', '2024-05-06'], nonWorkingDates: ['2024-05-04', '2024-05-05'], nonWorkingDayGain: 2, }, objectiveVector: current, comparison: { before, after: current, delta: [], firstDecisiveObjective: 'unplaced-at-risk', }, })), }; const { calendar, calculation } = calendarFor(plan, readable); calculation.snapshot.rules = [ { id: 'spring', name: 'Spring break', type: 'time_window', force: 'preference', startDate: '2024-05-01', endDate: '2024-05-06', placeDays: 2, placeType: 'cp', }, ]; const analysis: CounterfactualAnalysis = { kind: 'Counterfactual', calculationId: 'test', placement: plan.placements[0], hypothetical: true, baseline: before, results: [ { date: '2024-05-03', status: 'eligible', comparison: { before, after: current, delta: [] }, }, { date: '2024-05-04', status: 'ineligible', reason: { code: 'non-working', message: 'Weekend' }, }, { date: '2024-05-07', status: 'eligible', comparison: { before, after: alternative, delta: [] }, }, { date: '2024-05-09', status: 'eligible', comparison: { before, after: current, delta: [] }, }, ], }; vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue(analysis); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const element: HTMLElement = document.body; element.querySelector('button[data-date="2024-05-03"]')!.click(); fixture.detectChanges(); expect( Array.from(element.querySelectorAll('.break-equation strong'), (node) => node.textContent?.trim(), ), ).toEqual(['2', '2', '4']); expect(element.querySelector('.leave-story')?.textContent).toContain( 'This date uses 1 day from Paid leave.', ); expect( Array.from(element.querySelectorAll('.day-impact td'), (node) => node.textContent?.trim(), ), ).toEqual(['1', '0', '1', '2', '0', '4', '12', '6']); expect(element.querySelector('.day-impact')?.textContent).toContain( 'Preference: Spring break', ); expect(element.querySelector('.day-impact')?.textContent).toContain( 'Leave days in this preferred period.', ); expect(element.querySelector('.day-impact')?.textContent).not.toContain('placement-date'); const details = element.querySelector('.technical-details')!; details.open = true; expect(details.querySelectorAll('.criteria-table')).toHaveLength(1); expect(details.querySelector('.decisive-row')?.textContent).toContain( 'Leave left at risk of expiry', ); expect(details.querySelector('.decisive-label')?.textContent).toContain('First difference'); expect(details.querySelector('.balance-ledger')?.textContent).toContain( 'Unused days in the final plan', ); expect(details.querySelectorAll('.criteria-table tbody tr')).toHaveLength(4); expect(details.querySelector('.criteria-detail')?.textContent).not.toContain('2024-05-03'); expect(details.querySelector('.tie-breaks')?.textContent).toContain('Earlier dates first'); expect(details.querySelector('.criterion-help')?.open).toBe(false); expect(details.querySelector('.audit-data')?.open).toBe(false); expect(JSON.parse(details.querySelector('pre')!.textContent!).objectiveVector).toEqual( current, ); element.querySelector('.compare-dates-button')!.click(); await fixture.whenStable(); fixture.detectChanges(); const input = element.querySelector('.compare-date-field input')!; expect(input.type).toBe('text'); expect(input.value).toBe('07/05/2024'); element .querySelector('.compare-date-field mat-datepicker-toggle button')! .click(); await fixture.whenStable(); fixture.detectChanges(); expect(document.querySelector('mat-calendar')).not.toBeNull(); const chosenDay = document.querySelector( 'mat-calendar button[aria-label="3 May 2024"]', )!; chosenDay.click(); await fixture.whenStable(); fixture.detectChanges(); expect(input.value).toBe('03/05/2024'); expect(fixture.componentInstance['selectedCounterfactualDate']()).toBe('2024-05-03'); expect(fixture.nativeElement.querySelector('.counterfactual-panel')).toBeNull(); expect(element.querySelector('[role="dialog"]')).not.toBeNull(); expect( fixture.componentInstance['nearbyComparisonDates']().map((result) => result.date), ).toEqual(['2024-05-07', '2024-05-09']); input.value = '03/05/2024'; input.dispatchEvent(new Event('input')); fixture.detectChanges(); expect(element.querySelector('.counterfactual-result')?.textContent).toContain( 'This is the planned date.', ); input.value = '07/05/2024'; input.dispatchEvent(new Event('input')); fixture.detectChanges(); // Both alternatives share the removed-day baseline, but moving must compare // current vs alternative (4 -> 2, 6 -> 8), not removed vs alternative (0 -> 2). expect( Array.from( element.querySelectorAll('.counterfactual-result > .impact-table td'), (node) => node.textContent?.trim(), ), ).toEqual(['2', '1', '4', '2', '6', '8']); const dateDetails = element.querySelector('.date-calculation-details')!; expect( Array.from(dateDetails.querySelectorAll('.criteria-table td'), (node) => node.textContent?.trim(), ), ).toEqual(['0', '0', '2', '1', '4', '2', '6', '8']); expect(dateDetails.querySelector('.decisive-row')?.textContent).toContain( 'Preference: Spring break', ); expect(dateDetails.querySelector('thead')?.textContent).toContain('Current plan'); expect(element.querySelectorAll('button.selected')).toHaveLength(2); input.value = ''; input.dispatchEvent(new Event('input')); fixture.detectChanges(); expect(input.value).toBe(''); expect(element.querySelector('.counterfactual-result')).toBeNull(); expect(element.querySelector('.comparison-empty')?.textContent).toContain('Choose a date'); input.value = '01/01/2025'; input.dispatchEvent(new Event('input')); fixture.detectChanges(); expect(input.value).toBe('01/01/2025'); expect(input.getAttribute('aria-invalid')).toBe('true'); expect(element.querySelector('#comparison-date-error')).not.toBeNull(); element.querySelector('.nearby-dates button')!.click(); fixture.detectChanges(); expect(input.value).toBe('07/05/2024'); expect(input.getAttribute('aria-invalid')).toBe('false'); expect(input.getAttribute('aria-describedby')).toContain('comparison-range'); input.value = '09/05/2024'; input.dispatchEvent(new Event('input')); fixture.detectChanges(); expect(element.querySelector('.counterfactual-result')?.textContent).toContain( 'These totals are unchanged.', ); expect(element.querySelector('.counterfactual-result > .impact-table')).toBeNull(); input.value = '04/05/2024'; input.dispatchEvent(new Event('input')); fixture.detectChanges(); expect(element.querySelector('.counterfactual-result')?.textContent).toContain( 'No leave is needed.', ); await fixture.whenStable(); }); it('dismisses a pending comparison, ignores its late result, and keeps errors inside the dialog', async () => { const { calendar, calculation } = calendarFor(plan, explanation); let finish!: (result: CounterfactualAnalysis) => void; vi.spyOn(calculation, 'analyzeCounterfactual') .mockImplementationOnce( () => new Promise((resolve) => { finish = resolve; }), ) .mockRejectedValueOnce(new Error('worker unavailable')); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const element: HTMLElement = fixture.nativeElement; element.querySelector('button[data-date="2024-05-03"]')!.click(); fixture.detectChanges(); const compare = element.querySelector('.compare-dates-button')!; compare.click(); fixture.detectChanges(); expect(document.querySelector('.comparison-loading')).not.toBeNull(); document.querySelector('.comparison-close')!.click(); await fixture.whenStable(); finish({ kind: 'Counterfactual', calculationId: 'test', placement: plan.placements[0], hypothetical: true, baseline: objectiveVector, results: [{ date: '2024-05-03', status: 'eligible' }], }); await fixture.whenStable(); fixture.detectChanges(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); expect(fixture.componentInstance['counterfactual']()).toBeNull(); expect(fixture.componentInstance['counterfactualLoading']()).toBe(false); expect(element.querySelectorAll('button.selected')).toHaveLength(2); compare.click(); await fixture.whenStable(); fixture.detectChanges(); expect( document.querySelector('.counterfactual-panel .counterfactual-inline-error') ?.textContent, ).toContain('Date alternatives could not be calculated.'); expect(document.querySelector('.comparison-done')).not.toBeNull(); document.querySelector('.comparison-close')!.click(); await fixture.whenStable(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); }); it('announces trace loading and completion while keeping the completed plan available', async () => { const traceDelivery = deferred(); const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const liveMessage = root.querySelector('.calendar-year > p[aria-live]')!; const traceDetails = root.querySelector('.trace-explorer')!; activateWithKeyboard(traceDetails.querySelector('summary')!, ' '); fixture.detectChanges(); expect(traceDetails.querySelector('[role="status"]')?.textContent).toContain( 'decision trace is still loading', ); expect(liveMessage.getAttribute('aria-atomic')).toBe('true'); expect(root.querySelector('.plan-summary')).not.toBeNull(); traceDelivery.resolve(jointTrace()); await fixture.whenStable(); fixture.detectChanges(); expect(liveMessage.textContent).toContain('decision trace has loaded'); await vi.waitFor(() => expect(traceDetails.querySelector('[role="status"]')).toBeNull()); root.querySelector( '.trace-decisions nav button[aria-label="Next decision"]', )!.click(); fixture.detectChanges(); expect(root.querySelector('.candidate-list details')).not.toBeNull(); }); it('announces trace delivery failure without discarding the completed plan', async () => { const traceDelivery = deferred(); const { calendar } = calendarFor(plan, explanation, undefined, traceDelivery.promise); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const traceDetails = root.querySelector('.trace-explorer')!; activateWithKeyboard(traceDetails.querySelector('summary')!, ' '); fixture.detectChanges(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); traceDelivery.reject(new Error('trace transfer failed')); await vi.waitFor(() => { fixture.detectChanges(); expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( 'No decision trace is available', ); }); expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( 'No decision trace is available', ); expect(traceDetails.querySelector('[role="alert"]')?.textContent).toContain( 'trace transfer failed', ); expect(root.querySelector('.plan-summary')).not.toBeNull(); }); it('announces an explicit comparison failure and retries inside the dialog', async () => { const { calendar, calculation } = calendarFor(plan, explanation); const firstAttempt = deferred(); const retryAttempt = deferred(); vi.spyOn(calculation, 'analyzeCounterfactual') .mockImplementationOnce(() => firstAttempt.promise) .mockImplementationOnce(() => retryAttempt.promise); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const plannedDate = root.querySelector( 'button[data-date="2024-05-03"]', )!; activateWithKeyboard(plannedDate); fixture.detectChanges(); expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); expect(document.activeElement).toBe(plannedDate); root.querySelector('.compare-dates-button')!.click(); fixture.detectChanges(); expect(document.querySelector('.comparison-loading')?.textContent).toContain( 'Analyzing every date', ); expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( 'Analyzing every date', ); firstAttempt.reject(new Error('worker unavailable')); await fixture.whenStable(); fixture.detectChanges(); expect( document.querySelector( '.counterfactual-panel .counterfactual-inline-error[role="alert"]', )?.textContent, ).toContain('worker unavailable'); expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( 'Date alternatives could not be calculated.', ); expect(root.querySelector('.counterfactual-inline-error')).toBeNull(); document.querySelector('.comparison-done')!.click(); fixture.detectChanges(); expect(document.querySelector('.comparison-loading')).not.toBeNull(); const focusedBeforeResult = document.activeElement; retryAttempt.resolve({ kind: 'Counterfactual', calculationId: 'test', placement: plan.placements[0], hypothetical: true, baseline: objectiveVector, results: [ { date: '2024-05-03', status: 'eligible' }, { date: '2024-05-06', status: 'eligible' }, ], }); await fixture.whenStable(); fixture.detectChanges(); expect(document.querySelector('.counterfactual-result')).not.toBeNull(); expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain( 'Date alternatives are ready.', ); expect(document.activeElement).toBe(focusedBeforeResult); }); it('renders conflict causes and replays an atomic trace decision', async () => { const conflictExplanation: PlanningExplanation = { kind: 'ConflictExplanation', conflictingRules: [{ id: 'rule', label: 'Required spring leave' }], bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' }, }; let setup = calendarFor(conflict, conflictExplanation); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: setup.calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); let fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain( 'Required spring leave', ); expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain( 'No CP entitlement is available.', ); TestBed.resetTestingModule(); const trace = jointTrace(); setup = calendarFor(plan, explanation, trace); vi.mocked(setup.calculation.historicalEligibility).mockResolvedValue([ { date: '2024-01-02', reason: { code: 'unavailable', message: 'This balance is not available until January 4.', }, }, { date: '2024-01-08', reason: { code: 'expired', message: 'This balance expired on January 7.' }, }, ]); const eligibilityRetry = deferred(); vi.mocked(setup.calculation.historicalEligibility) .mockRejectedValueOnce(new Error('Historical eligibility unavailable.')) .mockImplementationOnce(() => eligibilityRetry.promise); setup.calendar.selectedDatesForPlan.mockImplementation((value) => selectedDatesForPlan(value, true), ); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: setup.calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const traceDetails = root.querySelector('.trace-explorer')!; activateWithKeyboard(traceDetails.querySelector('summary')!, ' '); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(traceDetails.open).toBe(true); const nextDecision = root.querySelector( '.trace-decisions nav button[aria-label="Next decision"]', )!; activateWithKeyboard(nextDecision); await vi.waitFor(() => { expect(fixture.componentInstance['historicalEligibilityError']()).toBe( 'Historical eligibility unavailable.', ); }); fixture.detectChanges(); expect(fixture.componentInstance['traceReplay']()).toBe(true); expect(root.querySelector('[role="alert"]')?.textContent).toContain( 'Historical eligibility unavailable.', ); expect(root.querySelector('.sr-only[role="status"]')?.textContent).toContain( 'Original date eligibility could not be checked.', ); const retryEligibility = Array.from(root.querySelectorAll('button')).find((button) => button.textContent?.includes('Retry date eligibility check'), )!; activateWithKeyboard(retryEligibility); fixture.detectChanges(); expect(root.querySelector('[role="status"]')?.textContent).toContain( 'Checking which dates this leave could use', ); eligibilityRetry.resolve([ { date: '2024-01-02', reason: { code: 'unavailable', message: 'This balance is not available until January 4.', }, }, { date: '2024-01-08', reason: { code: 'expired', message: 'This balance expired on January 7.' }, }, ]); await vi.waitFor(() => { expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([ '2024-01-02', '2024-01-08', ]); }); fixture.detectChanges(); expect(setup.calculation.historicalEligibility).toHaveBeenCalledWith({ decisionId: 'decision:joint', placement: plan.placements[0], }); expect(fixture.componentInstance['traceReplay']()).toBe(true); expect(fixture.componentInstance['historicalEligibilityError']()).toBeNull(); expect([...fixture.componentInstance['historicalIneligibility']().keys()]).toEqual([ '2024-01-02', '2024-01-08', ]); const historicalAvailability = fixture.componentInstance['dateStatuses'](); expect(historicalAvailability['2024-01-02']).toMatchObject({ kind: 'ineligible', reason: 'This leave balance cannot be used on this date.', }); expect(historicalAvailability['2024-01-08']).toMatchObject({ kind: 'ineligible', reason: 'This date is after the leave balance expires.', }); let earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) => item.textContent?.includes('earned:spring:2024'), )!; expect(earnedBalance.textContent).toContain('Available from 4 May 2024'); expect(earnedBalance.textContent).toContain('Expires on 4 May 2025'); const jointAfterButton = root.querySelectorAll('.trace-toolbar button')[1]!; activateWithKeyboard(jointAfterButton); fixture.detectChanges(); earnedBalance = Array.from(root.querySelectorAll('.trace-state-balance')).find((item) => item.textContent?.includes('earned:spring:2024'), )!; expect(earnedBalance.textContent).toContain('Available from 7 May 2024'); expect(earnedBalance.textContent).toContain('Expires on 7 May 2025'); expect( root .querySelector('.trace-decisions output[aria-current="step"]') ?.getAttribute('aria-label'), ).toContain('2024-05-03, 2024-05-06'); const candidates = Array.from( root.querySelectorAll('.candidate-list > details'), ); expect(candidates).toHaveLength(2); expect(candidates[0].querySelector('summary')?.textContent).toContain( '2024-05-10, 2024-05-13', ); expect(candidates[1].querySelector('summary')?.textContent).toContain( '2024-05-03, 2024-05-06', ); expect(candidates.every((candidate) => !candidate.open)).toBe(true); const technicalComparison = candidates[0].querySelector('.technical-comparison')!; expect(technicalComparison.closest('details')).toBe(candidates[0]); expect(candidates[0].open).toBe(false); activateWithKeyboard(candidates[0].querySelector('summary')!, 'Enter'); fixture.detectChanges(); expect(candidates[0].open).toBe(true); expect(candidates[0].textContent).toContain( 'This branch cannot satisfy both required dates.', ); expect(technicalComparison.textContent).toContain('Actual stage comparison'); const traceImpact = candidates[0].querySelector('.criteria-detail')!; expect(traceImpact.textContent).toContain('Full-plan effect of this branch'); expect(traceImpact.querySelectorAll('.decisive-row')).toHaveLength(0); expect(traceImpact.textContent).not.toContain('First difference'); const decisiveComparator = candidates[0].querySelector( '.technical-comparison tbody tr.decisive-row', )!; expect(decisiveComparator.textContent).toContain('preference:spring'); expect(decisiveComparator.textContent).toContain('1'); expect(decisiveComparator.textContent).toContain('0'); expect( candidates[0].textContent!.indexOf('This branch cannot satisfy both required dates.'), ).toBeLessThan(candidates[0].textContent!.indexOf('preference:spring')); expect(document.activeElement).toBe(candidates[0].querySelector('summary')); const beforeButton = root.querySelector('.trace-toolbar button') as HTMLButtonElement; const afterButton = root.querySelectorAll('.trace-toolbar button')[1] as HTMLButtonElement; activateWithKeyboard(beforeButton, ' '); fixture.detectChanges(); expect(beforeButton.getAttribute('aria-pressed')).toBe('true'); expect(root.querySelector('.trace-state-summary')?.textContent).toContain( '1 earned activations', ); expect(root.querySelector('.trace-state-summary')?.textContent).toContain( 'cp: 1 remaining', ); activateWithKeyboard(afterButton); fixture.detectChanges(); expect(afterButton.getAttribute('aria-pressed')).toBe('true'); expect(root.querySelector('.trace-state-summary')?.textContent).toContain('2 placements'); expect(root.querySelector('.trace-state-summary')?.textContent).toContain( '1 earned activations', ); expect(root.querySelector('.trace-state-summary')?.textContent).toContain( 'cp: 0 remaining', ); const weekend = root.querySelector('button[data-date="2024-05-04"]')!; expect(weekend.getAttribute('aria-label')).toContain('Ineligible'); expect(weekend.querySelector('.status-marker')?.textContent).toBe('×'); const legend = root.querySelector('.calendar-legend')?.textContent ?? ''; expect(legend).toContain('Evaluated and rejected'); expect(legend).toContain('Not examined'); expect(root.querySelector('.counterfactual-result')).toBeNull(); }); it('turns planner rejection into an incomplete error and clears loading', async () => { const calendar = { getSelectedDatesForYear: vi.fn(() => new SelectedDates()), getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), createPlanningCalculation: vi.fn(() => ({ ...calculationFor(plan), outcome: Promise.reject(new Error('planner exploded')), })), selectedDatesForPlan: vi.fn(() => new SelectedDates()), }; await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const userInput = TestBed.inject(UserInputService); userInput.remainingVacationDaysSignal.set([ { id: 'stale', type: SelectableDayType.CP, numberOfDays: 1, expiryDate: new Date('2025-01-01'), }, ]); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const outcome = fixture.componentInstance['planningOutcome'](); expect(outcome?.kind).toBe('PlanningIncomplete'); if (outcome?.kind === 'PlanningIncomplete') expect(outcome.reasons[0].code).toBe('planner-error'); expect(fixture.componentInstance['isLoading']()).toBe(false); expect(userInput.remainingVacationDaysSignal()).toEqual([]); }); });