54d1534d4d
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { signal } from '@angular/core';
|
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { provideRouter, Router } from '@angular/router';
|
|
import { ProjectContextService } from '../projects/project-context.service';
|
|
import { vi } from 'vitest';
|
|
import { Dashboard } from './dashboard';
|
|
import { IssueEntity, IssuesStore } from '../issues/issues.store';
|
|
import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store';
|
|
import { StatusesStore } from '../statuses/statuses.store';
|
|
|
|
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
|
id: 1,
|
|
type: 'Story',
|
|
assignee: '',
|
|
epic: '',
|
|
name: 'Test Issue',
|
|
startDate: '',
|
|
startDateMode: 'forced',
|
|
endDate: '',
|
|
dueDate: '',
|
|
description: '',
|
|
estimatedTime: null,
|
|
dependsOnIds: [],
|
|
comments: [],
|
|
priority: 'MOYENNE',
|
|
status: 'todo',
|
|
progress: 0,
|
|
...overrides,
|
|
});
|
|
|
|
class FakeIssuesStore {
|
|
private _data = signal<IssueEntity[]>([]);
|
|
readonly issues = this._data.asReadonly();
|
|
readonly loading = signal(false);
|
|
readonly loaded = signal(true);
|
|
|
|
seed(issues: IssueEntity[]): void {
|
|
this._data.set(issues);
|
|
}
|
|
|
|
load = vi.fn().mockResolvedValue(undefined);
|
|
getById = vi.fn();
|
|
getNextId = vi.fn().mockReturnValue(1);
|
|
upsert = vi.fn();
|
|
deleteById = vi.fn();
|
|
}
|
|
|
|
class FakeStatusesStore {
|
|
private completedIds = new Set<string>(['done']);
|
|
|
|
setCompleted(ids: string[]): void {
|
|
this.completedIds = new Set(ids);
|
|
}
|
|
|
|
isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId));
|
|
}
|
|
|
|
class FakeMilestonesStore {
|
|
private _data = signal<MilestoneEntity[]>([]);
|
|
readonly milestones = this._data.asReadonly();
|
|
readonly loading = signal(false);
|
|
readonly loaded = signal(true);
|
|
|
|
seed(milestones: MilestoneEntity[]): void {
|
|
this._data.set(milestones);
|
|
}
|
|
|
|
load = vi.fn().mockResolvedValue(undefined);
|
|
upsert = vi.fn();
|
|
deleteById = vi.fn();
|
|
}
|
|
|
|
describe('Dashboard', () => {
|
|
let component: Dashboard;
|
|
let fixture: ComponentFixture<Dashboard>;
|
|
let issuesStore: FakeIssuesStore;
|
|
let milestonesStore: FakeMilestonesStore;
|
|
let statusesStore: FakeStatusesStore;
|
|
let router: Router;
|
|
|
|
beforeEach(async () => {
|
|
issuesStore = new FakeIssuesStore();
|
|
milestonesStore = new FakeMilestonesStore();
|
|
statusesStore = new FakeStatusesStore();
|
|
|
|
await TestBed.configureTestingModule({
|
|
imports: [Dashboard],
|
|
providers: [
|
|
provideRouter([]),
|
|
{ provide: IssuesStore, useValue: issuesStore },
|
|
{ provide: MilestonesStore, useValue: milestonesStore },
|
|
{ provide: StatusesStore, useValue: statusesStore },
|
|
{ provide: ProjectContextService, useValue: { projectId: signal(1), project: signal(null) } },
|
|
],
|
|
}).compileComponents();
|
|
|
|
router = TestBed.inject(Router);
|
|
fixture = TestBed.createComponent(Dashboard);
|
|
component = fixture.componentInstance;
|
|
fixture.detectChanges();
|
|
});
|
|
|
|
it('should create', () => {
|
|
expect(component).toBeTruthy();
|
|
});
|
|
|
|
describe('totalIssues', () => {
|
|
it('retourne 0 quand il n\'y a pas d\'issues', () => {
|
|
expect((component as any).totalIssues()).toBe(0);
|
|
});
|
|
|
|
it('retourne le nombre total d\'issues', () => {
|
|
issuesStore.seed([makeIssue({ id: 1 }), makeIssue({ id: 2 }), makeIssue({ id: 3 })]);
|
|
expect((component as any).totalIssues()).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('completionRate', () => {
|
|
it('retourne 0 quand il n\'y a pas d\'issues', () => {
|
|
expect((component as any).completionRate()).toBe(0);
|
|
});
|
|
|
|
it('retourne 0 quand aucune issue n\'est terminée', () => {
|
|
issuesStore.seed([makeIssue({ status: 'todo' }), makeIssue({ status: 'in-progress' })]);
|
|
expect((component as any).completionRate()).toBe(0);
|
|
});
|
|
|
|
it('retourne 100 quand toutes les issues sont terminées', () => {
|
|
issuesStore.seed([makeIssue({ status: 'done' }), makeIssue({ status: 'done' })]);
|
|
expect((component as any).completionRate()).toBe(100);
|
|
});
|
|
|
|
it('calcule le pourcentage correct', () => {
|
|
issuesStore.seed([
|
|
makeIssue({ id: 1, status: 'done' }),
|
|
makeIssue({ id: 2, status: 'done' }),
|
|
makeIssue({ id: 3, status: 'todo' }),
|
|
makeIssue({ id: 4, status: 'todo' }),
|
|
]);
|
|
expect((component as any).completionRate()).toBe(50);
|
|
});
|
|
|
|
it('compte un statut personnalisé countsAsCompleted comme terminé', () => {
|
|
statusesStore.setCompleted(['done', 'abandoned']);
|
|
issuesStore.seed([
|
|
makeIssue({ id: 1, status: 'done' }),
|
|
makeIssue({ id: 2, status: 'abandoned' }),
|
|
makeIssue({ id: 3, status: 'todo' }),
|
|
makeIssue({ id: 4, status: 'todo' }),
|
|
]);
|
|
expect((component as any).completionRate()).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('statusCounts', () => {
|
|
it('compte correctement chaque statut', () => {
|
|
issuesStore.seed([
|
|
makeIssue({ id: 1, status: 'draft' }),
|
|
makeIssue({ id: 2, status: 'todo' }),
|
|
makeIssue({ id: 3, status: 'todo' }),
|
|
makeIssue({ id: 4, status: 'in-progress' }),
|
|
makeIssue({ id: 5, status: 'done' }),
|
|
]);
|
|
const counts = (component as any).statusCounts();
|
|
expect(counts.draft).toBe(1);
|
|
expect(counts.todo).toBe(2);
|
|
expect(counts.inProgress).toBe(1);
|
|
expect(counts.done).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('overdueCount', () => {
|
|
it('retourne 0 quand aucune issue n\'est en retard', () => {
|
|
const future = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
|
issuesStore.seed([makeIssue({ dueDate: future, status: 'todo' })]);
|
|
expect((component as any).overdueCount()).toBe(0);
|
|
});
|
|
|
|
it('ne compte pas les issues terminées même dépassées', () => {
|
|
const past = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
|
|
issuesStore.seed([makeIssue({ dueDate: past, status: 'done' })]);
|
|
expect((component as any).overdueCount()).toBe(0);
|
|
});
|
|
|
|
it('compte les issues non terminées avec date passée', () => {
|
|
const past = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
|
|
issuesStore.seed([
|
|
makeIssue({ id: 1, dueDate: past, status: 'todo' }),
|
|
makeIssue({ id: 2, dueDate: past, status: 'in-progress' }),
|
|
]);
|
|
expect((component as any).overdueCount()).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('highPriorityIssues', () => {
|
|
it('retourne les issues HAUTE et TRES_HAUTE non terminées', () => {
|
|
issuesStore.seed([
|
|
makeIssue({ id: 1, priority: 'TRES_HAUTE', status: 'todo' }),
|
|
makeIssue({ id: 2, priority: 'HAUTE', status: 'in-progress' }),
|
|
makeIssue({ id: 3, priority: 'MOYENNE', status: 'todo' }),
|
|
]);
|
|
expect((component as any).highPriorityIssues().length).toBe(2);
|
|
});
|
|
|
|
it('exclut les issues haute priorité terminées', () => {
|
|
issuesStore.seed([makeIssue({ priority: 'TRES_HAUTE', status: 'done' })]);
|
|
expect((component as any).highPriorityIssues().length).toBe(0);
|
|
});
|
|
|
|
it('exclut les issues avec un statut personnalisé countsAsCompleted', () => {
|
|
statusesStore.setCompleted(['done', 'abandoned']);
|
|
issuesStore.seed([makeIssue({ priority: 'TRES_HAUTE', status: 'abandoned' })]);
|
|
expect((component as any).highPriorityIssues().length).toBe(0);
|
|
});
|
|
|
|
it('limite à 6 résultats', () => {
|
|
issuesStore.seed(
|
|
Array.from({ length: 10 }, (_, i) =>
|
|
makeIssue({ id: i + 1, priority: 'HAUTE', status: 'todo' }),
|
|
),
|
|
);
|
|
expect((component as any).highPriorityIssues().length).toBe(6);
|
|
});
|
|
});
|
|
|
|
describe('upcomingIssues', () => {
|
|
it('retourne les issues avec échéance dans les 14 prochains jours', () => {
|
|
const soon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
issuesStore.seed([makeIssue({ dueDate: soon, status: 'todo' })]);
|
|
expect((component as any).upcomingIssues().length).toBe(1);
|
|
});
|
|
|
|
it('exclut les issues terminées', () => {
|
|
const soon = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
issuesStore.seed([makeIssue({ dueDate: soon, status: 'done' })]);
|
|
expect((component as any).upcomingIssues().length).toBe(0);
|
|
});
|
|
|
|
it('exclut les issues sans date d\'échéance', () => {
|
|
issuesStore.seed([makeIssue({ dueDate: '', status: 'todo' })]);
|
|
expect((component as any).upcomingIssues().length).toBe(0);
|
|
});
|
|
|
|
it('exclut les issues au-delà de 14 jours', () => {
|
|
const farFuture = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString();
|
|
issuesStore.seed([makeIssue({ dueDate: farFuture, status: 'todo' })]);
|
|
expect((component as any).upcomingIssues().length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('issuesByType', () => {
|
|
it('regroupe correctement par type', () => {
|
|
issuesStore.seed([
|
|
makeIssue({ id: 1, type: 'Bug' }),
|
|
makeIssue({ id: 2, type: 'Bug' }),
|
|
makeIssue({ id: 3, type: 'Story' }),
|
|
]);
|
|
const byType = (component as any).issuesByType();
|
|
const bug = byType.find((t: any) => t.type === 'Bug');
|
|
const story = byType.find((t: any) => t.type === 'Story');
|
|
expect(bug?.count).toBe(2);
|
|
expect(story?.count).toBe(1);
|
|
});
|
|
|
|
it('exclut les types sans issues', () => {
|
|
issuesStore.seed([makeIssue({ type: 'Bug' })]);
|
|
const byType = (component as any).issuesByType();
|
|
expect(byType.every((t: any) => t.count > 0)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('activeMilestones', () => {
|
|
it('exclut les milestones terminés à 100%', () => {
|
|
issuesStore.seed([makeIssue({ id: 1, status: 'done' })]);
|
|
milestonesStore.seed([{ id: 1, name: 'Done Milestone', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1], dependsOnIds: [] }]);
|
|
expect((component as any).activeMilestones().length).toBe(0);
|
|
});
|
|
|
|
it('inclut les milestones non terminés', () => {
|
|
issuesStore.seed([
|
|
makeIssue({ id: 1, status: 'done' }),
|
|
makeIssue({ id: 2, status: 'todo' }),
|
|
]);
|
|
milestonesStore.seed([{ id: 1, name: 'Active', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [1, 2], dependsOnIds: [] }]);
|
|
expect((component as any).activeMilestones().length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('formatDate', () => {
|
|
it('retourne "—" pour une chaîne vide', () => {
|
|
expect((component as any).formatDate('')).toBe('—');
|
|
});
|
|
|
|
it('formate une date ISO en format français', () => {
|
|
const result = (component as any).formatDate('2025-06-15T00:00:00.000Z');
|
|
expect(result).toMatch(/15\/06\/2025/);
|
|
});
|
|
});
|
|
|
|
describe('navigation', () => {
|
|
it('navigue vers /projects/:pid/issues/:id via openIssue', () => {
|
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
(component as any).openIssue(42);
|
|
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]);
|
|
});
|
|
|
|
it('navigue vers /projects/:pid/milestones/:id via openMilestone', () => {
|
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
(component as any).openMilestone(7);
|
|
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones', 7]);
|
|
});
|
|
|
|
it('navigue vers /projects/:pid/issues via navigateToIssues', () => {
|
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
(component as any).navigateToIssues();
|
|
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues']);
|
|
});
|
|
|
|
it('navigue vers /projects/:pid/milestones via navigateToMilestones', () => {
|
|
const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
(component as any).navigateToMilestones();
|
|
expect(spy).toHaveBeenCalledWith(['/projects', 1, 'milestones']);
|
|
});
|
|
});
|
|
});
|