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>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
|
import { provideZonelessChangeDetection } from '@angular/core';
|
|
import { UserInputService } from './user-input-service';
|
|
import { SelectableDayType, VacDay } from './calendar-service';
|
|
import { vi } from '../../testing/vitest-compat';
|
|
|
|
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();
|
|
localStorage.clear();
|
|
});
|
|
|
|
it('should be created', () => {
|
|
expect(service).toBeTruthy();
|
|
});
|
|
|
|
it('adds, removes and replaces saved declarations without changing balances or calculating', () => {
|
|
const balances = service.vacationNumberSignal();
|
|
expect(service.declaredDaysSignal()).toEqual([]);
|
|
service.toggleDeclaredDay('2024-01-02', 'cp');
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
|
{ date: '2024-01-02', type: 'cp' },
|
|
]);
|
|
service.toggleDeclaredDay('2024-01-02', 'cp');
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([]);
|
|
service.toggleDeclaredDay('2024-01-02', 'rtt');
|
|
service.toggleDeclaredDay('2024-01-02', 'other');
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
|
{ date: '2024-01-02', type: 'other' },
|
|
]);
|
|
expect(service.vacationNumberSignal()).toBe(balances);
|
|
expect(localStorage.getItem('leaveBalances')).toBeNull();
|
|
});
|
|
|
|
it('rejects weekends, French national holidays and malformed calendar dates', () => {
|
|
for (const date of [
|
|
'2024-01-06',
|
|
'2024-01-07',
|
|
'2024-05-08',
|
|
'2024-05-09',
|
|
'2024-02-30',
|
|
'not-a-date',
|
|
]) {
|
|
service.toggleDeclaredDay(date, 'cp');
|
|
}
|
|
expect(service.declaredDaysSignal()).toEqual([]);
|
|
expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull();
|
|
service.toggleDeclaredDay('2024-05-10', 'other');
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
|
{ date: '2024-05-10', type: 'other' },
|
|
]);
|
|
});
|
|
|
|
it('toggles each date in a batch once and preserves unrelated declarations', () => {
|
|
service.toggleDeclaredDay('2024-04-19', 'other');
|
|
service.toggleDeclaredDay('2024-04-25', 'rtt');
|
|
const balances = service.vacationNumberSignal();
|
|
const save = vi.spyOn(Storage.prototype, 'setItem');
|
|
service.toggleDeclaredDays(
|
|
['2024-04-22', '2024-04-23', '2024-04-24', '2024-04-25', '2024-04-26'],
|
|
'cp',
|
|
);
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
|
{ date: '2024-04-19', type: 'other' },
|
|
{ date: '2024-04-22', type: 'cp' },
|
|
{ date: '2024-04-23', type: 'cp' },
|
|
{ date: '2024-04-24', type: 'cp' },
|
|
{ date: '2024-04-25', type: 'cp' },
|
|
{ date: '2024-04-26', type: 'cp' },
|
|
]);
|
|
expect(save).toHaveBeenCalledOnce();
|
|
service.toggleDeclaredDays(
|
|
[
|
|
'2024-04-25',
|
|
'2024-04-26',
|
|
'2024-04-27',
|
|
'2024-04-28',
|
|
'2024-04-29',
|
|
'2024-04-30',
|
|
'2024-05-01',
|
|
'2024-04-29',
|
|
'invalid',
|
|
],
|
|
'cp',
|
|
);
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
|
{ date: '2024-04-19', type: 'other' },
|
|
{ date: '2024-04-22', type: 'cp' },
|
|
{ date: '2024-04-23', type: 'cp' },
|
|
{ date: '2024-04-24', type: 'cp' },
|
|
{ date: '2024-04-29', type: 'cp' },
|
|
{ date: '2024-04-30', type: 'cp' },
|
|
]);
|
|
expect(save).toHaveBeenCalledTimes(2);
|
|
service.toggleDeclaredDays(['2024-04-29', '2024-04-30'], null);
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([
|
|
{ date: '2024-04-19', type: 'other' },
|
|
{ date: '2024-04-22', type: 'cp' },
|
|
{ date: '2024-04-23', type: 'cp' },
|
|
{ date: '2024-04-24', type: 'cp' },
|
|
]);
|
|
expect(save).toHaveBeenCalledTimes(3);
|
|
expect(service.vacationNumberSignal()).toBe(balances);
|
|
});
|
|
|
|
it('restores an empty list when saved declarations are unreadable', () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
for (const saved of [
|
|
'{',
|
|
'null',
|
|
'[{"date":"2024-05-08","type":"cp"}]',
|
|
'[{"date":"2024-01-02","type":"any"}]',
|
|
]) {
|
|
localStorage.setItem('declaredNonWorkingDays', saved);
|
|
expect(new UserInputService().declaredDaysSignal()).toEqual([]);
|
|
expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull();
|
|
}
|
|
expect(warn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should initialize with vacation days array', () => {
|
|
const vacations = service.vacationNumberSignal();
|
|
expect(Array.isArray(vacations)).toBe(true);
|
|
expect(vacations.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should have vacation days with valid properties', () => {
|
|
const vacations = service.vacationNumberSignal();
|
|
vacations.forEach((day) => {
|
|
expect(day.id).toBeDefined();
|
|
expect(day.type).toBeDefined();
|
|
expect(day.numberOfDays).toBeGreaterThanOrEqual(10);
|
|
expect(day.numberOfDays).toBeLessThanOrEqual(20);
|
|
expect(day.expiryDate).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
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],
|
|
])('starts every balance at %s random draw with %s days', (draw, expected) => {
|
|
vi.spyOn(Math, 'random').mockReturnValue(draw);
|
|
const initialBalances = new UserInputService().vacationNumberSignal();
|
|
expect(initialBalances.map((balance) => balance.numberOfDays)).toEqual([
|
|
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', () => {
|
|
const newVacations: VacDay[] = [
|
|
{
|
|
id: 'test-id',
|
|
type: SelectableDayType.CP,
|
|
numberOfDays: 5,
|
|
expiryDate: new Date(),
|
|
},
|
|
];
|
|
service.vacationNumberSignal.set(newVacations);
|
|
expect(service.vacationNumberSignal()).toEqual(newVacations);
|
|
});
|
|
|
|
it('should create a new vacation day', () => {
|
|
const newDay = service.createVacationDay();
|
|
expect(newDay.id).toBeDefined();
|
|
expect(newDay.type).toBe(SelectableDayType.CP);
|
|
expect(newDay.numberOfDays).toBe(1);
|
|
expect(newDay.expiryDate).toBeInstanceOf(Date);
|
|
});
|
|
});
|