test: add unit tests for application components
This commit is contained in:
parent
7d9c4acc7a
commit
91f0071d3a
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue