This commit is contained in:
1 parent
5a34160ab2
commit
578aafbf91
4 files changed
+256
-6
No files matched your search
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user