import { createOptimizationPresetRules } from '../../services/rule-service'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatOptionHarness } from '@angular/material/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { MatDialogHarness } from '@angular/material/dialog/testing'; import { MatSelectHarness } from '@angular/material/select/testing'; import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing'; import { type MockInstance, vi } from '../../../testing/vitest-compat'; import { UserInput } from './user-input'; import { UserInputService } from '../../services/user-input-service'; import { CalendarService } from '../../services/calendar-service'; import { PlanningCalculationService } from '../../services/planning-calculation.service'; import { UserSettings } from '../user-settings/user-settings'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { createPlanningCalculation, type PlanningCalculationContext, type PlanningOutcome, PlanningRequest, } from '../../services/leave-planner.service'; import { TranslationService } from '../../services/translation-service'; const presets = createOptimizationPresetRules(); describe('UserInput', () => { let component: UserInput; let fixture: ComponentFixture; let userInputService: UserInputService; let createCalculation: MockInstance; let calculations: PlanningCalculationService; beforeEach(async () => { localStorage.clear(); localStorage.setItem('app.language', '"en"'); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(2026, 9, 4, 12)); vi.spyOn(Math, 'random').mockReturnValue(0.5); vi.spyOn(crypto, 'randomUUID') .mockReturnValueOnce('00000000-0000-4000-8000-000000000001') .mockReturnValueOnce('00000000-0000-4000-8000-000000000002') .mockReturnValueOnce('00000000-0000-4000-8000-000000000003') .mockReturnValueOnce('00000000-0000-4000-8000-000000000004'); await createInput(); }); async function createInput(): Promise { await TestBed.configureTestingModule({ imports: [UserInput, UserSettings], providers: [provideZonelessChangeDetection()], }).compileComponents(); fixture = TestBed.createComponent(UserInput); component = fixture.componentInstance; userInputService = TestBed.inject(UserInputService); calculations = TestBed.inject(PlanningCalculationService); // Unless a test supplies a result, a started calculation stays pending. createCalculation = vi .spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation') .mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never); fixture.detectChanges(); await fixture.whenStable(); } async function revisit(): Promise { fixture.destroy(); TestBed.resetTestingModule(); await createInput(); } afterEach(() => { fixture.destroy(); TestBed.resetTestingModule(); vi.restoreAllMocks(); vi.useRealTimers(); localStorage.clear(); document.body.classList.remove('light-mode', 'dark-mode'); }); function editInput(selector: string, value: string): void { const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement; input.value = value; input.dispatchEvent(new Event('input')); input.dispatchEvent(new Event('change')); fixture.detectChanges(); } async function selectOption(select: MatSelectHarness, text: string): Promise { await select.open(); const loader = TestbedHarnessEnvironment.documentRootLoader(fixture); for (const option of await loader.getAllHarnesses(MatOptionHarness)) { if ((await option.getText()) === text) { await option.click(); await expect.poll(() => document.querySelector('[role="listbox"]')).toBeNull(); return; } } throw new Error(`No option labelled ${text}`); } it('should create', () => { expect(component).toBeTruthy(); }); async function calculate(request: PlanningRequest): Promise { const browserWindow = globalThis.window; let calculation: PlanningCalculationContext; try { vi.stubGlobal('window', undefined); calculation = createPlanningCalculation(request); } finally { vi.stubGlobal('window', browserWindow); } await show(calculation); } async function show( calculation: PlanningCalculationContext, outcome?: PlanningOutcome, ): Promise { const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation; createCalculation.mockReturnValueOnce(shown); calculations.start(); await shown.outcome; await Promise.resolve(); fixture.detectChanges(); } it('explains availability using the calculated inclusive last date, independently of unsubmitted edits', async () => { TestBed.inject(TranslationService).setLanguage('fr'); await calculate({ horizon: { start: '2027-05-01', end: '2027-05-10' }, balances: [ { id: 'other', type: 'other', quantity: 1, availableFrom: '2027-05-10', expiryDate: '2028-05-10', }, ], rules: [], }); const root = fixture.nativeElement as HTMLElement; const card = root.querySelector('.warning-card')!; expect(card.textContent).toContain('Disponible seulement à partir du 10 mai 2027'); expect(card.textContent).toContain('la dernière date du planning, le 9 mai 2027'); expect(card.textContent).toContain('prolonger la période planifiée'); expect(card.textContent).not.toContain('manuellement'); const explanation = card.textContent; TestBed.inject(CalendarSettingsService).setPlanningDates('2027-01-01', '2028-12-31'); component.updateDay(0, { ...userInputService.vacationNumberSignal()[0], numberOfDays: 0 }); fixture.detectChanges(); expect(card.textContent).toBe(explanation); }); it('clears the remaining-days summary when a valid plan is replaced by a conflict', async () => { await calculate({ horizon: { start: '2027-05-01', end: '2027-05-10' }, balances: [ { id: 'other', type: 'other', quantity: 1, availableFrom: '2027-05-10', expiryDate: '2028-05-10', }, ], rules: [], }); const root = fixture.nativeElement as HTMLElement; expect(calculations.displayedPlan()).toBeDefined(); expect(root.querySelector('.warning-card')).not.toBeNull(); await show(calculations.accepted()!.calculation!, { kind: 'PlanningConflict', reasons: [], conflictingRuleIds: ['rule'], }); expect(calculations.displayedPlan()).toBeUndefined(); expect(root.querySelector('.warning-card')).toBeNull(); }); it('explains usable leave kept for later and incomplete balances without attempt controls', async () => { TestBed.inject(TranslationService).setLanguage('fr'); await calculate({ horizon: { start: '2024-06-04', end: '2024-06-13' }, balances: [ { id: 'other', type: 'other', quantity: 1, availableFrom: '2024-06-04', expiryDate: '2025-05-10', }, ], declaredDays: [{ date: '2024-06-07', type: 'cp' }], rules: [presets[3]], }); const root = fixture.nativeElement as HTMLElement; const card = root.querySelector('.warning-card')!; expect(card.classList.contains('is-informational')).toBe(true); expect(card.textContent).toContain('Conservé pour plus tard'); expect(card.textContent).toContain('Aucune action nécessaire'); expect(card.textContent).not.toContain('Disponible seulement'); expect(root.querySelector('.remaining-history')).toBeNull(); const outcome = JSON.stringify(calculations.outcome()); expect(card.querySelector('details')).toBeNull(); expect(card.textContent).not.toContain('trace'); expect(JSON.stringify(calculations.outcome())).toBe(outcome); const plan = calculations.displayedPlan()!; await show(calculations.accepted()!.calculation!, { kind: 'PlanningIncomplete', incumbent: plan, completedStages: [], reasons: [{ code: 'user-cancelled', message: 'Cancelled' }], }); expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive'); expect(card.textContent).not.toContain('Conservé pour plus tard'); expect(root.querySelector('.remaining-history')).toBeNull(); expect(card.querySelector('details')).toBeNull(); }); it('describes Calculate with an indicative range that follows unsubmitted edits', () => { const root = fixture.nativeElement as HTMLElement; const calculate = root.querySelector('.calculate-button')!; const estimateText = () => root.querySelector('#planning-duration-estimate')?.textContent?.trim(); expect(estimateText()).toMatch(/Approximate wait: \d+–\d+ (s|min)/); expect(calculate.getAttribute('aria-describedby')).toBe('planning-duration-estimate'); const initial = estimateText(); TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03'); fixture.detectChanges(); expect(estimateText()).not.toBe(initial); expect(createCalculation).not.toHaveBeenCalled(); }); it('saves both calendar selections as inclusive planning dates without recalculating', async () => { const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-02-29', '2024-03-08'); TestBed.inject(TranslationService).setLanguage('fr'); await fixture.whenStable(); const toggles = fixture.nativeElement.querySelectorAll( '.planning-dates mat-datepicker-toggle button', ); expect(toggles).toHaveLength(2); for (const [index, day] of ['27', '9'].entries()) { toggles[index].click(); await fixture.whenStable(); const calendar = document.querySelector('mat-calendar')!; if (index === 0) expect( calendar .querySelector('.mat-calendar-period-button') ?.textContent?.toLowerCase(), ).toContain('févr'); Array.from(calendar.querySelectorAll('.mat-calendar-body-cell')) .find((cell) => cell.textContent?.trim() === day)! .click(); await fixture.whenStable(); await expect.poll(() => document.querySelector('mat-calendar')).toBeNull(); } expect(settings.planningHorizon()).toEqual({ start: '2024-02-27', end: '2024-03-10' }); expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({ start: '2024-02-27', lastPlanningDate: '2024-03-09', }); expect(createCalculation).not.toHaveBeenCalled(); }); it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => { const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-03-01', '2024-03-01'); await fixture.whenStable(); editInput('input[name="lastPlanningDate"]', '29/02/2024'); const root = fixture.nativeElement as HTMLElement; const calculate = root.querySelector('.calculate-button')!; expect(calculate.disabled).toBe(true); expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after'); expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error'); calculate.click(); component.triggerComputation(); expect(createCalculation).not.toHaveBeenCalled(); editInput('input[name="planningStart"]', '29/02/2024'); expect(calculate.disabled).toBe(false); calculate.click(); expect(createCalculation).toHaveBeenCalledTimes(1); }); it.each(['', '30/02/2024', 'not-a-date', '2'])( 'reports an invalid planning date without saving it: %s', async (value) => { const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-02-29', '2027-02-28'); await fixture.whenStable(); editInput('input[name="planningStart"]', value); await fixture.whenStable(); expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe( value, ); expect( fixture.nativeElement.querySelector('#planning-dates-error')?.textContent, ).toContain('two valid calendar dates'); expect(settings.planningHorizon()).toBeNull(); expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({ start: '2024-02-29', lastPlanningDate: '2027-02-28', }); }, ); it('edits inclusive dates with inline validation while preserving the saved planning horizon', async () => { const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-02-29', '2027-02-28'); await fixture.whenStable(); const root = fixture.nativeElement as HTMLElement; const start = root.querySelector('input[name="planningStart"]')!; const last = root.querySelector('input[name="lastPlanningDate"]')!; expect(start.value).toBe('29/02/2024'); expect(last.value).toBe('28/02/2027'); expect(root.textContent).toContain('Last planning date (inclusive)'); editInput('input[name="lastPlanningDate"]', '28/02/2024'); expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after'); expect(last.getAttribute('aria-invalid')).toBe('true'); expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error'); expect(settings.planningHorizon()).toBeNull(); editInput('input[name="lastPlanningDate"]', '29/02/2024'); expect(root.querySelector('#planning-dates-error')).toBeNull(); expect(last.getAttribute('aria-invalid')).toBe('false'); expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error'); expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' }); expect(createCalculation).not.toHaveBeenCalled(); }); it('should initialize with vacation days from service', () => { const vacationDays = userInputService.vacationNumberSignal(); expect(vacationDays).toBeDefined(); expect(Array.isArray(vacationDays)).toBe(true); expect(vacationDays.length).toBeGreaterThan(0); }); it('should add a new vacation day', () => { const initialLength = userInputService.vacationNumberSignal().length; component.addDay(); expect(userInputService.vacationNumberSignal().length).toBe(initialLength + 1); }); it('should remove a vacation day', () => { const initialLength = userInputService.vacationNumberSignal().length; component.removeFromList(0); expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1); }); it('renders restored quantities, leave types, and unchanged availability and expiry dates', async () => { const saved = JSON.stringify([ { id: 'rtt-first', type: 'rtt', numberOfDays: 36, availableDate: '2026-03-29', expiryDate: '2026-10-25', }, { id: 'expired-cp', type: 'cp', numberOfDays: 2, availableDate: '2024-02-29', expiryDate: '2024-05-31', }, ]); localStorage.setItem('leaveBalances', saved); await revisit(); const root = fixture.nativeElement as HTMLElement; expect( Array.from(root.querySelectorAll('app-day-input input')).map( (input) => input.value, ), ).toEqual(['36', '29/03/2026', '25/10/2026', '2', '29/02/2024', '31/05/2024']); const types = await TestbedHarnessEnvironment.loader(fixture).getAllHarnesses( MatSelectHarness.with({ selector: 'app-day-input mat-select' }), ); expect(await Promise.all(types.map((type) => type.getValueText()))).toEqual([ 'RTT', 'Paid Leave', ]); expect(userInputService.vacationNumberSignal().map((balance) => balance.id)).toEqual([ 'rtt-first', 'expired-cp', ]); expect(localStorage.getItem('leaveBalances')).toBe(saved); }); it('renders no balance rows on a fresh visit with a saved empty list', async () => { localStorage.setItem('leaveBalances', '[]'); await revisit(); expect(fixture.nativeElement.querySelector('app-day-input')).toBeNull(); expect(localStorage.getItem('leaveBalances')).toBe('[]'); }); it.each([ ['absent', null], ['unreadable', '{'], ])('renders unsaved random defaults when balance data is %s', async (_, saved) => { vi.spyOn(console, 'warn').mockImplementation(() => {}); if (saved !== null) localStorage.setItem('leaveBalances', saved); await revisit(); const root = fixture.nativeElement as HTMLElement; expect( Array.from(root.querySelectorAll('app-day-input input')).map( (input) => input.value, ), ).toEqual([ '15', '04/10/2026', '04/10/2027', '15', '04/10/2026', '04/10/2027', '15', '04/10/2026', '04/10/2027', ]); expect(localStorage.getItem('leaveBalances')).toBeNull(); expect(localStorage.getItem('app.language')).toBe('"en"'); }); it('restores the complete edited list on a fresh visit', async () => { editInput('app-day-input input[type="number"]', '7'); const saved = localStorage.getItem('leaveBalances'); await revisit(); const root = fixture.nativeElement as HTMLElement; expect( Array.from(root.querySelectorAll('input[type="number"]')).map( (input) => input.value, ), ).toEqual(['7', '15', '15']); expect(localStorage.getItem('leaveBalances')).toBe(saved); }); it('saves the complete ordered balance list on the first quantity edit', () => { expect(localStorage.getItem('leaveBalances')).toBeNull(); editInput('app-day-input input[type="number"]', '7'); expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toEqual([ { id: '00000000-0000-4000-8000-000000000001', type: 'cp', numberOfDays: 7, availableDate: '2026-10-04', expiryDate: '2027-10-04', }, { id: '00000000-0000-4000-8000-000000000002', type: 'rtt', numberOfDays: 15, availableDate: '2026-10-04', expiryDate: '2027-10-04', }, { id: '00000000-0000-4000-8000-000000000003', type: 'other', numberOfDays: 15, availableDate: '2026-10-04', expiryDate: '2027-10-04', }, ]); }); it('saves a leave-type selection as the first balance edit', async () => { const loader = TestbedHarnessEnvironment.loader(fixture); const type = await loader.getHarness( MatSelectHarness.with({ selector: 'app-day-input mat-select' }), ); await selectOption(type, 'RTT'); expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([ { id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15, type: 'rtt' }, { id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15, type: 'rtt' }, { id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15, type: 'other' }, ]); }); it.each([ [ 'availability', 1, '29/03/2026', { availableDate: '2026-03-29', expiryDate: '2027-10-04' }, ], ['expiry', 2, '25/10/2026', { availableDate: '2026-10-04', expiryDate: '2026-10-25' }], ])('saves an %s date edit without shifting its calendar date', (_, field, value, dates) => { editInput(`app-day-input .date-fields mat-form-field:nth-child(${field}) input`, value); expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([ { id: '00000000-0000-4000-8000-000000000001', ...dates }, { id: '00000000-0000-4000-8000-000000000002' }, { id: '00000000-0000-4000-8000-000000000003' }, ]); }); it('saves adding a balance as the first edit and keeps saving subsequent edits', () => { fixture.nativeElement.querySelector('.add-day-button').click(); fixture.detectChanges(); expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([ { id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 }, { id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 }, { id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15 }, { id: '00000000-0000-4000-8000-000000000004', type: 'cp', numberOfDays: 1, availableDate: '2026-10-04', expiryDate: '2027-10-04', }, ]); editInput('app-day-input input[type="number"]', '8'); expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8); editInput('app-day-input input[type="number"]', '9'); expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(9); }); it('saves removing a balance as the first edit and retains an entry after the final deletion', () => { fixture.nativeElement.querySelectorAll('.delete-button')[2].click(); fixture.detectChanges(); expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([ { id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 }, { id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 }, ]); fixture.nativeElement.querySelectorAll('.delete-button')[1].click(); fixture.detectChanges(); expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toHaveLength(1); fixture.nativeElement.querySelector('.delete-button').click(); fixture.detectChanges(); expect(userInputService.vacationNumberSignal()).toEqual([]); expect(localStorage.getItem('leaveBalances')).toBe('[]'); }); it('keeps untouched random balances unsaved through settings, rule changes, and calculation', async () => { const initialBalances = userInputService.vacationNumberSignal(); expect(localStorage.getItem('leaveBalances')).toBeNull(); const loader = TestbedHarnessEnvironment.documentRootLoader(fixture); const dialogRef = TestBed.inject(MatDialog).open(UserSettings); const dialog = await loader.getHarness(MatDialogHarness); expect(localStorage.getItem('leaveBalances')).toBeNull(); const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness); await selectOption(theme, 'Dark'); await selectOption(language, 'Français'); await (await dialog.getHarness(MatSlideToggleHarness)).check(); expect(localStorage.getItem('theme.preference')).toBe('"dark"'); expect(localStorage.getItem('app.language')).toBe('"fr"'); expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true'); dialogRef.close(); const rule = await loader.getHarness( MatSlideToggleHarness.with({ selector: 'app-rules-panel mat-slide-toggle' }), ); await rule.uncheck(); await expect.poll(() => localStorage.getItem('vacationRules')).not.toBeNull(); expect(JSON.parse(localStorage.getItem('vacationRules')!).rules[0].enabled).toBe(false); fixture.nativeElement.querySelector('.calculate-button').click(); await fixture.whenStable(); expect(createCalculation).toHaveBeenCalledTimes(1); expect(userInputService.vacationNumberSignal()).toEqual(initialBalances); expect(localStorage.getItem('leaveBalances')).toBeNull(); }); it('preserves unrelated stored preferences, rules, and other values when editing balances', () => { const unrelated = { 'theme.preference': '"system"', 'app.language': '"en"', 'calendarSettings.showWeekNumbers': 'true', vacationRules: '[]', 'unrelated.entry': 'keep this value', }; for (const [key, value] of Object.entries(unrelated)) localStorage.setItem(key, value); editInput('app-day-input input[type="number"]', '7'); for (const [key, value] of Object.entries(unrelated)) { expect(localStorage.getItem(key)).toBe(value); } expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(7); }); it('keeps balance edits usable when storage fails and saves the next edit when it recovers', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}); const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new DOMException('Storage is unavailable', 'SecurityError'); }); editInput('app-day-input input[type="number"]', '7'); expect(userInputService.vacationNumberSignal()[0].numberOfDays).toBe(7); expect(localStorage.getItem('leaveBalances')).toBeNull(); write.mockRestore(); editInput('app-day-input input[type="number"]', '8'); expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8); }); });