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';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user