Files
Bonsai-webapp/src/app/milestones/milestone-detail/milestone-detail.spec.ts
T
2026-05-30 06:40:13 +02:00

645 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {}): IssueEntity => ({
id: 99,
type: 'Story',
assignee: '',
epic: '',
name: 'Test Issue',
startDate: '',
endDate: '',
dueDate: '',
description: '',
estimatedTime: null,
dependsOnIds: [],
comments: [],
priority: 'MOYENNE',
status: 'draft',
progress: 0,
...overrides,
});
const makeMilestone = (overrides: Partial<MilestoneEntity> = {}): MilestoneEntity => ({
id: 1,
name: 'Sprint 1',
description: '',
startDate: '',
endDate: '',
dueDate: '',
issueIds: [],
...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);
}
getById(id: number): IssueEntity | undefined {
return this._data().find((i) => i.id === id);
}
load(): Promise<void> {
return Promise.resolve();
}
upsert(issue: IssueEntity): Promise<IssueEntity> {
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<MilestoneEntity[]>([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<MilestoneEntity[]> {
return Promise.resolve(this._data());
}
upsert(milestone: MilestoneEntity): Promise<MilestoneEntity> {
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<void> {
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<MilestoneDetail>;
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('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('MilestoneDetail — new route', () => {
let component: MilestoneDetail;
let fixture: ComponentFixture<MilestoneDetail>;
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]);
});
});