This commit is contained in:
1 parent
578aafbf91
commit
b0ba4227ac
5 files changed
+426
-8
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user