Files
vac-optimizer/src/app/components/user-input/user-input.spec.ts
T
nessar d9d9befa12
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
refactor(planning): remove the original decision trace
2026-10-15 20:42:33 +02:00

559 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createOptimizationPresetRules } from '../../services/rule-service';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatOptionHarness } from '@angular/material/core/testing';
import { MatDialog } from '@angular/material/dialog';
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 { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
import { UserSettings } from '../user-settings/user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
const presets = createOptimizationPresetRules();
describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture<UserInput>;
let userInputService: UserInputService;
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('app.language', '"en"');
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 9, 4, 12));
vi.spyOn(Math, 'random').mockReturnValue(0.5);
vi.spyOn(crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000003')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000004');
await createInput();
});
async function createInput(): Promise<void> {
await TestBed.configureTestingModule({
imports: [UserInput, UserSettings],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
fixture = TestBed.createComponent(UserInput);
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
fixture.detectChanges();
await fixture.whenStable();
}
async function revisit(): Promise<void> {
fixture.destroy();
TestBed.resetTestingModule();
await createInput();
}
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
vi.restoreAllMocks();
vi.useRealTimers();
localStorage.clear();
document.body.classList.remove('light-mode', 'dark-mode');
});
function editInput(selector: string, value: string): void {
const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event('input'));
input.dispatchEvent(new Event('change'));
fixture.detectChanges();
}
async function selectOption(select: MatSelectHarness, text: string): Promise<void> {
await select.open();
const loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
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}`);
}
it('should create', () => {
expect(component).toBeTruthy();
});
async function calculate(request: PlanningRequest): Promise<void> {
const browserWindow = globalThis.window;
let calculation: ReturnType<typeof createPlanningCalculation>;
try {
vi.stubGlobal('window', undefined);
calculation = createPlanningCalculation(request);
} finally {
vi.stubGlobal('window', browserWindow);
}
const outcome = await calculation.outcome;
userInputService.planningSnapshotSignal.set(calculation.snapshot);
userInputService.planningOutcomeSignal.set(outcome);
fixture.detectChanges();
}
it('explains availability using the calculated inclusive last date, independently of unsubmitted edits', async () => {
TestBed.inject(TranslationService).setLanguage('fr');
await calculate({
horizon: { start: '2027-05-01', end: '2027-05-10' },
balances: [
{
id: 'other',
type: 'other',
quantity: 1,
availableFrom: '2027-05-10',
expiryDate: '2028-05-10',
},
],
rules: [],
});
const root = fixture.nativeElement as HTMLElement;
const card = root.querySelector<HTMLElement>('.warning-card')!;
expect(card.textContent).toContain('Disponible seulement à partir du 10 mai 2027');
expect(card.textContent).toContain('la dernière date du planning, le 9 mai 2027');
expect(card.textContent).toContain('prolonger la période planifiée');
expect(card.textContent).not.toContain('manuellement');
const explanation = card.textContent;
TestBed.inject(CalendarSettingsService).setPlanningDates('2027-01-01', '2028-12-31');
component.updateDay(0, { ...userInputService.vacationNumberSignal()[0], numberOfDays: 0 });
fixture.detectChanges();
expect(card.textContent).toBe(explanation);
});
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
TestBed.inject(TranslationService).setLanguage('fr');
await calculate({
horizon: { start: '2024-06-04', end: '2024-06-13' },
balances: [
{
id: 'other',
type: 'other',
quantity: 1,
availableFrom: '2024-06-04',
expiryDate: '2025-05-10',
},
],
declaredDays: [{ date: '2024-06-07', type: 'cp' }],
rules: [presets[3]],
});
const root = fixture.nativeElement as HTMLElement;
const card = root.querySelector<HTMLElement>('.warning-card')!;
expect(card.classList.contains('is-informational')).toBe(true);
expect(card.textContent).toContain('Conservé pour plus tard');
expect(card.textContent).toContain('Aucune action nécessaire');
expect(card.textContent).not.toContain('Disponible seulement');
expect(root.querySelector('.remaining-history')).toBeNull();
const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
expect(card.querySelector('details')).toBeNull();
expect(card.textContent).not.toContain('trace');
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
const plan = userInputService.displayedPlanSignal()!;
userInputService.planningOutcomeSignal.set({
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
fixture.detectChanges();
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
expect(card.textContent).not.toContain('Conservé pour plus tard');
expect(root.querySelector('.remaining-history')).toBeNull();
expect(card.querySelector('details')).toBeNull();
});
it('describes Calculate with an indicative range that follows unsubmitted edits', () => {
const root = fixture.nativeElement as HTMLElement;
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
const estimateText = () =>
root.querySelector('#planning-duration-estimate')?.textContent?.trim();
expect(estimateText()).toMatch(/Approximate wait: \d+–\d+ (s|min)/);
expect(calculate.getAttribute('aria-describedby')).toBe('planning-duration-estimate');
const initial = estimateText();
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
fixture.detectChanges();
expect(estimateText()).not.toBe(initial);
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2024-03-08');
TestBed.inject(TranslationService).setLanguage('fr');
await fixture.whenStable();
const toggles = fixture.nativeElement.querySelectorAll(
'.planning-dates mat-datepicker-toggle button',
);
expect(toggles).toHaveLength(2);
for (const [index, day] of ['27', '9'].entries()) {
toggles[index].click();
await fixture.whenStable();
const calendar = document.querySelector<HTMLElement>('mat-calendar')!;
if (index === 0)
expect(
calendar
.querySelector('.mat-calendar-period-button')
?.textContent?.toLowerCase(),
).toContain('févr');
Array.from(calendar.querySelectorAll<HTMLButtonElement>('.mat-calendar-body-cell'))
.find((cell) => cell.textContent?.trim() === day)!
.click();
await fixture.whenStable();
await expect.poll(() => document.querySelector('mat-calendar')).toBeNull();
}
expect(settings.planningHorizon()).toEqual({ start: '2024-02-27', end: '2024-03-10' });
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-27',
lastPlanningDate: '2024-03-09',
});
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-03-01', '2024-03-01');
await fixture.whenStable();
editInput('input[name="lastPlanningDate"]', '29/02/2024');
const root = fixture.nativeElement as HTMLElement;
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
expect(calculate.disabled).toBe(true);
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(userInputService.computeTriggerSignal()).toBe(1);
});
it.each(['', '30/02/2024', 'not-a-date', '2'])(
'reports an invalid planning date without saving it: %s',
async (value) => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2027-02-28');
await fixture.whenStable();
editInput('input[name="planningStart"]', value);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('input[name="planningStart"]').value).toBe(
value,
);
expect(
fixture.nativeElement.querySelector('#planning-dates-error')?.textContent,
).toContain('two valid calendar dates');
expect(settings.planningHorizon()).toBeNull();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2027-02-28',
});
},
);
it('edits inclusive dates with inline validation while preserving the saved planning horizon', async () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-02-29', '2027-02-28');
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
expect(start.value).toBe('29/02/2024');
expect(last.value).toBe('28/02/2027');
expect(root.textContent).toContain('Last planning date (inclusive)');
editInput('input[name="lastPlanningDate"]', '28/02/2024');
expect(root.querySelector('#planning-dates-error')?.textContent).toContain('on or after');
expect(last.getAttribute('aria-invalid')).toBe('true');
expect(last.getAttribute('aria-describedby')).toContain('planning-dates-error');
expect(settings.planningHorizon()).toBeNull();
editInput('input[name="lastPlanningDate"]', '29/02/2024');
expect(root.querySelector('#planning-dates-error')).toBeNull();
expect(last.getAttribute('aria-invalid')).toBe('false');
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
expect(userInputService.computeTriggerSignal()).toBe(0);
});
it('should initialize with vacation days from service', () => {
const vacationDays = userInputService.vacationNumberSignal();
expect(vacationDays).toBeDefined();
expect(Array.isArray(vacationDays)).toBe(true);
expect(vacationDays.length).toBeGreaterThan(0);
});
it('should add a new vacation day', () => {
const initialLength = userInputService.vacationNumberSignal().length;
component.addDay();
expect(userInputService.vacationNumberSignal().length).toBe(initialLength + 1);
});
it('should remove a vacation day', () => {
const initialLength = userInputService.vacationNumberSignal().length;
component.removeFromList(0);
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toEqual([
{
id: '00000000-0000-4000-8000-000000000001',
type: 'cp',
numberOfDays: 7,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
{
id: '00000000-0000-4000-8000-000000000002',
type: 'rtt',
numberOfDays: 15,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
{
id: '00000000-0000-4000-8000-000000000003',
type: 'other',
numberOfDays: 15,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
]);
});
it('saves a leave-type selection as the first balance edit', async () => {
const loader = TestbedHarnessEnvironment.loader(fixture);
const type = await loader.getHarness(
MatSelectHarness.with({ selector: 'app-day-input mat-select' }),
);
await selectOption(type, 'RTT');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15, type: 'rtt' },
{ id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15, type: 'rtt' },
{ id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15, type: 'other' },
]);
});
it.each([
[
'availability',
1,
'29/03/2026',
{ availableDate: '2026-03-29', expiryDate: '2027-10-04' },
],
['expiry', 2, '25/10/2026', { availableDate: '2026-10-04', expiryDate: '2026-10-25' }],
])('saves an %s date edit without shifting its calendar date', (_, field, value, dates) => {
editInput(`app-day-input .date-fields mat-form-field:nth-child(${field}) input`, value);
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', ...dates },
{ id: '00000000-0000-4000-8000-000000000002' },
{ id: '00000000-0000-4000-8000-000000000003' },
]);
});
it('saves adding a balance as the first edit and keeps saving subsequent edits', () => {
fixture.nativeElement.querySelector('.add-day-button').click();
fixture.detectChanges();
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 },
{ id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 },
{ id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15 },
{
id: '00000000-0000-4000-8000-000000000004',
type: 'cp',
numberOfDays: 1,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
]);
editInput('app-day-input input[type="number"]', '8');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8);
editInput('app-day-input input[type="number"]', '9');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(9);
});
it('saves removing a balance as the first edit and retains an entry after the final deletion', () => {
fixture.nativeElement.querySelectorAll('.delete-button')[2].click();
fixture.detectChanges();
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 },
{ id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 },
]);
fixture.nativeElement.querySelectorAll('.delete-button')[1].click();
fixture.detectChanges();
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toHaveLength(1);
fixture.nativeElement.querySelector('.delete-button').click();
fixture.detectChanges();
expect(userInputService.vacationNumberSignal()).toEqual([]);
expect(localStorage.getItem('leaveBalances')).toBe('[]');
});
it('keeps untouched random balances unsaved through settings, rule changes, and calculation', async () => {
const initialBalances = userInputService.vacationNumberSignal();
expect(localStorage.getItem('leaveBalances')).toBeNull();
const loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
const dialogRef = TestBed.inject(MatDialog).open(UserSettings);
const dialog = await loader.getHarness(MatDialogHarness);
expect(localStorage.getItem('leaveBalances')).toBeNull();
const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness);
await selectOption(theme, 'Dark');
await selectOption(language, 'Français');
await (await dialog.getHarness(MatSlideToggleHarness)).check();
expect(localStorage.getItem('theme.preference')).toBe('"dark"');
expect(localStorage.getItem('app.language')).toBe('"fr"');
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
dialogRef.close();
const rule = await loader.getHarness(
MatSlideToggleHarness.with({ selector: 'app-rules-panel mat-slide-toggle' }),
);
await rule.uncheck();
await expect.poll(() => localStorage.getItem('vacationRules')).not.toBeNull();
expect(JSON.parse(localStorage.getItem('vacationRules')!).rules[0].enabled).toBe(false);
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
it('preserves unrelated stored preferences, rules, and other values when editing balances', () => {
const unrelated = {
'theme.preference': '"system"',
'app.language': '"en"',
'calendarSettings.showWeekNumbers': 'true',
vacationRules: '[]',
'unrelated.entry': 'keep this value',
};
for (const [key, value] of Object.entries(unrelated)) localStorage.setItem(key, value);
editInput('app-day-input input[type="number"]', '7');
for (const [key, value] of Object.entries(unrelated)) {
expect(localStorage.getItem(key)).toBe(value);
}
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(7);
});
it('keeps balance edits usable when storage fails and saves the next edit when it recovers', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('Storage is unavailable', 'SecurityError');
});
editInput('app-day-input input[type="number"]', '7');
expect(userInputService.vacationNumberSignal()[0].numberOfDays).toBe(7);
expect(localStorage.getItem('leaveBalances')).toBeNull();
write.mockRestore();
editInput('app-day-input input[type="number"]', '8');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8);
});
});