test: add unit tests for application components

This commit is contained in:
Andres Fabian Patiño Bermudez 2026-05-14 21:39:06 -05:00
parent 7d9c4acc7a
commit 91f0071d3a
32 changed files with 2918 additions and 0 deletions

View File

@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { unsavedChangesGuard, CanComponentDeactivate } from '@app/core/guards/unsaved-changes-guard';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
describe('unsavedChangesGuard', () => {
it('should return true when component has no canDeactivate method', () => {
const component = {} as CanComponentDeactivate;
const result = unsavedChangesGuard(component, {} as ActivatedRouteSnapshot, {} as RouterStateSnapshot, {} as RouterStateSnapshot);
expect(result).toBe(true);
});
it('should return result of component.canDeactivate when method exists', () => {
const component = { canDeactivate: () => false } as CanComponentDeactivate;
const result = unsavedChangesGuard(component, {} as ActivatedRouteSnapshot, {} as RouterStateSnapshot, {} as RouterStateSnapshot);
expect(result).toBe(false);
});
it('should return true when canDeactivate returns true', () => {
const component = { canDeactivate: () => true } as CanComponentDeactivate;
const result = unsavedChangesGuard(component, {} as ActivatedRouteSnapshot, {} as RouterStateSnapshot, {} as RouterStateSnapshot);
expect(result).toBe(true);
});
});

View File

@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { HttpResponse } from '@angular/common/http';
import { of } from 'rxjs';
import { apiResponseInterceptor } from '@app/core/interceptors/api-response-interceptor';
describe('apiResponseInterceptor', () => {
it('should extract data from ApiResponse wrapper', async () => {
const req = {} as never;
const apiResponse = { success: true, data: { id: 1, name: 'test' } };
const response = new HttpResponse({ body: apiResponse });
const next = () => of(response);
const result = await apiResponseInterceptor(req, next).toPromise();
expect((result as HttpResponse<unknown>).body).toEqual({ id: 1, name: 'test' });
});
it('should pass through when body does not have ApiResponse structure', async () => {
const req = {} as never;
const body = { id: 1, name: 'test' };
const response = new HttpResponse({ body });
const next = () => of(response);
const result = await apiResponseInterceptor(req, next).toPromise();
expect((result as HttpResponse<unknown>).body).toEqual(body);
});
it('should pass through when event is not HttpResponse', async () => {
const req = {} as never;
const next = () => of({ type: 0 });
const result = await apiResponseInterceptor(req, next).toPromise();
expect(result).toEqual({ type: 0 });
});
});

View File

@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { errorInterceptor } from '@app/core/interceptors/error-interceptor';
import { Notification } from '@app/core/services/notification';
describe('errorInterceptor', () => {
let notification: Notification;
beforeEach(() => {
TestBed.configureTestingModule({});
notification = TestBed.inject(Notification);
vi.spyOn(notification, 'error');
});
it('should pass through successful requests', async () => {
const req = {} as never;
const response = new HttpResponse({ body: { data: 'test' } });
const next = () => of(response);
const result = await TestBed.runInInjectionContext(() =>
errorInterceptor(req, next).toPromise()
);
expect(result).toEqual(response);
});
it('should call notification.error on HTTP error', async () => {
const req = {} as never;
const error = new HttpErrorResponse({ status: 500, error: { message: 'Server error' } });
const next = () => throwError(() => error);
try {
await TestBed.runInInjectionContext(() =>
errorInterceptor(req, next).toPromise()
);
} catch {}
expect(notification.error).toHaveBeenCalledWith('A server error occurred. Please try again later.');
});
it('should handle network errors', async () => {
const req = {} as never;
const error = new HttpErrorResponse({ status: 0 });
const next = () => throwError(() => error);
try {
await TestBed.runInInjectionContext(() =>
errorInterceptor(req, next).toPromise()
);
} catch {}
expect(notification.error).toHaveBeenCalledWith('Unable to connect to the server. Please check your connection.');
});
it('should handle 404 errors', async () => {
const req = {} as never;
const error = new HttpErrorResponse({ status: 404 });
const next = () => throwError(() => error);
try {
await TestBed.runInInjectionContext(() =>
errorInterceptor(req, next).toPromise()
);
} catch {}
expect(notification.error).toHaveBeenCalledWith('The requested resource was not found.');
});
it('should handle 400 errors', async () => {
const req = {} as never;
const error = new HttpErrorResponse({ status: 400, error: { message: 'Bad request' } });
const next = () => throwError(() => error);
try {
await TestBed.runInInjectionContext(() =>
errorInterceptor(req, next).toPromise()
);
} catch {}
expect(notification.error).toHaveBeenCalledWith('Bad request');
});
it('should rethrow the error', async () => {
const req = {} as never;
const error = new HttpErrorResponse({ status: 500 });
const next = () => throwError(() => error);
await expect(
TestBed.runInInjectionContext(() =>
errorInterceptor(req, next).toPromise()
)
).rejects.toThrow();
});
});

View File

@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Notification } from '@app/core/services/notification';
describe('Notification', () => {
let service: Notification;
beforeEach(() => {
service = new Notification();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should create', () => {
expect(service).toBeTruthy();
});
it('should start with empty notifications', () => {
expect(service.notifications()).toEqual([]);
});
it('should add success notification', () => {
service.success('Operation successful');
expect(service.notifications()).toHaveLength(1);
expect(service.notifications()[0].type).toBe('success');
expect(service.notifications()[0].message).toBe('Operation successful');
});
it('should add error notification', () => {
service.error('Something failed');
expect(service.notifications()).toHaveLength(1);
expect(service.notifications()[0].type).toBe('error');
expect(service.notifications()[0].message).toBe('Something failed');
});
it('should add warning notification', () => {
service.warning('Be careful');
expect(service.notifications()).toHaveLength(1);
expect(service.notifications()[0].type).toBe('warning');
});
it('should add info notification', () => {
service.info('FYI');
expect(service.notifications()).toHaveLength(1);
expect(service.notifications()[0].type).toBe('info');
});
it('should dismiss notification by id', () => {
service.success('Test 1');
service.error('Test 2');
expect(service.notifications()).toHaveLength(2);
service.dismiss(service.notifications()[0].id);
expect(service.notifications()).toHaveLength(1);
expect(service.notifications()[0].message).toBe('Test 2');
});
it('should auto-dismiss after 5 seconds', () => {
service.success('Auto dismiss');
expect(service.notifications()).toHaveLength(1);
vi.advanceTimersByTime(5000);
expect(service.notifications()).toHaveLength(0);
});
it('should assign unique ids', () => {
service.success('First');
service.error('Second');
service.warning('Third');
const ids = service.notifications().map(n => n.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(3);
});
});

View File

@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { defaultFilter, defaultSort } from '@app/features/tasks/data-access/models/task-state.model';
describe('task-state.model', () => {
describe('defaultFilter', () => {
it('should have state as all', () => {
expect(defaultFilter.state).toBe('all');
});
it('should have empty search string', () => {
expect(defaultFilter.search).toBe('');
});
});
describe('defaultSort', () => {
it('should have field as dueDate', () => {
expect(defaultSort.field).toBe('dueDate');
});
it('should have direction as asc', () => {
expect(defaultSort.direction).toBe('asc');
});
});
});

View File

@ -0,0 +1,46 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createTask } from '@app/features/tasks/data-access/models/task.model';
describe('task.model', () => {
afterEach(() => {
vi.useRealTimers();
});
describe('createTask', () => {
it('should create task with all provided values', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13'));
const dto = { title: 'My Task', description: 'My description', dueDate: '2026-12-01' };
const task = createTask(dto);
expect(task.title).toBe('My Task');
expect(task.description).toBe('My description');
expect(task.dueDate).toBe('2026-12-01');
expect(task.stateHistory).toEqual([{ state: 'new', date: '2026-05-13' }]);
expect(task.notes).toEqual([]);
expect(task.id).toBeDefined();
});
it('should create task with default values when optional fields are missing', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13'));
const dto = { title: 'Minimal Task' };
const task = createTask(dto);
expect(task.title).toBe('Minimal Task');
expect(task.description).toBe('');
expect(task.dueDate).toBe('2026-05-13');
});
it('should generate unique ids for different tasks', () => {
const dto = { title: 'Task' };
const task1 = createTask(dto);
const task2 = createTask(dto);
expect(task1.id).not.toBe(task2.id);
});
});
});

View File

