import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { afterEach, vi } from 'vitest'; import { MilestoneDetail } from './milestone-detail'; import { IssueEntity, IssuesStore } from '../../issues/issues.store'; import { MilestoneEntity, MilestonesStore } from '../milestones.store'; 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: 0, ...overrides, }); const makeMilestone = (overrides: Partial = {}): MilestoneEntity => ({ id: 1, name: 'Sprint 1', description: '', startDate: '', endDate: '', dueDate: '', issueIds: [], dependsOnIds: [], ...overrides, }); class FakeIssuesStore { private _data = signal([]); readonly issues = this._data.asReadonly(); readonly loading = signal(false); readonly loaded = signal(true); seed(issues: IssueEntity[]): void { this._data.set(issues); } getById(id: number): IssueEntity | undefined { return this._data().find((i) => i.id === id); } load(): Promise { return Promise.resolve(); } upsert(issue: IssueEntity): Promise { const normalized = { ...makeIssue(), ...issue }; this._data.update((list) => { const idx = list.findIndex((i) => i.id === normalized.id); if (idx === -1) return [...list, { ...normalized, id: normalized.id || list.length + 1 }]; const copy = [...list]; copy[idx] = normalized; return copy; }); return Promise.resolve(normalized); } } class FakeMilestonesStore { private _data = signal([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); readonly milestones = this._data.asReadonly(); readonly loading = signal(false); readonly loaded = signal(true); seed(milestones: MilestoneEntity[]): void { this._data.set(milestones); } getById(id: number): MilestoneEntity | undefined { return this._data().find((m) => m.id === id); } load(): Promise { return Promise.resolve(this._data()); } 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(); } } function makeRoute(id = '1', path = 'milestones/:id') { return { snapshot: { routeConfig: { path }, paramMap: convertToParamMap(id ? { id } : {}), }, paramMap: of(convertToParamMap(id ? { id } : {})), }; } describe('MilestoneDetail', () => { let component: MilestoneDetail; let fixture: ComponentFixture; let issuesStore: FakeIssuesStore; let milestonesStore: FakeMilestonesStore; let router: Router; beforeEach(async () => { issuesStore = new FakeIssuesStore(); milestonesStore = new FakeMilestonesStore(); await TestBed.configureTestingModule({ imports: [MilestoneDetail], providers: [ provideRouter([]), { provide: ActivatedRoute, useValue: makeRoute('1') }, { provide: IssuesStore, useValue: issuesStore }, { provide: MilestonesStore, useValue: milestonesStore }, ], }).compileComponents(); router = TestBed.inject(Router); fixture = TestBed.createComponent(MilestoneDetail); component = fixture.componentInstance; await fixture.whenStable(); }); it('should create', () => { expect(component).toBeTruthy(); }); describe('linkedIssues', () => { it('returns issues whose id is in milestone.issueIds', () => { issuesStore.seed([makeIssue({ id: 1 }), makeIssue({ id: 2 })]); (component as any).milestone.issueIds = [1]; const linked: IssueEntity[] = (component as any).linkedIssues; expect(linked.map((i) => i.id)).toEqual([1]); }); it('returns empty array when milestone has no issues', () => { issuesStore.seed([makeIssue({ id: 1 })]); (component as any).milestone.issueIds = []; expect((component as any).linkedIssues).toHaveLength(0); }); }); describe('availableIssues', () => { beforeEach(() => { issuesStore.seed([ makeIssue({ id: 1, name: 'Issue 1', epic: '' }), makeIssue({ id: 2, name: 'Issue 2', epic: '' }), makeIssue({ id: 3, name: 'Issue 3', epic: '' }), ]); milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }); }); it('returns all issues when milestone is empty', () => { expect((component as any).availableIssues).toHaveLength(3); }); it('excludes issues already in this milestone', () => { (component as any).milestone.issueIds = [1]; const available: IssueEntity[] = (component as any).availableIssues; expect(available.some((i) => i.id === 1)).toBe(false); expect(available).toHaveLength(2); }); it('excludes issues assigned to another milestone', () => { milestonesStore.seed([ makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }), makeMilestone({ id: 2, name: 'Sprint 2', issueIds: [2] }), ]); const available: IssueEntity[] = (component as any).availableIssues; expect(available.some((i) => i.id === 2)).toBe(false); expect(available).toHaveLength(2); }); it('excludes children of an epic already in this milestone', () => { issuesStore.seed([ makeIssue({ id: 1, type: 'Epic', name: 'My Epic', epic: '' }), makeIssue({ id: 2, name: 'Child 1', epic: 'My Epic' }), makeIssue({ id: 3, name: 'Independent', epic: '' }), ]); (component as any).milestone.issueIds = [1]; const available: IssueEntity[] = (component as any).availableIssues; expect(available.some((i) => i.id === 2)).toBe(false); expect(available.some((i) => i.id === 3)).toBe(true); }); it('does not exclude issues whose epic is not in the milestone', () => { issuesStore.seed([ makeIssue({ id: 1, type: 'Epic', name: 'Other Epic', epic: '' }), makeIssue({ id: 2, name: 'Child', epic: 'Other Epic' }), makeIssue({ id: 3, name: 'Independent', epic: '' }), ]); (component as any).milestone.issueIds = []; const available: IssueEntity[] = (component as any).availableIssues; expect(available.some((i) => i.id === 2)).toBe(true); }); }); describe('displayedIssues', () => { it('shows all linked issues when no epic is in the milestone', () => { issuesStore.seed([ makeIssue({ id: 1, name: 'Story A', epic: '' }), makeIssue({ id: 2, name: 'Story B', epic: '' }), ]); (component as any).milestone = makeMilestone({ id: 1, issueIds: [1, 2] }); expect((component as any).displayedIssues).toHaveLength(2); }); it('hides children of an epic already in the milestone', () => { issuesStore.seed([ makeIssue({ id: 1, type: 'Epic', name: 'My Epic', epic: '' }), makeIssue({ id: 2, name: 'Child', epic: 'My Epic' }), makeIssue({ id: 3, name: 'Independent', epic: '' }), ]); (component as any).milestone = makeMilestone({ id: 1, issueIds: [1, 2, 3] }); const displayed: IssueEntity[] = (component as any).displayedIssues; expect(displayed.some((i) => i.id === 1)).toBe(true); expect(displayed.some((i) => i.id === 2)).toBe(false); expect(displayed.some((i) => i.id === 3)).toBe(true); }); it('shows children when their epic is not in the milestone', () => { issuesStore.seed([ makeIssue({ id: 1, type: 'Epic', name: 'Other Epic', epic: '' }), makeIssue({ id: 2, name: 'Child', epic: 'Other Epic' }), ]); (component as any).milestone = makeMilestone({ id: 1, issueIds: [2] }); const displayed: IssueEntity[] = (component as any).displayedIssues; expect(displayed.some((i) => i.id === 2)).toBe(true); }); }); describe('progress', () => { it('returns 0 when milestone has no issues', () => { issuesStore.seed([]); (component as any).milestone.issueIds = []; expect((component as any).progress).toBe(0); }); it('returns 100 when all linked issues are done', () => { issuesStore.seed([ makeIssue({ id: 1, status: 'done' }), makeIssue({ id: 2, status: 'done' }), ]); (component as any).milestone.issueIds = [1, 2]; expect((component as any).progress).toBe(100); }); it('returns 50 when half the linked issues are done', () => { issuesStore.seed([ makeIssue({ id: 1, status: 'done' }), makeIssue({ id: 2, status: 'todo' }), ]); (component as any).milestone.issueIds = [1, 2]; expect((component as any).progress).toBe(50); }); }); describe('milestoneGanttTasks', () => { it('returns empty array when no linked issues', () => { issuesStore.seed([]); (component as any).milestone.issueIds = []; expect((component as any).milestoneGanttTasks).toHaveLength(0); }); it('excludes issues missing startDate or endDate', () => { issuesStore.seed([ makeIssue({ id: 1, startDate: '2025-01-01', endDate: '' }), makeIssue({ id: 2, startDate: '', endDate: '2025-01-31' }), ]); (component as any).milestone.issueIds = [1, 2]; expect((component as any).milestoneGanttTasks).toHaveLength(0); }); it('returns a task for each issue with both dates', () => { issuesStore.seed([ makeIssue({ id: 1, name: 'Task A', startDate: '2025-01-01', endDate: '2025-01-15', progress: 50 }), makeIssue({ id: 2, name: 'Task B', startDate: '2025-01-10', endDate: '2025-01-31', progress: 0 }), ]); (component as any).milestone.issueIds = [1, 2]; const tasks = (component as any).milestoneGanttTasks; expect(tasks).toHaveLength(2); expect(tasks[0]).toMatchObject({ id: 'issue-1', name: '#1 Task A', start: '2025-01-01', end: '2025-01-15', progress: 50 }); expect(tasks[1]).toMatchObject({ id: 'issue-2', name: '#2 Task B', start: '2025-01-10', end: '2025-01-31', progress: 0 }); }); it('only includes issues linked to the milestone', () => { issuesStore.seed([ makeIssue({ id: 1, startDate: '2025-01-01', endDate: '2025-01-31' }), makeIssue({ id: 2, startDate: '2025-02-01', endDate: '2025-02-28' }), ]); (component as any).milestone.issueIds = [1]; const tasks = (component as any).milestoneGanttTasks; expect(tasks).toHaveLength(1); expect(tasks[0].id).toBe('issue-1'); }); }); describe('issueSuggestions', () => { beforeEach(() => { issuesStore.seed([ makeIssue({ id: 1, name: 'Fix login bug' }), makeIssue({ id: 2, name: 'Add dashboard' }), makeIssue({ id: 3, name: 'Fix signup bug' }), ]); milestonesStore.seed([makeMilestone({ id: 1, issueIds: [] })]); (component as any).milestone = makeMilestone({ id: 1, issueIds: [] }); }); it('returns all available issues when query is empty', () => { (component as any).issueSearchQuery = ''; expect((component as any).issueSuggestions).toHaveLength(3); }); it('filters by name match', () => { (component as any).issueSearchQuery = 'Fix'; const suggestions: IssueEntity[] = (component as any).issueSuggestions; expect(suggestions).toHaveLength(2); expect(suggestions.every((i) => i.name.toLowerCase().includes('fix'))).toBe(true); }); it('filters by id match', () => { (component as any).issueSearchQuery = '2'; const suggestions: IssueEntity[] = (component as any).issueSuggestions; expect(suggestions.some((i) => i.id === 2)).toBe(true); }); }); describe('openAddIssue / cancelAddIssue', () => { it('openAddIssue shows the add form', () => { (component as any).openAddIssue(); expect((component as any).showAddIssue).toBe(true); expect((component as any).showCreateIssue).toBe(false); }); it('cancelAddIssue hides the form', () => { (component as any).showAddIssue = true; (component as any).cancelAddIssue(); expect((component as any).showAddIssue).toBe(false); }); }); describe('openCreateIssue / cancelCreateIssue', () => { it('openCreateIssue shows the create form', () => { (component as any).openCreateIssue(); expect((component as any).showCreateIssue).toBe(true); expect((component as any).showAddIssue).toBe(false); }); it('cancelCreateIssue hides the form and clears name', () => { (component as any).showCreateIssue = true; (component as any).newIssueName = 'Draft'; (component as any).cancelCreateIssue(); expect((component as any).showCreateIssue).toBe(false); expect((component as any).newIssueName).toBe(''); }); }); describe('navigateToIssue', () => { it('navigates to the issue detail page', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).navigateToIssue(42); expect(spy).toHaveBeenCalledWith(['/issues', 42]); }); }); 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('addIssueFromSearch', () => { it('adds the issue to milestone.issueIds and saves', async () => { milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }); await (component as any).addIssueFromSearch(5); expect((component as any).milestone.issueIds).toContain(5); expect((component as any).showAddIssue).toBe(false); }); }); describe('removeIssue', () => { it('removes the issue from milestone.issueIds and saves', async () => { milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [1, 2] })]); (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [1, 2] }); await (component as any).removeIssue(1); expect((component as any).milestone.issueIds).not.toContain(1); expect((component as any).milestone.issueIds).toContain(2); }); }); describe('confirmCreateIssue', () => { beforeEach(() => { milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] }); }); it('creates an issue and adds it to the milestone', async () => { (component as any).newIssueName = 'New Task'; await (component as any).confirmCreateIssue(); expect((component as any).milestone.issueIds).toHaveLength(1); expect((component as any).showCreateIssue).toBe(false); expect((component as any).newIssueName).toBe(''); }); it('does nothing when name is blank', async () => { (component as any).newIssueName = ' '; await (component as any).confirmCreateIssue(); expect((component as any).milestone.issueIds).toHaveLength(0); }); }); describe('saveMilestone', () => { it('persists the milestone to the store', async () => { milestonesStore.seed([makeMilestone({ id: 1, name: 'Sprint 1', issueIds: [] })]); (component as any).milestone = makeMilestone({ id: 1, name: 'Updated', issueIds: [] }); await (component as any).saveMilestone(); expect(milestonesStore.getById(1)?.name).toBe('Updated'); }); it('does nothing when name is blank', async () => { (component as any).milestone.name = ' '; await (component as any).saveMilestone(); expect(milestonesStore.getById(1)?.name).toBe('Sprint 1'); }); }); describe('statusBadge', () => { it('returns correct label and colors for draft', () => { const badge = (component as any).statusBadge('draft'); expect(badge.label).toBe('BROUILLON'); }); it('returns correct label for done', () => { expect((component as any).statusBadge('done').label).toBe('TERMINÉ'); }); it('returns correct label for in-progress', () => { expect((component as any).statusBadge('in-progress').label).toBe('EN COURS'); }); it('returns correct label for todo', () => { expect((component as any).statusBadge('todo').label).toBe('À FAIRE'); }); }); describe('priorityDisplay', () => { it('returns correct symbol for TRES_HAUTE', () => { expect((component as any).priorityDisplay('TRES_HAUTE').symbol).toBe('↑↑'); }); it('returns correct symbol for BASSE', () => { expect((component as any).priorityDisplay('BASSE').symbol).toBe('↓'); }); it('returns correct symbol for MOYENNE', () => { expect((component as any).priorityDisplay('MOYENNE').symbol).toBe('–'); }); }); describe('typeIcon', () => { it('returns correct letter for Epic', () => { expect((component as any).typeIcon('Epic').letter).toBe('E'); }); it('returns correct letter for Bug', () => { expect((component as any).typeIcon('Bug').letter).toBe('B'); }); }); describe('dependencies', () => { beforeEach(() => { milestonesStore.seed([ makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] }), makeMilestone({ id: 2, name: 'Sprint 2', dependsOnIds: [] }), makeMilestone({ id: 3, name: 'Sprint 3', dependsOnIds: [] }), ]); (component as any).milestone = makeMilestone({ id: 1, name: 'Sprint 1', dependsOnIds: [] }); }); it('hasDependencies returns false when dependsOnIds is empty', () => { expect((component as any).hasDependencies).toBe(false); }); it('hasDependencies returns true when dependsOnIds is non-empty', () => { (component as any).milestone.dependsOnIds = [2]; expect((component as any).hasDependencies).toBe(true); }); it('dependencyIds returns the dependsOnIds array', () => { (component as any).milestone.dependsOnIds = [2, 3]; expect((component as any).dependencyIds).toEqual([2, 3]); }); it('availableCandidates excludes the current milestone', () => { const candidates: MilestoneEntity[] = (component as any).availableCandidates; expect(candidates.some((m: MilestoneEntity) => m.id === 1)).toBe(false); }); it('availableCandidates excludes already-added dependencies', () => { (component as any).milestone.dependsOnIds = [2]; const candidates: MilestoneEntity[] = (component as any).availableCandidates; expect(candidates.some((m: MilestoneEntity) => m.id === 2)).toBe(false); expect(candidates.some((m: MilestoneEntity) => m.id === 3)).toBe(true); }); it('resolveDependency returns the milestone with the given id', () => { expect((component as any).resolveDependency(2)?.name).toBe('Sprint 2'); }); it('resolveDependency returns undefined for unknown id', () => { expect((component as any).resolveDependency(999)).toBeUndefined(); }); it('openAddDependency shows the form and resets selection', () => { (component as any).selectedCandidateMilestoneId = 2; (component as any).openAddDependency(); expect((component as any).showAddDependency).toBe(true); expect((component as any).selectedCandidateMilestoneId).toBeNull(); }); it('cancelAddDependency hides the form and resets selection', () => { (component as any).showAddDependency = true; (component as any).selectedCandidateMilestoneId = 2; (component as any).cancelAddDependency(); expect((component as any).showAddDependency).toBe(false); expect((component as any).selectedCandidateMilestoneId).toBeNull(); }); it('confirmAddDependency does nothing when no candidate is selected', async () => { (component as any).selectedCandidateMilestoneId = null; await (component as any).confirmAddDependency(); expect((component as any).milestone.dependsOnIds).toHaveLength(0); }); it('confirmAddDependency adds the id and saves', async () => { (component as any).selectedCandidateMilestoneId = 2; await (component as any).confirmAddDependency(); expect((component as any).milestone.dependsOnIds).toContain(2); expect((component as any).showAddDependency).toBe(false); expect((component as any).selectedCandidateMilestoneId).toBeNull(); }); it('removeDependency removes the id and saves', async () => { (component as any).milestone.dependsOnIds = [2, 3]; await (component as any).removeDependency(2); expect((component as any).milestone.dependsOnIds).not.toContain(2); expect((component as any).milestone.dependsOnIds).toContain(3); }); }); describe('deleteMilestone', () => { it('removes the milestone and navigates to /milestones', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); await (component as any).deleteMilestone(); expect(spy).toHaveBeenCalledWith(['/milestones']); }); }); describe('cancelCreation', () => { it('navigates to /milestones', () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); (component as any).cancelCreation(); expect(spy).toHaveBeenCalledWith(['/milestones']); }); }); describe('descriptionHtml', () => { it('returns sanitized HTML from markdown description', () => { (component as any).milestone.description = '**bold**'; const html = (component as any).descriptionHtml; expect(html).toBeTruthy(); }); it('returns sanitized HTML for empty description', () => { (component as any).milestone.description = ''; const html = (component as any).descriptionHtml; expect(html).toBeTruthy(); }); }); describe('hideIssueSuggestions', () => { afterEach(() => vi.useRealTimers()); it('hides suggestions after 150ms', () => { vi.useFakeTimers(); (component as any).showIssueSuggestions = true; (component as any).hideIssueSuggestions(); expect((component as any).showIssueSuggestions).toBe(true); vi.advanceTimersByTime(150); expect((component as any).showIssueSuggestions).toBe(false); }); }); describe('onDescriptionPaste', () => { afterEach(() => vi.unstubAllGlobals()); it('does not change description when clipboard has no image', () => { const ta = document.createElement('textarea'); const event = { clipboardData: { items: [] }, preventDefault: vi.fn(), target: ta, } as unknown as ClipboardEvent; const before = (component as any).milestone.description; (component as any).onDescriptionPaste(event); expect((component as any).milestone.description).toBe(before); }); it('updates description with markdown when an image is pasted', async () => { vi.stubGlobal('FileReader', class { readonly result = 'data:image/png;base64,abc'; onload: ((e: any) => void) | null = null; readAsDataURL(_file: File) { Promise.resolve().then(() => this.onload?.({ target: { result: this.result } })); } }); const file = new File([''], 'img.png', { type: 'image/png' }); const ta = document.createElement('textarea'); ta.value = ''; const event = { clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] }, preventDefault: vi.fn(), target: ta, } as unknown as ClipboardEvent; (component as any).milestone.description = ''; (component as any).onDescriptionPaste(event); await Promise.resolve(); expect((component as any).milestone.description).toContain('![image]'); }); }); describe('totalEstimatedTime', () => { it('returns null when no linked issues', () => { issuesStore.seed([]); (component as any).milestone.issueIds = []; expect((component as any).totalEstimatedTime).toBeNull(); }); it('returns null when all linked issues have null estimatedTime', () => { issuesStore.seed([ makeIssue({ id: 1, estimatedTime: null }), makeIssue({ id: 2, estimatedTime: null }), ]); (component as any).milestone.issueIds = [1, 2]; expect((component as any).totalEstimatedTime).toBeNull(); }); it('returns the sum of estimatedTime for non-Epic issues', () => { issuesStore.seed([ makeIssue({ id: 1, estimatedTime: 8 }), makeIssue({ id: 2, estimatedTime: 4 }), ]); (component as any).milestone.issueIds = [1, 2]; expect((component as any).totalEstimatedTime).toBe(12); }); it('ignores null estimatedTime in the sum', () => { issuesStore.seed([ makeIssue({ id: 1, estimatedTime: 8 }), makeIssue({ id: 2, estimatedTime: null }), ]); (component as any).milestone.issueIds = [1, 2]; expect((component as any).totalEstimatedTime).toBe(8); }); it('uses the Epic own estimatedTime, not its children', () => { issuesStore.seed([ makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 10 }), makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }), makeIssue({ id: 3, epic: 'My Epic', estimatedTime: 3 }), ]); (component as any).milestone.issueIds = [1]; expect((component as any).totalEstimatedTime).toBe(10); }); it('returns null for an Epic with null estimatedTime', () => { issuesStore.seed([ makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: null }), makeIssue({ id: 2, epic: 'My Epic', estimatedTime: 5 }), ]); (component as any).milestone.issueIds = [1]; expect((component as any).totalEstimatedTime).toBeNull(); }); it('mixes Epics and plain issues correctly', () => { issuesStore.seed([ makeIssue({ id: 1, type: 'Epic', name: 'My Epic', estimatedTime: 8 }), makeIssue({ id: 3, type: 'Story', estimatedTime: 6 }), ]); (component as any).milestone.issueIds = [1, 3]; expect((component as any).totalEstimatedTime).toBe(14); }); }); }); describe('MilestoneDetail — new route', () => { let component: MilestoneDetail; let fixture: ComponentFixture; let milestonesStore: FakeMilestonesStore; let router: Router; beforeEach(async () => { milestonesStore = new FakeMilestonesStore(); await TestBed.configureTestingModule({ imports: [MilestoneDetail], providers: [ provideRouter([]), { provide: ActivatedRoute, useValue: { snapshot: { routeConfig: { path: 'milestones/new' }, paramMap: convertToParamMap({}), }, paramMap: of(convertToParamMap({})), }, }, { provide: IssuesStore, useValue: new FakeIssuesStore() }, { provide: MilestonesStore, useValue: milestonesStore }, ], }).compileComponents(); router = TestBed.inject(Router); fixture = TestBed.createComponent(MilestoneDetail); component = fixture.componentInstance; await fixture.whenStable(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('isNewRoute is true', () => { expect((component as any).isNewRoute).toBe(true); }); it('buildMilestone creates an empty milestone', () => { expect((component as any).milestone.id).toBe(0); expect((component as any).milestone.name).toBe(''); }); it('saveMilestone without explicit flag does nothing', async () => { (component as any).milestone.name = 'New Sprint'; await (component as any).saveMilestone(); expect(milestonesStore.getById(0)).toBeUndefined(); }); it('saveMilestone with explicit=true saves and navigates to the new milestone', async () => { const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); milestonesStore.seed([makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] })]); (component as any).milestone = makeMilestone({ id: 1, name: 'New Sprint', issueIds: [] }); await (component as any).saveMilestone(true); expect(spy).toHaveBeenCalledWith(['/milestones', 1]); }); });