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
559 lines
22 KiB
TypeScript
559 lines
22 KiB
TypeScript
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);
|
||
});
|
||
});
|