@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { of, throwError } from 'rxjs';
import { taskResolver } from '@app/features/tasks/data-access/resolvers/task-resolver';
import { TaskService } from '@app/features/tasks/data-access/services/task';
import { TaskStore } from '@app/features/tasks/data-access/store/task-store';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('taskResolver', () => {
let taskService: TaskService;
let taskStore: TaskStore;
beforeEach(() => {
taskService = {
getById: vi.fn(),
} as unknown as TaskService;
taskStore = {
setSelectedTask: vi.fn(),
} as unknown as TaskStore;
TestBed.configureTestingModule({
providers: [
{ provide: TaskService, useValue: taskService },
{ provide: TaskStore, useValue: taskStore },
],
});
});
function createRoute(id: string | null) {
return {
paramMap: {
get: vi.fn().mockReturnValue(id),
},
} as unknown as ActivatedRouteSnapshot;
}
it('should return null when no id param', async () => {
const route = createRoute(null);
const result = await TestBed.runInInjectionContext(() => taskResolver(route, {} as RouterStateSnapshot));
expect(result).toBeNull();
});
it('should return task and set it in store when id exists', async () => {
const task = new TaskBuilder().withId('task-1').build();
vi.mocked(taskService.getById).mockReturnValue(of(task));
const route = createRoute('task-1');
const result = await TestBed.runInInjectionContext(() => taskResolver(route, {} as RouterStateSnapshot));
expect(result).toEqual(task);
expect(taskStore.setSelectedTask).toHaveBeenCalledWith(task);
});
it('should return null when service throws error', async () => {
vi.mocked(taskService.getById).mockReturnValue(throwError(() => new Error('Not found')));
const route = createRoute('task-1');
const result = await TestBed.runInInjectionContext(() => taskResolver(route, {} as RouterStateSnapshot));
expect(result).toBeNull();
});
});

View File

@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { TaskService } from '@app/features/tasks/data-access/services/task';
import { TaskDataSource } from '@app/features/tasks/data-access/data-sources/task-data-source.interface';
import { TASK_DATA_SOURCE } from '@app/features/tasks/data-access/data-sources/task-data-source.token';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskService', () => {
let service: TaskService;
let dataSource: TaskDataSource;
beforeEach(() => {
dataSource = {
getAll: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transition: vi.fn(),
addNote: vi.fn(),
deleteNote: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
TaskService,
{ provide: TASK_DATA_SOURCE, useValue: dataSource },
],
});
service = TestBed.inject(TaskService);
});
describe('getAll', () => {
it('should return paginated tasks from datasource', () => {
const tasks = TaskBuilder.buildMany(3);
const response = { data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } };
vi.mocked(dataSource.getAll).mockReturnValue(of(response));
service.getAll().subscribe(result => {
expect(result).toEqual(response);
});
expect(dataSource.getAll).toHaveBeenCalledOnce();
});
it('should pass pagination params to datasource', () => {
const params = { page: 2, pageSize: 5 };
const response = { data: [], pagination: { page: 2, pageSize: 5, total: 0, totalPages: 0 } };
vi.mocked(dataSource.getAll).mockReturnValue(of(response));
service.getAll(params).subscribe();
expect(dataSource.getAll).toHaveBeenCalledWith(params);
});
});
describe('getById', () => {
it('should return task by id from datasource', () => {
const task = new TaskBuilder().withId('task-1').build();
vi.mocked(dataSource.getById).mockReturnValue(of(task));
service.getById('task-1').subscribe(result => {
expect(result).toEqual(task);
});
expect(dataSource.getById).toHaveBeenCalledWith('task-1');
});
});
describe('create', () => {
it('should create task through datasource', () => {
const dto = { title: 'New Task', description: 'Description' };
const created = new TaskBuilder().withTitle('New Task').build();
vi.mocked(dataSource.create).mockReturnValue(of(created));
service.create(dto).subscribe(result => {
expect(result).toEqual(created);
});
expect(dataSource.create).toHaveBeenCalledWith(dto);
});
});
describe('update', () => {
it('should update task through datasource', () => {
const dto = { title: 'Updated' };
const updated = new TaskBuilder().withId('task-1').withTitle('Updated').build();
vi.mocked(dataSource.update).mockReturnValue(of(updated));
service.update('task-1', dto).subscribe(result => {
expect(result).toEqual(updated);
});
expect(dataSource.update).toHaveBeenCalledWith('task-1', dto);
});
});
describe('delete', () => {
it('should delete task through datasource', () => {
vi.mocked(dataSource.delete).mockReturnValue(of(undefined));
service.delete('task-1').subscribe();
expect(dataSource.delete).toHaveBeenCalledWith('task-1');
});
});
describe('transition', () => {
it('should transition task state through datasource', () => {
const updated = new TaskBuilder().withId('task-1').withState('active').build();
vi.mocked(dataSource.transition).mockReturnValue(of(updated));
service.transition('task-1', 'active').subscribe(result => {
expect(result).toEqual(updated);
});
expect(dataSource.transition).toHaveBeenCalledWith('task-1', 'active');
});
});
describe('addNote', () => {
it('should add note through datasource', () => {
const updated = new TaskBuilder().withId('task-1').withNotes(['New note']).build();
vi.mocked(dataSource.addNote).mockReturnValue(of(updated));
service.addNote('task-1', 'New note').subscribe(result => {
expect(result).toEqual(updated);
});
expect(dataSource.addNote).toHaveBeenCalledWith('task-1', 'New note');
});
});
describe('deleteNote', () => {
it('should delete note through datasource', () => {
const updated = new TaskBuilder().withId('task-1').withNotes([]).build();
vi.mocked(dataSource.deleteNote).mockReturnValue(of(updated));
service.deleteNote('task-1', 0).subscribe(result => {
expect(result).toEqual(updated);
});
expect(dataSource.deleteNote).toHaveBeenCalledWith('task-1', 0);
});
});
});

View File

