Files
vac-optimizer/src/app/services/user-input-service.spec.ts
T
nessarandClaude Opus 5.5 94d57add2c test: run the suite and benchmark with Bun's native test runner
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>
2026-10-17 13:40:42 +02:00

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);
});
});