Files
vac-optimizer/src/app/components/user-input/user-input.spec.ts
T
nessarandClaude Opus 5.5 94d57add2c test: run the suite and benchmark with Bun's native test runner
Replace Vitest with bun test across the whole suite. A preload compiles
components through Angular's JIT transform with inlined templates, sets
up jsdom, TestBed and zoneless change detection, and a small shim covers
the Vitest APIs bun:test lacks. Files run isolated, real-worker specs
spawn the running Bun, and line coverage from lcov is converted to the
existing HTML and Cobertura reports. The CI benchmark replaces its heap
cap with a container memory limit. Vitest, Jest, Nx and their Vite
configuration are removed.

Refs #291

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:40:42 +02:00

608 lines
23 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 { type MockInstance, vi } from '../../../testing/vitest-compat';
import { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
import { CalendarService } from '../../services/calendar-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { UserSettings } from '../user-settings/user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import {
createPlanningCalculation,
type PlanningCalculationContext,
type PlanningOutcome,
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;
let createCalculation: MockInstance<CalendarService['createPlanningCalculation']>;
let calculations: PlanningCalculationService;
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);
calculations = TestBed.inject(PlanningCalculationService);
// Unless a test supplies a result, a started calculation stays pending.
createCalculation = vi
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
.mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never);
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: PlanningCalculationContext;
try {
vi.stubGlobal('window', undefined);
calculation = createPlanningCalculation(request);
} finally {
vi.stubGlobal('window', browserWindow);
}
await show(calculation);
}
async function show(
calculation: PlanningCalculationContext,
outcome?: PlanningOutcome,
): Promise<void> {
const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation;
createCalculation.mockReturnValueOnce(shown);
calculations.start();
await shown.outcome;
await Promise.resolve();
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('clears the remaining-days summary when a valid plan is replaced by a conflict', async () => {
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;
expect(calculations.displayedPlan()).toBeDefined();
expect(root.querySelector('.warning-card')).not.toBeNull();
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningConflict',
reasons: [],
conflictingRuleIds: ['rule'],
});
expect(calculations.displayedPlan()).toBeUndefined();
expect(root.querySelector('.warning-card')).toBeNull();
});
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(calculations.outcome());
expect(card.querySelector('details')).toBeNull();
expect(card.textContent).not.toContain('trace');
expect(JSON.stringify(calculations.outcome())).toBe(outcome);
const plan = calculations.displayedPlan()!;
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
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(createCalculation).not.toHaveBeenCalled();
});
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(createCalculation).not.toHaveBeenCalled();
});
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(createCalculation).not.toHaveBeenCalled();
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(createCalculation).toHaveBeenCalledTimes(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(createCalculation).not.toHaveBeenCalled();
});
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(createCalculation).toHaveBeenCalledTimes(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);
});
});