551 lines
19 KiB
TypeScript
551 lines
19 KiB
TypeScript
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 { vi } from 'vitest';
|
|
import { IssueDetail } from './issue-detail';
|
|
import { IssueEntity, IssuesStore } from '../issues.store';
|
|
|
|
const makeIssue = (overrides: Partial<IssueEntity> = {}): IssueEntity => ({
|
|
id: 99,
|
|
type: 'Story',
|
|
assignee: '',
|
|
epic: '',
|
|
name: 'Test Issue',
|
|
dueDate: '',
|
|
description: '',
|
|
estimatedTime: null,
|
|
dependsOnIds: [],
|
|
comments: [],
|
|
priority: 'MOYENNE',
|
|
status: 'draft',
|
|
progress: 0,
|
|
...overrides,
|
|
});
|
|
|
|
class FakeIssuesStore {
|
|
private _data = signal<IssueEntity[]>([
|
|
makeIssue({ id: 1, name: 'Issue 1' }),
|
|
makeIssue({ id: 2, name: 'Issue 2' }),
|
|
makeIssue({ id: 3, name: 'Issue 3' }),
|
|
]);
|
|
|
|
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 = {
|
|
...makeIssue(),
|
|
...rest,
|
|
dependsOnIds: Array.isArray(deps)
|
|
? deps.filter((v: unknown) => typeof v === 'number')
|
|
: typeof legacy === 'number'
|
|
? [legacy]
|
|
: [],
|
|
comments: Array.isArray(c) ? c : [],
|
|
estimatedTime: et ?? null,
|
|
};
|
|
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();
|
|
}
|
|
}
|
|
|
|
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<IssueDetail>;
|
|
let store: FakeIssuesStore;
|
|
let router: Router;
|
|
|
|
beforeEach(async () => {
|
|
store = new FakeIssuesStore();
|
|
await TestBed.configureTestingModule({
|
|
imports: [IssueDetail],
|
|
providers: [
|
|
provideRouter([]),
|
|
{ provide: ActivatedRoute, useValue: makeRoute('1') },
|
|
{ provide: IssuesStore, useValue: store },
|
|
],
|
|
}).compileComponents();
|
|
|
|
router = TestBed.inject(Router);
|
|
fixture = TestBed.createComponent(IssueDetail);
|
|
component = fixture.componentInstance;
|
|
await fixture.whenStable();
|
|
});
|
|
|
|
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);
|
|
await (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', async () => {
|
|
(component as any).selectedCandidateId = 2;
|
|
await (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', async () => {
|
|
(component as any).newIssueName = 'Child Issue';
|
|
await (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', async () => {
|
|
(component as any).selectedEpicCandidateId = 2;
|
|
await (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<IssueDetail>;
|
|
let store: FakeIssuesStore;
|
|
let router: Router;
|
|
|
|
beforeEach(async () => {
|
|
store = new FakeIssuesStore();
|
|
await TestBed.configureTestingModule({
|
|
imports: [IssueDetail],
|
|
providers: [
|
|
provideRouter([]),
|
|
{
|
|
provide: ActivatedRoute,
|
|
useValue: {
|
|
snapshot: {
|
|
routeConfig: { path: 'issues/new' },
|
|
paramMap: convertToParamMap({}),
|
|
queryParamMap: convertToParamMap({ draftId: '10' }),
|
|
},
|
|
paramMap: of(convertToParamMap({})),
|
|
},
|
|
},
|
|
{ provide: IssuesStore, useValue: store },
|
|
],
|
|
}).compileComponents();
|
|
|
|
router = TestBed.inject(Router);
|
|
fixture = TestBed.createComponent(IssueDetail);
|
|
component = fixture.componentInstance;
|
|
await fixture.whenStable();
|
|
});
|
|
|
|
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();
|
|
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';
|
|
await (component as any).saveIssue(true);
|
|
expect(store.issues().some((i) => i.name === 'Brand New Issue')).toBe(true);
|
|
expect(spy).toHaveBeenCalledWith(['/issues', 10]);
|
|
});
|
|
});
|