This commit is contained in:
nessar committed 2026-10-04 23:19:21 +02:00
1 parent 578aafbf91
commit b0ba4227ac
5 files changed
+426 -8

No files matched your search

+146 -1
View File
@@ -5,16 +5,27 @@ import { SelectableDayType, VacDay } from './calendar-service';
import { vi } from 'vitest';
describe('UserInputService', () => {
const savedBalance = {
id: 'saved-cp',
type: 'cp',
numberOfDays: 3,
availableDate: '2026-01-01',
expiryDate: '2026-05-31',
};
let service: UserInputService;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(UserInputService);
});
afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
it('should be created', () => {
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([
[0, 10],
[1 - Number.EPSILON, 20],
@@ -48,6 +187,12 @@ describe('UserInputService', () => {
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', () => {
+57 -1
View File
@@ -6,6 +6,60 @@ import dayjs from 'dayjs';
providedIn: 'root',
})
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 {
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 computeTriggerSignal = signal(0);