import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { HarnessLoader, TestKey } from '@angular/cdk/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; 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 { type MockInstance, vi } from '../testing/vitest-compat'; import { App } from './app'; import { CalendarService } from './services/calendar-service'; import { calculatePlanningInternal } from './services/leave-planner.core'; describe('Application Settings', () => { const savedBalances = JSON.stringify([ { id: 'saved-balance', type: 'cp', numberOfDays: 3, availableDate: '2026-01-01', expiryDate: '2026-05-31', }, ]); let fixture: ComponentFixture; let loader: HarnessLoader; let opener: HTMLButtonElement; let mediaQuery: EventTarget & { matches: boolean; media: string }; let calculate: MockInstance; beforeEach(async () => { localStorage.clear(); localStorage.setItem('app.language', JSON.stringify('en')); localStorage.setItem('leaveBalances', savedBalances); localStorage.setItem('vacationRules', '{"version":1,"rules":[]}'); localStorage.setItem('unrelated.entry', 'keep this value'); mediaQuery = Object.assign(new EventTarget(), { matches: true, media: '(prefers-color-scheme: light)', }); const otherMedia = window.matchMedia('(max-width: 0px)'); vi.spyOn(window, 'matchMedia').mockImplementation((query) => query === mediaQuery.media ? (mediaQuery as unknown as MediaQueryList) : { ...otherMedia, media: query }, ); await createApp(); }); async function createApp(): Promise { await TestBed.configureTestingModule({ imports: [App], providers: [provideZonelessChangeDetection()], }).compileComponents(); calculate = vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation'); fixture = TestBed.createComponent(App); fixture.detectChanges(); await fixture.whenStable(); loader = TestbedHarnessEnvironment.documentRootLoader(fixture); } afterEach(() => { fixture?.destroy(); vi.restoreAllMocks(); vi.useRealTimers(); localStorage.clear(); document.body.classList.remove('light-mode', 'dark-mode'); }); async function openSettings(): Promise { const root = fixture.nativeElement as HTMLElement; opener = root.querySelector( 'header.app-header button[aria-haspopup="dialog"]', )!; expect(opener).toBeDefined(); opener.focus(); opener.click(); return loader.getHarness(MatDialogHarness); } async function selectOption(select: MatSelectHarness, text: string): Promise { await select.open(); 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}`); } function changeSystemAppearance(isLight: boolean): void { mediaQuery.matches = isLight; mediaQuery.dispatchEvent(Object.assign(new Event('change'), { matches: isLight })); 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('keeps random starting balances unsaved through calculation and restores the first edited list', async () => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(2026, 9, 4, 12)); const random = vi .spyOn(Math, 'random') .mockReturnValueOnce(0) .mockReturnValueOnce(0.5) .mockReturnValue(1 - Number.EPSILON); localStorage.removeItem('leaveBalances'); await revisitApp(true); const root = fixture.nativeElement as HTMLElement; expect( Array.from(root.querySelectorAll('app-day-input input')).map( (input) => input.value, ), ).toEqual([ '10', '04/10/2026', '04/10/2027', '15', '04/10/2026', '04/10/2027', '20', '04/10/2026', '04/10/2027', ]); const types = await loader.getAllHarnesses( MatSelectHarness.with({ selector: 'app-day-input mat-select' }), ); expect(await Promise.all(types.map((type) => type.getValueText()))).toEqual([ 'Paid Leave', 'RTT', 'Other', ]); expect(calculate).toHaveBeenCalledOnce(); const initialRequest = calculate.mock.calls[0][0]; expect(initialRequest.balances.map((balance) => balance.quantity)).toEqual([10, 15, 20]); expect(calculatePlanningInternal(initialRequest).kind).toBe('Plan'); await expect.poll(() => root.querySelector('.planning-status')).not.toBeNull(); expect(localStorage.getItem('leaveBalances')).toBeNull(); root.querySelector('.calculate-button')!.click(); await fixture.whenStable(); expect(calculate).toHaveBeenCalledTimes(2); expect(calculate.mock.calls[1][0].balances).toEqual(initialRequest.balances); await expect.poll(() => root.querySelector('.planning-status')).not.toBeNull(); expect(localStorage.getItem('leaveBalances')).toBeNull(); const quantity = root.querySelector( 'app-day-input input[type="number"]', )!; quantity.value = '7'; quantity.dispatchEvent(new Event('input')); await fixture.whenStable(); const saved = localStorage.getItem('leaveBalances'); expect(JSON.parse(saved!)).toMatchObject([ { type: 'cp', numberOfDays: 7, availableDate: '2026-10-04', expiryDate: '2027-10-04' }, { type: 'rtt', numberOfDays: 15, availableDate: '2026-10-04', expiryDate: '2027-10-04', }, { type: 'other', numberOfDays: 20, availableDate: '2026-10-04', expiryDate: '2027-10-04', }, ]); random.mockReturnValue(0); await revisitApp(true); expect( Array.from( fixture.nativeElement.querySelectorAll('app-day-input input[type="number"]'), (input) => (input as HTMLInputElement).value, ), ).toEqual(['7', '15', '20']); expect(calculate).toHaveBeenCalledOnce(); expect(calculate.mock.calls[0][0].balances).toEqual( initialRequest.balances.map((balance, index) => index === 0 ? { ...balance, quantity: 7 } : balance, ), ); expect(localStorage.getItem('leaveBalances')).toBe(saved); }); it('calculates with a saved empty balance list without generating replacement rows', async () => { localStorage.setItem('leaveBalances', '[]'); const random = vi.spyOn(Math, 'random'); await revisitApp(true); expect(fixture.nativeElement.querySelector('app-day-input')).toBeNull(); expect(calculate).toHaveBeenCalledOnce(); expect(calculate.mock.calls[0][0].balances).toEqual([]); expect(calculatePlanningInternal(calculate.mock.calls[0][0]).kind).toBe('Plan'); await expect .poll(() => fixture.nativeElement.querySelector('.planning-status')) .not.toBeNull(); expect(random).not.toHaveBeenCalled(); expect(localStorage.getItem('leaveBalances')).toBe('[]'); }); 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); expect(await dialog.getTitleText()).toBe('Settings'); expect(await dialog.getRole()).toBe('dialog'); const titleId = await dialog.getAriaLabelledby(); expect(document.getElementById(titleId!)?.textContent?.trim()).toBe('Settings'); const selects = await dialog.getAllHarnesses(MatSelectHarness); expect(selects).toHaveLength(2); expect(await selects[0].getLabel()).toBe('Theme'); expect(await selects[1].getLabel()).toBe('Language'); expect(await selects[0].getValueText()).toBe('System'); expect(await selects[1].getValueText()).toBe('English'); expect(await dialog.getHarness(MatSlideToggleHarness)).toBeDefined(); expect( fixture.nativeElement.querySelector('app-calendar-year app-calendar-settings'), ).toBeNull(); expect( fixture.nativeElement.querySelector('app-user-input app-rules-panel'), ).not.toBeNull(); expect(await dialog.getAllHarnesses(MatButtonHarness)).toHaveLength(1); }); it('saves planning dates before balances and recalculates only when Calculate is selected', async () => { const root = fixture.nativeElement as HTMLElement; const period = root.querySelector('app-user-input .planning-dates')!; expect( period.compareDocumentPosition(root.querySelector('.listOfDays')!) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect(period.querySelector('legend')?.textContent).toBe('Planning period'); expect(period.querySelector('#planning-dates-help')?.textContent).toContain( 'Calculate plan', ); const start = period.querySelector('input[name="planningStart"]')!; const last = period.querySelector('input[name="lastPlanningDate"]')!; start.value = '29/02/2024'; start.dispatchEvent(new Event('input')); last.value = '08/03/2024'; last.dispatchEvent(new Event('input')); await fixture.whenStable(); expect(calculate).toHaveBeenCalledOnce(); expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({ start: '2024-02-29', lastPlanningDate: '2024-03-08', }); root.querySelector('.calculate-button')!.click(); await fixture.whenStable(); expect(calculate).toHaveBeenCalledTimes(2); expect(calculate.mock.calls[1][0].horizon).toEqual({ start: '2024-02-29', end: '2024-03-09', }); const dialog = await openSettings(); expect(await dialog.getContentText()).not.toContain('Planning period'); const [, language] = await dialog.getAllHarnesses(MatSelectHarness); await selectOption(language, 'Français'); expect(period.querySelector('legend')?.textContent).toBe('Période à planifier'); await dialog.close(); await revisitApp(true); expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe( '29/02/2024', ); expect(fixture.nativeElement.querySelector('input[name="lastPlanningDate"]').value).toBe( '08/03/2024', ); }); it('supports keyboard theme selection and moves focus into the dialog', async () => { const dialog = await openSettings(); const [theme] = await dialog.getAllHarnesses(MatSelectHarness); await expect .poll(() => document.querySelector('[role="dialog"]')?.contains(document.activeElement)) .toBe(true); await theme.focus(); expect(await theme.isFocused()).toBe(true); expect(document.querySelector('[role="dialog"]')?.contains(document.activeElement)).toBe( true, ); const control = await theme.host(); await control.sendKeys(TestKey.ENTER); expect(await theme.isOpen()).toBe(true); await control.sendKeys(TestKey.END, TestKey.ENTER); expect(await theme.getValueText()).toBe('Dark'); expect(document.body.classList.contains('dark-mode')).toBe(true); expect(localStorage.getItem('theme.preference')).toBe('"dark"'); }); it('follows live System appearance and keeps explicit theme selections fixed', async () => { expect(document.body.classList.contains('light-mode')).toBe(true); const dialog = await openSettings(); const [theme] = await dialog.getAllHarnesses(MatSelectHarness); expect(await dialog.getContentText()).toMatch(/Current system appearance:\s+Light/); expect(localStorage.getItem('theme.preference')).toBeNull(); changeSystemAppearance(false); expect(await theme.getValueText()).toBe('System'); expect(await dialog.getContentText()).toMatch(/Current system appearance:\s+Dark/); expect(document.body.classList.contains('dark-mode')).toBe(true); expect(document.body.classList.contains('light-mode')).toBe(false); expect(localStorage.getItem('theme.preference')).toBeNull(); await selectOption(theme, 'Light'); changeSystemAppearance(true); changeSystemAppearance(false); expect(await theme.getValueText()).toBe('Light'); expect(document.body.classList.contains('light-mode')).toBe(true); expect(document.body.classList.contains('dark-mode')).toBe(false); await selectOption(theme, 'Dark'); changeSystemAppearance(true); expect(await theme.getValueText()).toBe('Dark'); expect(document.body.classList.contains('dark-mode')).toBe(true); expect(document.body.classList.contains('light-mode')).toBe(false); await selectOption(theme, 'System'); expect(document.body.classList.contains('light-mode')).toBe(true); changeSystemAppearance(false); expect(document.body.classList.contains('dark-mode')).toBe(true); expect(localStorage.getItem('theme.preference')).toBe('"system"'); }); it.each(['Close', 'Escape'])( 'applies and saves selections immediately, retaining them after %s', async (closing) => { const dialog = await openSettings(); const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness); await selectOption(theme, 'Dark'); expect(document.body.classList.contains('dark-mode')).toBe(true); expect(localStorage.getItem('theme.preference')).toBe('"dark"'); await selectOption(language, 'Français'); expect(localStorage.getItem('app.language')).toBe('"fr"'); expect(await dialog.getTitleText()).toBe('Paramètres'); expect(await theme.getValueText()).toBe('Sombre'); expect(fixture.nativeElement.querySelector('header.app-header p').textContent).toBe( 'Faites plus de place aux vacances.', ); expect(fixture.nativeElement.querySelector('#leave-settings-title').textContent).toBe( 'Configurez vos congés', ); expect(fixture.nativeElement.querySelector('.week-number')).toBeNull(); await (await dialog.getHarness(MatSlideToggleHarness)).check(); expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true'); expect(localStorage.getItem('leaveBalances')).toBe(savedBalances); expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}'); expect(localStorage.getItem('unrelated.entry')).toBe('keep this value'); expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan( 0, ); if (closing === 'Close') { const closeButton = await dialog.getHarness(MatButtonHarness); expect(await closeButton.getText()).toBe('Fermer'); await closeButton.click(); } else { await dialog.close(); } await expect .poll(async () => (await loader.getAllHarnesses(MatDialogHarness)).length) .toBe(0); expect(document.activeElement).toBe(opener); expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull(); const reopened = await openSettings(); const [restoredTheme, restoredLanguage] = await reopened.getAllHarnesses(MatSelectHarness); expect(await restoredTheme.getValueText()).toBe('Sombre'); expect(await restoredLanguage.getValueText()).toBe('Français'); expect(await (await reopened.getHarness(MatSlideToggleHarness)).isChecked()).toBe(true); fixture.destroy(); TestBed.resetTestingModule(); await createApp(); expect(localStorage.getItem('leaveBalances')).toBe(savedBalances); expect(localStorage.getItem('vacationRules')).toBe('{"version":1,"rules":[]}'); expect(localStorage.getItem('unrelated.entry')).toBe('keep this value'); expect(document.body.classList.contains('dark-mode')).toBe(true); expect( fixture.nativeElement.querySelector('header.app-header button').textContent, ).toContain('Paramètres'); expect(fixture.nativeElement.querySelector('.week-number')).not.toBeNull(); const freshDialog = await openSettings(); const [savedTheme, savedLanguage] = await freshDialog.getAllHarnesses(MatSelectHarness); expect(await savedTheme.getValueText()).toBe('Sombre'); expect(await savedLanguage.getValueText()).toBe('Français'); expect(await (await freshDialog.getHarness(MatSlideToggleHarness)).isChecked()).toBe( true, ); }, ); });