diff --git a/src/app/app-settings.spec.ts b/src/app/app-settings.spec.ts index 28f9de2..a647928 100644 --- a/src/app/app-settings.spec.ts +++ b/src/app/app-settings.spec.ts @@ -7,9 +7,10 @@ import { MatOptionHarness } from '@angular/material/core/testing'; import { MatDialogHarness } from '@angular/material/dialog/testing'; import { MatSelectHarness } from '@angular/material/select/testing'; import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing'; -import { vi } from 'vitest'; +import { type MockInstance, vi } from 'vitest'; import { App } from './app'; -import { UserInputService } from './services/user-input-service'; +import { CalendarService } from './services/calendar-service'; +import { calculatePlanningInternal } from './services/leave-planner.core'; describe('Application Settings', () => { const savedBalances = JSON.stringify([ @@ -25,6 +26,7 @@ describe('Application Settings', () => { let loader: HarnessLoader; let opener: HTMLButtonElement; let mediaQuery: EventTarget & { matches: boolean; media: string }; + let calculate: MockInstance; beforeEach(async () => { localStorage.clear(); @@ -50,7 +52,7 @@ describe('Application Settings', () => { imports: [App], providers: [provideZonelessChangeDetection()], }).compileComponents(); - TestBed.inject(UserInputService).vacationNumberSignal.set([]); + calculate = vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation'); fixture = TestBed.createComponent(App); fixture.detectChanges(); await fixture.whenStable(); @@ -93,6 +95,121 @@ describe('Application Settings', () => { fixture.detectChanges(); } + async function revisitApp(isLight: boolean): Promise { + fixture.destroy(); + TestBed.resetTestingModule(); + mediaQuery = Object.assign(new EventTarget(), { + matches: isLight, + media: '(prefers-color-scheme: light)', + }); + await createApp(); + } + + it('passes restored expired balances to the initial calculation without rewriting storage', () => { + const root = fixture.nativeElement as HTMLElement; + expect( + root.querySelector('app-day-input input[type="number"]')?.value, + ).toBe('3'); + expect(root.querySelectorAll('.date-fields input')[1].value).toBe( + '31/05/2026', + ); + expect(calculate).toHaveBeenCalledOnce(); + const request = calculate.mock.calls[0][0]; + expect(request.balances).toMatchObject([ + { + id: 'saved-balance', + type: 'cp', + quantity: 3, + availableFrom: '2026-01-01', + expiryDate: '2026-05-31', + }, + ]); + const outcome = calculatePlanningInternal(request); + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') + throw new Error('Expected the restored balance to be readable.'); + expect(outcome.remainingBalances).toMatchObject([ + { + id: 'saved-balance', + quantity: 3, + availableFrom: '2026-01-01', + expiryDate: '2026-05-31', + classification: 'unavoidable-expiry', + }, + ]); + expect(localStorage.getItem('leaveBalances')).toBe(savedBalances); + }); + + it.each([ + ['negative quantity', { numberOfDays: -1 }, 'balance-quantity'], + ['reversed dates', { availableDate: '2026-06-01' }, 'balance-dates'], + ])('leaves readable saved inputs (%s) to planner validation', async (_, invalid, reason) => { + const saved = JSON.stringify([ + { + id: 'saved-balance', + type: 'cp', + numberOfDays: 3, + availableDate: '2026-01-01', + expiryDate: '2026-05-31', + ...invalid, + }, + ]); + localStorage.setItem('leaveBalances', saved); + await revisitApp(true); + await expect + .poll(() => fixture.nativeElement.querySelector('.reason-list')?.textContent) + .toContain(reason.replaceAll('-', ' ')); + expect(localStorage.getItem('leaveBalances')).toBe(saved); + }); + + it.each([ + ['light', 'Clair', false], + ['dark', 'Sombre', true], + ] as const)( + 'restores %s, language, and week numbers before presenting their controls', + async (preference, label, isLight) => { + localStorage.setItem('theme.preference', JSON.stringify(preference)); + localStorage.setItem('app.language', '"fr"'); + localStorage.setItem('calendarSettings.showWeekNumbers', 'true'); + await revisitApp(isLight); + + expect(document.body.classList.contains(`${preference}-mode`)).toBe(true); + expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull(); + const dialog = await openSettings(); + const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness); + expect(await dialog.getTitleText()).toBe('Paramètres'); + expect(await theme.getValueText()).toBe(label); + expect(await language.getValueText()).toBe('Français'); + expect(await (await dialog.getHarness(MatSlideToggleHarness)).isChecked()).toBe(true); + changeSystemAppearance(!isLight); + changeSystemAppearance(isLight); + expect(document.body.classList.contains(`${preference}-mode`)).toBe(true); + expect(localStorage.getItem('theme.preference')).toBe(JSON.stringify(preference)); + expect(localStorage.getItem('leaveBalances')).toBe(savedBalances); + }, + ); + + it.each([true, false])( + 'restores System using the current device appearance (%s)', + async (isLight) => { + localStorage.setItem('theme.preference', '"system"'); + await revisitApp(isLight); + expect(document.body.classList.contains(isLight ? 'light-mode' : 'dark-mode')).toBe( + true, + ); + const dialog = await openSettings(); + const [theme] = await dialog.getAllHarnesses(MatSelectHarness); + expect(await theme.getValueText()).toBe('System'); + changeSystemAppearance(!isLight); + expect(document.body.classList.contains(isLight ? 'dark-mode' : 'light-mode')).toBe( + true, + ); + expect(await theme.getValueText()).toBe('System'); + expect(localStorage.getItem('theme.preference')).toBe('"system"'); + expect(localStorage.getItem('leaveBalances')).toBe(savedBalances); + }, + ); + it('opens one labelled Settings dialog from the header and keeps planner inputs in place', async () => { const dialog = await openSettings(); expect(await loader.getAllHarnesses(MatDialogHarness)).toHaveLength(1); diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 4758c5a..f9f52da 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core'; import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import dayjs from 'dayjs'; import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; @@ -459,8 +460,10 @@ export class CalendarYear { type: balance.type, quantity: balance.numberOfDays, label: this.translationService.getDayTypeLabel(balance.type), - availableFrom: balance.availableDate, - expiryDate: balance.expiryDate, + availableFrom: balance.availableDate + ? dayjs(balance.availableDate).format('YYYY-MM-DD') + : undefined, + expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'), })), rules: currentRules, labels: Object.fromEntries( diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts index 5ce8918..3f5ddbd 100644 --- a/src/app/components/user-input/user-input.spec.ts +++ b/src/app/components/user-input/user-input.spec.ts @@ -27,6 +27,10 @@ describe('UserInput', () => { .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()], @@ -36,7 +40,13 @@ describe('UserInput', () => { component = fixture.componentInstance; userInputService = TestBed.inject(UserInputService); fixture.detectChanges(); - }); + } + + async function revisit(): Promise { + fixture.destroy(); + TestBed.resetTestingModule(); + await createInput(); + } afterEach(() => { fixture.destroy(); @@ -91,6 +101,93 @@ describe('UserInput', () => { 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'); diff --git a/src/app/services/user-input-service.spec.ts b/src/app/services/user-input-service.spec.ts index 735bccd..ff47d4f 100644 --- a/src/app/services/user-input-service.spec.ts +++ b/src/app/services/user-input-service.spec.ts @@ -5,16 +5,27 @@ import { SelectableDayType, VacDay } from './calendar-service'; import { vi } from 'vitest'; 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()); + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); it('should be created', () => { expect(service).toBeTruthy(); @@ -37,6 +48,134 @@ describe('UserInputService', () => { }); }); + 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], @@ -48,6 +187,12 @@ describe('UserInputService', () => { 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', () => { diff --git a/src/app/services/user-input-service.ts b/src/app/services/user-input-service.ts index 7415bc2..02f44f8 100644 --- a/src/app/services/user-input-service.ts +++ b/src/app/services/user-input-service.ts @@ -6,6 +6,60 @@ import dayjs from 'dayjs'; providedIn: 'root', }) export class UserInputService { + private restoreDate(value: unknown): Date { + if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + const date = new Date(`${value}T00:00:00`); + if (dayjs(date).format('YYYY-MM-DD') === value) return date; + } + throw new Error('Unsupported saved balance date.'); + } + + private getSavedValues(): VacDay[] | undefined { + let stored: string | null = null; + try { + if (typeof localStorage === 'undefined') return; + stored = localStorage.getItem('leaveBalances'); + if (stored === null) return; + const saved: unknown = JSON.parse(stored); + if (!Array.isArray(saved)) throw new Error('Saved leave balances must be a list.'); + return saved.map((value: unknown) => { + if (typeof value !== 'object' || value === null) + throw new Error('Unsupported saved balance record.'); + const { id, type, numberOfDays, availableDate, expiryDate } = value as Record< + string, + unknown + >; + if ( + typeof id !== 'string' || + typeof numberOfDays !== 'number' || + !Number.isFinite(numberOfDays) || + (type !== SelectableDayType.CP && + type !== SelectableDayType.RTT && + type !== SelectableDayType.OTHER) + ) + throw new Error('Unsupported saved balance record.'); + return { + id, + type, + numberOfDays, + availableDate: + availableDate === undefined ? undefined : this.restoreDate(availableDate), + expiryDate: this.restoreDate(expiryDate), + }; + }); + } catch (error) { + console.warn('Failed to restore leave balances from localStorage.', error); + if (stored !== null) { + try { + localStorage.removeItem('leaveBalances'); + } catch { + // Keep the random fallback usable when browser storage is unavailable. + } + } + } + return undefined; + } + private getRandomNumber(): number { return Math.floor(Math.random() * 11) + 10; } @@ -48,7 +102,9 @@ export class UserInputService { }; } - readonly vacationNumberSignal = signal(this.getInitialValues()); + readonly vacationNumberSignal = signal( + this.getSavedValues() ?? this.getInitialValues(), + ); readonly remainingVacationDaysSignal = signal([]); readonly computeTriggerSignal = signal(0);