@ -0,0 +1,345 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { TaskStore } from '@app/features/tasks/data-access/store/task-store';
import { TaskService } from '@app/features/tasks/data-access/services/task';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskStore', () => {
let store: TaskStore;
let taskService: TaskService;
beforeEach(() => {
taskService = {
getAll: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transition: vi.fn(),
addNote: vi.fn(),
deleteNote: vi.fn(),
} as unknown as TaskService;
TestBed.configureTestingModule({
providers: [
TaskStore,
{ provide: TaskService, useValue: taskService },
],
});
store = TestBed.inject(TaskStore);
});
describe('loadTasks', () => {
it('should load tasks and update signals', () => {
const tasks = TaskBuilder.buildMany(3);
const pagination = { page: 1, pageSize: 10, total: 3, totalPages: 1 };
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination }));
store.loadTasks();
expect(store.tasks()).toEqual(tasks);
expect(store.pagination()).toEqual(pagination);
expect(store.loading()).toBe(false);
expect(store.error()).toBeNull();
});
it('should set loading true while fetching', () => {
vi.mocked(taskService.getAll).mockReturnValue(of({ data: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } }));
store.loadTasks();
expect(store.loading()).toBe(false);
});
it('should set error when request fails', () => {
vi.mocked(taskService.getAll).mockReturnValue(throwError(() => new Error('Network error')));
store.loadTasks();
expect(store.error()).toBe('Network error');
expect(store.loading()).toBe(false);
});
});
describe('loadTask', () => {
it('should load single task and set selected task', () => {
const task = new TaskBuilder().withId('task-1').build();
vi.mocked(taskService.getById).mockReturnValue(of(task));
store.loadTask('task-1');
expect(store.selectedTask()).toEqual(task);
expect(store.loading()).toBe(false);
});
it('should set error when loading single task fails', () => {
vi.mocked(taskService.getById).mockReturnValue(throwError(() => new Error('Not found')));
store.loadTask('task-1');
expect(store.error()).toBe('Not found');
expect(store.loading()).toBe(false);
});
});
describe('setSelectedTask', () => {
it('should set the selected task', () => {
const task = new TaskBuilder().withId('task-1').build();
store.setSelectedTask(task);
expect(store.selectedTask()).toEqual(task);
});
});
describe('createTask', () => {
it('should add new task to tasks list', () => {
const existing = TaskBuilder.buildMany(2);
const newTask = new TaskBuilder().withId('task-3').withTitle('New').build();
vi.mocked(taskService.getAll).mockReturnValue(of({ data: existing, pagination: { page: 1, pageSize: 10, total: 2, totalPages: 1 } }));
vi.mocked(taskService.create).mockReturnValue(of(newTask));
store.loadTasks();
store.createTask({ title: 'New' });
expect(store.tasks()).toHaveLength(3);
expect(store.tasks()).toContainEqual(newTask);
expect(store.loading()).toBe(false);
});
it('should set error when creation fails', () => {
vi.mocked(taskService.create).mockReturnValue(throwError(() => new Error('Create failed')));
store.createTask({ title: 'Fail' });
expect(store.error()).toBe('Create failed');
expect(store.loading()).toBe(false);
});
});
describe('updateTask', () => {
it('should update existing task in list', () => {
const original = TaskBuilder.buildMany(2);
const updated = new TaskBuilder().withId('task-1').withTitle('Updated').build();
vi.mocked(taskService.getAll).mockReturnValue(of({ data: original, pagination: { page: 1, pageSize: 10, total: 2, totalPages: 1 } }));
vi.mocked(taskService.update).mockReturnValue(of(updated));
store.loadTasks();
store.updateTask('task-1', { title: 'Updated' });
expect(store.tasks().find(t => t.id === 'task-1')?.title).toBe('Updated');
expect(store.loading()).toBe(false);
});
it('should update selected task if it matches', () => {
const task = new TaskBuilder().withId('task-1').build();
const updated = new TaskBuilder().withId('task-1').withTitle('Updated').build();
vi.mocked(taskService.getById).mockReturnValue(of(task));
vi.mocked(taskService.update).mockReturnValue(of(updated));
store.loadTask('task-1');
store.updateTask('task-1', { title: 'Updated' });
expect(store.selectedTask()?.title).toBe('Updated');
});
it('should set error when update fails', () => {
vi.mocked(taskService.update).mockReturnValue(throwError(() => new Error('Update failed')));
store.updateTask('task-1', { title: 'Fail' });
expect(store.error()).toBe('Update failed');
expect(store.loading()).toBe(false);
});
});
describe('deleteTask', () => {
it('should remove task from list', () => {
const tasks = TaskBuilder.buildMany(3);
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } }));
vi.mocked(taskService.delete).mockReturnValue(of(undefined));
store.loadTasks();
store.deleteTask('task-1');
expect(store.tasks()).toHaveLength(2);
expect(store.tasks().find(t => t.id === 'task-1')).toBeUndefined();
});
it('should clear selected task if deleted task was selected', () => {
const task = new TaskBuilder().withId('task-1').build();
vi.mocked(taskService.getById).mockReturnValue(of(task));
vi.mocked(taskService.delete).mockReturnValue(of(undefined));
store.loadTask('task-1');
store.deleteTask('task-1');
expect(store.selectedTask()).toBeNull();
});
it('should set error when deletion fails', () => {
vi.mocked(taskService.delete).mockReturnValue(throwError(() => new Error('Delete failed')));
store.deleteTask('task-1');
expect(store.error()).toBe('Delete failed');
expect(store.loading()).toBe(false);
});
});
describe('transitionTask', () => {
it('should update task state in list', () => {
const tasks = TaskBuilder.buildMany(2, (builder, i) => {
if (i === 0) builder.withState('new');
});
const transitioned = new TaskBuilder().withId('task-1').withState('active').build();
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 2, totalPages: 1 } }));
vi.mocked(taskService.transition).mockReturnValue(of(transitioned));
store.loadTasks();
store.transitionTask('task-1', 'active');
expect(store.tasks().find(t => t.id === 'task-1')?.stateHistory[0].state).toBe('active');
});
it('should rollback task when transition fails', () => {
const tasks = TaskBuilder.buildMany(1, (builder) => {
builder.withId('task-1').withState('new');
});
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 } }));
vi.mocked(taskService.transition).mockReturnValue(throwError(() => new Error('Transition failed')));
store.loadTasks();
store.transitionTask('task-1', 'active');
expect(store.tasks().find(t => t.id === 'task-1')?.stateHistory.at(-1)?.state).toBe('new');
expect(store.transitioningState()).toBeNull();
});
});
describe('addNote', () => {
it('should update task with new note', () => {
const tasks = [new TaskBuilder().withId('task-1').withNotes([]).build()];
const updated = new TaskBuilder().withId('task-1').withNotes(['New note']).build();
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 } }));
vi.mocked(taskService.addNote).mockReturnValue(of(updated));
store.loadTasks();
store.addNote('task-1', 'New note');
expect(store.tasks().find(t => t.id === 'task-1')?.notes).toContain('New note');
});
it('should set error when adding note fails', () => {
vi.mocked(taskService.addNote).mockReturnValue(throwError(() => new Error('Note failed')));
store.addNote('task-1', 'Note');
expect(store.error()).toBe('Note failed');
});
});
describe('deleteNote', () => {
it('should remove note from task', () => {
const tasks = [new TaskBuilder().withId('task-1').withNotes(['Note 1', 'Note 2']).build()];
const updated = new TaskBuilder().withId('task-1').withNotes(['Note 2']).build();
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 1, totalPages: 1 } }));
vi.mocked(taskService.deleteNote).mockReturnValue(of(updated));
store.loadTasks();
store.deleteNote('task-1', 0);
expect(store.tasks().find(t => t.id === 'task-1')?.notes).toEqual(['Note 2']);
});
});
describe('filteredTasks', () => {
it('should filter tasks by state', () => {
const tasks = [
new TaskBuilder().withId('task-1').withState('active').build(),
new TaskBuilder().withId('task-2').withState('new').build(),
new TaskBuilder().withId('task-3').withState('active').build(),
];
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } }));
store.loadTasks();
store.updateFilter({ state: 'active' });
expect(store.filteredTasks()).toHaveLength(2);
});
it('should filter tasks by search term', () => {
const tasks = [
new TaskBuilder().withId('task-1').withTitle('Angular task').build(),
new TaskBuilder().withId('task-2').withTitle('React task').build(),
new TaskBuilder().withId('task-3').withTitle('Angular component').build(),
];
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } }));
store.loadTasks();
store.updateFilter({ search: 'angular' });
expect(store.filteredTasks()).toHaveLength(2);
});
it('should return all tasks when filter is all', () => {
const tasks = TaskBuilder.buildMany(5);
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 5, totalPages: 1 } }));
store.loadTasks();
expect(store.filteredTasks()).toHaveLength(5);
});
});
describe('updateFilter', () => {
it('should merge partial filter with existing filter', () => {
store.updateFilter({ state: 'active' });
expect(store.filter().state).toBe('active');
expect(store.filter().search).toBe('');
});
});
describe('updateSort', () => {
it('should update sort criteria', () => {
store.updateSort({ field: 'title', direction: 'desc' });
expect(store.sort().field).toBe('title');
expect(store.sort().direction).toBe('desc');
});
});
describe('clearError', () => {
it('should set error to null', () => {
vi.mocked(taskService.getAll).mockReturnValue(throwError(() => new Error('Error')));
store.loadTasks();
expect(store.error()).toBe('Error');
store.clearError();
expect(store.error()).toBeNull();
});
});
describe('reset', () => {
it('should reset all signals to default values', () => {
const tasks = TaskBuilder.buildMany(3);
vi.mocked(taskService.getAll).mockReturnValue(of({ data: tasks, pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 } }));
store.loadTasks();
store.updateFilter({ state: 'active', search: 'test' });
store.reset();
expect(store.tasks()).toEqual([]);
expect(store.selectedTask()).toBeNull();
expect(store.filter()).toEqual({ state: 'all', search: '' });
expect(store.sort()).toEqual({ field: 'dueDate', direction: 'asc' });
expect(store.loading()).toBe(false);
expect(store.error()).toBeNull();
expect(store.pagination()).toBeNull();
});
});
});

