import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { provideRouter } from '@angular/router'; import { vi } from 'vitest'; import { Issues } from './issues'; import { IssueEntity, IssuesStore } from './issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones/milestones.store'; import { StatusesStore } from '../statuses/statuses.store'; import { ProjectContextService } from '../projects/project-context.service'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, type: 'Story', assignee: '', epic: '', name: 'Test Issue', startDate: '', startDateMode: 'forced', endDate: '', dueDate: '', description: '', estimatedTime: null, dependsOnIds: [], comments: [], priority: 'MOYENNE', status: 'draft', progress: 50, ...overrides, }); class FakeIssuesStore { private _data = signal([ makeIssue({ id: 1, name: 'Issue 1', progress: 0 }), makeIssue({ id: 2, name: 'Issue 2', progress: 0 }), makeIssue({ id: 3, name: 'Issue 3', progress: 0 }), ]); readonly issues = this._data.asReadonly(); readonly loading = signal(false); readonly loaded = signal(true); getById(id: number): IssueEntity | undefined { return this._data().find((i) => i.id === id); } getNextId(): number { const ids = this._data().map((i) => i.id); return ids.length === 0 ? 1 : Math.max(...ids) + 1; } load(): Promise { return Promise.resolve(); } upsert(issue: any): Promise { const { comments: c, estimatedTime: et, dependsOnIds: deps, dependsOnId: legacy, ...rest } = issue; const normalized: IssueEntity = { type: 'Story', assignee: '', epic: '', name: '', dueDate: '', description: '', estimatedTime: et ?? null, comments: Array.isArray(c) ? c : [], priority: 'MOYENNE', status: 'draft', progress: 0, ...rest, dependsOnIds: Array.isArray(deps) ? deps.filter((v: unknown) => typeof v === 'number') : typeof legacy === 'number' ? [legacy] : [], }; this._data.update((issues) => { const idx = issues.findIndex((i) => i.id === normalized.id); if (idx === -1) return [...issues, normalized]; const copy = [...issues]; copy[idx] = normalized; return copy; }); return Promise.resolve(normalized); } deleteById(id: number): Promise { this._data.update((issues) => issues .filter((i) => i.id !== id) .map((i) => ({ ...i, dependsOnIds: i.dependsOnIds.filter((d) => d !== id) })), ); return Promise.resolve(); } } const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({ id: 1, name: 'Sprint 1', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [], ...overrides, }); class FakeStatusesStore { private completedIds = new Set(['done']); setCompleted(ids: string[]): void { this.completedIds = new Set(ids); } isCompleted = vi.fn((statusId: string) => this.completedIds.has(statusId)); getById = vi.fn((id: string) => id === 'done' ? { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true } : undefined, ); readonly statuses = signal([ { id: 'done', label: 'TERMINÉ', bg: '#dcfce7', color: '#166534', order: 3, countsAsCompleted: true }, ]); } class FakeMilestonesStore { private _data = signal([]); readonly milestones = this._data.asReadonly(); readonly loading = signal(false); readonly loaded = signal(true); seed(milestones: MilestoneEntity[]): void { this._data.set(milestones); } load(): Promise { return Promise.resolve(); } upsert(milestone: MilestoneEntity): Promise { this._data.update((list) => { const idx = list.findIndex((m) => m.id === milestone.id); if (idx === -1) return [...list, milestone]; const copy = [...list]; copy[idx] = milestone; return copy; }); return Promise.resolve(milestone); } deleteById(id: number): Promise { this._data.update((list) => list.filter((m) => m.id !== id)); return Promise.resolve(); } } describe('Issues', () => { let component: Issues; let fixture: ComponentFixture; let store: FakeIssuesStore; let milestonesStore: FakeMilestonesStore; let statusesStore: FakeStatusesStore; let router: Router; beforeEach(async () => { store = new FakeIssuesStore(); milestonesStore = new FakeMilestonesStore(); statusesStore = new FakeStatusesStore(); await TestBed.configureTestingModule({ imports: [Issues], providers: [ provideRouter([]), { provide: IssuesStore, useValue: store }, { 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(Issues); component = fixture.componentInstance; await fixture.whenStable(); }); it('should create', () => { expect(component).toBeTruthy(); }); const mockEvent = { stopPropagation: () => {} } as unknown as Event; describe('filteredIssues', () => { it('returns all issues when no types are selected', () => { (component as any).selectedTypes = new Set(); expect((component as any).filteredIssues.length).toBe(store.issues().length); }); it('returns only issues matching the selected type', () => { (component as any).selectedTypes = new Set(['Bug']); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.every((i) => i.type === 'Bug')).toBe(true); }); it('returns empty array when no issues match the selected types', () => { (component as any).selectedTypes = new Set(['Epic']); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.every((i) => i.type === 'Epic')).toBe(true); }); it('returns issues matching any of multiple selected types', () => { (component as any).selectedTypes = new Set(['Bug', 'Story']); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.every((i) => i.type === 'Bug' || i.type === 'Story')).toBe(true); }); it('filters by status when selectedStatuses is set', () => { (component as any).selectedStatuses = new Set(['done']); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.every((i) => i.status === 'done')).toBe(true); }); it('filters by search query on issue name', () => { (component as any).searchQuery = 'Issue 1'; const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.length).toBe(1); expect(filtered[0].name).toBe('Issue 1'); }); }); describe('toggleType', () => { it('adds a type when not already selected', () => { (component as any).selectedTypes = new Set(); (component as any).toggleType('Bug', mockEvent); expect((component as any).selectedTypes.has('Bug')).toBe(true); }); it('removes a type when already selected (toggle off)', () => { (component as any).selectedTypes = new Set(['Bug']); (component as any).toggleType('Bug', mockEvent); expect((component as any).selectedTypes.has('Bug')).toBe(false); }); it('can select multiple types simultaneously', () => { (component as any).selectedTypes = new Set(); (component as any).toggleType('Bug', mockEvent); (component as any).toggleType('Story', mockEvent); expect((component as any).selectedTypes.size).toBe(2); expect((component as any).selectedTypes.has('Bug')).toBe(true); expect((component as any).selectedTypes.has('Story')).toBe(true); }); }); describe('clearTypes', () => { it('empties the type selection', () => { (component as any).selectedTypes = new Set(['Bug', 'Story']); (component as any).clearTypes(mockEvent); expect((component as any).selectedTypes.size).toBe(0); }); }); describe('toggleStatus', () => { it('adds a status when not already selected', () => { (component as any).selectedStatuses = new Set(); (component as any).toggleStatus('done', mockEvent); expect((component as any).selectedStatuses.has('done')).toBe(true); }); it('removes a status when already selected (toggle off)', () => { (component as any).selectedStatuses = new Set(['done']); (component as any).toggleStatus('done', mockEvent); expect((component as any).selectedStatuses.has('done')).toBe(false); }); }); describe('clearStatuses', () => { it('empties the status selection', () => { (component as any).selectedStatuses = new Set(['todo', 'done']); (component as any).clearStatuses(mockEvent); expect((component as any).selectedStatuses.size).toBe(0); }); }); describe('createIssue', () => { it('navigates to /projects/:pid/issues/new', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).createIssue(); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 'new']); }); }); describe('openIssue', () => { it('navigates to the issue detail page', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).openIssue(42); expect(spy).toHaveBeenCalledWith(['/projects', 1, 'issues', 42]); }); }); describe('getProgress', () => { it('returns issue.progress for non-Epic types', () => { const issue = makeIssue({ type: 'Story', progress: 75 }); expect((component as any).getProgress(issue)).toBe(75); }); it('returns 0 for an Epic with no children', () => { const epic = makeIssue({ id: 50, type: 'Epic', name: 'Empty Epic', progress: 0 }); store.upsert(epic); expect((component as any).getProgress(epic)).toBe(0); }); it('returns 100 for an Epic where all children are done', () => { const epic = makeIssue({ id: 51, type: 'Epic', name: 'Full Epic', progress: 0 }); store.upsert(epic); store.upsert(makeIssue({ id: 52, name: 'Child 1', epic: 'Full Epic', status: 'done' })); store.upsert(makeIssue({ id: 53, name: 'Child 2', epic: 'Full Epic', status: 'done' })); expect((component as any).getProgress(epic)).toBe(100); }); it('calculates percentage for an Epic with some done children', () => { const epic = makeIssue({ id: 54, type: 'Epic', name: 'Partial Epic', progress: 0 }); store.upsert(epic); store.upsert(makeIssue({ id: 55, name: 'Done', epic: 'Partial Epic', status: 'done' })); store.upsert(makeIssue({ id: 56, name: 'Pending', epic: 'Partial Epic', status: 'todo' })); expect((component as any).getProgress(epic)).toBe(50); }); it('counts children by dependsOnIds as well as epic name', () => { const epic = makeIssue({ id: 57, type: 'Epic', name: 'Dep Epic', progress: 0 }); store.upsert(epic); store.upsert(makeIssue({ id: 58, name: 'DepChild', dependsOnIds: [57], status: 'done' })); expect((component as any).getProgress(epic)).toBe(100); }); it('counts a custom countsAsCompleted status as done for an Epic', () => { statusesStore.setCompleted(['done', 'abandoned']); const epic = makeIssue({ id: 60, type: 'Epic', name: 'Custom Epic', progress: 0 }); store.upsert(epic); store.upsert(makeIssue({ id: 61, name: 'Done Child', epic: 'Custom Epic', status: 'abandoned' })); store.upsert(makeIssue({ id: 62, name: 'Todo Child', epic: 'Custom Epic', status: 'todo' })); expect((component as any).getProgress(epic)).toBe(50); }); }); describe('getMilestoneForIssue', () => { it('returns the milestone that contains the issue', () => { milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] })]); const m = (component as any).getMilestoneForIssue(1); expect(m?.id).toBe(10); }); it('returns undefined when no milestone contains the issue', () => { milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [99] })]); expect((component as any).getMilestoneForIssue(1)).toBeUndefined(); }); }); describe('filteredIssues — milestone filter', () => { beforeEach(() => { milestonesStore.seed([ makeMilestone({ id: 10, name: 'Sprint A', issueIds: [1] }), makeMilestone({ id: 20, name: 'Sprint B', issueIds: [2] }), ]); }); it('shows all issues when no milestone filter is active', () => { expect((component as any).filteredIssues.length).toBe(3); }); it('shows only issues of the selected milestone', () => { (component as any).selectedMilestoneIds = new Set([10]); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.length).toBe(1); expect(filtered[0].id).toBe(1); }); it('shows issues from multiple selected milestones', () => { (component as any).selectedMilestoneIds = new Set([10, 20]); const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.map((i) => i.id).sort()).toEqual([1, 2]); }); it('shows only issues without milestone when showNoMilestone is true', () => { (component as any).showNoMilestone = true; const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.length).toBe(1); expect(filtered[0].id).toBe(3); }); it('combines milestone selection and no-milestone option as OR', () => { (component as any).selectedMilestoneIds = new Set([10]); (component as any).showNoMilestone = true; const filtered: IssueEntity[] = (component as any).filteredIssues; expect(filtered.map((i) => i.id).sort()).toEqual([1, 3]); }); }); describe('toggleMilestone', () => { it('adds a milestone id when not already selected', () => { (component as any).toggleMilestone(10, mockEvent); expect((component as any).selectedMilestoneIds.has(10)).toBe(true); }); it('removes a milestone id when already selected', () => { (component as any).selectedMilestoneIds = new Set([10]); (component as any).toggleMilestone(10, mockEvent); expect((component as any).selectedMilestoneIds.has(10)).toBe(false); }); }); describe('toggleNoMilestone', () => { it('sets showNoMilestone to true when false', () => { (component as any).showNoMilestone = false; (component as any).toggleNoMilestone(mockEvent); expect((component as any).showNoMilestone).toBe(true); }); it('sets showNoMilestone to false when true', () => { (component as any).showNoMilestone = true; (component as any).toggleNoMilestone(mockEvent); expect((component as any).showNoMilestone).toBe(false); }); }); describe('clearMilestones', () => { it('clears selected milestone ids and showNoMilestone', () => { (component as any).selectedMilestoneIds = new Set([10, 20]); (component as any).showNoMilestone = true; (component as any).clearMilestones(mockEvent); expect((component as any).selectedMilestoneIds.size).toBe(0); expect((component as any).showNoMilestone).toBe(false); }); }); describe('milestoneDropdownLabel', () => { it('returns "Milestone" when nothing is selected', () => { expect((component as any).milestoneDropdownLabel()).toBe('Milestone'); }); it('returns "Sans milestone" when only showNoMilestone is true', () => { (component as any).showNoMilestone = true; expect((component as any).milestoneDropdownLabel()).toBe('Sans milestone'); }); it('returns the milestone name when exactly one milestone is selected', () => { milestonesStore.seed([makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] })]); (component as any).selectedMilestoneIds = new Set([10]); expect((component as any).milestoneDropdownLabel()).toBe('Sprint A'); }); it('returns a count when multiple filters are active', () => { milestonesStore.seed([ makeMilestone({ id: 10, name: 'Sprint A', issueIds: [] }), makeMilestone({ id: 20, name: 'Sprint B', issueIds: [] }), ]); (component as any).selectedMilestoneIds = new Set([10, 20]); expect((component as any).milestoneDropdownLabel()).toBe('Milestone (2)'); }); }); describe('milestoneFilterActive', () => { it('is false when nothing is selected', () => { expect((component as any).milestoneFilterActive).toBe(false); }); it('is true when a milestone id is selected', () => { (component as any).selectedMilestoneIds = new Set([10]); expect((component as any).milestoneFilterActive).toBe(true); }); it('is true when showNoMilestone is true', () => { (component as any).showNoMilestone = true; expect((component as any).milestoneFilterActive).toBe(true); }); }); describe('typeBadgeClass', () => { it('maps Bug to text-bg-danger', () => { expect((component as any).typeBadgeClass('Bug')).toBe('text-bg-danger'); }); it('maps Study to text-bg-secondary', () => { expect((component as any).typeBadgeClass('Study')).toBe('text-bg-secondary'); }); it('maps Story to text-bg-success', () => { expect((component as any).typeBadgeClass('Story')).toBe('text-bg-success'); }); it('maps Task to text-bg-primary', () => { expect((component as any).typeBadgeClass('Task')).toBe('text-bg-primary'); }); it('maps Technical Story to text-bg-warning', () => { expect((component as any).typeBadgeClass('Technical Story')).toBe('text-bg-warning'); }); it('maps Epic to text-bg-info', () => { expect((component as any).typeBadgeClass('Epic')).toBe('text-bg-info'); }); }); });