This commit is contained in:
1 parent
5a34160ab2
commit
578aafbf91
4 files changed
+256
-6
No files matched your search
@@ -12,6 +12,15 @@ import { App } from './app';
|
|||||||
import { UserInputService } from './services/user-input-service';
|
import { UserInputService } from './services/user-input-service';
|
||||||
|
|
||||||
describe('Application Settings', () => {
|
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 fixture: ComponentFixture<App>;
|
||||||
let loader: HarnessLoader;
|
let loader: HarnessLoader;
|
||||||
let opener: HTMLButtonElement;
|
let opener: HTMLButtonElement;
|
||||||
@@ -20,6 +29,9 @@ describe('Application Settings', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
localStorage.setItem('app.language', JSON.stringify('en'));
|
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(), {
|
mediaQuery = Object.assign(new EventTarget(), {
|
||||||
matches: true,
|
matches: true,
|
||||||
media: '(prefers-color-scheme: light)',
|
media: '(prefers-color-scheme: light)',
|
||||||
@@ -178,6 +190,9 @@ describe('Application Settings', () => {
|
|||||||
expect(fixture.nativeElement.querySelector('.week-number')).toBeNull();
|
expect(fixture.nativeElement.querySelector('.week-number')).toBeNull();
|
||||||
await (await dialog.getHarness(MatSlideToggleHarness)).check();
|
await (await dialog.getHarness(MatSlideToggleHarness)).check();
|
||||||
expect(localStorage.getItem('calendarSettings.showWeekNumbers')).toBe('true');
|
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(
|
expect(fixture.nativeElement.querySelectorAll('.week-number').length).toBeGreaterThan(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -204,6 +219,9 @@ describe('Application Settings', () => {
|
|||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
await createApp();
|
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(document.body.classList.contains('dark-mode')).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelector('header.app-header button').textContent,
|
fixture.nativeElement.querySelector('header.app-header button').textContent,
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
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 { UserInput } from './user-input';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
|
import { UserSettings } from '../user-settings/user-settings';
|
||||||
|
|
||||||
describe('UserInput', () => {
|
describe('UserInput', () => {
|
||||||
let component: UserInput;
|
let component: UserInput;
|
||||||
@@ -9,8 +17,18 @@ describe('UserInput', () => {
|
|||||||
let userInputService: UserInputService;
|
let userInputService: UserInputService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
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({
|
await TestBed.configureTestingModule({
|
||||||
imports: [UserInput],
|
imports: [UserInput, UserSettings],
|
||||||
providers: [provideZonelessChangeDetection()],
|
providers: [provideZonelessChangeDetection()],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
@@ -20,6 +38,36 @@ describe('UserInput', () => {
|
|||||||
fixture.detectChanges();
|
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', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -42,4 +90,169 @@ describe('UserInput', () => {
|
|||||||
component.removeFromList(0);
|
component.removeFromList(0);
|
||||||
expect(userInputService.vacationNumberSignal().length).toBe(initialLength - 1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -82,20 +82,18 @@ export class UserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public addDay() {
|
public addDay() {
|
||||||
this.userInputService.vacationNumberSignal.update((days) => [
|
this.userInputService.updateBalances((days) => [
|
||||||
...days,
|
...days,
|
||||||
this.userInputService.createVacationDay(),
|
this.userInputService.createVacationDay(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeFromList(index: number) {
|
public removeFromList(index: number) {
|
||||||
this.userInputService.vacationNumberSignal.update((days) =>
|
this.userInputService.updateBalances((days) => days.filter((_, i) => i !== index));
|
||||||
days.filter((_, i) => i !== index),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateDay(index: number, updatedDay: VacDay) {
|
public updateDay(index: number, updatedDay: VacDay) {
|
||||||
this.userInputService.vacationNumberSignal.update((days) =>
|
this.userInputService.updateBalances((days) =>
|
||||||
days.map((day, i) => (i === index ? updatedDay : day)),
|
days.map((day, i) => (i === index ? updatedDay : day)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { SelectableDayType, VacDay } from './calendar-service';
|
import { SelectableDayType, VacDay } from './calendar-service';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -51,6 +52,26 @@ export class UserInputService {
|
|||||||
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
|
||||||
readonly computeTriggerSignal = signal(0);
|
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 {
|
public requestComputation(): void {
|
||||||
this.computeTriggerSignal.update((count) => count + 1);
|
this.computeTriggerSignal.update((count) => count + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user