diff --git a/angular.json b/angular.json index 37c6373..d7cc641 100644 --- a/angular.json +++ b/angular.json @@ -25,7 +25,9 @@ "input": "public" } ], - "styles": ["src/styles.css"] + "styles": [ + "src/styles.css" + ] }, "configurations": { "production": { @@ -64,7 +66,26 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular/build:unit-test", + "options": { + "coverage": true, + "coverageReporters": [ + "text", + "lcov" + ], + "coverageThresholds": { + "lines": 90, + "functions": 90, + "branches": 80, + "statements": 90 + }, + "coverageExclude": [ + "src/main.ts", + "src/app/app.config.ts", + "src/app/app.routes.ts", + "**/*.html" + ] + } } } } diff --git a/package-lock.json b/package-lock.json index d894d24..20e7ffc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@angular/build": "^21.2.0", "@angular/cli": "^21.2.0", "@angular/compiler-cli": "^21.2.0", + "@vitest/coverage-v8": "^4.1.7", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "~5.9.2", @@ -956,6 +957,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3943,6 +3954,37 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", @@ -4217,6 +4259,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5490,6 +5551,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -5562,6 +5633,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -5840,6 +5918,35 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -6158,6 +6265,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-fetch-happen": { "version": "15.0.5", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", @@ -7882,6 +8017,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index 15712c2..c7cc323 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@angular/build": "^21.2.0", "@angular/cli": "^21.2.0", "@angular/compiler-cli": "^21.2.0", + "@vitest/coverage-v8": "^4.1.7", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "~5.9.2", diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index e617ef3..7c2aa3f 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,10 +1,12 @@ import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [provideRouter([])], }).compileComponents(); }); @@ -14,10 +16,10 @@ describe('App', () => { expect(app).toBeTruthy(); }); - it('should render title', async () => { + it('should render the app layout', async () => { const fixture = TestBed.createComponent(App); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Bonsai-webapp'); + expect(compiled.querySelector('app-menu')).toBeTruthy(); }); }); diff --git a/src/app/issues/issue-comments/issue-comments.spec.ts b/src/app/issues/issue-comments/issue-comments.spec.ts new file mode 100644 index 0000000..9ae7810 --- /dev/null +++ b/src/app/issues/issue-comments/issue-comments.spec.ts @@ -0,0 +1,195 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { IssueComments } from './issue-comments'; +import { IssuesStore } from '../issues.store'; + +describe('IssueComments', () => { + let component: IssueComments; + let fixture: ComponentFixture; + let store: IssuesStore; + + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [IssueComments], + }).compileComponents(); + + store = TestBed.inject(IssuesStore); + fixture = TestBed.createComponent(IssueComments); + component = fixture.componentInstance; + fixture.componentRef.setInput('issueId', 1); + await fixture.whenStable(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('comments signal', () => { + it('returns empty array when issue has no comments', () => { + expect((component as any).comments()).toEqual([]); + }); + + it('reflects comments after adding one', () => { + (component as any).newCommentText = 'Hello'; + (component as any).addComment(); + expect((component as any).comments().length).toBe(1); + }); + }); + + describe('formatDate', () => { + it('formats an ISO date string as a localised date', () => { + const result = (component as any).formatDate('2026-01-15T10:30:00.000Z'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('parseMarkdown', () => { + it('converts markdown bold to HTML', () => { + const result = (component as any).parseMarkdown('**bold**'); + expect(String(result)).toContain('strong'); + }); + + it('returns a truthy SafeHtml object', () => { + const result = (component as any).parseMarkdown('hello'); + expect(result).toBeTruthy(); + }); + }); + + describe('addComment', () => { + it('adds a comment to the issue in the store', () => { + (component as any).newCommentText = 'Test comment'; + (component as any).addComment(); + expect(store.getById(1)?.comments.length).toBe(1); + expect(store.getById(1)?.comments[0].text).toBe('Test comment'); + }); + + it('clears newCommentText after adding', () => { + (component as any).newCommentText = 'Some text'; + (component as any).addComment(); + expect((component as any).newCommentText).toBe(''); + }); + + it('does nothing when newCommentText is empty', () => { + (component as any).newCommentText = ' '; + (component as any).addComment(); + expect(store.getById(1)?.comments.length).toBe(0); + }); + + it('assigns an incrementing id to new comments', () => { + (component as any).newCommentText = 'First'; + (component as any).addComment(); + (component as any).newCommentText = 'Second'; + (component as any).addComment(); + const comments = store.getById(1)?.comments ?? []; + expect(comments[0].id).toBe(1); + expect(comments[1].id).toBe(2); + }); + + it('sets createdAt to an ISO date string', () => { + (component as any).newCommentText = 'Dated comment'; + (component as any).addComment(); + const comment = store.getById(1)?.comments[0]; + expect(comment?.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('sets updatedAt to null initially', () => { + (component as any).newCommentText = 'New comment'; + (component as any).addComment(); + expect(store.getById(1)?.comments[0].updatedAt).toBeNull(); + }); + }); + + describe('startEditComment', () => { + it('sets editingCommentId and editingCommentText', () => { + (component as any).newCommentText = 'Editable'; + (component as any).addComment(); + const comment = store.getById(1)!.comments[0]; + + (component as any).startEditComment(comment); + + expect((component as any).editingCommentId).toBe(comment.id); + expect((component as any).editingCommentText).toBe('Editable'); + }); + }); + + describe('cancelEditComment', () => { + it('resets editingCommentId and editingCommentText', () => { + (component as any).editingCommentId = 1; + (component as any).editingCommentText = 'In progress edit'; + (component as any).cancelEditComment(); + expect((component as any).editingCommentId).toBeNull(); + expect((component as any).editingCommentText).toBe(''); + }); + }); + + describe('saveEditComment', () => { + beforeEach(() => { + (component as any).newCommentText = 'Original text'; + (component as any).addComment(); + const comment = store.getById(1)!.comments[0]; + (component as any).startEditComment(comment); + }); + + it('updates the comment text in the store', () => { + (component as any).editingCommentText = 'Updated text'; + (component as any).saveEditComment(); + expect(store.getById(1)?.comments[0].text).toBe('Updated text'); + }); + + it('sets updatedAt on the edited comment', () => { + (component as any).editingCommentText = 'Changed'; + (component as any).saveEditComment(); + expect(store.getById(1)?.comments[0].updatedAt).not.toBeNull(); + }); + + it('resets editing state after saving', () => { + (component as any).editingCommentText = 'Done'; + (component as any).saveEditComment(); + expect((component as any).editingCommentId).toBeNull(); + expect((component as any).editingCommentText).toBe(''); + }); + + it('does nothing when editingCommentText is empty', () => { + (component as any).editingCommentText = ' '; + (component as any).saveEditComment(); + expect(store.getById(1)?.comments[0].text).toBe('Original text'); + }); + + it('does nothing when editingCommentId is null', () => { + (component as any).editingCommentId = null; + (component as any).editingCommentText = 'Will not save'; + (component as any).saveEditComment(); + expect(store.getById(1)?.comments[0].text).toBe('Original text'); + }); + }); + + describe('deleteComment', () => { + it('removes the comment from the issue', () => { + (component as any).newCommentText = 'To delete'; + (component as any).addComment(); + const commentId = store.getById(1)!.comments[0].id; + + (component as any).deleteComment(commentId); + + expect(store.getById(1)?.comments.length).toBe(0); + }); + + it('only removes the targeted comment', () => { + (component as any).newCommentText = 'Keep me'; + (component as any).addComment(); + (component as any).newCommentText = 'Delete me'; + (component as any).addComment(); + + const comments = store.getById(1)!.comments; + (component as any).deleteComment(comments[1].id); + + expect(store.getById(1)?.comments.length).toBe(1); + expect(store.getById(1)?.comments[0].text).toBe('Keep me'); + }); + }); +}); diff --git a/src/app/issues/issue-detail/issue-detail.spec.ts b/src/app/issues/issue-detail/issue-detail.spec.ts index 93f93d3..01d22a1 100644 --- a/src/app/issues/issue-detail/issue-detail.spec.ts +++ b/src/app/issues/issue-detail/issue-detail.spec.ts @@ -1,22 +1,500 @@ 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'; -describe('IssueDetail', () => { +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]); + }); }); diff --git a/src/app/issues/issues.spec.ts b/src/app/issues/issues.spec.ts index f4bba0a..09c39a0 100644 --- a/src/app/issues/issues.spec.ts +++ b/src/app/issues/issues.spec.ts @@ -1,22 +1,179 @@ 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'; + +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: 50, + ...overrides, +}); describe('Issues', () => { let component: Issues; let fixture: ComponentFixture; + let store: IssuesStore; + let router: Router; beforeEach(async () => { + localStorage.clear(); await TestBed.configureTestingModule({ imports: [Issues], + providers: [provideRouter([])], }).compileComponents(); + store = TestBed.inject(IssuesStore); + router = TestBed.inject(Router); fixture = TestBed.createComponent(Issues); component = fixture.componentInstance; await fixture.whenStable(); }); + afterEach(() => { + localStorage.clear(); + }); + it('should create', () => { expect(component).toBeTruthy(); }); + + describe('filteredIssues', () => { + it('returns all issues when no type is selected', () => { + (component as any).selectedType = null; + expect((component as any).filteredIssues.length).toBe(store.issues().length); + }); + + it('returns only issues matching the selected type', () => { + (component as any).selectedType = '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 type', () => { + (component as any).selectedType = 'Epic'; + const filtered: IssueEntity[] = (component as any).filteredIssues; + // Default store has no Epics, so this should be empty + expect(filtered.every((i) => i.type === 'Epic')).toBe(true); + }); + }); + + describe('selectType', () => { + it('sets selectedType when none is active', () => { + (component as any).selectedType = null; + (component as any).selectType('Bug'); + expect((component as any).selectedType).toBe('Bug'); + }); + + it('clears selectedType when the same type is selected again (toggle off)', () => { + (component as any).selectedType = 'Bug'; + (component as any).selectType('Bug'); + expect((component as any).selectedType).toBeNull(); + }); + + it('switches to a different type', () => { + (component as any).selectedType = 'Bug'; + (component as any).selectType('Story'); + expect((component as any).selectedType).toBe('Story'); + }); + + it('selectType(null) clears the filter', () => { + (component as any).selectedType = 'Bug'; + (component as any).selectType(null); + expect((component as any).selectedType).toBeNull(); + }); + }); + + describe('createIssue', () => { + it('navigates to /issues/new with a draftId query param', async () => { + const spy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + (component as any).createIssue(); + expect(spy).toHaveBeenCalledWith( + ['/issues/new'], + expect.objectContaining({ queryParams: expect.objectContaining({ mode: 'edit' }) }), + ); + }); + }); + + 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(['/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); + }); + }); + + 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'); + }); + }); }); diff --git a/src/app/issues/issues.store.spec.ts b/src/app/issues/issues.store.spec.ts new file mode 100644 index 0000000..6cdf313 --- /dev/null +++ b/src/app/issues/issues.store.spec.ts @@ -0,0 +1,172 @@ +import { TestBed } from '@angular/core/testing'; +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, +}); + +describe('IssuesStore', () => { + let store: IssuesStore; + + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({}); + store = TestBed.inject(IssuesStore); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should be created', () => { + expect(store).toBeTruthy(); + }); + + it('should load default issues when localStorage is empty', () => { + expect(store.issues().length).toBeGreaterThan(0); + }); + + describe('getById', () => { + it('returns the issue with the given id', () => { + const issue = store.getById(1); + expect(issue?.id).toBe(1); + }); + + it('returns undefined for an unknown id', () => { + expect(store.getById(9999)).toBeUndefined(); + }); + }); + + describe('getNextId', () => { + it('returns max id + 1', () => { + const ids = store.issues().map((i) => i.id); + const expectedNext = Math.max(...ids) + 1; + expect(store.getNextId()).toBe(expectedNext); + }); + + it('returns 1 when there are no issues', () => { + store.deleteById(1); + store.deleteById(2); + store.deleteById(3); + expect(store.getNextId()).toBe(1); + }); + }); + + describe('upsert', () => { + it('adds a new issue when the id does not exist', () => { + const before = store.issues().length; + store.upsert(makeIssue({ id: 999 })); + expect(store.issues().length).toBe(before + 1); + expect(store.getById(999)?.name).toBe('Test Issue'); + }); + + it('updates an existing issue', () => { + store.upsert(makeIssue({ id: 1, name: 'Updated Name' })); + expect(store.getById(1)?.name).toBe('Updated Name'); + expect(store.issues().filter((i) => i.id === 1).length).toBe(1); + }); + + it('persists the issue list to localStorage', () => { + store.upsert(makeIssue({ id: 999 })); + const raw = localStorage.getItem('bonsai.issues'); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(true); + }); + + it('normalizes legacy dependsOnId (single number) to dependsOnIds array when dependsOnIds is absent', () => { + // dependsOnIds must be omitted (not an array) for the legacy field to take effect + store.upsert({ id: 998, type: 'Story', name: 'Legacy', dependsOnId: 1 } as any); + expect(store.getById(998)?.dependsOnIds).toEqual([1]); + }); + + it('filters non-number values from dependsOnIds', () => { + store.upsert({ ...makeIssue({ id: 997 }), dependsOnIds: [1, 'two', null] } as any); + expect(store.getById(997)?.dependsOnIds).toEqual([1]); + }); + + it('ensures comments is always an array when missing', () => { + store.upsert({ ...makeIssue({ id: 996 }), comments: undefined } as any); + expect(store.getById(996)?.comments).toEqual([]); + }); + + it('sets default type to Story when type is missing', () => { + store.upsert({ ...makeIssue({ id: 995 }), type: undefined } as any); + expect(store.getById(995)?.type).toBe('Story'); + }); + + it('sets estimatedTime to null when missing', () => { + store.upsert({ ...makeIssue({ id: 994 }), estimatedTime: undefined } as any); + expect(store.getById(994)?.estimatedTime).toBeNull(); + }); + }); + + describe('deleteById', () => { + it('removes the issue from the store', () => { + store.upsert(makeIssue({ id: 999 })); + store.deleteById(999); + expect(store.getById(999)).toBeUndefined(); + }); + + it('removes the deleted id from dependsOnIds of other issues', () => { + store.upsert(makeIssue({ id: 100 })); + store.upsert(makeIssue({ id: 101, dependsOnIds: [100] })); + store.deleteById(100); + expect(store.getById(101)?.dependsOnIds).toEqual([]); + }); + + it('persists the updated list to localStorage', () => { + store.upsert(makeIssue({ id: 999 })); + store.deleteById(999); + const raw = localStorage.getItem('bonsai.issues'); + const parsed = JSON.parse(raw!); + expect(parsed.some((i: IssueEntity) => i.id === 999)).toBe(false); + }); + }); + + describe('localStorage persistence', () => { + it('loads issues from localStorage on construction', () => { + const saved: IssueEntity[] = [makeIssue({ id: 42, name: 'From storage' })]; + localStorage.setItem('bonsai.issues', JSON.stringify(saved)); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({}); + const freshStore = TestBed.inject(IssuesStore); + + expect(freshStore.getById(42)?.name).toBe('From storage'); + }); + + it('falls back to defaults when localStorage contains invalid JSON', () => { + localStorage.setItem('bonsai.issues', 'not-valid-json'); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({}); + const freshStore = TestBed.inject(IssuesStore); + + expect(freshStore.issues().length).toBeGreaterThan(0); + }); + + it('falls back to defaults when localStorage contains a non-array', () => { + localStorage.setItem('bonsai.issues', '{"key":"value"}'); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({}); + const freshStore = TestBed.inject(IssuesStore); + + expect(freshStore.issues().length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/app/menu/menu.spec.ts b/src/app/menu/menu.spec.ts index acdf0d0..f8ec9c6 100644 --- a/src/app/menu/menu.spec.ts +++ b/src/app/menu/menu.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - +import { provideRouter } from '@angular/router'; import { Menu } from './menu'; describe('Menu', () => { @@ -9,6 +9,7 @@ describe('Menu', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [Menu], + providers: [provideRouter([])], }).compileComponents(); fixture = TestBed.createComponent(Menu); @@ -19,4 +20,14 @@ describe('Menu', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have three menu items', () => { + const items = (component as any).menuItems as { label: string; path: string }[]; + expect(items.length).toBe(3); + }); + + it('should contain Issues link', () => { + const items = (component as any).menuItems as { label: string; path: string }[]; + expect(items.some((i) => i.path === '/issues')).toBe(true); + }); }); diff --git a/src/app/projects/projects.spec.ts b/src/app/projects/projects.spec.ts index c14c6a0..131c8d4 100644 --- a/src/app/projects/projects.spec.ts +++ b/src/app/projects/projects.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { Projects } from './projects'; describe('Projects', () => { @@ -19,4 +18,33 @@ describe('Projects', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should have 3 default projects', () => { + expect((component as any).projects().length).toBe(3); + }); + + it('createProject adds a new project', () => { + (component as any).createProject(); + expect((component as any).projects().length).toBe(4); + }); + + it('createProject increments the id each time', () => { + (component as any).createProject(); + (component as any).createProject(); + const projects = (component as any).projects(); + expect(projects[3].id).toBe(4); + expect(projects[4].id).toBe(5); + }); + + it('new project starts with Nouveau status', () => { + (component as any).createProject(); + const newProject = (component as any).projects()[3]; + expect(newProject.status).toBe('Nouveau'); + }); + + it('new project starts with 0 progress', () => { + (component as any).createProject(); + const newProject = (component as any).projects()[3]; + expect(newProject.progress).toBe(0); + }); });