feat: add unified Settings dialog (#18)

This commit is contained in:
nessar committed 2026-10-04 22:01:00 +02:00
1 parent bdb1e3e230
commit 5a34160ab2
29 files changed
+420 -453

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
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 { vi } from 'vitest';
import { App } from './app';
import { UserInputService } from './services/user-input-service';
describe('Application Settings', () => {
let fixture: ComponentFixture<App>;
let loader: HarnessLoader;
let opener: HTMLButtonElement;
let mediaQuery: EventTarget & { matches: boolean; media: string };
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('app.language', JSON.stringify('en'));
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<void> {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(UserInputService).vacationNumberSignal.set([]);
fixture = TestBed.createComponent(App);
fixture.detectChanges();
await fixture.whenStable();
loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
}
afterEach(() => {
fixture?.destroy();
vi.restoreAllMocks();
localStorage.clear();
document.body.classList.remove('light-mode', 'dark-mode');
});
async function openSettings(): Promise<MatDialogHarness> {
const root = fixture.nativeElement as HTMLElement;
opener = root.querySelector<HTMLButtonElement>(
'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<void> {
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();
}
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('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(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(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,
);
},
);
});