View File

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TaskCreatePage } from '@app/features/tasks/feature/task-create-page/task-create-page';
import { TaskService } from '@app/features/tasks/data-access/services/task';
describe('TaskCreatePage', () => {
let component: TaskCreatePage;
let fixture: ComponentFixture<TaskCreatePage>;
let taskService: TaskService;
beforeEach(async () => {
taskService = {
create: vi.fn(),
} as unknown as TaskService;
await TestBed.configureTestingModule({
imports: [TaskCreatePage],
providers: [
provideRouter([]),
{ provide: TaskService, useValue: taskService },
],
}).compileComponents();
fixture = TestBed.createComponent(TaskCreatePage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have a form with title, description, dueDate and initialNote', () => {
expect(component.form.get('title')).toBeTruthy();
expect(component.form.get('description')).toBeTruthy();
expect(component.form.get('dueDate')).toBeTruthy();
expect(component.form.get('initialNote')).toBeTruthy();
});
it('should have title as required', () => {
const title = component.form.get('title');
title?.setValue('');
expect(title?.valid).toBe(false);
});
it('should have initialNote as required', () => {
const initialNote = component.form.get('initialNote');
initialNote?.setValue('');
expect(initialNote?.valid).toBe(false);
});
it('should validate title max length', () => {
const title = component.form.get('title');
const longTitle = 'a'.repeat(101);
title?.setValue(longTitle);
expect(title?.valid).toBe(false);
});
it('should accept valid title', () => {
const title = component.form.get('title');
title?.setValue('Valid Task Title');
expect(title?.valid).toBe(true);
});
it('should return true from canDeactivate when form is pristine', () => {
expect(component.canDeactivate()).toBe(true);
});
it('should return false from canDeactivate when form is dirty', () => {
component.form.markAsDirty();
expect(component.canDeactivate()).toBe(false);
});
});

View File

@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, ActivatedRoute } from '@angular/router';
import { TaskDetailPage } from '@app/features/tasks/feature/task-detail-page/task-detail-page';
import { TaskStore } from '@app/features/tasks/data-access/store/task-store';
import { TaskService } from '@app/features/tasks/data-access/services/task';
import { of } from 'rxjs';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskDetailPage', () => {
let component: TaskDetailPage;
let fixture: ComponentFixture<TaskDetailPage>;
let taskService: TaskService;
beforeEach(async () => {
taskService = {
getById: vi.fn().mockReturnValue(of(new TaskBuilder().build())),
update: vi.fn().mockReturnValue(of(new TaskBuilder().build())),
transition: vi.fn().mockReturnValue(of(new TaskBuilder().build())),
delete: vi.fn().mockReturnValue(of(undefined)),
addNote: vi.fn().mockReturnValue(of(new TaskBuilder().build())),
deleteNote: vi.fn().mockReturnValue(of(new TaskBuilder().build())),
} as unknown as TaskService;
await TestBed.configureTestingModule({
imports: [TaskDetailPage],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: {
get: vi.fn().mockReturnValue('task-1'),
},
},
},
},
{ provide: TaskService, useValue: taskService },
],
}).compileComponents();
fixture = TestBed.createComponent(TaskDetailPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should return current state from task history', () => {
const store = TestBed.inject(TaskStore);
store.setSelectedTask(new TaskBuilder().withState('active').build());
expect(component.currentState).toBe('active');
});
it('should return null for currentState when task has empty history', () => {
const store = TestBed.inject(TaskStore);
store.setSelectedTask(new TaskBuilder().withStateHistory([]).build());
expect(component.currentState).toBeNull();
});
});

View File

@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TaskListPage } from '@app/features/tasks/feature/task-list-page/task-list-page';
import { TaskStore } from '@app/features/tasks/data-access/store/task-store';
import { TaskService } from '@app/features/tasks/data-access/services/task';
import { of } from 'rxjs';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskListPage', () => {
let component: TaskListPage;
let fixture: ComponentFixture<TaskListPage>;
let store: TaskStore;
let taskService: TaskService;
beforeEach(async () => {
taskService = {
getAll: vi.fn().mockReturnValue(of({ data: [], pagination: { page: 1, pageSize: 10, total: 0, totalPages: 0 } })),
delete: vi.fn().mockReturnValue(of(undefined)),
update: vi.fn(),
transition: vi.fn(),
addNote: vi.fn(),
deleteNote: vi.fn(),
} as unknown as TaskService;
await TestBed.configureTestingModule({
imports: [TaskListPage],
providers: [
provideRouter([]),
{ provide: TaskService, useValue: taskService },
],
}).compileComponents();
fixture = TestBed.createComponent(TaskListPage);
component = fixture.componentInstance;
store = TestBed.inject(TaskStore);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default view as table', () => {
expect(component.activeView()).toBe('table');
});
it('should have PAGE_SIZE of 5', () => {
expect(component.PAGE_SIZE).toBe(5);
});
it('should have default currentPage as 1', () => {
expect(component.currentPage()).toBe(1);
});
it('should change view mode', () => {
component.onViewChange('board');
expect(component.activeView()).toBe('board');
});
it('should change page', () => {
component.onPageChange(3);
expect(component.currentPage()).toBe(3);
});
it('should return current state from task history', () => {
const task = new TaskBuilder().withState('active').build();
expect(component.getState(task)).toBe('active');
});
it('should return new when history is empty', () => {
const task = new TaskBuilder().withStateHistory([]).build();
expect(component.getState(task)).toBe('new');
});
it('should return correct state label', () => {
const task = new TaskBuilder().withState('resolved').build();
expect(component.getStateLabel(task)).toBe('Resolved');
});
it('should return correct state progress', () => {
const task = new TaskBuilder().withState('active').build();
expect(component.getStateProgress(task)).toBe(50);
});
it('should return true for finalized tasks', () => {
const resolved = new TaskBuilder().withState('resolved').build();
const closed = new TaskBuilder().withState('closed').build();
expect(component.isFinalized(resolved)).toBe(true);
expect(component.isFinalized(closed)).toBe(true);
});
it('should return false for non-finalized tasks', () => {
const active = new TaskBuilder().withState('active').build();
const newTask = new TaskBuilder().withState('new').build();
expect(component.isFinalized(active)).toBe(false);
expect(component.isFinalized(newTask)).toBe(false);
});
it('should set selected task on view', () => {
const task = new TaskBuilder().build();
component.onViewTask(task);
expect(component.selectedTask()).toEqual(task);
});
it('should clear selected task on close', () => {
store.setSelectedTask(new TaskBuilder().build());
component.onCloseDetailSidebar();
expect(component.selectedTask()).toBeNull();
});
it('should open create sidebar', () => {
component.onOpenCreateSidebar();
expect(component.showCreateSidebar()).toBe(true);
});
it('should close create sidebar', () => {
component.showCreateSidebar.set(true);
component.onCloseCreateSidebar();
expect(component.showCreateSidebar()).toBe(false);
});
it('should close create sidebar on task created', () => {
component.showCreateSidebar.set(true);
component.onTaskCreated();
expect(component.showCreateSidebar()).toBe(false);
});
it('should close create sidebar when viewing task', () => {
component.showCreateSidebar.set(true);
const task = new TaskBuilder().build();
component.onViewTask(task);
expect(component.showCreateSidebar()).toBe(false);
});
});

View File

