This commit is contained in:
nessar committed 2026-10-04 22:48:37 +02:00
1 parent 5a34160ab2
commit 578aafbf91
4 files changed
+256 -6

No files matched your search

+18
View File
@@ -12,6 +12,15 @@ import { App } from './app';
import { UserInputService } from './services/user-input-service';
describe('Application Settings', () => {
const savedBalances = JSON.stringify([
{
id: 'saved-balance',
type: 'cp',
numberOfDays: 3,
availableDate: '2026-01-01',
expiryDate: '2026-05-31',
},
]);
let fixture: ComponentFixture<App>;
let loader: HarnessLoader;
let opener: HTMLButtonElement;
@@ -20,6 +29,9 @@ describe('Application Settings', () => {
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('app.language', JSON.stringify('en'));
localStorage.setItem('leaveBalances', savedBalances);
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('unrelated.entry', 'keep this value');
mediaQuery = Object.assign(new EventTarget(), {
matches: true,
media: '(prefers-color-scheme: light)',
@@ -178,6 +190,9 @@ describe('Application Settings', () => {
expect(fixture.nativeElement.querySelector('.week-number')).toBeNull();
await (await dialog.getHarness(MatSlideToggleHarness)).check();
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
expect(localStorage.getItem('vacationRules')).toBe('[]');
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
0,
);
@@ -204,6 +219,9 @@ describe('Application Settings', () => {
fixture.destroy();
TestBed.resetTestingModule();
await createApp();
expect(localStorage.getItem('leaveBalances')).toBe(savedBalances);
expect(localStorage.getItem('vacationRules')).toBe('[]');
expect(localStorage.getItem('unrelated.entry')).toBe('keep this value');
expect(document.body.classList.contains('dark-mode')).toBe(true);
expect(
fixture.nativeElement.querySelector('header.app-header button').textContent,
@@ -1,7 +1,15 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatOptionHarness } from '@angular/material/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { vi } from 'vitest';
import { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
import { UserSettings } from '../user-settings/user-settings';
describe('UserInput', () => {
let component: UserInput;
@@ -9,8 +17,18 @@ describe('UserInput', () => {
let userInputService: UserInputService;
beforeEach(async () => {
localStorage.clear();
localStorage.setItem('app.language', '"en"');
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 9, 4, 12));
vi.spyOn(Math, 'random').mockReturnValue(0.5);
vi.spyOn(crypto, 'randomUUID')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000001')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000002')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000003')
.mockReturnValueOnce('00000000-0000-4000-8000-000000000004');
await TestBed.configureTestingModule({
imports: [UserInput],
imports: [UserInput, UserSettings],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
@@ -20,6 +38,36 @@ describe('UserInput', () => {
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
vi.restoreAllMocks();
vi.useRealTimers();
localStorage.clear();
document.body.classList.remove('light-mode', 'dark-mode');
});
function editInput(selector: string, value: string): void {
const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event('input'));
input.dispatchEvent(new Event('change'));
fixture.detectChanges();
}
async function selectOption(select: MatSelectHarness, text: string): Promise<void> {
await select.open();
const loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
for (const option of await loader.getAllHarnesses(MatOptionHarness)) {
if ((await option.getText()) === text) {
await option.click();
await expect.poll(() => document.querySelector('[role="listbox"]')).toBeNull();
return;
}
}
throw new Error(`No option labelled ${text}`);
}
it('should create', () => {
expect(component).toBeTruthy();
});
@@ -42,4 +90,169 @@ describe('UserInput', () => {
component.removeFromList(0);
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
});
it('saves the complete ordered balance list on the first quantity edit', () => {
expect(localStorage.getItem('leaveBalances')).toBeNull();
editInput('app-day-input input[type="number"]', '7');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toEqual([
{
id: '00000000-0000-4000-8000-000000000001',
type: 'cp',
numberOfDays: 7,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
{
id: '00000000-0000-4000-8000-000000000002',
type: 'rtt',
numberOfDays: 15,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
{
id: '00000000-0000-4000-8000-000000000003',
type: 'other',
numberOfDays: 15,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
]);
});
it('saves a leave-type selection as the first balance edit', async () => {
const loader = TestbedHarnessEnvironment.loader(fixture);
const type = await loader.getHarness(
MatSelectHarness.with({ selector: 'app-day-input mat-select' }),
);
await selectOption(type, 'RTT');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15, type: 'rtt' },
{ id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15, type: 'rtt' },
{ id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15, type: 'other' },
]);
});
it.each([
[
'availability',
1,
'29/03/2026',
{ availableDate: '2026-03-29', expiryDate: '2027-10-04' },
],
['expiry', 2, '25/10/2026', { availableDate: '2026-10-04', expiryDate: '2026-10-25' }],
])('saves an %s date edit without shifting its calendar date', (_, field, value, dates) => {
editInput(`app-day-input .date-fields mat-form-field:nth-child(${field}) input`, value);
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', ...dates },
{ id: '00000000-0000-4000-8000-000000000002' },
{ id: '00000000-0000-4000-8000-000000000003' },
]);
});
it('saves adding a balance as the first edit and keeps saving subsequent edits', () => {
fixture.nativeElement.querySelector('.add-day-button').click();
fixture.detectChanges();
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 },
{ id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 },
{ id: '00000000-0000-4000-8000-000000000003', numberOfDays: 15 },
{
id: '00000000-0000-4000-8000-000000000004',
type: 'cp',
numberOfDays: 1,
availableDate: '2026-10-04',
expiryDate: '2027-10-04',
},
]);
editInput('app-day-input input[type="number"]', '8');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8);
editInput('app-day-input input[type="number"]', '9');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(9);
});
it('saves removing a balance as the first edit and retains an entry after the final deletion', () => {
fixture.nativeElement.querySelectorAll('.delete-button')[2].click();
fixture.detectChanges();
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toMatchObject([
{ id: '00000000-0000-4000-8000-000000000001', numberOfDays: 15 },
{ id: '00000000-0000-4000-8000-000000000002', numberOfDays: 15 },
]);
fixture.nativeElement.querySelectorAll('.delete-button')[1].click();
fixture.detectChanges();
expect(JSON.parse(localStorage.getItem('leaveBalances')!)).toHaveLength(1);
fixture.nativeElement.querySelector('.delete-button').click();
fixture.detectChanges();
expect(userInputService.vacationNumberSignal()).toEqual([]);
expect(localStorage.getItem('leaveBalances')).toBe('[]');
});
it('keeps untouched random balances unsaved through settings, rule changes, and calculation', async () => {
const initialBalances = userInputService.vacationNumberSignal();
expect(localStorage.getItem('leaveBalances')).toBeNull();
const loader = TestbedHarnessEnvironment.documentRootLoader(fixture);
const dialogRef = TestBed.inject(MatDialog).open(UserSettings);
const dialog = await loader.getHarness(MatDialogHarness);
expect(localStorage.getItem('leaveBalances')).toBeNull();
const [theme, language] = await dialog.getAllHarnesses(MatSelectHarness);
await selectOption(theme, 'Dark');
await selectOption(language, 'Français');
await (await dialog.getHarness(MatSlideToggleHarness)).check();
expect(localStorage.getItem('theme.preference')).toBe('"dark"');
expect(localStorage.getItem('app.language')).toBe('"fr"');
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
dialogRef.close();
const rule = await loader.getHarness(
MatSlideToggleHarness.with({ selector: 'app-rules-panel mat-slide-toggle' }),
);
await rule.uncheck();
await expect.poll(() => localStorage.getItem('vacationRules')).not.toBeNull();
expect(JSON.parse(localStorage.getItem('vacationRules')!)[0].enabled).toBe(false);
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
it('preserves unrelated stored preferences, rules, and other values when editing balances', () => {
const unrelated = {
'theme.preference': '"system"',
'app.language': '"en"',
'calendarSettings.showWeekNumbers': 'true',
vacationRules: '[]',
'unrelated.entry': 'keep this value',
};
for (const [key, value] of Object.entries(unrelated)) localStorage.setItem(key, value);
editInput('app-day-input input[type="number"]', '7');
for (const [key, value] of Object.entries(unrelated)) {
expect(localStorage.getItem(key)).toBe(value);
}
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(7);
});
it('keeps balance edits usable when storage fails and saves the next edit when it recovers', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('Storage is unavailable', 'SecurityError');
});
editInput('app-day-input input[type="number"]', '7');
expect(userInputService.vacationNumberSignal()[0].numberOfDays).toBe(7);
expect(localStorage.getItem('leaveBalances')).toBeNull();
write.mockRestore();
editInput('app-day-input input[type="number"]', '8');
expect(JSON.parse(localStorage.getItem('leaveBalances')!)[0].numberOfDays).toBe(8);
});
});
+3 -5
View File
@@ -82,20 +82,18 @@ export class UserInput {
}
public addDay() {
this.userInputService.vacationNumberSignal.update((days) => [
this.userInputService.updateBalances((days) => [
...days,
this.userInputService.createVacationDay(),
]);
}
public removeFromList(index: number) {
this.userInputService.vacationNumberSignal.update((days) =>
days.filter((_, i) => i !== index),
);
this.userInputService.updateBalances((days) => days.filter((_, i) => i !== index));
}
public updateDay(index: number, updatedDay: VacDay) {
this.userInputService.vacationNumberSignal.update((days) =>
this.userInputService.updateBalances((days) =>
days.map((day, i) => (i === index ? updatedDay : day)),
);
}
+21
View File
@@ -1,5 +1,6 @@
import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
@Injectable({
providedIn: 'root',
@@ -51,6 +52,26 @@ export class UserInputService {
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
readonly computeTriggerSignal = signal(0);
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
this.vacationNumberSignal.update(updater);
try {
if (typeof localStorage === 'undefined') return;
// Store calendar dates, so restoring them does not depend on a UTC offset.
const balances = this.vacationNumberSignal().map((balance) => ({
id: balance.id,
type: balance.type,
numberOfDays: balance.numberOfDays,
availableDate: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
}));
localStorage.setItem('leaveBalances', JSON.stringify(balances));
} catch (error) {
console.warn('Failed to save leave balances to localStorage.', error);
}
}
public requestComputation(): void {
this.computeTriggerSignal.update((count) => count + 1);
}