54d1534d4d
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
504 lines
18 KiB
TypeScript
504 lines
18 KiB
TypeScript
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> = {}): 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<IssueEntity[]>([
|
|
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<void> {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
upsert(issue: any): Promise<IssueEntity> {
|
|
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<void> {
|
|
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> = {}): MilestoneEntity => ({
|
|
id: 1,
|
|
name: 'Sprint 1',
|
|
description: '',
|
|
startDate: '',
|
|
endDate: '',
|
|
dueDate: '',
|
|
issueIds: [],
|
|
dependsOnIds: [],
|
|
...overrides,
|
|
});
|
|
|
|
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));
|
|
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<MilestoneEntity[]>([]);
|
|
|
|
readonly milestones = this._data.asReadonly();
|
|
readonly loading = signal(false);
|
|
readonly loaded = signal(true);
|
|
|
|
seed(milestones: MilestoneEntity[]): void {
|
|
this._data.set(milestones);
|
|
}
|
|
|
|
load(): Promise<void> {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
describe('Issues', () => {
|
|
let component: Issues;
|
|
let fixture: ComponentFixture<Issues>;
|
|
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');
|
|
});
|
|
});
|
|
});
|