Files
vac-optimizer/src/app/app-settings.spec.ts
T
nessar 6811142299 feat(planning): make optimization rules composable
Evaluate explicit, ordered optimization configurations through one shared
planner model. Add six editable presets, durable one-time legacy migration,
and consistent completed and cancelled plan provenance.

Include functional regressions and the agreed trace-removal and benchmark
follow-up specs. Performance acceptance remains in #78 under unchanged gates.

Refs: #70, #71, #72, #73, #74, #75, #76, #77, #78
2026-10-10 16:07:34 +02:00

510 lines
20 KiB
TypeScript

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 'vitest';
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<App>;
let loader: HarnessLoader;
let opener: HTMLButtonElement;
let mediaQuery: EventTarget & { matches: boolean; media: string };
let calculate: MockInstance<CalendarService['createPlanningCalculation']>;
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<void> {
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<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();
}
async function revisitApp(isLight: boolean): Promise<void> {
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<HTMLInputElement>('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<HTMLButtonElement>('.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<HTMLInputElement>(
'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<HTMLInputElement>('app-day-input input[type="number"]')?.value,
).toBe('3');
expect(root.querySelectorAll<HTMLInputElement>('.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<HTMLElement>('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<HTMLInputElement>('input[name="planningStart"]')!;
const last = period.querySelector<HTMLInputElement>('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<HTMLButtonElement>('.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,
);
},
);
});