feat: Implement tasks feature using NGRX signals and remove the old counter store, alongside general project configuration and skill documentation updates.
continuous-integration/drone/pr Build is passing

This commit is contained in:
Dennis Hundertmark
2026-03-08 09:50:17 +01:00
parent 2184971175
commit 9d13cc652a
47 changed files with 15272 additions and 14144 deletions
+248 -253
View File
@@ -19,19 +19,19 @@ Configure in angular.json:
```json
{
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
}
}
}
}
}
}
}
```
@@ -48,41 +48,41 @@ For Vitest migration from Jasmine and advanced configuration, see [references/vi
## Basic Component Test
```typescript
import { describe, it, expect, beforeEach } from "vitest";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Counter } from "./counter.component";
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Counter } from './counter.component';
describe("Counter", () => {
let component: Counter;
let fixture: ComponentFixture<Counter>;
describe('Counter', () => {
let component: Counter;
let fixture: ComponentFixture<Counter>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Counter], // Standalone component
}).compileComponents();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Counter], // Standalone component
}).compileComponents();
fixture = TestBed.createComponent(Counter);
component = fixture.componentInstance;
fixture.detectChanges();
});
fixture = TestBed.createComponent(Counter);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it("should increment count", () => {
expect(component.count()).toBe(0);
component.increment();
expect(component.count()).toBe(1);
});
it('should increment count', () => {
expect(component.count()).toBe(0);
component.increment();
expect(component.count()).toBe(1);
});
it("should display count in template", () => {
component.count.set(5);
fixture.detectChanges();
it('should display count in template', () => {
component.count.set(5);
fixture.detectChanges();
const element = fixture.nativeElement.querySelector(".count");
expect(element.textContent).toContain("5");
});
const element = fixture.nativeElement.querySelector('.count');
expect(element.textContent).toContain('5');
});
});
```
@@ -91,21 +91,21 @@ describe("Counter", () => {
### Direct Signal Testing
```typescript
import { signal, computed } from "@angular/core";
import { signal, computed } from '@angular/core';
describe("Signal logic", () => {
it("should update computed when signal changes", () => {
const count = signal(0);
const doubled = computed(() => count() * 2);
describe('Signal logic', () => {
it('should update computed when signal changes', () => {
const count = signal(0);
const doubled = computed(() => count() * 2);
expect(doubled()).toBe(0);
expect(doubled()).toBe(0);
count.set(5);
expect(doubled()).toBe(10);
count.set(5);
expect(doubled()).toBe(10);
count.update((c) => c + 1);
expect(doubled()).toBe(12);
});
count.update((c) => c + 1);
expect(doubled()).toBe(12);
});
});
```
@@ -113,60 +113,60 @@ describe("Signal logic", () => {
```typescript
@Component({
selector: "app-todo-list",
template: `
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li>{{ todo.text }}</li>
}
</ul>
<p>{{ remaining() }} remaining</p>
`,
selector: 'app-todo-list',
template: `
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li>{{ todo.text }}</li>
}
</ul>
<p>{{ remaining() }} remaining</p>
`,
})
export class TodoList {
todos = signal<Todo[]>([]);
filter = signal<"all" | "active" | "done">("all");
todos = signal<Todo[]>([]);
filter = signal<'all' | 'active' | 'done'>('all');
filteredTodos = computed(() => {
const todos = this.todos();
switch (this.filter()) {
case "active":
return todos.filter((t) => !t.done);
case "done":
return todos.filter((t) => t.done);
default:
return todos;
}
});
filteredTodos = computed(() => {
const todos = this.todos();
switch (this.filter()) {
case 'active':
return todos.filter((t) => !t.done);
case 'done':
return todos.filter((t) => t.done);
default:
return todos;
}
});
remaining = computed(() => this.todos().filter((t) => !t.done).length);
remaining = computed(() => this.todos().filter((t) => !t.done).length);
}
describe("TodoList", () => {
let component: TodoList;
let fixture: ComponentFixture<TodoList>;
describe('TodoList', () => {
let component: TodoList;
let fixture: ComponentFixture<TodoList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TodoList],
}).compileComponents();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TodoList],
}).compileComponents();
fixture = TestBed.createComponent(TodoList);
component = fixture.componentInstance;
});
fixture = TestBed.createComponent(TodoList);
component = fixture.componentInstance;
});
it("should filter active todos", () => {
component.todos.set([
{ id: "1", text: "Task 1", done: false },
{ id: "2", text: "Task 2", done: true },
{ id: "3", text: "Task 3", done: false },
]);
it('should filter active todos', () => {
component.todos.set([
{ id: '1', text: 'Task 1', done: false },
{ id: '2', text: 'Task 2', done: true },
{ id: '3', text: 'Task 3', done: false },
]);
component.filter.set("active");
component.filter.set('active');
expect(component.filteredTodos().length).toBe(2);
expect(component.remaining()).toBe(2);
});
expect(component.filteredTodos().length).toBe(2);
expect(component.remaining()).toBe(2);
});
});
```
@@ -176,29 +176,29 @@ OnPush components require explicit change detection:
```typescript
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ data().name }}</span>`,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ data().name }}</span>`,
})
export class OnPushCmpt {
data = input.required<{ name: string }>();
data = input.required<{ name: string }>();
}
describe("OnPushCmpt", () => {
it("should update when input signal changes", () => {
const fixture = TestBed.createComponent(OnPushCmpt);
describe('OnPushCmpt', () => {
it('should update when input signal changes', () => {
const fixture = TestBed.createComponent(OnPushCmpt);
// Set input using setInput (for signal inputs)
fixture.componentRef.setInput("data", { name: "Initial" });
fixture.detectChanges();
// Set input using setInput (for signal inputs)
fixture.componentRef.setInput('data', { name: 'Initial' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Initial");
expect(fixture.nativeElement.textContent).toContain('Initial');
// Update input
fixture.componentRef.setInput("data", { name: "Updated" });
fixture.detectChanges();
// Update input
fixture.componentRef.setInput('data', { name: 'Updated' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Updated");
});
expect(fixture.nativeElement.textContent).toContain('Updated');
});
});
```
@@ -207,72 +207,69 @@ describe("OnPushCmpt", () => {
### Basic Service Test
```typescript
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class CounterService {
private _count = signal(0);
readonly count = this._count.asReadonly();
private _count = signal(0);
readonly count = this._count.asReadonly();
increment() {
this._count.update((c) => c + 1);
}
reset() {
this._count.set(0);
}
increment() {
this._count.update((c) => c + 1);
}
reset() {
this._count.set(0);
}
}
describe("CounterService", () => {
let service: CounterService;
describe('CounterService', () => {
let service: CounterService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CounterService);
});
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CounterService);
});
it("should increment count", () => {
expect(service.count()).toBe(0);
service.increment();
expect(service.count()).toBe(1);
});
it('should increment count', () => {
expect(service.count()).toBe(0);
service.increment();
expect(service.count()).toBe(1);
});
});
```
### Service with HTTP
```typescript
import {
HttpTestingController,
provideHttpClientTesting,
} from "@angular/common/http/testing";
import { provideHttpClient } from "@angular/common/http";
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
describe("UserService", () => {
let service: UserService;
let httpMock: HttpTestingController;
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Verify no outstanding requests
});
it("should fetch user by id", () => {
const mockUser = { id: "1", name: "Test User" };
service.getUser("1").subscribe((user) => {
expect(user).toEqual(mockUser);
afterEach(() => {
httpMock.verify(); // Verify no outstanding requests
});
const req = httpMock.expectOne("/api/users/1");
expect(req.request.method).toBe("GET");
req.flush(mockUser);
});
it('should fetch user by id', () => {
const mockUser = { id: '1', name: 'Test User' };
service.getUser('1').subscribe((user) => {
expect(user).toEqual(mockUser);
});
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
req.flush(mockUser);
});
});
```
@@ -281,31 +278,31 @@ describe("UserService", () => {
### Using Vitest Mocks
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe("UserProfile", () => {
const mockUserService = {
getUser: vi.fn(),
updateUser: vi.fn(),
user: signal<User | null>(null),
};
describe('UserProfile', () => {
const mockUserService = {
getUser: vi.fn(),
updateUser: vi.fn(),
user: signal<User | null>(null),
};
beforeEach(async () => {
vi.clearAllMocks();
mockUserService.getUser.mockReturnValue(of({ id: "1", name: "Test" }));
beforeEach(async () => {
vi.clearAllMocks();
mockUserService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
await TestBed.configureTestingModule({
imports: [UserProfile],
providers: [{ provide: UserService, useValue: mockUserService }],
}).compileComponents();
});
await TestBed.configureTestingModule({
imports: [UserProfile],
providers: [{ provide: UserService, useValue: mockUserService }],
}).compileComponents();
});
it("should call getUser on init", () => {
const fixture = TestBed.createComponent(UserProfile);
fixture.detectChanges();
it('should call getUser on init', () => {
const fixture = TestBed.createComponent(UserProfile);
fixture.detectChanges();
expect(mockUserService.getUser).toHaveBeenCalledWith("1");
});
expect(mockUserService.getUser).toHaveBeenCalledWith('1');
});
});
```
@@ -313,28 +310,26 @@ describe("UserProfile", () => {
```typescript
const mockAuth = {
user: signal<User | null>(null),
isAuthenticated: computed(() => mockAuth.user() !== null),
login: vi.fn(),
logout: vi.fn(),
user: signal<User | null>(null),
isAuthenticated: computed(() => mockAuth.user() !== null),
login: vi.fn(),
logout: vi.fn(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProtectedPage],
providers: [{ provide: AuthService, useValue: mockAuth }],
}).compileComponents();
await TestBed.configureTestingModule({
imports: [ProtectedPage],
providers: [{ provide: AuthService, useValue: mockAuth }],
}).compileComponents();
});
it("should show content when authenticated", () => {
mockAuth.user.set({ id: "1", name: "Test User" });
it('should show content when authenticated', () => {
mockAuth.user.set({ id: '1', name: 'Test User' });
const fixture = TestBed.createComponent(ProtectedPage);
fixture.detectChanges();
const fixture = TestBed.createComponent(ProtectedPage);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(".protected-content"),
).toBeTruthy();
expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy();
});
```
@@ -342,33 +337,33 @@ it("should show content when authenticated", () => {
```typescript
@Component({
selector: "app-item",
template: `<div (click)="select()">{{ item().name }}</div>`,
selector: 'app-item',
template: `<div (click)="select()">{{ item().name }}</div>`,
})
export class ItemCmpt {
item = input.required<Item>();
selected = output<Item>();
item = input.required<Item>();
selected = output<Item>();
select() {
this.selected.emit(this.item());
}
select() {
this.selected.emit(this.item());
}
}
describe("ItemCmpt", () => {
it("should emit selected event on click", () => {
const fixture = TestBed.createComponent(ItemCmpt);
const item: Item = { id: "1", name: "Test Item" };
describe('ItemCmpt', () => {
it('should emit selected event on click', () => {
const fixture = TestBed.createComponent(ItemCmpt);
const item: Item = { id: '1', name: 'Test Item' };
fixture.componentRef.setInput("item", item);
fixture.detectChanges();
fixture.componentRef.setInput('item', item);
fixture.detectChanges();
let emittedItem: Item | undefined;
fixture.componentInstance.selected.subscribe((i) => (emittedItem = i));
let emittedItem: Item | undefined;
fixture.componentInstance.selected.subscribe((i) => (emittedItem = i));
fixture.nativeElement.querySelector("div").click();
fixture.nativeElement.querySelector('div').click();
expect(emittedItem).toEqual(item);
});
expect(emittedItem).toEqual(item);
});
});
```
@@ -377,36 +372,36 @@ describe("ItemCmpt", () => {
### Using fakeAsync
```typescript
import { fakeAsync, tick, flush } from "@angular/core/testing";
import { fakeAsync, tick, flush } from '@angular/core/testing';
it("should debounce search", fakeAsync(() => {
const fixture = TestBed.createComponent(SearchCmpt);
fixture.detectChanges();
it('should debounce search', fakeAsync(() => {
const fixture = TestBed.createComponent(SearchCmpt);
fixture.detectChanges();
fixture.componentInstance.query.set("test");
fixture.componentInstance.query.set('test');
tick(300); // Advance time for debounce
fixture.detectChanges();
tick(300); // Advance time for debounce
fixture.detectChanges();
expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
flush(); // Flush remaining timers
flush(); // Flush remaining timers
}));
```
### Using waitForAsync
```typescript
import { waitForAsync } from "@angular/core/testing";
import { waitForAsync } from '@angular/core/testing';
it("should load data", waitForAsync(() => {
const fixture = TestBed.createComponent(DataCmpt);
fixture.detectChanges();
fixture.whenStable().then(() => {
it('should load data', waitForAsync(() => {
const fixture = TestBed.createComponent(DataCmpt);
fixture.detectChanges();
expect(fixture.componentInstance.data()).toBeDefined();
});
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(fixture.componentInstance.data()).toBeDefined();
});
}));
```
@@ -414,43 +409,43 @@ it("should load data", waitForAsync(() => {
```typescript
@Component({
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.hasValue()) {
<p>{{ userResource.value().name }}</p>
}
`,
template: `
@if (userResource.isLoading()) {
<p>Loading...</p>
} @else if (userResource.hasValue()) {
<p>{{ userResource.value().name }}</p>
}
`,
})
export class UserCmpt {
userId = signal("1");
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
userId = signal('1');
userResource = httpResource<User>(() => `/api/users/${this.userId()}`);
}
describe("UserCmpt", () => {
let httpMock: HttpTestingController;
describe('UserCmpt', () => {
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UserCmpt],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
httpMock = TestBed.inject(HttpTestingController);
});
it("should display user name after loading", () => {
const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges();
it('should display user name after loading', () => {
const fixture = TestBed.createComponent(UserCmpt);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Loading");
expect(fixture.nativeElement.textContent).toContain('Loading');
const req = httpMock.expectOne("/api/users/1");
req.flush({ id: "1", name: "John Doe" });
fixture.detectChanges();
const req = httpMock.expectOne('/api/users/1');
req.flush({ id: '1', name: 'John Doe' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("John Doe");
});
expect(fixture.nativeElement.textContent).toContain('John Doe');
});
});
```
File diff suppressed because it is too large Load Diff
@@ -17,59 +17,56 @@
```typescript
// Jasmine
const spy = jasmine.createSpy("callback");
spy.and.returnValue("value");
expect(spy).toHaveBeenCalledWith("arg");
const spy = jasmine.createSpy('callback');
spy.and.returnValue('value');
expect(spy).toHaveBeenCalledWith('arg');
// Vitest
const spy = vi.fn();
spy.mockReturnValue("value");
expect(spy).toHaveBeenCalledWith("arg");
spy.mockReturnValue('value');
expect(spy).toHaveBeenCalledWith('arg');
```
### SpyOn Migration
```typescript
// Jasmine
spyOn(service, "method").and.returnValue(of(data));
spyOn(service, 'method').and.returnValue(of(data));
// Vitest
vi.spyOn(service, "method").mockReturnValue(of(data));
vi.spyOn(service, 'method').mockReturnValue(of(data));
```
### createSpyObj Migration
```typescript
// Jasmine
const mockService = jasmine.createSpyObj("UserService", [
"getUser",
"updateUser",
]);
mockService.getUser.and.returnValue(of({ id: "1", name: "Test" }));
const mockService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']);
mockService.getUser.and.returnValue(of({ id: '1', name: 'Test' }));
// Vitest
const mockService = {
getUser: vi.fn(),
updateUser: vi.fn(),
getUser: vi.fn(),
updateUser: vi.fn(),
};
mockService.getUser.mockReturnValue(of({ id: "1", name: "Test" }));
mockService.getUser.mockReturnValue(of({ id: '1', name: 'Test' }));
```
### Async Testing Migration
```typescript
// Jasmine - using done callback
it("should load data", (done) => {
service.loadData().subscribe((data) => {
expect(data).toBeDefined();
done();
});
it('should load data', (done) => {
service.loadData().subscribe((data) => {
expect(data).toBeDefined();
done();
});
});
// Vitest - using async/await
it("should load data", async () => {
const data = await firstValueFrom(service.loadData());
expect(data).toBeDefined();
it('should load data', async () => {
const data = await firstValueFrom(service.loadData());
expect(data).toBeDefined();
});
```
@@ -93,19 +90,19 @@ vi.useRealTimers();
```json
{
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
"projects": {
"your-app": {
"architect": {
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "tsconfig.spec.json",
"buildTarget": "your-app:build"
}
}
}
}
}
}
}
}
```
@@ -113,11 +110,11 @@ vi.useRealTimers();
```json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*.spec.ts"]
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["src/**/*.spec.ts"]
}
```
@@ -126,24 +123,19 @@ vi.useRealTimers();
For advanced configuration, create a `vite.config.ts`:
```typescript
import { defineConfig } from "vitest/config";
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
include: ["src/**/*.spec.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
exclude: [
"node_modules/",
"src/test-setup.ts",
"**/*.spec.ts",
"**/*.d.ts",
],
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
exclude: ['node_modules/', 'src/test-setup.ts', '**/*.spec.ts', '**/*.d.ts'],
},
},
},
});
```