@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskBoardColumn } from '@app/features/tasks/ui/task-board-column/task-board-column';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskBoardColumn', () => {
let component: TaskBoardColumn;
let fixture: ComponentFixture<TaskBoardColumn>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskBoardColumn],
}).compileComponents();
fixture = TestBed.createComponent(TaskBoardColumn);
component = fixture.componentInstance;
fixture.componentRef.setInput('state', 'new');
fixture.componentRef.setInput('title', 'New Tasks');
fixture.componentRef.setInput('tasks', []);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render column title', () => {
const title = fixture.nativeElement.querySelector('.board-column__title');
expect(title?.textContent).toContain('New Tasks');
});
it('should render task count', () => {
fixture.componentRef.setInput('tasks', TaskBuilder.buildMany(3));
fixture.detectChanges();
const count = fixture.nativeElement.querySelector('.board-column__count');
expect(count?.textContent).toContain('3');
});
it('should render empty state when no tasks', () => {
const empty = fixture.nativeElement.querySelector('.board-column__empty');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No tasks');
});
it('should render task cards', () => {
const tasks = TaskBuilder.buildMany(2);
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const cards = fixture.nativeElement.querySelectorAll('.board-column__card');
expect(cards.length).toBe(2);
});
it('should render task title in card', () => {
const tasks = [new TaskBuilder().withTitle('My Task').build()];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const cardTitle = fixture.nativeElement.querySelector('.board-column__card-title');
expect(cardTitle?.textContent).toContain('My Task');
});
it('should return state class', () => {
fixture.componentRef.setInput('state', 'active');
expect(component.stateClass).toBe('board-column--active');
});
it('should emit view event', () => {
const task = new TaskBuilder().build();
const spy = vi.fn();
component.view.subscribe(spy);
component.onView(task);
expect(spy).toHaveBeenCalledWith(task);
});
it('should emit edit event', () => {
const task = new TaskBuilder().build();
const spy = vi.fn();
component.edit.subscribe(spy);
component.onEdit(task);
expect(spy).toHaveBeenCalledWith(task);
});
it('should emit delete event', () => {
const task = new TaskBuilder().build();
const spy = vi.fn();
component.delete.subscribe(spy);
component.onDelete(task);
expect(spy).toHaveBeenCalledWith(task);
});
it('should emit add event', () => {
const spy = vi.fn();
component.add.subscribe(spy);
component.onAdd();
expect(spy).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskCard } from '@app/features/tasks/ui/task-card/task-card';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskCard', () => {
let component: TaskCard;
let fixture: ComponentFixture<TaskCard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskCard],
}).compileComponents();
fixture = TestBed.createComponent(TaskCard);
component = fixture.componentInstance;
fixture.componentRef.setInput('task', new TaskBuilder().build());
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render task title', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withTitle('My Task').build());
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('h3');
expect(title?.textContent).toContain('My Task');
});
it('should render task description', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withDescription('My Description').build());
fixture.detectChanges();
const desc = fixture.nativeElement.querySelector('.task-card__description');
expect(desc?.textContent).toContain('My Description');
});
it('should render due date', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withDueDate('2026-12-25').build());
fixture.detectChanges();
const date = fixture.nativeElement.querySelector('.task-card__date');
expect(date?.textContent).toContain('2026-12-25');
});
it('should return last state from stateHistory', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withState('active').build());
expect(component.currentState).toBe('active');
});
it('should return new when stateHistory is empty', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withStateHistory([]).build());
expect(component.currentState).toBe('new');
});
it('should emit view event', () => {
const spy = vi.fn();
component.view.subscribe(spy);
component.onView();
expect(spy).toHaveBeenCalledOnce();
});
it('should emit edit event', () => {
const spy = vi.fn();
component.edit.subscribe(spy);
component.onEdit();
expect(spy).toHaveBeenCalledOnce();
});
it('should emit delete event', () => {
const spy = vi.fn();
component.delete.subscribe(spy);
component.onDelete();
expect(spy).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskCreateSidebar } from '@app/features/tasks/ui/task-create-sidebar/task-create-sidebar';
import { TaskService } from '@app/features/tasks/data-access/services/task';
import { of } from 'rxjs';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskCreateSidebar', () => {
let component: TaskCreateSidebar;
let fixture: ComponentFixture<TaskCreateSidebar>;
let taskService: TaskService;
beforeEach(async () => {
taskService = {
create: vi.fn().mockReturnValue(of(new TaskBuilder().build())),
addNote: vi.fn().mockReturnValue(of(new TaskBuilder().build())),
} as unknown as TaskService;
await TestBed.configureTestingModule({
imports: [TaskCreateSidebar],
providers: [
{ provide: TaskService, useValue: taskService },
],
}).compileComponents();
fixture = TestBed.createComponent(TaskCreateSidebar);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render sidebar title', () => {
const title = fixture.nativeElement.querySelector('.sidebar__title');
expect(title?.textContent).toContain('New Task');
});
it('should have a form with title, description, dueDate and initialNote fields', () => {
expect(component.form.get('title')).toBeTruthy();
expect(component.form.get('description')).toBeTruthy();
expect(component.form.get('dueDate')).toBeTruthy();
expect(component.form.get('initialNote')).toBeTruthy();
});
it('should have initialNote as required', () => {
const initialNote = component.form.get('initialNote');
initialNote?.setValue('');
expect(initialNote?.valid).toBe(false);
});
it('should accept valid initialNote', () => {
const initialNote = component.form.get('initialNote');
initialNote?.setValue('This is a valid note');
expect(initialNote?.valid).toBe(true);
});
it('should emit close event', () => {
const spy = vi.fn();
component.close.subscribe(spy);
component.onClose();
expect(spy).toHaveBeenCalledOnce();
});
it('should not emit created when form is invalid', () => {
const spy = vi.fn();
component.created.subscribe(spy);
component.onSubmit();
expect(spy).not.toHaveBeenCalled();
});
it('should emit created when form is valid', () => {
const spy = vi.fn();
component.created.subscribe(spy);
component.form.patchValue({ title: 'New Task', initialNote: 'First note' });
component.onSubmit();
expect(spy).toHaveBeenCalledOnce();
});
it('should render initialNote field in template', () => {
const field = fixture.nativeElement.querySelector('#create-initialNote');
expect(field).toBeTruthy();
});
});

View File

@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskDetailSidebar } from '@app/features/tasks/ui/task-detail-sidebar/task-detail-sidebar';
import { TaskBuilder } from '@tests/builders/task.builder';
describe('TaskDetailSidebar', () => {
let component: TaskDetailSidebar;
let fixture: ComponentFixture<TaskDetailSidebar>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskDetailSidebar],
}).compileComponents();
fixture = TestBed.createComponent(TaskDetailSidebar);
component = fixture.componentInstance;
fixture.componentRef.setInput('task', new TaskBuilder().build());
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render task title', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withTitle('My Task').build());
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('.task-sidebar__title');
expect(title?.textContent).toContain('My Task');
});
it('should render description', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withDescription('My Description').build());
fixture.detectChanges();
const desc = fixture.nativeElement.querySelector('.task-sidebar__text');
expect(desc?.textContent).toContain('My Description');
});
it('should show fallback when no description', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withDescription('').build());
fixture.detectChanges();
const desc = fixture.nativeElement.querySelector('.task-sidebar__text');
expect(desc?.textContent).toContain('No description provided');
});
it('should render due date', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withDueDate('2026-12-25').build());
fixture.detectChanges();
const meta = fixture.nativeElement.querySelector('.task-sidebar__meta');
expect(meta?.textContent).toContain('2026-12-25');
});
it('should return current state from history', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withState('active').build());
expect(component.currentState()).toBe('active');
});
it('should return new when history is empty', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withStateHistory([]).build());
expect(component.currentState()).toBe('new');
});
it('should return 0 progress for new state', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withState('new').build());
expect(component.getStateProgress()).toBe(0);
});
it('should return 50 progress for active state', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withState('active').build());
expect(component.getStateProgress()).toBe(50);
});
it('should return 100 progress for resolved state', () => {
fixture.componentRef.setInput('task', new TaskBuilder().withState('resolved').build());
expect(component.getStateProgress()).toBe(100);
});
it('should emit close event', () => {
const spy = vi.fn();
component.close.subscribe(spy);
component.onClose();
expect(spy).toHaveBeenCalledOnce();
});
it('should emit changeState event', () => {
const spy = vi.fn();
component.changeState.subscribe(spy);
component.onChangeState('active');
expect(spy).toHaveBeenCalledWith('active');
});
it('should emit deleteTask event', () => {
const spy = vi.fn();
component.deleteTask.subscribe(spy);
component.onDeleteTask();
expect(spy).toHaveBeenCalledOnce();
});
it('should emit addNote event', () => {
const spy = vi.fn();
component.addNote.subscribe(spy);
component.onAddNote('New note');
expect(spy).toHaveBeenCalledWith('New note');
});
it('should emit deleteNote event', () => {
const spy = vi.fn();
component.deleteNote.subscribe(spy);
component.onDeleteNote(0);
expect(spy).toHaveBeenCalledWith(0);
});
});

View File

