This commit is contained in:
1 parent
578aafbf91
commit
b0ba4227ac
5 files changed
+426
-8
No files matched your search
@@ -7,9 +7,10 @@ import { MatOptionHarness } from '@angular/material/core/testing';
|
|||||||
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||||
import { MatSelectHarness } from '@angular/material/select/testing';
|
import { MatSelectHarness } from '@angular/material/select/testing';
|
||||||
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
||||||
import { vi } from 'vitest';
|
import { type MockInstance, vi } from 'vitest';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { UserInputService } from './services/user-input-service';
|
import { CalendarService } from './services/calendar-service';
|
||||||
|
import { calculatePlanningInternal } from './services/leave-planner.core';
|
||||||
|
|
||||||
describe('Application Settings', () => {
|
describe('Application Settings', () => {
|
||||||
const savedBalances = JSON.stringify([
|
const savedBalances = JSON.stringify([
|
||||||
@@ -25,6 +26,7 @@ describe('Application Settings', () => {
|
|||||||
let loader: HarnessLoader;
|
let loader: HarnessLoader;
|
||||||
let opener: HTMLButtonElement;
|
let opener: HTMLButtonElement;
|
||||||
let mediaQuery: EventTarget & { matches: boolean; media: string };
|
let mediaQuery: EventTarget & { matches: boolean; media: string };
|
||||||
|
let calculate: MockInstance<CalendarService['createPlanningCalculation']>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -50,7 +52,7 @@ describe('Application Settings', () => {
|
|||||||
imports: [App],
|
imports: [App],
|
||||||
providers: [provideZonelessChangeDetection()],
|
providers: [provideZonelessChangeDetection()],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
TestBed.inject(UserInputService).vacationNumberSignal.set([]);
|
calculate = vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation');
|
||||||
fixture = TestBed.createComponent(App);
|
fixture = TestBed.createComponent(App);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -93,6 +95,121 @@ describe('Application Settings', () => {
|
|||||||
fixture.detectChanges();
|
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('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 () => {
|
it('opens one labelled Settings dialog from the header and keeps planner inputs in place', async () => {
|
||||||
const dialog = await openSettings();
|
const dialog = await openSettings();
|
||||||
expect(await loader.getAllHarnesses(MatDialogHarness)).toHaveLength(1);
|
expect(await loader.getAllHarnesses(MatDialogHarness)).toHaveLength(1);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core';
|
|||||||
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
|
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
|
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
|
||||||
@@ -459,8 +460,10 @@ export class CalendarYear {
|
|||||||
type: balance.type,
|
type: balance.type,
|
||||||
quantity: balance.numberOfDays,
|
quantity: balance.numberOfDays,
|
||||||
label: this.translationService.getDayTypeLabel(balance.type),
|
label: this.translationService.getDayTypeLabel(balance.type),
|
||||||
availableFrom: balance.availableDate,
|
availableFrom: balance.availableDate
|
||||||
expiryDate: balance.expiryDate,
|
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||||
})),
|
})),
|
||||||
rules: currentRules,
|
rules: currentRules,
|
||||||
labels: Object.fromEntries(
|
labels: Object.fromEntries(
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ describe('UserInput', () => {
|
|||||||
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002')
|
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002')
|
||||||
.mockReturnValueOnce('00000000-0000-4000-8000-000000000003')
|
.mockReturnValueOnce('00000000-0000-4000-8000-000000000003')
|
||||||
.mockReturnValueOnce('00000000-0000-4000-8000-000000000004');
|
.mockReturnValueOnce('00000000-0000-4000-8000-000000000004');
|
||||||
|
await createInput();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createInput(): Promise<void> {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [UserInput, UserSettings],
|
imports: [UserInput, UserSettings],
|
||||||
providers: [provideZonelessChangeDetection()],
|
providers: [provideZonelessChangeDetection()],
|
||||||
@@ -36,7 +40,13 @@ describe('UserInput', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
userInputService = TestBed.inject(UserInputService);
|
userInputService = TestBed.inject(UserInputService);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
}
|
||||||
|
|
||||||
|
async function revisit(): Promise<void> {
|
||||||
|
fixture.destroy();
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
await createInput();
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
@@ -91,6 +101,93 @@ describe('UserInput', () => {
|
|||||||
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
|
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', () => {
|
it('saves the complete ordered balance list on the first quantity edit', () => {
|
||||||
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||||
editInput('app-day-input input[type="number"]', '7');
|
editInput('app-day-input input[type="number"]', '7');
|
||||||
|
|||||||
@@ -5,16 +5,27 @@ import { SelectableDayType, VacDay } from './calendar-service';
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
describe('UserInputService', () => {
|
describe('UserInputService', () => {
|
||||||
|
const savedBalance = {
|
||||||
|
id: 'saved-cp',
|
||||||
|
type: 'cp',
|
||||||
|
numberOfDays: 3,
|
||||||
|
availableDate: '2026-01-01',
|
||||||
|
expiryDate: '2026-05-31',
|
||||||
|
};
|
||||||
let service: UserInputService;
|
let service: UserInputService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideZonelessChangeDetection()],
|
providers: [provideZonelessChangeDetection()],
|
||||||
});
|
});
|
||||||
service = TestBed.inject(UserInputService);
|
service = TestBed.inject(UserInputService);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
expect(service).toBeTruthy();
|
expect(service).toBeTruthy();
|
||||||
@@ -37,6 +48,134 @@ describe('UserInputService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores the saved balance order, identities, quantities, types, and calendar dates', () => {
|
||||||
|
const saved = JSON.stringify([
|
||||||
|
{
|
||||||
|
id: 'saved-other',
|
||||||
|
type: 'other',
|
||||||
|
numberOfDays: 41,
|
||||||
|
availableDate: '2026-03-29',
|
||||||
|
expiryDate: '2026-10-25',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expired-cp',
|
||||||
|
type: 'cp',
|
||||||
|
numberOfDays: 5,
|
||||||
|
availableDate: '2024-02-29',
|
||||||
|
expiryDate: '2024-05-31',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empty-rtt',
|
||||||
|
type: 'rtt',
|
||||||
|
numberOfDays: 0,
|
||||||
|
availableDate: '2027-01-01',
|
||||||
|
expiryDate: '2027-12-31',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
localStorage.setItem('leaveBalances', saved);
|
||||||
|
|
||||||
|
expect(new UserInputService().vacationNumberSignal()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'saved-other',
|
||||||
|
type: SelectableDayType.OTHER,
|
||||||
|
numberOfDays: 41,
|
||||||
|
availableDate: new Date(2026, 2, 29),
|
||||||
|
expiryDate: new Date(2026, 9, 25),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expired-cp',
|
||||||
|
type: SelectableDayType.CP,
|
||||||
|
numberOfDays: 5,
|
||||||
|
availableDate: new Date(2024, 1, 29),
|
||||||
|
expiryDate: new Date(2024, 4, 31),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empty-rtt',
|
||||||
|
type: SelectableDayType.RTT,
|
||||||
|
numberOfDays: 0,
|
||||||
|
availableDate: new Date(2027, 0, 1),
|
||||||
|
expiryDate: new Date(2027, 11, 31),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(localStorage.getItem('leaveBalances')).toBe(saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a saved empty list without generating or saving replacement balances', () => {
|
||||||
|
localStorage.setItem('leaveBalances', '[]');
|
||||||
|
expect(new UserInputService().vacationNumberSignal()).toEqual([]);
|
||||||
|
expect(localStorage.getItem('leaveBalances')).toBe('[]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves an omitted availability date from the supported save format', () => {
|
||||||
|
const saved = JSON.stringify([{ ...savedBalance, availableDate: undefined }]);
|
||||||
|
localStorage.setItem('leaveBalances', saved);
|
||||||
|
expect(new UserInputService().vacationNumberSignal()).toEqual([
|
||||||
|
{
|
||||||
|
id: 'saved-cp',
|
||||||
|
type: SelectableDayType.CP,
|
||||||
|
numberOfDays: 3,
|
||||||
|
availableDate: undefined,
|
||||||
|
expiryDate: new Date(2026, 4, 31),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(localStorage.getItem('leaveBalances')).toBe(saved);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['empty storage value', ''],
|
||||||
|
['malformed JSON', '{'],
|
||||||
|
['non-list', '{}'],
|
||||||
|
['null list', 'null'],
|
||||||
|
['null record', '[null]'],
|
||||||
|
['missing identity', JSON.stringify([{ ...savedBalance, id: undefined }])],
|
||||||
|
['unreadable quantity', JSON.stringify([{ ...savedBalance, numberOfDays: '3' }])],
|
||||||
|
['invalid availability', JSON.stringify([{ ...savedBalance, availableDate: null }])],
|
||||||
|
['missing expiry', JSON.stringify([{ ...savedBalance, expiryDate: undefined }])],
|
||||||
|
['rolled-over date', JSON.stringify([{ ...savedBalance, expiryDate: '2026-02-30' }])],
|
||||||
|
[
|
||||||
|
'mixed supported and unsupported records',
|
||||||
|
JSON.stringify([savedBalance, { ...savedBalance, type: 'any' }]),
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
'deletes only unreadable balance data (%s) before using unsaved random defaults',
|
||||||
|
(_, saved) => {
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||||
|
const unrelated = {
|
||||||
|
'theme.preference': '"dark"',
|
||||||
|
'app.language': '"fr"',
|
||||||
|
'calendarSettings.showWeekNumbers': 'true',
|
||||||
|
vacationRules: '[]',
|
||||||
|
'unrelated.entry': 'keep this value',
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(unrelated)) localStorage.setItem(key, value);
|
||||||
|
localStorage.setItem('leaveBalances', saved);
|
||||||
|
|
||||||
|
const balances = new UserInputService().vacationNumberSignal();
|
||||||
|
expect(balances.map((balance) => [balance.type, balance.numberOfDays])).toEqual([
|
||||||
|
[SelectableDayType.CP, 15],
|
||||||
|
[SelectableDayType.RTT, 15],
|
||||||
|
[SelectableDayType.OTHER, 15],
|
||||||
|
]);
|
||||||
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||||
|
for (const [key, value] of Object.entries(unrelated))
|
||||||
|
expect(localStorage.getItem(key)).toBe(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('keeps saved data intact when browser storage temporarily cannot be read', () => {
|
||||||
|
const saved = JSON.stringify([savedBalance]);
|
||||||
|
localStorage.setItem('leaveBalances', saved);
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new DOMException('Storage is unavailable', 'SecurityError');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(new UserInputService().vacationNumberSignal()).toHaveLength(3);
|
||||||
|
read.mockRestore();
|
||||||
|
expect(localStorage.getItem('leaveBalances')).toBe(saved);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[0, 10],
|
[0, 10],
|
||||||
[1 - Number.EPSILON, 20],
|
[1 - Number.EPSILON, 20],
|
||||||
@@ -48,6 +187,12 @@ describe('UserInputService', () => {
|
|||||||
expected,
|
expected,
|
||||||
expected,
|
expected,
|
||||||
]);
|
]);
|
||||||
|
expect(initialBalances.map((balance) => balance.type)).toEqual([
|
||||||
|
SelectableDayType.CP,
|
||||||
|
SelectableDayType.RTT,
|
||||||
|
SelectableDayType.OTHER,
|
||||||
|
]);
|
||||||
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update vacation number signal', () => {
|
it('should update vacation number signal', () => {
|
||||||
|
|||||||
@@ -6,6 +6,60 @@ import dayjs from 'dayjs';
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class UserInputService {
|
export class UserInputService {
|
||||||
|
private restoreDate(value: unknown): Date {
|
||||||
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
const date = new Date(`${value}T00:00:00`);
|
||||||
|
if (dayjs(date).format('YYYY-MM-DD') === value) return date;
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported saved balance date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSavedValues(): VacDay[] | undefined {
|
||||||
|
let stored: string | null = null;
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
stored = localStorage.getItem('leaveBalances');
|
||||||
|
if (stored === null) return;
|
||||||
|
const saved: unknown = JSON.parse(stored);
|
||||||
|
if (!Array.isArray(saved)) throw new Error('Saved leave balances must be a list.');
|
||||||
|
return saved.map((value: unknown) => {
|
||||||
|
if (typeof value !== 'object' || value === null)
|
||||||
|
throw new Error('Unsupported saved balance record.');
|
||||||
|
const { id, type, numberOfDays, availableDate, expiryDate } = value as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
if (
|
||||||
|
typeof id !== 'string' ||
|
||||||
|
typeof numberOfDays !== 'number' ||
|
||||||
|
!Number.isFinite(numberOfDays) ||
|
||||||
|
(type !== SelectableDayType.CP &&
|
||||||
|
type !== SelectableDayType.RTT &&
|
||||||
|
type !== SelectableDayType.OTHER)
|
||||||
|
)
|
||||||
|
throw new Error('Unsupported saved balance record.');
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
numberOfDays,
|
||||||
|
availableDate:
|
||||||
|
availableDate === undefined ? undefined : this.restoreDate(availableDate),
|
||||||
|
expiryDate: this.restoreDate(expiryDate),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to restore leave balances from localStorage.', error);
|
||||||
|
if (stored !== null) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('leaveBalances');
|
||||||
|
} catch {
|
||||||
|
// Keep the random fallback usable when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private getRandomNumber(): number {
|
private getRandomNumber(): number {
|
||||||
return Math.floor(Math.random() * 11) + 10;
|
return Math.floor(Math.random() * 11) + 10;
|
||||||
}
|
}
|
||||||
@@ -48,7 +102,9 @@ export class UserInputService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly vacationNumberSignal = signal<VacDay[]>(this.getInitialValues());
|
readonly vacationNumberSignal = signal<VacDay[]>(
|
||||||
|
this.getSavedValues() ?? this.getInitialValues(),
|
||||||
|
);
|
||||||
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
||||||
readonly computeTriggerSignal = signal(0);
|
readonly computeTriggerSignal = signal(0);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user