feat: add unified Settings dialog (#18)
This commit is contained in:
1 parent
bdb1e3e230
commit
5a34160ab2
29 files changed
+420
-453
No files matched your search
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user