@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { TaskForm } from '@app/features/tasks/ui/task-form/task-form';
describe('TaskForm', () => {
let component: TaskForm;
let fixture: ComponentFixture<TaskForm>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskForm, ReactiveFormsModule],
}).compileComponents();
fixture = TestBed.createComponent(TaskForm);
component = fixture.componentInstance;
const form = new FormGroup({
title: new FormControl('Test Task', [Validators.required]),
description: new FormControl('Test Description'),
dueDate: new FormControl('2026-12-01'),
});
fixture.componentRef.setInput('form', form);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default submit label', () => {
expect(component.submitLabel()).toBe('Save');
});
it('should have isEditing false by default', () => {
expect(component.isEditing()).toBe(false);
});
it('should have loading false by default', () => {
expect(component.loading()).toBe(false);
});
it('should emit submitted with form values when form is valid', () => {
const spy = vi.fn();
component.submitted.subscribe(spy);
component.onSubmit();
expect(spy).toHaveBeenCalledWith({
title: 'Test Task',
description: 'Test Description',
dueDate: '2026-12-01',
});
});
it('should not emit submitted when form is invalid', () => {
const spy = vi.fn();
component.submitted.subscribe(spy);
const form = new FormGroup({
title: new FormControl('', [Validators.required]),
});
fixture.componentRef.setInput('form', form);
fixture.detectChanges();
component.onSubmit();
expect(spy).not.toHaveBeenCalled();
});
it('should emit cancelled on cancel', () => {
const spy = vi.fn();
component.cancelled.subscribe(spy);
component.onCancel();
expect(spy).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskNoteList } from '@app/features/tasks/ui/task-note-list/task-note-list';
describe('TaskNoteList', () => {
let component: TaskNoteList;
let fixture: ComponentFixture<TaskNoteList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskNoteList],
}).compileComponents();
fixture = TestBed.createComponent(TaskNoteList);
component = fixture.componentInstance;
fixture.componentRef.setInput('notes', []);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render empty state when no notes', () => {
const empty = fixture.nativeElement.querySelector('.note-list__empty');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No notes yet');
});
it('should render notes list', () => {
fixture.componentRef.setInput('notes', ['Note 1', 'Note 2', 'Note 3']);
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.note-list__item');
expect(items.length).toBe(3);
});
it('should render note content', () => {
fixture.componentRef.setInput('notes', ['My first note']);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('.note-list__content');
expect(content?.textContent).toContain('My first note');
});
it('should have list role for accessibility', () => {
const list = fixture.nativeElement.querySelector('.note-list');
expect(list.getAttribute('role')).toBe('list');
});
it('should have aria-label on list', () => {
const list = fixture.nativeElement.querySelector('.note-list');
expect(list.getAttribute('aria-label')).toBe('Task notes');
});
it('should emit addNote with content', () => {
const spy = vi.fn();
component.addNote.subscribe(spy);
component.onAddNote('New note');
expect(spy).toHaveBeenCalledWith('New note');
});
it('should not emit addNote when content is empty', () => {
const spy = vi.fn();
component.addNote.subscribe(spy);
component.onAddNote(' ');
expect(spy).not.toHaveBeenCalled();
});
it('should emit deleteNote with index', () => {
const spy = vi.fn();
component.deleteNote.subscribe(spy);
component.onDeleteNote(2);
expect(spy).toHaveBeenCalledWith(2);
});
it('should have delete button with aria-label', () => {
fixture.componentRef.setInput('notes', ['Test note']);
fixture.detectChanges();
const deleteBtn = fixture.nativeElement.querySelector('.note-list__delete');
expect(deleteBtn.getAttribute('aria-label')).toBe('Delete note: Test note');
});
});

View File

@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskStateBadge } from '@app/features/tasks/ui/task-state-badge/task-state-badge';
describe('TaskStateBadge', () => {
let component: TaskStateBadge;
let fixture: ComponentFixture<TaskStateBadge>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskStateBadge],
}).compileComponents();
fixture = TestBed.createComponent(TaskStateBadge);
component = fixture.componentInstance;
fixture.componentRef.setInput('state', 'new');
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render span with role status', () => {
const span = fixture.nativeElement.querySelector('.state-badge');
expect(span).toBeTruthy();
expect(span.getAttribute('role')).toBe('status');
});
it('should show New label for new state', () => {
fixture.componentRef.setInput('state', 'new');
fixture.detectChanges();
expect(component.stateLabel).toBe('New');
expect(fixture.nativeElement.textContent).toContain('New');
});
it('should show Active label for active state', () => {
fixture.componentRef.setInput('state', 'active');
fixture.detectChanges();
expect(component.stateLabel).toBe('Active');
});
it('should show Resolved label for resolved state', () => {
fixture.componentRef.setInput('state', 'resolved');
fixture.detectChanges();
expect(component.stateLabel).toBe('Resolved');
});
it('should show Closed label for closed state', () => {
fixture.componentRef.setInput('state', 'closed');
fixture.detectChanges();
expect(component.stateLabel).toBe('Closed');
});
it('should apply state-specific class', () => {
fixture.componentRef.setInput('state', 'active');
fixture.detectChanges();
const span = fixture.nativeElement.querySelector('.state-badge');
expect(span.classList.contains('state-badge--active')).toBe(true);
});
it('should have aria-label with state', () => {
fixture.componentRef.setInput('state', 'resolved');
fixture.detectChanges();
const span = fixture.nativeElement.querySelector('.state-badge');
expect(span.getAttribute('aria-label')).toBe('Task state: Resolved');
});
});

View File

@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { ok, err, isOk, isErr } from '@app/shared/models/result.model';
describe('Result model', () => {
describe('ok', () => {
it('should create a success result with data', () => {
const data = { name: 'test' };
const result = ok(data);
expect(result.success).toBe(true);
expect((result as { success: true; data: typeof data }).data).toEqual(data);
});
});
describe('err', () => {
it('should create an error result with error', () => {
const error = new Error('fail');
const result = err(error);
expect(result.success).toBe(false);
expect((result as { success: false; error: Error }).error).toEqual(error);
});
});
describe('isOk', () => {
it('should return true for success result', () => {
const result = ok('value');
const check = isOk(result);
expect(check).toBe(true);
});
it('should return false for error result', () => {
const result = err(new Error('fail'));
const check = isOk(result);
expect(check).toBe(false);
});
});
describe('isErr', () => {
it('should return true for error result', () => {
const result = err(new Error('fail'));
const check = isErr(result);
expect(check).toBe(true);
});
it('should return false for success result', () => {
const result = ok('value');
const check = isErr(result);
expect(check).toBe(false);
});
});
});

View File

@ -0,0 +1,38 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { SafeDatePipe } from '@app/shared/pipes/safe-date-pipe';
describe('SafeDatePipe', () => {
let pipe: SafeDatePipe;
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => {
pipe = new SafeDatePipe();
});
it('should transform a date string to relative format', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = pipe.transform('2026-05-13T10:00:00');
expect(result).toBe('Today');
});
it('should transform a Date object to relative format', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = pipe.transform(new Date('2026-05-12T10:00:00'));
expect(result).toBe('Yesterday');
});
it('should return empty string for null value', () => {
const result = pipe.transform(null);
expect(result).toBe('');
});
});

View File

@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Button } from '@app/shared/ui/button/button';
describe('Button', () => {
let component: Button;
let fixture: ComponentFixture<Button>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Button],
}).compileComponents();
fixture = TestBed.createComponent(Button);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render a button element', () => {
const button = fixture.nativeElement.querySelector('button');
expect(button).toBeTruthy();
});
it('should apply primary class by default', () => {
const button = fixture.nativeElement.querySelector('button');
expect(button.classList.contains('btn--primary')).toBe(true);
});
it('should apply outline class when variant is outline', () => {
fixture.componentRef.setInput('variant', 'outline');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList.contains('btn--outline')).toBe(true);
expect(button.classList.contains('btn--primary')).toBe(false);
});
it('should apply ghost class when variant is ghost', () => {
fixture.componentRef.setInput('variant', 'ghost');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList.contains('btn--ghost')).toBe(true);
});
it('should apply danger class when variant is danger', () => {
fixture.componentRef.setInput('variant', 'danger');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList.contains('btn--danger')).toBe(true);
});
it('should apply sm class when size is sm', () => {
fixture.componentRef.setInput('size', 'sm');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList.contains('btn--sm')).toBe(true);
});
it('should apply lg class when size is lg', () => {
fixture.componentRef.setInput('size', 'lg');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList.contains('btn--lg')).toBe(true);
});
it('should not apply sm or lg class when size is md', () => {
fixture.componentRef.setInput('size', 'md');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.classList.contains('btn--sm')).toBe(false);
expect(button.classList.contains('btn--lg')).toBe(false);
});
it('should disable button when disabled is true', () => {
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.disabled).toBe(true);
});
it('should set button type', () => {
fixture.componentRef.setInput('buttonType', 'submit');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
expect(button.type).toBe('submit');
});
it('should emit clicked event on click', () => {
const spy = vi.fn();
component.clicked.subscribe(spy);
const button = fixture.nativeElement.querySelector('button');
button.click();
expect(spy).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Card } from '@app/shared/ui/card/card';
describe('Card', () => {
let component: Card;
let fixture: ComponentFixture<Card>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Card],
}).compileComponents();
fixture = TestBed.createComponent(Card);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render a div with card class', () => {
const div = fixture.nativeElement.querySelector('.card');
expect(div).toBeTruthy();
});
it('should not apply elevated class by default', () => {
const div = fixture.nativeElement.querySelector('.card');
expect(div.classList.contains('card--elevated')).toBe(false);
});
it('should apply elevated class when elevated is true', () => {
fixture.componentRef.setInput('elevated', true);
fixture.detectChanges();
const div = fixture.nativeElement.querySelector('.card');
expect(div.classList.contains('card--elevated')).toBe(true);
});
it('should apply bordered class when bordered is true', () => {
fixture.componentRef.setInput('bordered', true);
fixture.detectChanges();
const div = fixture.nativeElement.querySelector('.card');
expect(div.classList.contains('card--bordered')).toBe(true);
});
it('should apply inverse class when inverse is true', () => {
fixture.componentRef.setInput('inverse', true);
fixture.detectChanges();
const div = fixture.nativeElement.querySelector('.card');
expect(div.classList.contains('card--inverse')).toBe(true);
});
it('should render content projection', () => {
const hostFixture = TestBed.createComponent(Card);
hostFixture.nativeElement.innerHTML = '<emi-card><p>Test content</p></emi-card>';
hostFixture.detectChanges();
const content = hostFixture.nativeElement.querySelector('p');
expect(content?.textContent).toBe('Test content');
});
});

