import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { vi } from 'vitest'; import { IssueDetail } from './issue-detail'; import { IssueEntity, IssuesStore } from '../issues.store'; const makeIssue = (overrides: Partial = {}): IssueEntity => ({ id: 99, type: 'Story', assignee: '', epic: '', name: 'Test Issue', dueDate: '', description: '', estimatedTime: null, dependsOnIds: [], comments: [], priority: 'Moyenne', status: 'draft', progress: 0, ...overrides, }); function makeRoute(id = '1', path = 'issues/:id') { return { snapshot: { routeConfig: { path }, paramMap: convertToParamMap(id ? { id } : {}), queryParamMap: convertToParamMap({}), }, paramMap: of(convertToParamMap(id ? { id } : {})), }; } describe('IssueDetail — existing issue', () => { let component: IssueDetail; let fixture: ComponentFixture; let store: IssuesStore; let router: Router; beforeEach(async () => { localStorage.clear(); await TestBed.configureTestingModule({ imports: [IssueDetail], providers: [ provideRouter([]), { provide: ActivatedRoute, useValue: makeRoute('1') }, ], }).compileComponents(); store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(IssueDetail); component = fixture.componentInstance; await fixture.whenStable(); }); afterEach(() => { localStorage.clear(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('isNewIssueRoute is false', () => { expect((component as any).isNewIssueRoute).toBe(false); }); it('loads the issue from the route param', () => { expect((component as any).issue.id).toBe(1); }); describe('updateStatus', () => { it('updates the status and persists to the store', () => { (component as any).updateStatus('done'); expect((component as any).issue.status).toBe('done'); expect(store.getById(1)?.status).toBe('done'); }); }); describe('toggleMoreMenu / closeMoreMenu', () => { it('toggleMoreMenu switches moreMenuOpen', () => { expect((component as any).moreMenuOpen).toBe(false); (component as any).toggleMoreMenu(); expect((component as any).moreMenuOpen).toBe(true); (component as any).toggleMoreMenu(); expect((component as any).moreMenuOpen).toBe(false); }); it('closeMoreMenu sets moreMenuOpen to false', () => { (component as any).moreMenuOpen = true; (component as any).closeMoreMenu(); expect((component as any).moreMenuOpen).toBe(false); }); }); describe('saveIssue', () => { it('persists the issue to the store', () => { (component as any).issue.name = 'Renamed'; (component as any).saveIssue(); expect(store.getById(1)?.name).toBe('Renamed'); }); it('does nothing when name is blank', () => { const countBefore = store.issues().length; (component as any).issue.name = ' '; (component as any).saveIssue(); expect(store.issues().length).toBe(countBefore); }); }); describe('deleteIssue', () => { it('removes the issue and navigates to /issues', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).deleteIssue(); expect(store.getById(1)).toBeUndefined(); expect(spy).toHaveBeenCalledWith(['/issues']); }); }); describe('cancelCreation', () => { it('navigates to /issues', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).cancelCreation(); expect(spy).toHaveBeenCalledWith(['/issues']); }); }); describe('dependency management', () => { it('dependencyIds returns the issue dependsOnIds', () => { (component as any).issue.dependsOnIds = [2, 3]; expect((component as any).dependencyIds).toEqual([2, 3]); }); it('availableCandidates excludes current issue', () => { const candidates: IssueEntity[] = (component as any).availableCandidates; expect(candidates.some((i) => i.id === 1)).toBe(false); }); it('availableCandidates excludes existing dependencies', () => { (component as any).issue.dependsOnIds = [2]; const candidates: IssueEntity[] = (component as any).availableCandidates; expect(candidates.some((i) => i.id === 2)).toBe(false); }); it('resolveDependency returns the matching issue', () => { const resolved = (component as any).resolveDependency(2); expect(resolved?.id).toBe(2); }); it('resolveDependency returns undefined for unknown id', () => { expect((component as any).resolveDependency(9999)).toBeUndefined(); }); it('openAddDependency sets showAddDependency to true', () => { (component as any).openAddDependency(); expect((component as any).showAddDependency).toBe(true); expect((component as any).selectedCandidateId).toBeNull(); }); it('cancelAddDependency hides the form and resets candidate', () => { (component as any).showAddDependency = true; (component as any).selectedCandidateId = 2; (component as any).cancelAddDependency(); expect((component as any).showAddDependency).toBe(false); expect((component as any).selectedCandidateId).toBeNull(); }); it('confirmAddDependency adds the selected id and saves', () => { (component as any).selectedCandidateId = 2; (component as any).confirmAddDependency(); expect((component as any).issue.dependsOnIds).toContain(2); expect(store.getById(1)?.dependsOnIds).toContain(2); expect((component as any).showAddDependency).toBe(false); }); it('confirmAddDependency does nothing when no candidate is selected', () => { (component as any).selectedCandidateId = null; (component as any).confirmAddDependency(); expect((component as any).issue.dependsOnIds).toEqual([]); }); it('removeDependency removes the id and saves', () => { (component as any).issue.dependsOnIds = [2, 3]; store.upsert({ ...(component as any).issue }); (component as any).removeDependency(2); expect((component as any).issue.dependsOnIds).not.toContain(2); expect((component as any).issue.dependsOnIds).toContain(3); }); }); describe('estimatedTimeValue getter / setter', () => { it('getter returns issue.estimatedTime', () => { (component as any).issue.estimatedTime = 8; expect((component as any).estimatedTimeValue).toBe(8); }); it('setter converts string to number', () => { (component as any).estimatedTimeValue = '3.5'; expect((component as any).issue.estimatedTime).toBe(3.5); }); it('setter stores null when value is null', () => { (component as any).estimatedTimeValue = null; expect((component as any).issue.estimatedTime).toBeNull(); }); }); describe('issueTypeValue getter / setter', () => { it('getter returns issue.type', () => { (component as any).issue.type = 'Bug'; expect((component as any).issueTypeValue).toBe('Bug'); }); it('setter updates issue.type', () => { (component as any).issueTypeValue = 'Epic'; expect((component as any).issue.type).toBe('Epic'); }); }); describe('isEpicIssue', () => { it('is false for Story type', () => { (component as any).issue.type = 'Story'; expect((component as any).isEpicIssue).toBe(false); }); it('is true for Epic type', () => { (component as any).issue.type = 'Epic'; expect((component as any).isEpicIssue).toBe(true); }); }); describe('getBadgeClass / typeBadgeClass', () => { it('typeBadgeClass returns class for current issue type', () => { (component as any).issue.type = 'Bug'; expect((component as any).typeBadgeClass).toBe('text-bg-danger'); }); it('getBadgeClass maps Bug to text-bg-danger', () => { expect((component as any).getBadgeClass('Bug')).toBe('text-bg-danger'); }); it('getBadgeClass maps Study to text-bg-secondary', () => { expect((component as any).getBadgeClass('Study')).toBe('text-bg-secondary'); }); it('getBadgeClass maps Story to text-bg-success', () => { expect((component as any).getBadgeClass('Story')).toBe('text-bg-success'); }); it('getBadgeClass maps Task to text-bg-primary', () => { expect((component as any).getBadgeClass('Task')).toBe('text-bg-primary'); }); it('getBadgeClass maps Technical Story to text-bg-warning', () => { expect((component as any).getBadgeClass('Technical Story')).toBe('text-bg-warning'); }); it('getBadgeClass maps Epic to text-bg-info', () => { expect((component as any).getBadgeClass('Epic')).toBe('text-bg-info'); }); }); describe('epicIssues / epicIssueId', () => { it('epicIssues returns only Epic-type issues', () => { store.upsert(makeIssue({ id: 100, type: 'Epic', name: 'My Epic' })); const epics: IssueEntity[] = (component as any).epicIssues; expect(epics.every((e) => e.type === 'Epic')).toBe(true); expect(epics.some((e) => e.id === 100)).toBe(true); }); it('epicIssueId returns the id of the linked epic', () => { store.upsert(makeIssue({ id: 100, type: 'Epic', name: 'Linked Epic' })); (component as any).issue.epic = 'Linked Epic'; expect((component as any).epicIssueId).toBe(100); }); it('epicIssueId returns null when no matching epic', () => { (component as any).issue.epic = ''; expect((component as any).epicIssueId).toBeNull(); }); }); describe('navigateToEpic', () => { it('navigates to the epic issue', async () => { store.upsert(makeIssue({ id: 100, type: 'Epic', name: 'Nav Epic' })); (component as any).issue.epic = 'Nav Epic'; const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).navigateToEpic(); expect(spy).toHaveBeenCalledWith(['/issues', 100]); }); it('does nothing when no matching epic is found', () => { (component as any).issue.epic = 'Ghost Epic'; const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).navigateToEpic(); expect(spy).not.toHaveBeenCalled(); }); }); describe('openComposedIssue', () => { it('navigates to the composed issue detail', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).openComposedIssue(42); expect(spy).toHaveBeenCalledWith(['/issues', 42]); }); }); describe('composedIssues / epicCandidates', () => { beforeEach(() => { (component as any).issue.type = 'Epic'; (component as any).issue.name = 'Test Epic'; }); it('composedIssues includes issues whose epic matches the current name', () => { store.upsert(makeIssue({ id: 200, name: 'Child', epic: 'Test Epic' })); const composed: IssueEntity[] = (component as any).composedIssues; expect(composed.some((i) => i.id === 200)).toBe(true); }); it('composedIssues includes issues that depend on the current issue', () => { store.upsert(makeIssue({ id: 201, name: 'Dep', dependsOnIds: [1] })); const composed: IssueEntity[] = (component as any).composedIssues; expect(composed.some((i) => i.id === 201)).toBe(true); }); it('composedIssues does not include the current issue itself', () => { const composed: IssueEntity[] = (component as any).composedIssues; expect(composed.some((i) => i.id === 1)).toBe(false); }); it('epicCandidates excludes already composed issues', () => { store.upsert(makeIssue({ id: 200, name: 'Child', epic: 'Test Epic' })); const candidates: IssueEntity[] = (component as any).epicCandidates; expect(candidates.some((i) => i.id === 200)).toBe(false); }); }); describe('create-in-epic flow', () => { beforeEach(() => { (component as any).issue.type = 'Epic'; (component as any).issue.name = 'My Epic'; }); it('openCreateInEpic shows the create form and hides add form', () => { (component as any).showAddToEpic = true; (component as any).openCreateInEpic(); expect((component as any).showCreateInEpic).toBe(true); expect((component as any).showAddToEpic).toBe(false); }); it('cancelCreateInEpic hides the form and clears the name', () => { (component as any).showCreateInEpic = true; (component as any).newIssueName = 'Draft'; (component as any).cancelCreateInEpic(); expect((component as any).showCreateInEpic).toBe(false); expect((component as any).newIssueName).toBe(''); }); it('confirmCreateInEpic creates a child issue linked to the epic', () => { (component as any).newIssueName = 'Child Issue'; const before = store.issues().length; (component as any).confirmCreateInEpic(); expect(store.issues().length).toBe(before + 1); const created = store.issues().find((i) => i.name === 'Child Issue'); expect(created?.epic).toBe('My Epic'); expect(created?.type).toBe('Story'); }); it('confirmCreateInEpic resets the form', () => { (component as any).newIssueName = 'Child Issue'; (component as any).confirmCreateInEpic(); expect((component as any).showCreateInEpic).toBe(false); expect((component as any).newIssueName).toBe(''); }); it('confirmCreateInEpic does nothing when name is blank', () => { (component as any).newIssueName = ' '; const before = store.issues().length; (component as any).confirmCreateInEpic(); expect(store.issues().length).toBe(before); }); }); describe('add-to-epic flow', () => { beforeEach(() => { (component as any).issue.type = 'Epic'; (component as any).issue.name = 'My Epic'; }); it('openAddToEpic shows the add form', () => { (component as any).openAddToEpic(); expect((component as any).showAddToEpic).toBe(true); expect((component as any).selectedEpicCandidateId).toBeNull(); }); it('cancelAddToEpic hides the form', () => { (component as any).showAddToEpic = true; (component as any).selectedEpicCandidateId = 2; (component as any).cancelAddToEpic(); expect((component as any).showAddToEpic).toBe(false); expect((component as any).selectedEpicCandidateId).toBeNull(); }); it('confirmAddToEpic assigns the epic name to the selected issue', () => { (component as any).selectedEpicCandidateId = 2; (component as any).confirmAddToEpic(); expect(store.getById(2)?.epic).toBe('My Epic'); expect((component as any).showAddToEpic).toBe(false); }); it('confirmAddToEpic does nothing when no candidate is selected', () => { const epicBefore = store.getById(2)?.epic; (component as any).selectedEpicCandidateId = null; (component as any).confirmAddToEpic(); expect(store.getById(2)?.epic).toBe(epicBefore); }); }); describe('descriptionHtml', () => { it('returns a truthy SafeHtml for markdown input', () => { (component as any).issue.description = '# Title\n**bold**'; expect((component as any).descriptionHtml).toBeTruthy(); }); it('handles empty description', () => { (component as any).issue.description = ''; expect((component as any).descriptionHtml).toBeTruthy(); }); }); }); describe('IssueDetail — new issue route', () => { let component: IssueDetail; let fixture: ComponentFixture; let store: IssuesStore; let router: Router; beforeEach(async () => { localStorage.clear(); await TestBed.configureTestingModule({ imports: [IssueDetail], providers: [ provideRouter([]), { provide: ActivatedRoute, useValue: { snapshot: { routeConfig: { path: 'issues/new' }, paramMap: convertToParamMap({}), queryParamMap: convertToParamMap({ draftId: '10' }), }, paramMap: of(convertToParamMap({})), }, }, ], }).compileComponents(); store = TestBed.inject(IssuesStore); router = TestBed.inject(Router); fixture = TestBed.createComponent(IssueDetail); component = fixture.componentInstance; await fixture.whenStable(); }); afterEach(() => { localStorage.clear(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('isNewIssueRoute is true', () => { expect((component as any).isNewIssueRoute).toBe(true); }); it('buildIssue creates an empty issue with draft id', () => { expect((component as any).issue.id).toBe(10); expect((component as any).issue.name).toBe(''); }); it('saveIssue without explicit flag does nothing for new route', () => { (component as any).issue.name = 'Draft Name'; const countBefore = store.issues().length; (component as any).saveIssue(); // explicit = false expect(store.issues().length).toBe(countBefore); }); it('saveIssue with explicit=true creates the issue and navigates', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).issue.name = 'Brand New Issue'; (component as any).saveIssue(true); expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true); expect(spy).toHaveBeenCalledWith(['/issues', 10]); }); });