import { TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { UserInputService } from './user-input-service'; import { SelectableDayType, VacDay } from './calendar-service'; import { vi } from '../../testing/vitest-compat'; describe('UserInputService', () => { const savedBalance = { id: 'saved-cp', type: 'cp', numberOfDays: 3, availableDate: '2026-01-01', expiryDate: '2026-05-31', }; let service: UserInputService; beforeEach(() => { localStorage.clear(); TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()], }); service = TestBed.inject(UserInputService); }); afterEach(() => { vi.restoreAllMocks(); localStorage.clear(); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('adds, removes and replaces saved declarations without changing balances or calculating', () => { const balances = service.vacationNumberSignal(); expect(service.declaredDaysSignal()).toEqual([]); service.toggleDeclaredDay('2024-01-02', 'cp'); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-01-02', type: 'cp' }, ]); service.toggleDeclaredDay('2024-01-02', 'cp'); expect(new UserInputService().declaredDaysSignal()).toEqual([]); service.toggleDeclaredDay('2024-01-02', 'rtt'); service.toggleDeclaredDay('2024-01-02', 'other'); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-01-02', type: 'other' }, ]); expect(service.vacationNumberSignal()).toBe(balances); expect(localStorage.getItem('leaveBalances')).toBeNull(); }); it('rejects weekends, French national holidays and malformed calendar dates', () => { for (const date of [ '2024-01-06', '2024-01-07', '2024-05-08', '2024-05-09', '2024-02-30', 'not-a-date', ]) { service.toggleDeclaredDay(date, 'cp'); } expect(service.declaredDaysSignal()).toEqual([]); expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull(); service.toggleDeclaredDay('2024-05-10', 'other'); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-05-10', type: 'other' }, ]); }); it('toggles each date in a batch once and preserves unrelated declarations', () => { service.toggleDeclaredDay('2024-04-19', 'other'); service.toggleDeclaredDay('2024-04-25', 'rtt'); const balances = service.vacationNumberSignal(); const save = vi.spyOn(Storage.prototype, 'setItem'); service.toggleDeclaredDays( ['2024-04-22', '2024-04-23', '2024-04-24', '2024-04-25', '2024-04-26'], 'cp', ); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-04-19', type: 'other' }, { date: '2024-04-22', type: 'cp' }, { date: '2024-04-23', type: 'cp' }, { date: '2024-04-24', type: 'cp' }, { date: '2024-04-25', type: 'cp' }, { date: '2024-04-26', type: 'cp' }, ]); expect(save).toHaveBeenCalledOnce(); service.toggleDeclaredDays( [ '2024-04-25', '2024-04-26', '2024-04-27', '2024-04-28', '2024-04-29', '2024-04-30', '2024-05-01', '2024-04-29', 'invalid', ], 'cp', ); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-04-19', type: 'other' }, { date: '2024-04-22', type: 'cp' }, { date: '2024-04-23', type: 'cp' }, { date: '2024-04-24', type: 'cp' }, { date: '2024-04-29', type: 'cp' }, { date: '2024-04-30', type: 'cp' }, ]); expect(save).toHaveBeenCalledTimes(2); service.toggleDeclaredDays(['2024-04-29', '2024-04-30'], null); expect(new UserInputService().declaredDaysSignal()).toEqual([ { date: '2024-04-19', type: 'other' }, { date: '2024-04-22', type: 'cp' }, { date: '2024-04-23', type: 'cp' }, { date: '2024-04-24', type: 'cp' }, ]); expect(save).toHaveBeenCalledTimes(3); expect(service.vacationNumberSignal()).toBe(balances); }); it('restores an empty list when saved declarations are unreadable', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); for (const saved of [ '{', 'null', '[{"date":"2024-05-08","type":"cp"}]', '[{"date":"2024-01-02","type":"any"}]', ]) { localStorage.setItem('declaredNonWorkingDays', saved); expect(new UserInputService().declaredDaysSignal()).toEqual([]); expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull(); } expect(warn).toHaveBeenCalled(); }); it('should initialize with vacation days array', () => { const vacations = service.vacationNumberSignal(); expect(Array.isArray(vacations)).toBe(true); expect(vacations.length).toBeGreaterThan(0); }); it('should have vacation days with valid properties', () => { const vacations = service.vacationNumberSignal(); vacations.forEach((day) => { expect(day.id).toBeDefined(); expect(day.type).toBeDefined(); expect(day.numberOfDays).toBeGreaterThanOrEqual(10); expect(day.numberOfDays).toBeLessThanOrEqual(20); expect(day.expiryDate).toBeInstanceOf(Date); }); }); it('restores the saved balance order, identities, quantities, types, and calendar dates', () => { const saved = JSON.stringify([ { id: 'saved-other', type: 'other', numberOfDays: 41, availableDate: '2026-03-29', expiryDate: '2026-10-25', }, { id: 'expired-cp', type: 'cp', numberOfDays: 5, availableDate: '2024-02-29', expiryDate: '2024-05-31', }, { id: 'empty-rtt', type: 'rtt', numberOfDays: 0, availableDate: '2027-01-01', expiryDate: '2027-12-31', }, ]); localStorage.setItem('leaveBalances', saved); expect(new UserInputService().vacationNumberSignal()).toEqual([ { id: 'saved-other', type: SelectableDayType.OTHER, numberOfDays: 41, availableDate: new Date(2026, 2, 29), expiryDate: new Date(2026, 9, 25), }, { id: 'expired-cp', type: SelectableDayType.CP, numberOfDays: 5, availableDate: new Date(2024, 1, 29), expiryDate: new Date(2024, 4, 31), }, { id: 'empty-rtt', type: SelectableDayType.RTT, numberOfDays: 0, availableDate: new Date(2027, 0, 1), expiryDate: new Date(2027, 11, 31), }, ]); expect(localStorage.getItem('leaveBalances')).toBe(saved); }); it('restores a saved empty list without generating or saving replacement balances', () => { localStorage.setItem('leaveBalances', '[]'); expect(new UserInputService().vacationNumberSignal()).toEqual([]); expect(localStorage.getItem('leaveBalances')).toBe('[]'); }); it('preserves an omitted availability date from the supported save format', () => { const saved = JSON.stringify([{ ...savedBalance, availableDate: undefined }]); localStorage.setItem('leaveBalances', saved); expect(new UserInputService().vacationNumberSignal()).toEqual([ { id: 'saved-cp', type: SelectableDayType.CP, numberOfDays: 3, availableDate: undefined, expiryDate: new Date(2026, 4, 31), }, ]); expect(localStorage.getItem('leaveBalances')).toBe(saved); }); it.each([ ['empty storage value', ''], ['malformed JSON', '{'], ['non-list', '{}'], ['null list', 'null'], ['null record', '[null]'], ['missing identity', JSON.stringify([{ ...savedBalance, id: undefined }])], ['unreadable quantity', JSON.stringify([{ ...savedBalance, numberOfDays: '3' }])], ['invalid availability', JSON.stringify([{ ...savedBalance, availableDate: null }])], ['missing expiry', JSON.stringify([{ ...savedBalance, expiryDate: undefined }])], ['rolled-over date', JSON.stringify([{ ...savedBalance, expiryDate: '2026-02-30' }])], [ 'mixed supported and unsupported records', JSON.stringify([savedBalance, { ...savedBalance, type: 'any' }]), ], ])( 'deletes only unreadable balance data (%s) before using unsaved random defaults', (_, saved) => { vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(Math, 'random').mockReturnValue(0.5); const unrelated = { 'theme.preference': '"dark"', 'app.language': '"fr"', 'calendarSettings.showWeekNumbers': 'true', vacationRules: '[]', 'unrelated.entry': 'keep this value', }; for (const [key, value] of Object.entries(unrelated)) localStorage.setItem(key, value); localStorage.setItem('leaveBalances', saved); const balances = new UserInputService().vacationNumberSignal(); expect(balances.map((balance) => [balance.type, balance.numberOfDays])).toEqual([ [SelectableDayType.CP, 15], [SelectableDayType.RTT, 15], [SelectableDayType.OTHER, 15], ]); expect(localStorage.getItem('leaveBalances')).toBeNull(); for (const [key, value] of Object.entries(unrelated)) expect(localStorage.getItem(key)).toBe(value); }, ); it('keeps saved data intact when browser storage temporarily cannot be read', () => { const saved = JSON.stringify([savedBalance]); localStorage.setItem('leaveBalances', saved); vi.spyOn(console, 'warn').mockImplementation(() => {}); const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new DOMException('Storage is unavailable', 'SecurityError'); }); expect(new UserInputService().vacationNumberSignal()).toHaveLength(3); read.mockRestore(); expect(localStorage.getItem('leaveBalances')).toBe(saved); }); it.each([ [0, 10], [1 - Number.EPSILON, 20], ])('starts every balance at %s random draw with %s days', (draw, expected) => { vi.spyOn(Math, 'random').mockReturnValue(draw); const initialBalances = new UserInputService().vacationNumberSignal(); expect(initialBalances.map((balance) => balance.numberOfDays)).toEqual([ expected, expected, expected, ]); expect(initialBalances.map((balance) => balance.type)).toEqual([ SelectableDayType.CP, SelectableDayType.RTT, SelectableDayType.OTHER, ]); expect(localStorage.getItem('leaveBalances')).toBeNull(); }); it('should update vacation number signal', () => { const newVacations: VacDay[] = [ { id: 'test-id', type: SelectableDayType.CP, numberOfDays: 5, expiryDate: new Date(), }, ]; service.vacationNumberSignal.set(newVacations); expect(service.vacationNumberSignal()).toEqual(newVacations); }); it('should create a new vacation day', () => { const newDay = service.createVacationDay(); expect(newDay.id).toBeDefined(); expect(newDay.type).toBe(SelectableDayType.CP); expect(newDay.numberOfDays).toBe(1); expect(newDay.expiryDate).toBeInstanceOf(Date); }); });