View File

@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormField } from '@app/shared/ui/form-field/form-field';
describe('FormField', () => {
let component: FormField;
let fixture: ComponentFixture<FormField>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormField],
}).compileComponents();
fixture = TestBed.createComponent(FormField);
component = fixture.componentInstance;
fixture.componentRef.setInput('inputId', 'test-input');
fixture.componentRef.setInput('label', 'Test Label');
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render label with text', () => {
const label = fixture.nativeElement.querySelector('.form-field__label');
expect(label?.textContent).toContain('Test Label');
});
it('should set for attribute on label', () => {
const label = fixture.nativeElement.querySelector('.form-field__label');
expect(label.getAttribute('for')).toBe('test-input');
});
it('should not show required marker by default', () => {
const required = fixture.nativeElement.querySelector('.form-field__required');
expect(required).toBeNull();
});
it('should show required marker when required is true', () => {
fixture.componentRef.setInput('required', true);
fixture.detectChanges();
const required = fixture.nativeElement.querySelector('.form-field__required');
expect(required).toBeTruthy();
expect(required.textContent).toContain('*');
});
it('should not show error message by default', () => {
const error = fixture.nativeElement.querySelector('.form-field__error');
expect(error).toBeNull();
});
it('should show error message when hasError and errorMessage are set', () => {
fixture.componentRef.setInput('hasError', true);
fixture.componentRef.setInput('errorMessage', 'This field is required');
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('.form-field__error');
expect(error).toBeTruthy();
expect(error.textContent).toContain('This field is required');
});
it('should not show error when hasError is true but no errorMessage', () => {
fixture.componentRef.setInput('hasError', true);
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('.form-field__error');
expect(error).toBeNull();
});
it('should apply error class when hasError is true', () => {
fixture.componentRef.setInput('hasError', true);
fixture.detectChanges();
const div = fixture.nativeElement.querySelector('.form-field');
expect(div.classList.contains('form-field--error')).toBe(true);
});
it('should show helper text when set', () => {
fixture.componentRef.setInput('helperText', 'Some helper text');
fixture.detectChanges();
const helper = fixture.nativeElement.querySelector('.form-field__helper');
expect(helper).toBeTruthy();
expect(helper.textContent).toContain('Some helper text');
});
it('should not show helper text by default', () => {
const helper = fixture.nativeElement.querySelector('.form-field__helper');
expect(helper).toBeNull();
});
});

View File

@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Modal } from '@app/shared/ui/modal/modal';
describe('Modal', () => {
let component: Modal;
let fixture: ComponentFixture<Modal>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Modal],
}).compileComponents();
fixture = TestBed.createComponent(Modal);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render placeholder content', () => {
const p = fixture.nativeElement.querySelector('p');
expect(p?.textContent).toContain('modal works');
});
});

View File

@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NotFound } from '@app/shared/ui/not-found/not-found';
describe('NotFound', () => {
let component: NotFound;
let fixture: ComponentFixture<NotFound>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotFound],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(NotFound);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render 404 code', () => {
const code = fixture.nativeElement.querySelector('.not-found__code');
expect(code?.textContent).toContain('404');
});
it('should render title', () => {
const title = fixture.nativeElement.querySelector('.not-found__title');
expect(title?.textContent).toContain('Page not found');
});
it('should render description', () => {
const description = fixture.nativeElement.querySelector('.not-found__description');
expect(description?.textContent).toContain("doesn't exist");
});
it('should render link to tasks', () => {
const link = fixture.nativeElement.querySelector('a');
expect(link).toBeTruthy();
expect(link.getAttribute('routerLink')).toBe('/tasks');
expect(link.textContent).toContain('Go to Tasks');
});
it('should have section with aria-label', () => {
const section = fixture.nativeElement.querySelector('section');
expect(section.getAttribute('aria-label')).toBe('Page not found');
});
});

View File

@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Pagination } from '@app/shared/ui/pagination/pagination';
describe('Pagination', () => {
let component: Pagination;
let fixture: ComponentFixture<Pagination>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Pagination],
}).compileComponents();
fixture = TestBed.createComponent(Pagination);
component = fixture.componentInstance;
fixture.componentRef.setInput('currentPage', 1);
fixture.componentRef.setInput('totalPages', 5);
fixture.componentRef.setInput('totalItems', 25);
fixture.componentRef.setInput('pageSize', 5);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render pagination info', () => {
const info = fixture.nativeElement.querySelector('.pagination__info');
expect(info?.textContent).toContain('Showing 1 to 5 of 25 tasks');
});
it('should render page buttons', () => {
const buttons = fixture.nativeElement.querySelectorAll('.pagination__btn--page');
expect(buttons.length).toBe(5);
});
it('should highlight current page', () => {
const activeBtn = fixture.nativeElement.querySelector('.pagination__btn--active');
expect(activeBtn?.textContent?.trim()).toBe('1');
});
it('should disable previous button on first page', () => {
const prevBtn = fixture.nativeElement.querySelector('.pagination__btn--prev');
expect(prevBtn.disabled).toBe(true);
});
it('should disable next button on last page', () => {
fixture.componentRef.setInput('currentPage', 5);
fixture.detectChanges();
const nextBtn = fixture.nativeElement.querySelector('.pagination__btn--next');
expect(nextBtn.disabled).toBe(true);
});
it('should emit pageChange on page click', () => {
const spy = vi.fn();
component.pageChange.subscribe(spy);
component.onPageChange(3);
expect(spy).toHaveBeenCalledWith(3);
});
it('should emit pageChange on previous click', () => {
fixture.componentRef.setInput('currentPage', 3);
fixture.detectChanges();
const spy = vi.fn();
component.pageChange.subscribe(spy);
component.onPrevious();
expect(spy).toHaveBeenCalledWith(2);
});
it('should emit pageChange on next click', () => {
const spy = vi.fn();
component.pageChange.subscribe(spy);
component.onNext();
expect(spy).toHaveBeenCalledWith(2);
});
it('should not emit for invalid page', () => {
const spy = vi.fn();
component.pageChange.subscribe(spy);
component.onPageChange(0);
component.onPageChange(6);
expect(spy).not.toHaveBeenCalled();
});
it('should return correct start and end items', () => {
expect(component.startItem()).toBe(1);
expect(component.endItem()).toBe(5);
});
it('should return correct end item for last page', () => {
fixture.componentRef.setInput('currentPage', 5);
fixture.componentRef.setInput('totalItems', 23);
fixture.detectChanges();
expect(component.endItem()).toBe(23);
});
it('should show ellipsis for many pages', () => {
fixture.componentRef.setInput('totalPages', 10);
fixture.componentRef.setInput('currentPage', 5);
fixture.detectChanges();
const ellipsis = fixture.nativeElement.querySelectorAll('.pagination__ellipsis');
expect(ellipsis.length).toBe(2);
});
});

View File

@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Spinner } from '@app/shared/ui/spinner/spinner';
describe('Spinner', () => {
let component: Spinner;
let fixture: ComponentFixture<Spinner>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Spinner],
}).compileComponents();
fixture = TestBed.createComponent(Spinner);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render a span with spinner class', () => {
const span = fixture.nativeElement.querySelector('.spinner');
expect(span).toBeTruthy();
});
it('should have role status for accessibility', () => {
const span = fixture.nativeElement.querySelector('.spinner');
expect(span.getAttribute('role')).toBe('status');
});
it('should have aria-label for accessibility', () => {
const span = fixture.nativeElement.querySelector('.spinner');
expect(span.getAttribute('aria-label')).toBe('Loading');
});
it('should apply md class by default', () => {
const span = fixture.nativeElement.querySelector('.spinner');
expect(span.classList.contains('spinner--md')).toBe(true);
});
it('should apply sm class when size is sm', () => {
fixture.componentRef.setInput('size', 'sm');
fixture.detectChanges();
const span = fixture.nativeElement.querySelector('.spinner');
expect(span.classList.contains('spinner--sm')).toBe(true);
expect(span.classList.contains('spinner--md')).toBe(false);
});
it('should apply lg class when size is lg', () => {
fixture.componentRef.setInput('size', 'lg');
fixture.detectChanges();
const span = fixture.nativeElement.querySelector('.spinner');
expect(span.classList.contains('spinner--lg')).toBe(true);
});
});

