import { createOptimizationPresetRules } from '../../services/rule-service'; import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { vi } from '../../../testing/vitest-compat'; import { CalendarYear } from './calendar-year'; import { UserInput } from '../user-input/user-input'; import { CalendarService, DayType, SelectedDate, SelectedDates, calendarProjectionHorizon, SelectableDayType, } from '../../services/calendar-service'; import { CounterfactualAnalysis, CounterfactualSelection, LeavePlacement, PlanOutcome, PlanExplanation, PlanningCalculationContext, PlanningConflictOutcome, PlanningExplanation, PlanningIncompleteOutcome, InvalidInputOutcome, PlanningOutcome, PlanningObjectiveVector, } from '../../services/leave-planner.service'; import { UserInputService } from '../../services/user-input-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { RuleService, RuleType } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { PlanningDurationService } from '../../services/planning-duration.service'; import { PlanningCalculationService } from '../../services/planning-calculation.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, averageWorkSequence: 6, averageTimeOffPeriod: null, longestTimeOffPeriod: null, 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 projectionFor( value: Parameters[0] & { horizon: { start: string; end: string }; }, includeHoliday = false, ) { const horizon = calendarProjectionHorizon(value)!; const months: { monthIndex: number; year: number }[] = []; const last = new Date(`${horizon.end}T00:00:00`); last.setDate(last.getDate() - 1); const cursor = new Date(`${horizon.start}T00:00:00`); cursor.setDate(1); while (cursor <= last) { months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); cursor.setMonth(cursor.getMonth() + 1); } return { months, selections: selectedDatesForPlan(value, includeHoliday) }; } function selectedDatesForPlan( value: { placements: readonly PlanOutcome['placements'][number][] }, includeHoliday = false, ): SelectedDates { const ranges: SelectedDate[] = []; if (includeHoliday) { const holiday = new Date('2024-05-08T00:00:00'); ranges.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`); ranges.push(new SelectedDate(DayType.CP, new DateRange(date, date))); } return new SelectedDates(ranges); } function placementForSelection(selection: CounterfactualSelection): LeavePlacement { if (typeof selection === 'string') return ( plan.placements.find( (candidate) => candidate.date === selection || candidate.balanceId === selection, ) ?? plan.placements[0] ); return selection; } function calculationFor( outcome: PlanningOutcome, stableExplanation?: PlanningExplanation, ): 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' }, }, { 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' }, }, ], }; return { id: 'test', cancel: vi.fn(), snapshot: { horizon: plan.horizon, balances: [], rules: [], identities: {}, labels: {}, }, outcome: Promise.resolve(outcome), explanation: Promise.resolve(stableExplanation), explanationError: Promise.resolve(undefined), analyzeCounterfactual: vi.fn(async (selection: CounterfactualSelection) => ({ ...counterfactual, placement: placementForSelection(selection), })), }; } function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExplanation) { const calculation = calculationFor(outcome, stableExplanation); return { calculation, calendar: { getDefaultPlanningHorizon: vi.fn(() => plan.horizon), createPlanningCalculation: vi.fn(() => calculation), project: vi.fn((value: Parameters[0]) => projectionFor(value)), }, }; } 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(); } const presets = createOptimizationPresetRules(); describe('CalendarYear planning outcome boundary', () => { beforeEach(() => localStorage.clear()); afterEach(() => { localStorage.clear(); vi.useRealTimers(); }); it.each([ { label: 'a Monday holiday directly beside a weekend', start: '2024-05-21', end: '2024-05-22', placed: ['2024-05-21'], expected: 1, }, { label: 'a Friday holiday directly beside a weekend', start: '2024-10-31', end: '2024-11-01', placed: ['2024-10-31'], expected: 1, }, { label: 'a Tuesday holiday bridged by placed Monday leave', start: '2023-08-14', end: '2023-08-16', placed: ['2023-08-14'], expected: 1, }, { label: 'a Tuesday holiday bridged only by a declared Monday', start: '2023-08-14', end: '2023-08-16', declared: ['2023-08-14'], expected: 1, }, { label: 'one Wednesday holiday in a completed public-holiday week valued at three', start: '2024-04-29', end: '2024-05-04', declared: ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'], expected: 1, weighted: 3, }, { label: 'a weekday-only break without a weekend', start: '2024-04-30', end: '2024-05-02', declared: ['2024-04-30'], expected: 0, }, { label: 'a natural long weekend without any placement or declaration', start: '2024-11-01', end: '2024-11-04', expected: 0, }, { label: 'a public holiday on Saturday', start: '2021-04-30', end: '2021-05-01', placed: ['2021-04-30'], expected: 0, }, { label: 'a public holiday on Sunday', start: '2022-04-29', end: '2022-04-30', placed: ['2022-04-29'], expected: 0, }, { label: 'a declaration beside a weekend without a public holiday', start: '2024-06-14', end: '2024-06-15', declared: ['2024-06-14'], expected: 0, }, { label: 'a Monday holiday beyond the horizon after Friday leave and its weekend', start: '2024-05-17', end: '2024-05-18', placed: ['2024-05-17'], expected: 1, }, { label: 'a Friday holiday before the horizon beside declared Monday leave', start: '2024-11-04', end: '2024-11-05', declared: ['2024-11-04'], expected: 1, }, { label: 'two distinct weekday holidays within one break', start: '2024-05-10', end: '2024-05-11', declared: ['2024-05-10'], expected: 2, }, { label: 'duplicate holiday date contributions', start: '2024-05-10', end: '2024-05-11', declared: ['2024-05-10'], expected: 2, duplicate: true, }, ])( 'counts public holidays connected to weekends: $label', async ({ start, end, placed = [], declared = [], expected, weighted, duplicate }) => { const core = await import('../../services/leave-planner.core'); const outcome = core.calculatePlanningInternal({ calculationDate: '2020-01-01', horizon: { start, end }, balances: placed.length ? [{ id: 'cp', type: 'cp', quantity: placed.length, expiryDate: '2025-12-31' }] : [], rules: placed.map((date) => ({ id: date, type: 'time_window', force: 'obligation', startDate: date, endDate: date, placeDays: 1, placeType: 'cp', })), declaredDays: declared.map((date) => ({ date, type: 'other' })), }); if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan.'); expect(outcome.placements.map(({ date }) => date)).toEqual(placed); if (weighted !== undefined) expect(outcome.metrics.holidayWeekValue).toBe(weighted); if (duplicate) outcome.timeOffPeriods = outcome.timeOffPeriods.map((period) => ({ ...period, nonWorkingDates: [...period.nonWorkingDates, ...period.nonWorkingDates], })); const { calendar } = calendarFor(outcome); 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(); fixture.detectChanges(); fixture.nativeElement.querySelector('.statistics-button').click(); await fixture.whenStable(); const holidayLabel = Array.from(document.querySelectorAll('[role="dialog"] dt')).find( (label) => label.textContent?.includes('Public holidays connected to a weekend'), ); expect(holidayLabel?.textContent?.replace(/\s+/g, ' ').trim()).toBe( 'Public holidays connected to a weekend (calendar days)', ); expect(holidayLabel?.nextElementSibling?.textContent?.trim()).toBe(String(expected)); }, ); it.each([ { partial: false, placements: 2 }, { partial: true, placements: 2 }, { partial: false, placements: 0 }, { partial: true, placements: 0 }, ])( 'opens calculated statistics for the displayed plan (partial: $partial, placements: $placements)', async ({ partial, placements }) => { vi.setSystemTime(new Date('2024-05-01T12:00:00Z')); const validPlan: PlanOutcome = { ...plan, calculationDate: '2024-05-01', placements: placements ? plan.placements : [], declaredDays: [ { date: '2024-05-07', type: 'rtt' }, { date: '2024-05-10', type: 'other' }, ], timeOffPeriods: [ { start: '2024-05-08', end: '2024-05-12', duration: 5, placementDates: [], declaredDates: ['2024-05-10'], nonWorkingDates: ['2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12'], nonWorkingDayGain: 4, }, ], ifThenContributions: [ { ruleId: 'original-rule', label: 'Original generating rule', occurrence: '2024-05-08', requested: 1, declaredDays: [{ date: '2024-05-07', type: 'rtt' }], placementDates: [], earnedEntitlement: { id: 'generated-entitlement', type: 'cp', quantity: 3, availableFrom: '2024-05-09', expiryDate: '2025-05-09', added: true, }, }, ], metrics: { ...plan.metrics, averageWorkSequence: 10 / 3, averageTimeOffPeriod: 2.5, longestTimeOffPeriod: 4, }, }; const outcome: PlanningOutcome = partial ? { ...incomplete, incumbent: validPlan, reasons: [{ code: 'user-cancelled', message: 'Cancelled' }], } : validPlan; const { calendar, calculation } = calendarFor(outcome, undefined); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).setLanguage('en'); const input = TestBed.inject(UserInputService); const rules = TestBed.inject(RuleService); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const before = { balances: structuredClone(input.vacationNumberSignal()), displayed: structuredClone( TestBed.inject(PlanningCalculationService).displayedPlan(), ), declarations: structuredClone(input.declaredDaysSignal()), rules: structuredClone(rules.rules), }; const opener = fixture.nativeElement.querySelector( '.statistics-button', ) as HTMLButtonElement; expect(opener).not.toBeNull(); expect(opener.textContent).toContain('Statistics'); activateWithKeyboard(opener); await fixture.whenStable(); fixture.detectChanges(); const dialog = document.querySelector('[role="dialog"]')!; expect(dialog.textContent).toContain('Plan statistics'); const statistics = () => Array.from(dialog.querySelectorAll('dt'), (label) => [ label.textContent?.replace(/\s+/g, ' ').trim(), label.nextElementSibling?.textContent?.trim(), ]); const expectedStatistics = [ ['New leave days', String(placements)], ['Non-working days gained (calendar days)', '4'], ['Average work sequence (working days)', '3.333'], ['Longest work sequence (working days)', '6'], ['Average time-off period (calendar days)', '2.5'], ['Longest time-off period (calendar days)', '4'], ['Public holidays connected to a weekend (calendar days)', '2'], ['Earned leave before use (days)', '3'], ['Original generating rule', '3'], ]; expect(statistics()).toEqual(expectedStatistics); expect(dialog.textContent?.includes('Partial plan')).toBe(partial); expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome); expect(input.vacationNumberSignal()).toEqual(before.balances); expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual( before.displayed, ); expect(input.declaredDaysSignal()).toEqual(before.declarations); expect(rules.rules).toEqual(before.rules); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); await fixture.whenStable(); fixture.detectChanges(); expect(statistics()).toEqual(expectedStatistics); input.updateBalances(() => []); input.toggleDeclaredDay('2024-05-10', 'other'); rules.deleteRule(rules.rules[0].id); TestBed.inject(CalendarSettingsService).setPlanningDates('2021-05-14', '2021-05-14'); input.declarationEntrySignal.set(true); vi.setSystemTime(new Date('2024-05-10T12:00:00Z')); fixture.detectChanges(); await fixture.whenStable(); expect(statistics()).toEqual(expectedStatistics); expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); }, ); it.each([2, 3])('shows 3 gross earned days after consuming %s', async (used) => { vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); try { const core = await import('../../services/leave-planner.core'); const outcome = core.calculatePlanningInternal({ horizon: { start: '2024-04-29', end: used === 2 ? '2024-05-07' : '2024-05-09' }, balances: [], declaredDays: ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' })), rules: [ { id: 'samedi', name: 'Samedi Malin', type: 'if_then', force: 'preference', dayOfWeek: 3, placement: 'before', placeDays: 2, placeType: 'cp', rewardDays: 3, rewardType: 'other', }, { id: 'use', type: 'time_window', force: 'obligation', startDate: '2024-05-02', endDate: '2024-05-08', placeDays: used, placeType: 'other', }, ], }); if (outcome.kind !== 'Plan') throw new Error('Expected an earned-leave plan.'); expect(outcome.placements).toHaveLength(used); expect(outcome.remainingBalances).toMatchObject(used === 2 ? [{ quantity: 1 }] : []); const { calendar } = calendarFor(outcome); 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(); fixture.detectChanges(); vi.setSystemTime(new Date('2024-05-02T12:00:00Z')); fixture.nativeElement.querySelector('.statistics-button').click(); await fixture.whenStable(); const dialog = document.querySelector('[role="dialog"]')!; expect( dialog .querySelector('.statistics-summary > div:last-child dd') ?.textContent?.trim(), ).toBe('3'); expect( Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ label.textContent?.trim(), label.nextElementSibling?.textContent?.trim(), ]), ).toEqual([['Samedi Malin', '3']]); } finally { vi.useRealTimers(); } }); describe('gross earned leave from finalized occurrences', () => { const producer = { id: 'samedi', name: 'Samedi Malin', type: 'if_then', force: 'preference', dayOfWeek: 3, placement: 'before', placeDays: 2, placeType: 'cp', rewardDays: 2, rewardType: 'other', }; const declaredDays = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' })); const submitted = { horizon: { start: '2024-04-29', end: '2024-05-07' }, balances: [], rules: [producer], declaredDays, }; it.each([ { scenario: 'availability after the calculation date', today: '2024-05-01', request: submitted, total: 2, availableFrom: '2024-05-02', rows: [['Samedi Malin', '2']], }, { scenario: 'availability on the calculation date', today: '2024-05-02', request: submitted, total: 0, availableFrom: '2024-05-02', rows: [], }, { scenario: 'availability before the calculation date', today: '2024-05-03', request: submitted, total: 0, availableFrom: '2024-05-02', rows: [], }, { scenario: 'future earnings after the horizon without consumption', today: '2024-05-01', request: { ...submitted, horizon: { start: '2024-04-29', end: '2024-05-02' } }, total: 2, availableFrom: '2024-05-02', rows: [['Samedi Malin', '2']], }, { scenario: 'a later extension makes a provisional historical right future', today: '2024-05-03', request: { ...submitted, horizon: { start: '2024-04-29', end: '2024-05-04' }, declaredDays: [...declaredDays, { date: '2024-05-03', type: 'other' }], balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }], rules: [ producer, { id: 'extend', type: 'time_window', force: 'obligation', startDate: '2024-05-02', endDate: '2024-05-02', placeDays: 1, placeType: 'cp', }, ], }, total: 2, availableFrom: '2024-05-06', rows: [['Samedi Malin', '2']], }, { scenario: 'repeated shared occurrences attributed to distinct same-name rules', today: '2024-05-01', request: { ...submitted, horizon: { start: '2024-04-29', end: '2024-05-09' }, declaredDays: ['2024-04-30', '2024-05-07'].map((date) => ({ date, type: 'cp', })), rules: [ { ...producer, id: 'first', name: 'Same name', placeDays: 1, rewardDays: 1, }, { ...producer, id: 'second', name: 'Same name', placeDays: 1, rewardDays: 2, }, { ...producer, placeDays: 1, rewardDays: 3 }, { ...producer, id: 'disabled', enabled: false, rewardDays: 99 }, { ...producer, id: 'zero', rewardDays: 0, placeDays: 1 }, ], }, total: 12, availableFrom: '2024-05-02', rows: [ ['Same name', '2'], ['Same name', '4'], ['Samedi Malin', '6'], ], }, ])('renders $scenario', async ({ today, request, total, availableFrom, rows }) => { vi.setSystemTime(new Date(`${today}T12:00:00Z`)); try { const core = await import('../../services/leave-planner.core'); const outcome = core.calculatePlanningInternal(request); if (outcome.kind !== 'Plan') throw new Error('Expected a valid generating plan.'); expect(outcome.ifThenContributions?.[0].earnedEntitlement?.availableFrom).toBe( availableFrom, ); const { calendar } = calendarFor(outcome); 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(); fixture.detectChanges(); vi.setSystemTime(new Date('2025-01-01T12:00:00Z')); fixture.nativeElement.querySelector('.statistics-button').click(); await fixture.whenStable(); const dialog = document.querySelector('[role="dialog"]')!; expect( dialog .querySelector('.statistics-summary > div:last-child dd') ?.textContent?.trim(), ).toBe(String(total)); expect( Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ label.textContent?.trim(), label.nextElementSibling?.textContent?.trim(), ]), ).toEqual(rows); expect(!!dialog.querySelector('#earned-leave-title')).toBe(total > 0); } finally { vi.useRealTimers(); } }); it.each(['declarations', 'placements', 'both'])( 'renders an occurrence funded by %s', async (source) => { vi.setSystemTime(new Date('2024-05-01T12:00:00Z')); try { const core = await import('../../services/leave-planner.core'); const fixed = source === 'declarations' ? 2 : source === 'both' ? 1 : 0; const outcome = core.calculatePlanningInternal({ ...submitted, declaredDays: declaredDays.slice(0, fixed), balances: fixed === 2 ? [] : [ { id: 'cp', type: 'cp', quantity: 2 - fixed, expiryDate: '2025-12-31', }, ], }); if (outcome.kind !== 'Plan') throw new Error('Expected a valid generating plan.'); expect(outcome.ifThenContributions?.[0].declaredDays).toHaveLength(fixed); expect(outcome.ifThenContributions?.[0].placementDates).toHaveLength(2 - fixed); const { calendar } = calendarFor(outcome); 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(); fixture.detectChanges(); fixture.nativeElement.querySelector('.statistics-button').click(); await fixture.whenStable(); expect( document .querySelector( '[role="dialog"] .statistics-summary > div:last-child dd', ) ?.textContent?.trim(), ).toBe('2'); expect( Array.from( document.querySelectorAll('[role="dialog"] .earned-leave-breakdown dt'), (label) => [ label.textContent?.trim(), label.nextElementSibling?.textContent?.trim(), ], ), ).toEqual([['Samedi Malin', '2']]); } finally { vi.useRealTimers(); } }, ); }); it('shows the retained earnings after cancelling with a valid generating plan', async () => { vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); // Yield between work slices so cancellation can follow a delivered valid checkpoint. let elapsed = 0; const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 30)); let interrupted = false; let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; try { const core = await import('../../services/leave-planner.core'); const outcome = await core.calculatePlanningAsyncInternal( { horizon: { start: '2024-04-29', end: '2024-05-11' }, declaredDays: [ { date: '2024-04-29', type: 'cp' }, { date: '2024-05-03', type: 'cp' }, { date: '2024-05-06', type: 'other' }, ], balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }], rules: [ { id: 'consumer', type: 'time_window', force: 'obligation', startDate: '2024-05-10', endDate: '2024-05-10', placeDays: 1, placeType: 'other', }, ...['before', 'after'].map((placement, index) => ({ id: placement, name: placement, type: 'if_then', force: 'preference', dayOfWeek: 3, placement, placeDays: 2, placeType: 'cp', rewardDays: index + 1, rewardType: 'other', })), ], }, (value) => { if (value.ifThenContributions?.some(({ ruleId }) => ruleId === 'after')) interrupted = true; }, (value) => (artifacts = value), undefined, undefined, () => interrupted, ); clock.mockRestore(); if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent) throw new Error('Expected a real cancellation with a retained valid plan.'); expect(interrupted).toBe(true); expect(outcome.incumbent.ifThenContributions).toMatchObject([ { ruleId: 'after', earnedEntitlement: { quantity: 2, added: true } }, ]); expect(outcome.incumbent.ifThenContributions).toHaveLength(1); expect(outcome.incumbent.placements.map(({ date }) => date)).toEqual([ '2024-05-02', '2024-05-10', ]); const { calendar } = calendarFor(outcome, artifacts.explanation); 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(); fixture.detectChanges(); fixture.nativeElement.querySelector('.statistics-button').click(); await fixture.whenStable(); const dialog = document.querySelector('[role="dialog"]')!; expect(dialog.textContent).toContain('Partial plan'); expect( dialog .querySelector('.statistics-summary > div:last-child dd') ?.textContent?.trim(), ).toBe('2'); expect( Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [ label.textContent?.trim(), label.nextElementSibling?.textContent?.trim(), ]), ).toEqual([['after', '2']]); } finally { clock.mockRestore(); vi.useRealTimers(); } }); it.each([false, true])( 'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)', async (allDaysOff) => { const core = await import('../../services/leave-planner.core'); const validPlan = core.calculatePlanningInternal({ horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: [], rules: [], declaredDays: allDaysOff ? ['08', '09', '10', '11', '12'].map((day) => ({ date: `2024-01-${day}`, type: 'other', })) : [], }); if (validPlan.kind !== 'Plan') throw new Error('Expected a valid plan.'); const { calendar } = calendarFor(validPlan); 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(); fixture.detectChanges(); fixture.nativeElement.querySelector('.statistics-button').click(); await fixture.whenStable(); expect( Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) => value.textContent?.trim(), ), ).toEqual( allDaysOff ? ['0', '4', '—', '0', '9', '9', '0', '0'] : ['0', '0', '5', '5', '—', '—', '0', '0'], ); }, ); it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => { const { calendar } = calendarFor({ ...plan, metrics: { ...plan.metrics, averageWorkSequence: 10 / 3, averageTimeOffPeriod: 2.5, longestTimeOffPeriod: 4, }, }); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const translation = TestBed.inject(TranslationService); translation.setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const opener = fixture.nativeElement.querySelector( '.statistics-button', ) as HTMLButtonElement; activateWithKeyboard(opener, ' '); await fixture.whenStable(); const dialog = document.querySelector('[role="dialog"]') as HTMLElement; expect(opener.getAttribute('aria-haspopup')).toBe('dialog'); expect(dialog.getAttribute('aria-labelledby')).toBe('plan-statistics-title'); expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help'); await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2'))); for (const [language, workUnit, calendarUnit, holidayLabel] of [ ['de', 'Arbeitstage', 'Kalendertage', 'Feiertage mit Wochenendanschluss'], ['es', 'días laborables', 'días naturales', 'Festivos conectados con un fin de semana'], ['fr', 'jours ouvrés', 'jours calendaires', 'Jours fériés reliés à un week-end'], ]) { translation.setLanguage(language); fixture.detectChanges(); expect(dialog.textContent).toContain(workUnit); expect(dialog.textContent).toContain(calendarUnit); expect(dialog.textContent).toContain(holidayLabel); expect( Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()), ).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0', '0']); } expect(dialog.textContent).toContain('Statistiques du planning'); expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés'); expect(dialog.querySelector('button')?.textContent?.trim()).toBe('Fermer'); dialog.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }), ); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); expect(document.activeElement).toBe(opener); activateWithKeyboard(opener); await fixture.whenStable(); fixture.destroy(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); }); it.each([ ['a new valid plan', { ...plan, placements: [plan.placements[0]] }, false], ['invalid input', invalid, false], ['a planning conflict', conflict, false], ['an incomplete calculation without a retained plan', incomplete, false], ['a conflict after focus has moved to another control', conflict, true], ] satisfies [string, PlanningOutcome, boolean][])( 'replaces open statistics after %s and ignores an older valid outcome', async (_, replacement: PlanningOutcome, focusMoved) => { const oldOutcome = deferred(); const { calendar } = calendarFor(plan); calendar.createPlanningCalculation .mockReturnValueOnce(calculationFor(plan)) .mockReturnValueOnce({ ...calculationFor(plan, undefined), outcome: oldOutcome.promise, }) .mockReturnValueOnce(calculationFor(replacement)); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const root: HTMLElement = fixture.nativeElement; activateWithKeyboard(root.querySelector('.statistics-button')!); await fixture.whenStable(); expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2'); TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2'); TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); const otherControl = document.createElement('button'); otherControl.textContent = 'Another control'; document.body.appendChild(otherControl); if (focusMoved) otherControl.focus(); await fixture.whenStable(); fixture.detectChanges(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); oldOutcome.resolve(plan); await fixture.whenStable(); fixture.detectChanges(); expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement); const opener = root.querySelector('.statistics-button'); if (replacement.kind === 'Plan') { expect(opener).not.toBeNull(); opener!.click(); await fixture.whenStable(); expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('1'); } else { expect(opener).toBeNull(); expect(document.activeElement).toBe( focusMoved ? otherControl : root.querySelector('#planning-results-title'), ); } otherControl.remove(); }, ); it.each([false, true])( 'shows fixed rule credits separately from balance-consuming placements (partial: %s)', async (partial) => { const core = await import('../../services/leave-planner.core'); const declaredDays = partial ? [{ date: '2024-01-10', type: 'cp' as const }] : ['08', '09', '10'].map((day) => ({ date: `2024-01-${day}`, type: 'cp' as const, })); const submitted = { horizon: { start: '2024-01-08', end: '2024-01-13' }, balances: partial ? [ { id: 'cp', type: 'cp' as const, quantity: 1, availableFrom: '2024-01-12', expiryDate: '2024-01-12', }, ] : [], declaredDays, labels: { 'two-cp': 'Two CP including booked leave' }, rules: [ { id: 'two-cp', type: 'time_window', force: 'obligation', startDate: '2024-01-08', endDate: '2024-01-12', placeDays: 2, placeType: 'cp', }, ], }; let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; const outcome = core.calculatePlanningInternal( submitted, undefined, (value) => (artifacts = value), ); if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`); localStorage.setItem('leaveBalances', '[]'); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue({ ...calculationFor(outcome), explanation: Promise.resolve(artifacts.explanation), }); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-01-08', '2024-01-12'); TestBed.inject(TranslationService).setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const root: HTMLElement = fixture.nativeElement; const summary = root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ') ?? ''; expect(summary).toContain('Two CP including booked leave'); expect(summary).toContain( partial ? '1 Already booked + 1 leave days used = 2 / 2' : '2 Already booked + 0 leave days used = 2 / 2', ); expect(summary).toContain('Paid Leave'); expect(summary).toContain('no remaining balance debit'); for (const day of declaredDays) { const cell = root.querySelector( `button[data-date="${day.date}"]`, )!; expect(cell.classList).toContain('cp'); expect(cell.getAttribute('aria-label')).toContain('Already booked'); } if (!partial) expect(summary).not.toContain( fixture.componentInstance['displayDate']('2024-01-10'), ); expect(artifacts.explanation).toMatchObject({ timeWindowContributions: outcome.timeWindowContributions, }); TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-10', 'rtt'); settings.setPlanningDates('2025-01-01', '2025-01-31'); fixture.detectChanges(); expect(root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ')).toBe( summary, ); }, ); it.each([ ['2024-05-31', '2024-06-01', '2024-06-02', [], 3, 2], [ '2024-07-31', '2024-08-01', '2024-08-01', [{ date: '2024-08-01', type: 'cp' as const }], 2, 0, ], ] as const)( 'shows complete declared-only periods and context dates from %s', async (start, end, contextDate, outsideDeclarations, duration, gain) => { const core = await import('../../services/leave-planner.core'); const submitted = { horizon: { start, end }, balances: [], rules: [], declaredDays: [{ date: start, type: 'other' as const }, ...outsideDeclarations], }; let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; const outcome = core.calculatePlanningInternal( submitted, undefined, (value) => (artifacts = value), ); if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`); localStorage.setItem('leaveBalances', '[]'); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); const calculation = { ...calculationFor(outcome), explanation: Promise.resolve(artifacts.explanation), }; vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue( calculation, ); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates(start, start); const input = TestBed.inject(UserInputService); input.toggleDeclaredDay(start, 'other'); TestBed.inject(TranslationService).setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const root: HTMLElement = fixture.nativeElement; const summary = root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ') ?? ''; expect(summary).toContain(`${duration} days off in a row`); expect(summary).toContain(`${gain} weekend & holiday days`); expect(summary).toContain('0 leave days used'); expect(summary).toContain('no remaining balance debit'); const declared = root.querySelector(`button[data-date="${start}"]`)!; expect(declared.classList).toContain('other'); expect(declared.getAttribute('aria-label')).toContain('Already booked'); const context = root.querySelector( `button[data-date="${contextDate}"]`, )!; expect(context.disabled).toBe(true); expect(context.classList).toContain('context-only'); expect(context.classList).toContain('selected'); expect(context.getAttribute('aria-label')).toContain( 'Context date outside the planning horizon', ); if (outsideDeclarations.length) { expect(context.classList).toContain('cp'); expect(context.getAttribute('aria-label')).toContain('Already booked'); } input.toggleDeclaredDay(start, 'cp'); settings.setPlanningDates('2025-01-01', '2025-01-31'); fixture.detectChanges(); expect(root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ')).toBe( summary, ); expect(declared.getAttribute('aria-label')).toContain('Other'); expect(fixture.componentInstance['displayedPlan']()?.metrics.nonWorkingDayGain).toBe( gain, ); expect(input.vacationNumberSignal()).toEqual([]); }, ); it.each([false, true])( 'keeps adjacent context declarations attached to displayed anchors (fixed anchor: %s)', async (fixedAnchor) => { const core = await import('../../services/leave-planner.core'); const placementDate = fixedAnchor ? '2024-07-30' : '2024-07-31'; const submitted = { horizon: { start: '2024-07-30', end: '2024-08-01' }, balances: [ { id: 'rtt', type: 'rtt' as const, quantity: 1, availableFrom: placementDate, expiryDate: placementDate, }, ], rules: [presets[0]], declaredDays: [ { date: '2024-08-01', type: 'cp' as const }, ...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []), ], }; let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; const outcome = core.calculatePlanningInternal( submitted, undefined, (value) => (artifacts = value), ); if (outcome.kind !== 'Plan') throw new Error('Expected a plan.'); localStorage.setItem('leaveBalances', '[]'); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); const calculation = calculationFor(outcome, artifacts.explanation); vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue( calculation, ); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-07-30', '2024-07-31'); TestBed.inject(TranslationService).setLanguage('en'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const root: HTMLElement = fixture.nativeElement; fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); fixture.detectChanges(); const after = root.querySelector('button[data-date="2024-08-01"]')!; expect(after.classList).toContain('selected'); expect(after.classList).toContain('cp'); expect(after.classList).toContain('context-only'); expect(after.disabled).toBe(true); expect(after.getAttribute('aria-label')).toContain('Already booked'); expect(fixture.componentInstance['displayedPlan']()?.placements).toEqual( outcome.placements, ); TestBed.inject(UserInputService).toggleDeclaredDay('2024-08-01', 'rtt'); fixture.detectChanges(); expect(after.getAttribute('aria-label')).toContain('CP'); }, ); it('previews a growing and shrinking range across months, then saves it once on release', async () => { localStorage.setItem('app.language', '"fr"'); localStorage.setItem('leaveBalances', '[]'); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); await TestBed.configureTestingModule({ imports: [CalendarYear, UserInput], providers: [provideZonelessChangeDetection()], }).compileComponents(); const calculate = vi .spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation') .mockReturnValue(calculationFor({ ...plan, placements: [] })); TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-26', '2024-05-10'); const input = TestBed.inject(UserInputService); input.toggleDeclaredDay('2024-04-26', 'other'); input.toggleDeclaredDay('2024-04-30', 'cp'); input.toggleDeclaredDay('2024-05-02', 'rtt'); const controlsFixture = TestBed.createComponent(UserInput); const fixture = TestBed.createComponent(CalendarYear); controlsFixture.detectChanges(); fixture.detectChanges(); await fixture.whenStable(); const controls: HTMLElement = controlsFixture.nativeElement; const root: HTMLElement = fixture.nativeElement; controls.querySelector('button.declaration-entry-toggle')!.click(); controlsFixture.detectChanges(); fixture.detectChanges(); const day = (date: string) => root.querySelector(`[data-date="${date}"]`)!; const pointer = (event: string, date: string) => { day(date).dispatchEvent( new PointerEvent(event, { bubbles: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: event === 'pointerup' ? 0 : 1, clientX: 200, clientY: 200, }), ); fixture.detectChanges(); }; const saved = localStorage.getItem('declaredNonWorkingDays'); pointer('pointerdown', '2024-04-29'); pointer('pointermove', '2024-05-07'); expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(true); expect(day('2024-05-02').classList.contains('cp')).toBe(true); expect(day('2024-05-02').classList.contains('rtt')).toBe(false); expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true); expect(day('2024-04-30').classList.contains('cp')).toBe(false); expect(day('2024-04-30').getAttribute('aria-pressed')).toBe('false'); expect(day('2024-05-01').classList.contains('declaration-preview')).toBe(false); expect(day('2024-05-04').classList.contains('declaration-preview')).toBe(false); expect(localStorage.getItem('declaredNonWorkingDays')).toBe(saved); pointer('pointermove', '2024-04-29'); expect(day('2024-04-30').classList.contains('declaration-preview')).toBe(false); expect(day('2024-04-30').classList.contains('cp')).toBe(true); pointer('pointermove', '2024-05-03'); expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true); expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(false); pointer('pointerup', '2024-05-03'); // A browser click follows pointerup; it must not toggle the endpoint again. day('2024-05-03').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); fixture.detectChanges(); expect(root.querySelector('.declaration-preview')).toBeNull(); expect(root.querySelector('[role="status"]')?.textContent).toContain( 'Jours déjà posés enregistrés : 3 (Congé Payé). Déclarations supprimées : 1.', ); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-04-26', type: 'other' }, { date: '2024-04-29', type: 'cp' }, { date: '2024-05-02', type: 'cp' }, { date: '2024-05-03', type: 'cp' }, ]); expect(day('2024-05-02').textContent?.trim()).toBe('2'); expect(day('2024-05-02').getAttribute('aria-label')).toContain('Déjà posé (CP)'); controls.querySelector('[data-declaration-erase]')!.click(); controlsFixture.detectChanges(); fixture.detectChanges(); pointer('pointerdown', '2024-05-03'); pointer('pointermove', '2024-04-30'); expect(day('2024-05-02').classList.contains('declaration-erase')).toBe(true); expect(day('2024-05-02').classList.contains('cp')).toBe(false); expect(input.declaredDaysSignal()).toHaveLength(4); pointer('pointerup', '2024-04-30'); expect(root.querySelector('[role="status"]')?.textContent).toContain( 'Déclarations supprimées : 2.', ); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-04-26', type: 'other' }, { date: '2024-04-29', type: 'cp' }, ]); expect(input.vacationNumberSignal()).toEqual([]); expect(calculate).toHaveBeenCalledOnce(); const beforeCancellation = localStorage.getItem('declaredNonWorkingDays'); pointer('pointerdown', '2024-04-29'); pointer('pointermove', '2024-05-03'); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); pointer('pointerup', '2024-05-03'); expect(root.querySelector('.declaration-preview')).toBeNull(); expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation); pointer('pointerdown', '2024-04-29'); pointer('pointermove', '2024-05-03'); pointer('pointercancel', '2024-05-03'); pointer('pointerup', '2024-05-03'); expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation); // The first animation frame may have the same timestamp as the pointer move. let scrollFrame: FrameRequestCallback | undefined; const requestFrame = vi .spyOn(window, 'requestAnimationFrame') .mockImplementation((callback) => { scrollFrame = callback; return 1; }); const scroll = vi.spyOn(window, 'scrollBy').mockImplementation(() => undefined); pointer('pointerdown', '2024-04-29'); day('2024-05-03').dispatchEvent( new PointerEvent('pointermove', { bubbles: true, pointerId: 1, pointerType: 'mouse', buttons: 1, clientX: 200, clientY: window.innerHeight - 1, }), ); scrollFrame!(0); expect(scroll.mock.calls[0][1]).toBeGreaterThan(0); pointer('pointercancel', '2024-05-03'); requestFrame.mockRestore(); scroll.mockRestore(); // Touch scrolling remains native, and a subsequent tap must still work after a drag. controls.querySelector('[data-declared-type="other"]')!.click(); controlsFixture.detectChanges(); fixture.detectChanges(); for (const event of ['pointerdown', 'pointermove', 'pointerup']) { day('2024-05-06').dispatchEvent( new PointerEvent(event, { bubbles: true, pointerId: 2, pointerType: 'touch', isPrimary: true, button: 0, }), ); } expect(root.querySelector('.declaration-preview')).toBeNull(); day('2024-05-06').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); fixture.detectChanges(); expect(day('2024-05-06').getAttribute('aria-label')).toContain('Déjà posé (Autre)'); pointer('pointerdown', '2024-04-29'); pointer('pointermove', '2024-05-03'); controls.querySelector('button.declaration-entry-toggle')!.click(); controlsFixture.detectChanges(); fixture.detectChanges(); pointer('pointerup', '2024-05-03'); expect(input.declaredDaysSignal()).toEqual([ { date: '2024-04-26', type: 'other' }, { date: '2024-04-29', type: 'cp' }, { date: '2024-05-06', type: 'other' }, ]); }); it('edits declarations from configuration and restores the preceding result when switched off', async () => { localStorage.setItem('app.language', '"fr"'); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); localStorage.setItem('leaveBalances', '[]'); localStorage.setItem( 'declaredNonWorkingDays', JSON.stringify([{ date: '2024-01-03', type: 'rtt' }]), ); await TestBed.configureTestingModule({ imports: [CalendarYear, UserInput], providers: [provideZonelessChangeDetection()], }).compileComponents(); const calendar = TestBed.inject(CalendarService); const savedPlan: PlanOutcome = { ...plan, horizon: { start: '2024-01-02', end: '2024-01-08' }, placements: [], declaredDays: [{ date: '2024-01-03', type: 'rtt' }], }; const calculate = vi .spyOn(calendar, 'createPlanningCalculation') .mockReturnValue(calculationFor(savedPlan)); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-01-02', '2024-01-07'); const input = TestBed.inject(UserInputService); const controlsFixture = TestBed.createComponent(UserInput); controlsFixture.detectChanges(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); const root: HTMLElement = fixture.nativeElement; const controls: HTMLElement = controlsFixture.nativeElement; expect(root.querySelector('.declaration-controls')).toBeNull(); expect(controls.querySelector('.declaration-view-toggle')).toBeNull(); expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0); const editButton = controls.querySelector( 'button.declaration-entry-toggle', )!; expect(editButton.getAttribute('aria-pressed')).toBe('false'); expect(root.querySelector('.calendar-legend li')?.textContent?.trim()).toBe('Déjà posé'); const day = (date: string) => root.querySelector(`button[data-date="${date}"]`)!; expect(calculate.mock.calls[0][0]).toMatchObject({ declaredDays: savedPlan.declaredDays }); expect(day('2024-01-03').getAttribute('aria-label')).toContain('Déjà posé'); expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT'); settings.setPlanningDates('2024-01-08', '2024-02-02'); activateWithKeyboard(editButton, ' '); controlsFixture.detectChanges(); fixture.detectChanges(); expect(editButton.getAttribute('aria-pressed')).toBe('true'); expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(3); expect( controls.querySelector('[data-declared-type="cp"]')?.getAttribute('aria-pressed'), ).toBe('true'); expect(controls.querySelector('[data-declared-type="any"]')).toBeNull(); expect(day('2024-01-03').disabled).toBe(true); expect(day('2024-01-08').disabled).toBe(false); activateWithKeyboard(day('2024-01-08'), ' '); fixture.detectChanges(); expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('true'); expect(day('2024-01-08').getAttribute('aria-label')).toContain('Déjà posé'); expect(day('2024-01-08').getAttribute('aria-label')).toContain('CP'); activateWithKeyboard(day('2024-01-08')); fixture.detectChanges(); expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('false'); day('2024-01-08').click(); activateWithKeyboard( controls.querySelector('[data-declared-type="other"]')!, ); controlsFixture.detectChanges(); fixture.detectChanges(); day('2024-01-08').click(); fixture.detectChanges(); expect(day('2024-01-08').getAttribute('aria-label')).toContain('Autre'); expect(day('2024-01-13').disabled).toBe(true); expect(input.declaredDaysSignal()).toEqual([ { date: '2024-01-03', type: 'rtt' }, { date: '2024-01-08', type: 'other' }, ]); expect(input.vacationNumberSignal()).toEqual([]); expect(calculate).toHaveBeenCalledOnce(); activateWithKeyboard(editButton, ' '); controlsFixture.detectChanges(); fixture.detectChanges(); expect(editButton.getAttribute('aria-pressed')).toBe('false'); expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0); expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT'); expect(day('2024-01-08').disabled).toBe(true); activateWithKeyboard(day('2024-01-03')); fixture.detectChanges(); expect(root.querySelector('[role="status"]')?.textContent).toContain('Déjà posé'); expect(root.querySelector('[role="status"]')?.textContent).toContain('solde'); }); it('keeps the preceding calendar until a submitted snapshot returns and ignores later edits', async () => { localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); localStorage.setItem('leaveBalances', '[]'); await TestBed.configureTestingModule({ imports: [CalendarYear, UserInput], providers: [provideZonelessChangeDetection()], }).compileComponents(); const calendar = TestBed.inject(CalendarService); const initial: PlanOutcome = { ...plan, horizon: { start: '2024-01-02', end: '2024-01-08' }, placements: [{ ...plan.placements[0], date: '2024-01-04', ruleIds: ['linked'] }], declaredDays: [{ date: '2024-01-03', type: 'rtt' }], }; const oldExplanation: PlanExplanation = { kind: 'PlanExplanation', placements: [ { ...explanation.placements[0], placement: initial.placements[0], ruleIds: ['linked'], }, ], }; const lateExplanation = deferred(); const initialCalculation = { ...calculationFor(initial, oldExplanation), explanation: lateExplanation.promise, }; const rule = { id: 'linked', name: 'Original rule', type: 'time_window' as const, startDate: '2024-01-02', endDate: '2024-01-07', placeDays: 1, placeType: 'cp' as const, }; initialCalculation.snapshot.rules = [rule]; const calculate = vi .spyOn(calendar, 'createPlanningCalculation') .mockReturnValueOnce(initialCalculation); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-01-02', '2024-01-07'); const input = TestBed.inject(UserInputService); const controlsFixture = TestBed.createComponent(UserInput); controlsFixture.detectChanges(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const root: HTMLElement = fixture.nativeElement; const controls: HTMLElement = controlsFixture.nativeElement; const editButton = controls.querySelector( 'button.declaration-entry-toggle', )!; input.toggleDeclaredDay('2024-05-10', 'cp'); settings.setPlanningDates('2024-05-06', '2024-05-12'); editButton.click(); controlsFixture.detectChanges(); fixture.detectChanges(); for (const date of ['2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12']) { const day = root.querySelector(`button[data-date="${date}"]`)!; expect(day.disabled).toBe(true); day.click(); } expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'cp' }]); const submitted: PlanOutcome = { ...initial, horizon: { start: '2024-05-06', end: '2024-05-13' }, declaredDays: [{ date: '2024-05-10', type: 'cp' }], }; const pending = deferred(); const calculation = calculationFor(submitted); calculation.snapshot.rules = [{ ...rule, name: 'Replacement rule' }]; calculate.mockReturnValueOnce({ ...calculation, snapshot: { ...calculation.snapshot, ...submitted }, outcome: pending.promise, }); controls.querySelector('.calculate-button')!.click(); fixture.detectChanges(); controlsFixture.detectChanges(); expect(editButton.getAttribute('aria-pressed')).toBe('false'); expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0); input.toggleDeclaredDay('2024-05-10', 'other'); settings.setPlanningDates('2024-06-03', '2024-06-07'); fixture.detectChanges(); expect(fixture.componentInstance['planningOutcome']()).toBe(initial); expect( root.querySelector('button[data-date="2024-01-03"]')?.getAttribute('aria-label'), ).toContain('RTT'); expect(root.querySelector('button[data-date="2024-05-10"]')).toBeNull(); expect(calculate.mock.calls[1][0]).toMatchObject({ horizon: submitted.horizon, declaredDays: submitted.declaredDays, }); lateExplanation.resolve(oldExplanation); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); const details = root.querySelector('.plan-details')!; details.open = true; details.dispatchEvent(new Event('toggle')); fixture.detectChanges(); expect(root.querySelector('.leave-story')?.textContent).toContain('Original rule'); expect(root.querySelector('.leave-story')?.textContent).not.toContain('Replacement rule'); root.querySelector('.compare-dates-button')!.click(); await fixture.whenStable(); expect(initialCalculation.analyzeCounterfactual).toHaveBeenCalled(); expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); pending.resolve(submitted); await fixture.whenStable(); fixture.detectChanges(); expect( root.querySelector('button[data-date="2024-05-10"]')?.getAttribute('aria-label'), ).toContain('CP'); expect(root.querySelector('button[data-date="2024-06-03"]')).toBeNull(); expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'other' }]); }); it('calibrates the wait through calendar rendering independently of explanation delivery', async () => { let now = 1_000; const clock = vi.spyOn(performance, 'now').mockImplementation(() => now); try { localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); const pending = deferred(); const delayedExplanation = deferred(); const { calendar, calculation } = calendarFor(plan, undefined); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); 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'), }, ]); const duration = TestBed.inject(PlanningDurationService); const submitted = TestBed.inject(PlanningCalculationService).request()!; calendar.createPlanningCalculation.mockReturnValue({ ...calculation, outcome: pending.promise, explanation: delayedExplanation.promise, snapshot: { ...calculation.snapshot, ...submitted }, }); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); now = 21_000; expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' }); expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull(); expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(0); pending.resolve(plan); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull(); expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); expect(fixture.componentInstance['planningExplanation']()).toBeNull(); now = 321_000; delayedExplanation.resolve(explanation); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(fixture.componentInstance['planningExplanation']()).toBe(explanation); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); } finally { clock.mockRestore(); } }); it.each(['cancel before a plan', 'cancel with a plan', 'incomplete', 'error'])( 'excludes the unfinished duration after %s', async (mode) => { let now = 1_000; const clock = vi.spyOn(performance, 'now').mockImplementation(() => now); try { const pending = deferred(); const outcome: PlanningIncompleteOutcome = { ...incomplete, incumbent: mode === 'cancel with a plan' ? plan : undefined, reasons: [ { code: mode.startsWith('cancel') ? 'user-cancelled' : 'deadline', message: mode, }, ], }; const { calendar, calculation } = calendarFor(outcome); calendar.createPlanningCalculation.mockReturnValue({ ...calculation, outcome: pending.promise, }); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const duration = TestBed.inject(PlanningDurationService); const initial = duration.estimate(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); now = 61_000; if (mode.startsWith('cancel')) fixture.nativeElement.querySelector('.cancel-calculation').click(); if (mode === 'error') pending.reject(new Error('Worker failed')); else pending.resolve(outcome); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(duration.estimate()).toEqual(initial); expect(fixture.nativeElement.querySelector('.loading-overlay')).toBeNull(); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); } finally { clock.mockRestore(); } }, ); it('ignores a replaced calculation duration and measures the latest submitted horizon', async () => { let now = 1_000; const clock = vi.spyOn(performance, 'now').mockImplementation(() => now); try { localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); const old = deferred(); const latest = deferred(); const { calendar, calculation } = calendarFor(plan); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-01-01', '2024-12-31'); const duration = TestBed.inject(PlanningDurationService); calendar.createPlanningCalculation.mockImplementation(() => ({ ...calculation, snapshot: { ...calculation.snapshot, ...TestBed.inject(PlanningCalculationService).request()!, }, outcome: calendar.createPlanningCalculation.mock.calls.length === 1 ? old.promise : latest.promise, })); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); now = 11_000; settings.setPlanningDates('2024-01-01', '2026-12-31'); TestBed.inject(PlanningCalculationService).start(); now = 12_000; // Rendering/scheduling after the request is part of the wait. fixture.detectChanges(); const initial = duration.estimate(); now = 31_000; old.resolve(plan); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(duration.estimate()).toEqual(initial); expect(fixture.nativeElement.querySelector('.loading-overlay')).not.toBeNull(); latest.resolve({ ...plan, horizon: { start: '2024-01-01', end: '2027-01-01' } }); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(36); expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2); fixture.destroy(); const revisitedOutcome = deferred(); calendar.createPlanningCalculation.mockReturnValueOnce({ ...calculation, snapshot: { ...calculation.snapshot, ...TestBed.inject(PlanningCalculationService).request()!, }, outcome: revisitedOutcome.promise, }); now = 631_000; // Returning to the calendar is a fresh initial calculation. const revisited = TestBed.createComponent(CalendarYear); revisited.detectChanges(); now = 651_000; revisitedOutcome.resolve({ ...plan, horizon: { start: '2024-01-01', end: '2027-01-01' }, }); await revisited.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); revisited.detectChanges(); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); } finally { clock.mockRestore(); } }); it.each([false, true])( 'cancels accessibly with a valid incumbent: %s, then allows a new calculation', async (withIncumbent) => { const pending = deferred(); const cancelled: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', incumbent: withIncumbent ? plan : undefined, completedStages: ['validation'], reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }], }; const { calendar, calculation } = calendarFor(cancelled, undefined); calendar.createPlanningCalculation .mockReturnValueOnce({ ...calculation, outcome: pending.promise }) .mockReturnValueOnce(calculationFor(plan, explanation)); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); TestBed.inject(TranslationService).language.set('fr'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const cancel = root.querySelector('.cancel-calculation'); expect(cancel?.textContent).toContain('Annuler'); activateWithKeyboard(cancel!); fixture.detectChanges(); expect(calculation.cancel).toHaveBeenCalledOnce(); expect(cancel?.disabled).toBe(true); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); pending.resolve(cancelled); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(root.querySelector('.planning-status')?.textContent).toContain( withIncumbent ? 'Calcul annulé — plan partiel' : 'Calcul annulé', ); expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0); expect(root.querySelector('.loading-overlay')).toBeNull(); expect(document.activeElement).toBe(root.querySelector('#planning-results-title')); TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2); expect(root.querySelectorAll('button.selected')).toHaveLength(2); await fixture.whenStable(); expect(root.querySelectorAll('button.selected')).toHaveLength(2); }, ); it('does not calibrate or announce a previous result when the planner is remounted', async () => { const { calendar } = calendarFor(plan, explanation); calendar.createPlanningCalculation .mockReturnValueOnce(calculationFor(plan, explanation)) .mockReturnValueOnce({ ...calculationFor(plan, explanation), outcome: new Promise(() => undefined), }); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const record = vi.spyOn(TestBed.inject(PlanningDurationService), 'recordCompletion'); const settle = async (fixture: ComponentFixture) => { fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); }; const first = TestBed.createComponent(CalendarYear); await settle(first); expect(record).toHaveBeenCalledTimes(1); first.destroy(); const second = TestBed.createComponent(CalendarYear); await settle(second); expect(record).toHaveBeenCalledTimes(1); expect(second.componentInstance['liveMessage']()).toBe( second.componentInstance['tr']().loading, ); }); it('does not announce a previous rejected explanation when the planner is remounted', async () => { const { calendar } = calendarFor(plan, explanation); const rejected = { ...calculationFor(plan, explanation), explanation: Promise.reject(new Error('explanation exploded')), }; rejected.explanation.catch(() => undefined); calendar.createPlanningCalculation.mockReturnValueOnce(rejected).mockReturnValueOnce({ ...calculationFor(plan, explanation), outcome: new Promise(() => undefined), }); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const settle = async (fixture: ComponentFixture) => { fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); }; const first = TestBed.createComponent(CalendarYear); await settle(first); expect(first.componentInstance['liveMessage']()).toContain('explanation exploded'); first.destroy(); const second = TestBed.createComponent(CalendarYear); await settle(second); expect(second.componentInstance['liveMessage']()).toBe( second.componentInstance['tr']().loading, ); }); it('announces loading and keeps focus when a calculation starts during a cancellation', async () => { const first = deferred(); const { calendar, calculation } = calendarFor(plan, undefined); calendar.createPlanningCalculation .mockReturnValueOnce({ ...calculation, outcome: first.promise }) .mockReturnValueOnce({ ...calculation, outcome: new Promise(() => undefined) }); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; const live = () => fixture.componentInstance['liveMessage'](); const tr = fixture.componentInstance['tr'](); activateWithKeyboard(root.querySelector('.cancel-calculation')!); fixture.detectChanges(); expect(live()).toBe(tr.planning.cancelling); const outside = document.createElement('button'); document.body.append(outside); outside.focus(); TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); first.resolve(plan); await fixture.whenStable(); fixture.detectChanges(); expect(document.activeElement).toBe(outside); expect(live()).toBe(tr.loading); outside.remove(); }); it.each([ ['a delivered explanation error', false], ['a rejected explanation', true], ])('announces only %s when it rejected', async (_, rejected) => { const reason = { code: 'explanation-error', message: 'Explanation boom' }; const { calendar } = calendarFor(plan, undefined); calendar.createPlanningCalculation.mockReturnValue({ ...calculationFor(plan, undefined), explanation: rejected ? Promise.reject(new Error(reason.message)) : Promise.resolve(undefined), explanationError: Promise.resolve(rejected ? undefined : reason), }); 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 service = TestBed.inject(PlanningCalculationService); expect(service.explanationError()).toMatchObject(reason); expect(service.explanationRejected()).toBe(rejected); expect(fixture.componentInstance['liveMessage']().includes(reason.message)).toBe(rejected); }); it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => { await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue( calculationFor(conflict), ); TestBed.inject(CalendarSettingsService).setPlanningDates('0099-05-14', '0099-05-14'); 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.querySelectorAll('app-calendar-month')).toHaveLength(1); expect( root.querySelector('button[data-date="0099-05-14"]')?.disabled, ).toBe(false); expect( root.querySelector('button[data-date="0099-05-15"]')?.disabled, ).toBe(true); }); it.each([ ['invalid balances', invalid], ['conflict', conflict], [ 'cancellation without a plan', { ...incomplete, reasons: [{ code: 'cancelled', message: 'Cancelled.' }] }, ], ['worker error', new Error('Worker stopped.')], ['startup error', new Error('Worker could not start.')], ] satisfies [string, PlanningOutcome | Error][])( 'uses the requested historical three-year calendar after %s', async (label, outcome) => { await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); const calendar = TestBed.inject(CalendarService); vi.spyOn(calendar, 'createPlanningCalculation').mockImplementation(() => { if (label === 'startup error') throw outcome; return outcome instanceof Error ? { ...calculationFor(incomplete), outcome: Promise.reject(outcome) } : calculationFor(outcome); }); TestBed.inject(CalendarSettingsService).setPlanningDates('2021-03-15', '2024-02-14'); 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.querySelectorAll('app-calendar-month')).toHaveLength(36); for (const date of ['2021-03-15', '2024-02-14']) expect( root.querySelector(`button[data-date="${date}"]`)?.disabled, ).toBe(false); for (const date of ['2021-03-14', '2024-02-15']) expect( root.querySelector(`button[data-date="${date}"]`)?.disabled, ).toBe(true); expect( root .querySelector('button[data-date="2023-04-10"]') ?.classList.contains('closed-day'), ).toBe(true); expect(root.querySelector('.loading-overlay')).toBeNull(); }, ); it('ignores an older outcome and explanation after a new horizon is submitted', async () => { await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); const oldOutcome = deferred(); const oldExplanation = deferred(); const calendar = TestBed.inject(CalendarService); const calculate = vi .spyOn(calendar, 'createPlanningCalculation') .mockReturnValueOnce({ ...calculationFor(plan), outcome: oldOutcome.promise, explanation: oldExplanation.promise, }) .mockReturnValueOnce(calculationFor(conflict)); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-01-01', '2024-12-31'); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); settings.setPlanningDates('2021-05-14', '2021-05-14'); TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); oldOutcome.resolve(plan); oldExplanation.resolve(explanation); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(calculate).toHaveBeenCalledTimes(2); expect(fixture.componentInstance['planningOutcome']()).toBe(conflict); expect(fixture.componentInstance['planningExplanation']()).toBeNull(); expect(fixture.componentInstance['planningHorizon']()).toEqual({ start: '2021-05-14', end: '2021-05-15', }); expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1); }); it('captures selected dates with balances and rules only on an explicit calculation request', async () => { await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [provideZonelessChangeDetection()], }).compileComponents(); const calendar = TestBed.inject(CalendarService); const calculate = vi .spyOn(calendar, 'createPlanningCalculation') .mockReturnValueOnce(calculationFor(plan, explanation)) .mockReturnValueOnce(calculationFor(conflict)); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-01-01', '2024-12-31'); const input = TestBed.inject(UserInputService); const rules = TestBed.inject(RuleService); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(calculate).toHaveBeenCalledTimes(1); expect(calculate.mock.calls[0][0].horizon).toEqual(plan.horizon); expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12); settings.setPlanningDates('2021-05-14', '2021-05-14'); input.updateBalances(() => [ { id: 'historical', type: SelectableDayType.CP, numberOfDays: 1, availableDate: new Date(2021, 4, 14), expiryDate: new Date(2021, 4, 14), }, ]); rules.addRule({ id: 'historical-rule', name: 'Historical rule', type: RuleType.TIME_WINDOW, force: 'obligation', enabled: true, placeDays: 1, placeType: SelectableDayType.CP, startDate: new Date(2021, 4, 14), endDate: new Date(2021, 4, 14), }); fixture.detectChanges(); await fixture.whenStable(); expect(calculate).toHaveBeenCalledTimes(1); expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull(); expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12); TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(calculate).toHaveBeenCalledTimes(2); expect(calculate.mock.calls[1][0]).toMatchObject({ horizon: { start: '2021-05-14', end: '2021-05-15' }, balances: [ { id: 'historical', type: 'cp', quantity: 1, availableFrom: '2021-05-14', expiryDate: '2021-05-14', }, ], }); expect(calculate.mock.calls[1][0].rules).toContainEqual( expect.objectContaining({ id: 'historical-rule' }), ); expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1); expect( (fixture.nativeElement as HTMLElement).querySelector( 'button[data-date="2021-05-14"]', )?.disabled, ).toBe(false); expect( fixture.nativeElement .querySelector('button[data-date="2021-05-13"]') ?.classList.contains('closed-day'), ).toBe(true); }); 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); calendar.createPlanningCalculation .mockReturnValueOnce(calculationFor(plan, explanation)) .mockReturnValueOnce(calculationFor(outcome)); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); const fixture: ComponentFixture = TestBed.createComponent(CalendarYear); const service = TestBed.inject(PlanningCalculationService); const settle = async () => { fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); }; await settle(); const root = fixture.nativeElement as HTMLElement; expect(service.displayedPlan()).toBeDefined(); expect( Array.from(root.querySelectorAll('.plan-summary dd')).map((e) => e.textContent?.trim()), ).toEqual(['2', '4', '6']); service.start(); await settle(); expect(service.displayedPlan()).toBeUndefined(); expect(root.querySelector('.plan-summary')).toBeNull(); expect(root.querySelectorAll('button.selected')).toHaveLength(0); }); 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 fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect( TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances, ).toEqual(plan.remainingBalances); 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(); root.querySelector('button[data-date="2024-01-02"]')!.click(); fixture.detectChanges(); 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.project.mockImplementation((value) => projectionFor(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')!; expect(planDetails.open).toBe(false); activateWithKeyboard(planDetails.querySelector('summary')!); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(planDetails.open).toBe(true); 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('opens explanations only on user interaction and analyzes alternatives 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(false); expect(root.querySelector('button.current')).toBeNull(); details.querySelector('summary')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); 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(); root.querySelector('button[data-date="2024-05-06"]')!.click(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); expect(details.open).toBe(true); 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); TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); expect(root.querySelector('.plan-details')?.open).toBe(false); expect(root.querySelector('button.current')).toBeNull(); }); 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 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 with their reported obligations', async () => { const conflictExplanation: PlanningExplanation = { kind: 'ConflictExplanation', conflictingRules: [{ id: 'rule', label: 'Required spring leave' }], bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' }, }; const setup = calendarFor(conflict, conflictExplanation); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ provideZonelessChangeDetection(), { provide: CalendarService, useValue: setup.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(); expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain( 'Required spring leave', ); expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain( 'No CP entitlement is available.', ); }); it('turns planner rejection into an incomplete error and clears loading', async () => { const calendar = { getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), createPlanningCalculation: vi.fn(() => ({ ...calculationFor(plan), outcome: Promise.reject(new Error('planner exploded')), })), project: vi.fn((value: Parameters[0]) => projectionFor(value)), }; 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 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(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined(); }); });