View File

@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Toast } from '@app/shared/ui/toast/toast';
import { Notification } from '@app/core/services/notification';
describe('Toast', () => {
let component: Toast;
let fixture: ComponentFixture<Toast>;
let notification: Notification;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Toast],
}).compileComponents();
fixture = TestBed.createComponent(Toast);
component = fixture.componentInstance;
notification = TestBed.inject(Notification);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should not render when no notifications', () => {
const toasts = fixture.nativeElement.querySelectorAll('.toast');
expect(toasts.length).toBe(0);
});
it('should render error notification', () => {
notification.error('Test error');
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.toast');
expect(toast).toBeTruthy();
expect(toast.classList.contains('toast--error')).toBe(true);
expect(toast.textContent).toContain('Test error');
});
it('should render success notification', () => {
notification.success('Test success');
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.toast');
expect(toast.classList.contains('toast--success')).toBe(true);
});
it('should render warning notification', () => {
notification.warning('Test warning');
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.toast');
expect(toast.classList.contains('toast--warning')).toBe(true);
});
it('should render info notification', () => {
notification.info('Test info');
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.toast');
expect(toast.classList.contains('toast--info')).toBe(true);
});
it('should dismiss notification on close click', () => {
notification.error('To dismiss');
fixture.detectChanges();
const closeBtn = fixture.nativeElement.querySelector('.toast__close');
closeBtn.click();
fixture.detectChanges();
const toasts = fixture.nativeElement.querySelectorAll('.toast');
expect(toasts.length).toBe(0);
});
it('should render multiple notifications', () => {
notification.success('First');
notification.error('Second');
fixture.detectChanges();
const toasts = fixture.nativeElement.querySelectorAll('.toast');
expect(toasts.length).toBe(2);
});
it('should have aria-live attribute', () => {
const container = fixture.nativeElement.querySelector('.toast-container');
expect(container.getAttribute('aria-live')).toBe('polite');
});
});

View File

@ -0,0 +1,114 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { toISODateString, isFutureDate, formatRelative } from '@app/shared/utils/date.util';
describe('date.util', () => {
afterEach(() => {
vi.useRealTimers();
});
describe('toISODateString', () => {
it('should return date in YYYY-MM-DD format', () => {
const date = new Date('2026-05-13T10:30:00Z');
const result = toISODateString(date);
expect(result).toBe('2026-05-13');
});
});
describe('isFutureDate', () => {
it('should return true for future date as Date object', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const futureDate = new Date('2026-12-25');
const result = isFutureDate(futureDate);
expect(result).toBe(true);
});
it('should return false for past date as string', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = isFutureDate('2020-01-01');
expect(result).toBe(false);
});
it('should return false for today', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = isFutureDate(new Date('2026-05-13'));
expect(result).toBe(false);
});
});
describe('formatRelative', () => {
it('should return Today for current date', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = formatRelative(new Date('2026-05-13T10:00:00'));
expect(result).toBe('Today');
});
it('should return Yesterday for previous day', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = formatRelative(new Date('2026-05-12T10:00:00'));
expect(result).toBe('Yesterday');
});
it('should return days ago for less than 7 days', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = formatRelative(new Date('2026-05-10T12:00:00'));
expect(result).toBe('3 days ago');
});
it('should return weeks ago for less than 30 days', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = formatRelative(new Date('2026-04-27T12:00:00'));
expect(result).toBe('2 weeks ago');
});
it('should return months ago for less than 365 days', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-15T12:00:00'));
const result = formatRelative(new Date('2026-02-13T12:00:00'));
expect(result).toBe('4 months ago');
});
it('should return years ago for more than 365 days', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = formatRelative(new Date('2024-05-13T12:00:00'));
expect(result).toBe('2 years ago');
});
it('should accept string input', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const result = formatRelative('2026-05-13T10:00:00');
expect(result).toBe('Today');
});
});
});

View File

@ -0,0 +1,118 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { FormControl, FormGroup } from '@angular/forms';
import { futureDateValidator, atLeastOneFilledValidator, trimmedRequiredValidator } from '@app/shared/utils/form-validators.util';
describe('form-validators.util', () => {
afterEach(() => {
vi.useRealTimers();
});
describe('futureDateValidator', () => {
it('should return null for empty value', () => {
const control = new FormControl('');
const result = futureDateValidator(control);
expect(result).toBeNull();
});
it('should return null for future date', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const control = new FormControl('2030-01-01');
const result = futureDateValidator(control);
expect(result).toBeNull();
});
it('should return error for past date', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const control = new FormControl('2020-01-01');
const result = futureDateValidator(control);
expect(result).toEqual({ futureDate: { value: '2020-01-01' } });
});
it('should return error for today', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T12:00:00'));
const control = new FormControl('2026-05-13');
const result = futureDateValidator(control);
expect(result).toEqual({ futureDate: { value: '2026-05-13' } });
});
});
describe('atLeastOneFilledValidator', () => {
it('should return null when at least one field has value', () => {
const group = new FormGroup({
title: new FormControl('Hello'),
description: new FormControl(''),
});
const result = atLeastOneFilledValidator('title', 'description')(group);
expect(result).toBeNull();
});
it('should return error when all fields are empty', () => {
const group = new FormGroup({
title: new FormControl(''),
description: new FormControl(''),
});
const result = atLeastOneFilledValidator('title', 'description')(group);
expect(result).toEqual({ atLeastOneFilled: { fields: ['title', 'description'] } });
});
it('should return error when all fields are whitespace only', () => {
const group = new FormGroup({
title: new FormControl(' '),
description: new FormControl(' '),
});
const result = atLeastOneFilledValidator('title', 'description')(group);
expect(result).toEqual({ atLeastOneFilled: { fields: ['title', 'description'] } });
});
});
describe('trimmedRequiredValidator', () => {
it('should return null for non-empty trimmed string', () => {
const control = new FormControl('hello');
const result = trimmedRequiredValidator(control);
expect(result).toBeNull();
});
it('should return error for empty string', () => {
const control = new FormControl('');
const result = trimmedRequiredValidator(control);
expect(result).toEqual({ trimmedRequired: { value: '' } });
});
it('should return error for whitespace only', () => {
const control = new FormControl(' ');
const result = trimmedRequiredValidator(control);
expect(result).toEqual({ trimmedRequired: { value: ' ' } });
});
it('should return error for non-string value', () => {
const control = new FormControl(null);
const result = trimmedRequiredValidator(control);
expect(result).toEqual({ trimmedRequired: { value: null } });
});
});
});

View File

@ -0,0 +1,59 @@
import { Task, TaskState, StateHistoryEntry } from '@app/features/tasks/data-access/models/task.model';
export class TaskBuilder {
private task: Task = {
id: 'task-001',
title: 'Default Task',
description: 'Default description',
dueDate: '2030-01-01',
stateHistory: [{ state: 'new', date: '2026-01-01' }],
notes: [],
};
withId(id: string): this {
this.task.id = id;
return this;
}
withTitle(title: string): this {
this.task.title = title;
return this;
}
withDescription(description: string): this {
this.task.description = description;
return this;
}
withDueDate(dueDate: string): this {
this.task.dueDate = dueDate;
return this;
}
withState(state: TaskState, date?: string): this {
this.task.stateHistory = [{ state, date: date ?? '2026-01-01' }];
return this;
}
withStateHistory(stateHistory: StateHistoryEntry[]): this {
this.task.stateHistory = stateHistory;
return this;
}
withNotes(notes: string[]): this {
this.task.notes = notes;
return this;
}
build(): Task {
return { ...this.task };
}
static buildMany(count: number, customize?: (builder: TaskBuilder, index: number) => void): Task[] {
return Array.from({ length: count }, (_, i) => {
const builder = new TaskBuilder().withId(`task-${i + 1}`).withTitle(`Task ${i + 1}`);
customize?.(builder, i);
return builder.build();
});
}
}