From 10e8b8389aaf0f2bb173db1cd4889a56d8929554 Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Sat, 28 Feb 2026 10:11:23 -0700 Subject: [PATCH 1/3] test: add unit tests for TableEditor, SectionNav, and SpecPreview Adds 42 new tests across 3 previously untested components: - TableEditorComponent (16 tests): cell change, addRow, removeRow, moveRowUp/Down, no-op edge cases, empty state, immutability - SectionNavComponent (12 tests): nav item rendering, active state, click emission, level classes, dynamic updates - SpecPreviewComponent (14 tests): markdown rendering, validation panel states (valid/invalid/warnings), error/warning classes Contributes to #61 Co-Authored-By: Claude Opus 4.6 --- .../section-nav/section-nav.spec.ts | 120 ++++++++++++ .../spec-preview/spec-preview.spec.ts | 172 ++++++++++++++++++ .../table-editor/table-editor.spec.ts | 166 +++++++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 src/app/components/section-nav/section-nav.spec.ts create mode 100644 src/app/components/spec-preview/spec-preview.spec.ts create mode 100644 src/app/components/table-editor/table-editor.spec.ts diff --git a/src/app/components/section-nav/section-nav.spec.ts b/src/app/components/section-nav/section-nav.spec.ts new file mode 100644 index 0000000..9c5d0c8 --- /dev/null +++ b/src/app/components/section-nav/section-nav.spec.ts @@ -0,0 +1,120 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { SectionNavComponent } from './section-nav'; +import { type SpecSection } from '../../models/spec.model'; + +@Component({ + standalone: true, + imports: [SectionNavComponent], + template: ``, +}) +class TestHostComponent { + sections = signal([]); + activeIndex = signal(-1); + lastSelected: number | null = null; + onSelect(index: number): void { + this.lastSelected = index; + } +} + +describe('SectionNavComponent', () => { + let host: TestHostComponent; + let fixture: ReturnType>; + + const sampleSections: SpecSection[] = [ + { heading: 'Purpose', level: 2, content: 'Purpose content' }, + { heading: 'Public API', level: 2, content: 'API content' }, + { heading: 'Exported Classes', level: 3, content: 'Classes' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + host.sections.set(sampleSections); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(host).toBeTruthy(); + }); + + it('should render Frontmatter as first nav item', () => { + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons[0].textContent).toContain('Frontmatter'); + }); + + it('should render all sections as nav items', () => { + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + // Frontmatter + 3 sections = 4 + expect(buttons.length).toBe(4); + expect(buttons[1].textContent).toContain('Purpose'); + expect(buttons[2].textContent).toContain('Public API'); + expect(buttons[3].textContent).toContain('Exported Classes'); + }); + + it('should mark frontmatter as active when activeIndex is -1', () => { + host.activeIndex.set(-1); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons[0].classList.contains('active')).toBe(true); + expect(buttons[1].classList.contains('active')).toBe(false); + }); + + it('should mark correct section as active', () => { + host.activeIndex.set(1); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons[0].classList.contains('active')).toBe(false); + expect(buttons[2].classList.contains('active')).toBe(true); // index 1 = "Public API" = 3rd button + }); + + it('should emit -1 when frontmatter is clicked', () => { + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + buttons[0].click(); + expect(host.lastSelected).toBe(-1); + }); + + it('should emit section index when section is clicked', () => { + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + buttons[2].click(); + expect(host.lastSelected).toBe(1); + }); + + it('should apply level-3 class for deeply nested sections', () => { + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons[3].classList.contains('level-3')).toBe(true); + }); + + it('should apply level-2 class for standard sections', () => { + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons[1].classList.contains('level-2')).toBe(true); + }); + + it('should apply frontmatter class to frontmatter button', () => { + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons[0].classList.contains('frontmatter')).toBe(true); + }); + + it('should update nav items when sections input changes', () => { + host.sections.set([{ heading: 'Only Section', level: 2, content: '' }]); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons.length).toBe(2); // Frontmatter + 1 section + expect(buttons[1].textContent).toContain('Only Section'); + }); + + it('should render with empty sections', () => { + host.sections.set([]); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('button.nav-item'); + expect(buttons.length).toBe(1); // Only Frontmatter + }); +}); diff --git a/src/app/components/spec-preview/spec-preview.spec.ts b/src/app/components/spec-preview/spec-preview.spec.ts new file mode 100644 index 0000000..784046b --- /dev/null +++ b/src/app/components/spec-preview/spec-preview.spec.ts @@ -0,0 +1,172 @@ +import { Component, signal } from '@angular/core'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { SpecPreviewComponent } from './spec-preview'; +import { type ValidationResult } from '../../models/spec.model'; + +@Component({ + standalone: true, + imports: [SpecPreviewComponent], + template: ``, +}) +class TestHostComponent { + markdown = signal('# Hello'); + validation = signal(null); +} + +describe('SpecPreviewComponent', () => { + let host: TestHostComponent; + let fixture: ReturnType>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(host).toBeTruthy(); + }); + + it('should render markdown as HTML', async () => { + host.markdown.set('**bold text**'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const article = fixture.nativeElement.querySelector('.preview-content'); + expect(article.innerHTML).toContain('bold text'); + }); + + it('should render headings', async () => { + host.markdown.set('# Test Heading'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const article = fixture.nativeElement.querySelector('.preview-content'); + expect(article.innerHTML).toContain(' { + host.validation.set(null); + fixture.detectChanges(); + const panel = fixture.nativeElement.querySelector('.validation-panel'); + expect(panel).toBeFalsy(); + }); + + it('should show valid badge when validation is valid', () => { + host.validation.set({ valid: true, errors: [] }); + fixture.detectChanges(); + const badge = fixture.nativeElement.querySelector('.status-badge.valid'); + expect(badge).toBeTruthy(); + expect(badge.textContent.trim()).toBe('Valid'); + }); + + it('should show error count when validation has errors', () => { + host.validation.set({ + valid: false, + errors: [ + { level: 'error', field: 'module', message: 'Required' }, + { level: 'error', field: 'version', message: 'Must be positive' }, + ], + }); + fixture.detectChanges(); + const badge = fixture.nativeElement.querySelector('.status-badge.invalid'); + expect(badge).toBeTruthy(); + expect(badge.textContent.trim()).toBe('2 error(s)'); + }); + + it('should show warning count alongside errors', () => { + host.validation.set({ + valid: false, + errors: [ + { level: 'error', field: 'module', message: 'Required' }, + { level: 'warning', field: 'files', message: 'Consider adding files' }, + ], + }); + fixture.detectChanges(); + const warningBadge = fixture.nativeElement.querySelector('.status-badge.warning'); + expect(warningBadge).toBeTruthy(); + expect(warningBadge.textContent.trim()).toBe('1 warning(s)'); + }); + + it('should show warning count on valid spec with warnings', () => { + host.validation.set({ + valid: true, + errors: [{ level: 'warning', field: 'depends_on', message: 'No dependencies listed' }], + }); + fixture.detectChanges(); + const warningBadge = fixture.nativeElement.querySelector('.status-badge.warning'); + expect(warningBadge).toBeTruthy(); + expect(warningBadge.textContent.trim()).toBe('1 warning(s)'); + }); + + it('should list all validation issues', () => { + host.validation.set({ + valid: false, + errors: [ + { level: 'error', field: 'module', message: 'Required' }, + { level: 'warning', field: 'files', message: 'Consider adding files' }, + ], + }); + fixture.detectChanges(); + const items = fixture.nativeElement.querySelectorAll('.validation-list li'); + expect(items.length).toBe(2); + expect(items[0].textContent).toContain('module'); + expect(items[0].textContent).toContain('Required'); + expect(items[1].textContent).toContain('files'); + }); + + it('should apply error class to error items', () => { + host.validation.set({ + valid: false, + errors: [{ level: 'error', field: 'module', message: 'Required' }], + }); + fixture.detectChanges(); + const item = fixture.nativeElement.querySelector('.validation-list li'); + expect(item.classList.contains('error')).toBe(true); + }); + + it('should apply warning class to warning items', () => { + host.validation.set({ + valid: true, + errors: [{ level: 'warning', field: 'files', message: 'Consider adding files' }], + }); + fixture.detectChanges(); + const item = fixture.nativeElement.querySelector('.validation-list li'); + expect(item.classList.contains('warning')).toBe(true); + }); + + it('should not show validation list when valid with no warnings', () => { + host.validation.set({ valid: true, errors: [] }); + fixture.detectChanges(); + const list = fixture.nativeElement.querySelector('.validation-list'); + expect(list).toBeFalsy(); + }); + + it('should apply valid class to panel when valid', () => { + host.validation.set({ valid: true, errors: [] }); + fixture.detectChanges(); + const panel = fixture.nativeElement.querySelector('.validation-panel'); + expect(panel.classList.contains('valid')).toBe(true); + expect(panel.classList.contains('invalid')).toBe(false); + }); + + it('should apply invalid class to panel when invalid', () => { + host.validation.set({ + valid: false, + errors: [{ level: 'error', field: 'module', message: 'Required' }], + }); + fixture.detectChanges(); + const panel = fixture.nativeElement.querySelector('.validation-panel'); + expect(panel.classList.contains('invalid')).toBe(true); + expect(panel.classList.contains('valid')).toBe(false); + }); +}); diff --git a/src/app/components/table-editor/table-editor.spec.ts b/src/app/components/table-editor/table-editor.spec.ts new file mode 100644 index 0000000..55dd7d7 --- /dev/null +++ b/src/app/components/table-editor/table-editor.spec.ts @@ -0,0 +1,166 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { TableEditorComponent } from './table-editor'; +import { type MarkdownTable } from '../../models/markdown-table'; + +@Component({ + standalone: true, + imports: [TableEditorComponent], + template: ``, +}) +class TestHostComponent { + table = signal({ headers: ['Name', 'Type'], rows: [['id', 'number']] }); + lastEmitted: MarkdownTable | null = null; + onTableChange(t: MarkdownTable): void { + this.lastEmitted = t; + } +} + +describe('TableEditorComponent', () => { + let host: TestHostComponent; + let fixture: ReturnType>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + function getComponent(): TableEditorComponent { + return fixture.debugElement.children[0].componentInstance as TableEditorComponent; + } + + it('should create', () => { + expect(host).toBeTruthy(); + }); + + it('should render headers as table column headers', () => { + const headers = fixture.nativeElement.querySelectorAll('th'); + expect(headers[0].textContent.trim()).toBe('Name'); + expect(headers[1].textContent.trim()).toBe('Type'); + }); + + it('should render correct number of cell inputs', () => { + const inputs = fixture.nativeElement.querySelectorAll('input.cell-input'); + expect(inputs.length).toBe(2); + }); + + describe('onCellChange', () => { + it('should emit new table with updated cell value', () => { + (getComponent() as any).onCellChange(0, 1, 'string'); + expect(host.lastEmitted).toEqual({ + headers: ['Name', 'Type'], + rows: [['id', 'string']], + }); + }); + + it('should not mutate the original table', () => { + const original = host.table(); + const originalRow = [...original.rows[0]]; + (getComponent() as any).onCellChange(0, 0, 'changed'); + expect(original.rows[0]).toEqual(originalRow); + }); + }); + + describe('addRow', () => { + it('should emit table with new empty row appended', () => { + (getComponent() as any).addRow(); + expect(host.lastEmitted).toEqual({ + headers: ['Name', 'Type'], + rows: [['id', 'number'], ['', '']], + }); + }); + + it('should create empty cells matching header count', () => { + host.table.set({ headers: ['A', 'B', 'C'], rows: [] }); + fixture.detectChanges(); + (getComponent() as any).addRow(); + expect(host.lastEmitted!.rows[0]).toEqual(['', '', '']); + }); + }); + + describe('removeRow', () => { + it('should emit table without the removed row', () => { + host.table.set({ headers: ['Name'], rows: [['a'], ['b'], ['c']] }); + fixture.detectChanges(); + (getComponent() as any).removeRow(1); + expect(host.lastEmitted).toEqual({ + headers: ['Name'], + rows: [['a'], ['c']], + }); + }); + }); + + describe('moveRowUp', () => { + it('should swap row with the one above', () => { + host.table.set({ headers: ['Name'], rows: [['a'], ['b'], ['c']] }); + fixture.detectChanges(); + (getComponent() as any).moveRowUp(2); + expect(host.lastEmitted).toEqual({ + headers: ['Name'], + rows: [['a'], ['c'], ['b']], + }); + }); + + it('should be a no-op when row index is 0', () => { + host.table.set({ headers: ['Name'], rows: [['a'], ['b']] }); + fixture.detectChanges(); + (getComponent() as any).moveRowUp(0); + expect(host.lastEmitted).toBeNull(); + }); + }); + + describe('moveRowDown', () => { + it('should swap row with the one below', () => { + host.table.set({ headers: ['Name'], rows: [['a'], ['b'], ['c']] }); + fixture.detectChanges(); + (getComponent() as any).moveRowDown(0); + expect(host.lastEmitted).toEqual({ + headers: ['Name'], + rows: [['b'], ['a'], ['c']], + }); + }); + + it('should be a no-op when row is last', () => { + host.table.set({ headers: ['Name'], rows: [['a'], ['b']] }); + fixture.detectChanges(); + (getComponent() as any).moveRowDown(1); + expect(host.lastEmitted).toBeNull(); + }); + }); + + describe('empty state', () => { + it('should show empty state message when table has no rows', () => { + host.table.set({ headers: ['Name'], rows: [] }); + fixture.detectChanges(); + const emptyState = fixture.nativeElement.querySelector('.empty-state'); + expect(emptyState).toBeTruthy(); + expect(emptyState.textContent.trim()).toBe('No rows yet. Add one below.'); + }); + + it('should not show empty state when table has rows', () => { + const emptyState = fixture.nativeElement.querySelector('.empty-state'); + expect(emptyState).toBeFalsy(); + }); + }); + + describe('immutability', () => { + it('should preserve headers reference on row mutations', () => { + const originalHeaders = host.table().headers; + (getComponent() as any).addRow(); + expect(host.lastEmitted!.headers).toBe(originalHeaders); + }); + + it('should create new row arrays on moveRowUp', () => { + host.table.set({ headers: ['Name'], rows: [['a'], ['b']] }); + fixture.detectChanges(); + const originalRows = host.table().rows; + (getComponent() as any).moveRowUp(1); + expect(host.lastEmitted!.rows).not.toBe(originalRows); + }); + }); +}); From e5b049b0f08d0e099d96c9e92a6058e26244c48e Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Sun, 1 Mar 2026 03:07:26 -0700 Subject: [PATCH 2/3] test: add comprehensive unit tests for SectionEditorComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37 tests covering heading editing, navigation (prev/next/frontmatter), structured editing mode (table + text blocks), CodeMirror mode detection, section change handling, accessibility (ARIA labels/roles), and edge cases. Closes #61 (partial — SectionEditorComponent) Co-Authored-By: Claude Opus 4.6 --- .../section-editor/section-editor.spec.ts | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 src/app/components/section-editor/section-editor.spec.ts diff --git a/src/app/components/section-editor/section-editor.spec.ts b/src/app/components/section-editor/section-editor.spec.ts new file mode 100644 index 0000000..b433d8a --- /dev/null +++ b/src/app/components/section-editor/section-editor.spec.ts @@ -0,0 +1,389 @@ +import { Component, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { SectionEditorComponent } from './section-editor'; +import { type SpecSection } from '../../models/spec.model'; + +@Component({ + standalone: true, + imports: [SectionEditorComponent], + template: ` + + `, +}) +class TestHostComponent { + section = signal({ + heading: 'Public API', + level: 2, + content: '| Method | Returns |\n|---|---|\n| getId() | string |', + }); + sectionIndex = signal(1); + totalSections = signal(5); + + lastContent: string | null = null; + lastHeading: string | null = null; + lastNavigate: number | null = null; + + onContentChange(value: string): void { + this.lastContent = value; + } + onHeadingChange(value: string): void { + this.lastHeading = value; + } + onNavigate(index: number): void { + this.lastNavigate = index; + } +} + +function getComponent(fixture: ComponentFixture): SectionEditorComponent { + return fixture.debugElement.children[0].componentInstance as SectionEditorComponent; +} + +describe('SectionEditorComponent', () => { + let host: TestHostComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + // --- Creation --- + + it('should create', () => { + expect(getComponent(fixture)).toBeTruthy(); + }); + + // --- Heading --- + + describe('heading', () => { + it('should display the section heading in the input', () => { + const input: HTMLInputElement = fixture.nativeElement.querySelector('.heading-input'); + expect(input.value).toBe('Public API'); + }); + + it('should emit headingChange when heading is edited', () => { + const comp = getComponent(fixture); + (comp as any).onHeadingChange('New Heading'); + expect(host.lastHeading).toBe('New Heading'); + }); + + it('should update the internal headingValue signal', () => { + const comp = getComponent(fixture); + (comp as any).onHeadingChange('Updated'); + expect(comp['headingValue']()).toBe('Updated'); + }); + + it('should show heading prefix "##"', () => { + const prefix = fixture.nativeElement.querySelector('.heading-prefix'); + expect(prefix.textContent.trim()).toBe('##'); + }); + + it('should have accessible label for heading input', () => { + const label = fixture.nativeElement.querySelector('label.sr-only'); + expect(label).toBeTruthy(); + expect(label.textContent).toContain('Section heading'); + }); + }); + + // --- Navigation --- + + describe('navigation', () => { + it('should show both prev and next buttons when in the middle', () => { + host.sectionIndex.set(2); + host.totalSections.set(5); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('.section-nav-buttons button'); + expect(buttons.length).toBe(2); + }); + + it('goPrev from index 2 should emit 1', () => { + host.sectionIndex.set(2); + fixture.detectChanges(); + const comp = getComponent(fixture); + (comp as any).goPrev(); + expect(host.lastNavigate).toBe(1); + }); + + it('goPrev from index 0 should emit -1 (frontmatter)', () => { + host.sectionIndex.set(0); + fixture.detectChanges(); + const comp = getComponent(fixture); + (comp as any).goPrev(); + expect(host.lastNavigate).toBe(-1); + }); + + it('goNext from index 1 of 5 should emit 2', () => { + host.sectionIndex.set(1); + host.totalSections.set(5); + fixture.detectChanges(); + const comp = getComponent(fixture); + (comp as any).goNext(); + expect(host.lastNavigate).toBe(2); + }); + + it('goNext from last index should not emit', () => { + host.sectionIndex.set(4); + host.totalSections.set(5); + fixture.detectChanges(); + const comp = getComponent(fixture); + (comp as any).goNext(); + expect(host.lastNavigate).toBeNull(); + }); + + it('hasPrev is always true (sectionIndex >= 0)', () => { + host.sectionIndex.set(0); + fixture.detectChanges(); + const comp = getComponent(fixture); + expect(comp['hasPrev']).toBe(true); + }); + + it('hasNext is true when not at last section', () => { + host.sectionIndex.set(2); + host.totalSections.set(5); + fixture.detectChanges(); + const comp = getComponent(fixture); + expect(comp['hasNext']).toBe(true); + }); + + it('hasNext is false when at last section', () => { + host.sectionIndex.set(4); + host.totalSections.set(5); + fixture.detectChanges(); + const comp = getComponent(fixture); + expect(comp['hasNext']).toBe(false); + }); + + it('clicking prev button triggers goPrev', () => { + host.sectionIndex.set(2); + host.totalSections.set(5); + fixture.detectChanges(); + const prevBtn = fixture.nativeElement.querySelector('button[aria-label="Previous section"]'); + prevBtn.click(); + expect(host.lastNavigate).toBe(1); + }); + + it('clicking next button triggers goNext', () => { + host.sectionIndex.set(2); + host.totalSections.set(5); + fixture.detectChanges(); + const nextBtn = fixture.nativeElement.querySelector('button[aria-label="Next section"]'); + nextBtn.click(); + expect(host.lastNavigate).toBe(3); + }); + + it('hides next button when at last section', () => { + host.sectionIndex.set(4); + host.totalSections.set(5); + fixture.detectChanges(); + const nextBtn = fixture.nativeElement.querySelector('button[aria-label="Next section"]'); + expect(nextBtn).toBeFalsy(); + }); + }); + + // --- Content block parsing (structured mode) --- + + describe('structured editing mode', () => { + it('should detect table content and enter structured mode', () => { + const comp = getComponent(fixture); + expect(comp['hasTable']()).toBe(true); + }); + + it('should render table editor for table blocks', () => { + const tableEditor = fixture.nativeElement.querySelector('app-table-editor'); + expect(tableEditor).toBeTruthy(); + }); + + it('should not show CodeMirror editor host in structured mode', () => { + const editorHost = fixture.nativeElement.querySelector('.editor-host'); + expect(editorHost).toBeFalsy(); + }); + + it('should parse mixed text and table content into blocks', () => { + host.section.set({ + heading: 'Mixed', + level: 2, + content: 'Some intro text.\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nSome outro text.', + }); + fixture.detectChanges(); + const comp = getComponent(fixture); + const blocks = comp['contentBlocks'](); + expect(blocks.length).toBe(3); + expect(blocks[0].type).toBe('text'); + expect(blocks[1].type).toBe('table'); + expect(blocks[2].type).toBe('text'); + }); + + it('should render textareas for text blocks in structured mode', () => { + host.section.set({ + heading: 'Mixed', + level: 2, + content: 'Intro text.\n\n| A | B |\n|---|---|\n| 1 | 2 |', + }); + fixture.detectChanges(); + const textareas = fixture.nativeElement.querySelectorAll('textarea.text-block'); + expect(textareas.length).toBeGreaterThanOrEqual(1); + }); + + it('onTableChange should emit updated content', () => { + const comp = getComponent(fixture); + (comp as any).onTableChange(0, { + headers: ['Method', 'Returns'], + rows: [['getId()', 'number']], + }); + expect(host.lastContent).toBeTruthy(); + expect(host.lastContent).toContain('number'); + }); + + it('onTextBlockChange should emit updated content', () => { + host.section.set({ + heading: 'Mixed', + level: 2, + content: 'Intro.\n\n| A | B |\n|---|---|\n| 1 | 2 |', + }); + fixture.detectChanges(); + const comp = getComponent(fixture); + (comp as any).onTextBlockChange(0, 'Updated intro.'); + expect(host.lastContent).toBeTruthy(); + expect(host.lastContent).toContain('Updated intro.'); + }); + }); + + // --- CodeMirror mode --- + + describe('plain text mode (no tables)', () => { + beforeEach(() => { + host.section.set({ + heading: 'Purpose', + level: 2, + content: 'This module handles scheduling.', + }); + fixture.detectChanges(); + }); + + it('should detect no table content', () => { + const comp = getComponent(fixture); + expect(comp['hasTable']()).toBe(false); + }); + + it('should show editor host element', () => { + const editorHost = fixture.nativeElement.querySelector('.editor-host'); + expect(editorHost).toBeTruthy(); + }); + + it('should not show structured content', () => { + const structured = fixture.nativeElement.querySelector('.structured-content'); + expect(structured).toBeFalsy(); + }); + + it('should not render table editor', () => { + const tableEditor = fixture.nativeElement.querySelector('app-table-editor'); + expect(tableEditor).toBeFalsy(); + }); + }); + + // --- Section change --- + + describe('section change', () => { + it('should update heading when section changes', async () => { + host.section.set({ + heading: 'Invariants', + level: 2, + content: '| Rule | Enforced |\n|---|---|\n| No nulls | Yes |', + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const input: HTMLInputElement = fixture.nativeElement.querySelector('.heading-input'); + expect(input.value).toBe('Invariants'); + }); + + it('should reparse content blocks when section changes', () => { + const comp = getComponent(fixture); + const originalBlocks = comp['contentBlocks'](); + + host.section.set({ + heading: 'Different', + level: 2, + content: '| X | Y |\n|---|---|\n| a | b |\n| c | d |', + }); + host.sectionIndex.set(3); + fixture.detectChanges(); + + const newBlocks = comp['contentBlocks'](); + expect(newBlocks).not.toBe(originalBlocks); + }); + }); + + // --- Accessibility --- + + describe('accessibility', () => { + it('should have aria-label on the section element', () => { + const section = fixture.nativeElement.querySelector('.section-editor'); + expect(section.getAttribute('aria-label')).toContain('Public API'); + }); + + it('should have aria-label on nav buttons', () => { + const nav = fixture.nativeElement.querySelector('nav[aria-label="Section navigation"]'); + expect(nav).toBeTruthy(); + }); + + it('should have role="region" on section body', () => { + const body = fixture.nativeElement.querySelector('[role="region"]'); + expect(body).toBeTruthy(); + }); + + it('should update aria-label when heading changes', () => { + const comp = getComponent(fixture); + (comp as any).onHeadingChange('Updated API'); + fixture.detectChanges(); + const section = fixture.nativeElement.querySelector('.section-editor'); + expect(section.getAttribute('aria-label')).toContain('Updated API'); + }); + }); + + // --- Edge cases --- + + describe('edge cases', () => { + it('should handle empty content', () => { + host.section.set({ heading: 'Empty', level: 2, content: '' }); + fixture.detectChanges(); + const comp = getComponent(fixture); + // parseContentBlocks('') returns a single empty text block + expect(comp['contentBlocks']().every((b) => b.type === 'text')).toBe(true); + expect(comp['hasTable']()).toBe(false); + }); + + it('should handle section with only text (no tables)', () => { + host.section.set({ + heading: 'Notes', + level: 3, + content: 'Line 1\nLine 2\nLine 3', + }); + fixture.detectChanges(); + const comp = getComponent(fixture); + expect(comp['hasTable']()).toBe(false); + }); + + it('should clean up editor view on destroy', () => { + // Switch to plain text mode to create a CodeMirror editor + host.section.set({ heading: 'Plain', level: 2, content: 'No tables here.' }); + fixture.detectChanges(); + const comp = getComponent(fixture); + + // Destroying the fixture should not throw + expect(() => fixture.destroy()).not.toThrow(); + }); + }); +}); From f6432d1644eb229d27b76ae132500fad5b008946 Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Sun, 1 Mar 2026 03:10:04 -0700 Subject: [PATCH 3/3] test: add unit tests for GithubConnectComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 47 tests covering: idle state display, PAT form toggle and validation, signal state management, device flow (user code display, polling status, cancel), connected state (repo label, pull/disconnect buttons, loading), OAuth signed-in state (repo browser, manual form), connection actions (PAT vs OAuth connect, sign in/out, disconnect, pull specs), error display, and accessibility (ARIA labels, expanded state, alert roles). Closes #61 (partial — GithubConnectComponent) Co-Authored-By: Claude Opus 4.6 --- .../github-connect/github-connect.spec.ts | 476 ++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 src/app/components/github-connect/github-connect.spec.ts diff --git a/src/app/components/github-connect/github-connect.spec.ts b/src/app/components/github-connect/github-connect.spec.ts new file mode 100644 index 0000000..fed72f0 --- /dev/null +++ b/src/app/components/github-connect/github-connect.spec.ts @@ -0,0 +1,476 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { GitHubConnectComponent } from './github-connect'; +import { GitHubService } from '../../services/github.service'; +import { GitHubOAuthService, type OAuthState } from '../../services/github-oauth.service'; +import { signal } from '@angular/core'; + +function createGitHubServiceSpy() { + return { + connected: signal(false), + loading: signal(false), + error: signal(null), + config: signal<{ owner: string; repo: string; branch: string; specsPath: string } | null>(null), + connect: vi.fn(async () => {}), + connectWithOAuth: vi.fn(async () => {}), + disconnect: vi.fn(), + pullSpecs: vi.fn(async () => [] as { name: string; content: string; path: string; sha: string }[]), + listReposWithSpecs: vi.fn(async () => []), + initSpecsInRepo: vi.fn(async () => ({ pr: null })), + }; +} + +function createOAuthServiceSpy() { + return { + state: signal('idle'), + authenticated: signal(false), + userCode: signal(null), + verificationUri: signal(null), + accessToken: signal(null), + username: signal(null), + avatarUrl: signal(null), + error: signal(null), + startDeviceFlow: vi.fn(async () => {}), + cancelFlow: vi.fn(), + signOut: vi.fn(), + }; +} + +describe('GitHubConnectComponent', () => { + let component: GitHubConnectComponent; + let fixture: ComponentFixture; + let githubSpy: ReturnType; + let oauthSpy: ReturnType; + + beforeEach(async () => { + githubSpy = createGitHubServiceSpy(); + oauthSpy = createOAuthServiceSpy(); + + await TestBed.configureTestingModule({ + imports: [GitHubConnectComponent], + providers: [ + { provide: GitHubService, useValue: githubSpy }, + { provide: GitHubOAuthService, useValue: oauthSpy }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GitHubConnectComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + // --- Creation --- + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + // --- Idle state (not connected, not authenticated) --- + + describe('idle state', () => { + it('should show PAT toggle button', () => { + const patBtn = fixture.nativeElement.querySelector('.github-toggle'); + expect(patBtn).toBeTruthy(); + expect(patBtn.textContent.trim()).toContain('Use Personal Access Token'); + }); + + it('should not show connected view', () => { + const connected = fixture.nativeElement.querySelector('.github-connected'); + expect(connected).toBeFalsy(); + }); + + it('should not show device flow view', () => { + const deviceFlow = fixture.nativeElement.querySelector('.device-flow'); + expect(deviceFlow).toBeFalsy(); + }); + }); + + // --- PAT form toggle --- + + describe('PAT form', () => { + it('togglePat() should show form and set mode to pat', () => { + component.togglePat(); + fixture.detectChanges(); + expect(component.showForm()).toBe(true); + expect(component.connectMode()).toBe('pat'); + }); + + it('should show token input when PAT form is open', () => { + component.togglePat(); + fixture.detectChanges(); + const tokenInput = fixture.nativeElement.querySelector('input[type="password"]'); + expect(tokenInput).toBeTruthy(); + }); + + it('should show owner, repo, branch, specsPath inputs in PAT form', () => { + component.togglePat(); + fixture.detectChanges(); + const inputs = fixture.nativeElement.querySelectorAll('.form-input'); + expect(inputs.length).toBe(5); // token + owner + repo + branch + specsPath + }); + + it('calling togglePat() twice should hide the form', () => { + component.togglePat(); + fixture.detectChanges(); + expect(component.showForm()).toBe(true); + + component.togglePat(); + fixture.detectChanges(); + expect(component.showForm()).toBe(false); + }); + + it('connect button should be disabled when required fields are empty', () => { + component.togglePat(); + fixture.detectChanges(); + const connectBtn = fixture.nativeElement.querySelector('.btn-primary'); + expect(connectBtn.disabled).toBe(true); + }); + + it('connect button should be enabled when token, owner, and repo are filled', () => { + component.togglePat(); + component.token.set('ghp_test123'); + component.owner.set('test-org'); + component.repo.set('test-repo'); + fixture.detectChanges(); + const connectBtn = fixture.nativeElement.querySelector('.btn-primary'); + expect(connectBtn.disabled).toBe(false); + }); + }); + + // --- Signal state management --- + + describe('signal state', () => { + it('should have default values', () => { + expect(component.showForm()).toBe(false); + expect(component.showManualForm()).toBe(false); + expect(component.connectMode()).toBe('oauth'); + expect(component.token()).toBe(''); + expect(component.owner()).toBe(''); + expect(component.repo()).toBe(''); + expect(component.branch()).toBe('main'); + expect(component.specsPath()).toBe('specs'); + }); + + it('setMode should change connectMode', () => { + component.setMode('pat'); + expect(component.connectMode()).toBe('pat'); + component.setMode('oauth'); + expect(component.connectMode()).toBe('oauth'); + }); + + it('updateField should update the corresponding signal', () => { + const event = { target: { value: 'new-owner' } } as unknown as Event; + component.updateField('owner', event); + expect(component.owner()).toBe('new-owner'); + }); + + it('updateField should work for all supported fields', () => { + const fields = ['token', 'owner', 'repo', 'branch', 'specsPath'] as const; + for (const field of fields) { + const event = { target: { value: `test-${field}` } } as unknown as Event; + component.updateField(field, event); + expect(component[field]()).toBe(`test-${field}`); + } + }); + }); + + // --- Device flow (awaiting_code / polling) --- + + describe('device flow state', () => { + beforeEach(() => { + oauthSpy.state.set('awaiting_code'); + oauthSpy.userCode.set('ABCD-1234'); + oauthSpy.verificationUri.set('https://github.com/login/device'); + fixture.detectChanges(); + }); + + it('should show device flow UI', () => { + const deviceFlow = fixture.nativeElement.querySelector('.device-flow'); + expect(deviceFlow).toBeTruthy(); + }); + + it('should display the user code', () => { + const code = fixture.nativeElement.querySelector('.device-code'); + expect(code.textContent.trim()).toBe('ABCD-1234'); + }); + + it('should show verification link', () => { + const link = fixture.nativeElement.querySelector('.verification-link'); + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toBe('https://github.com/login/device'); + }); + + it('should show cancel button', () => { + const buttons = fixture.nativeElement.querySelectorAll('.device-flow > button'); + const cancelBtn = buttons[buttons.length - 1]; + expect(cancelBtn.textContent.trim()).toBe('Cancel'); + }); + + it('clicking cancel should call oauth.cancelFlow()', () => { + const buttons = fixture.nativeElement.querySelectorAll('.device-flow > button'); + const cancelBtn = buttons[buttons.length - 1]; + cancelBtn.click(); + expect(oauthSpy.cancelFlow).toHaveBeenCalled(); + }); + + it('should show polling status when state is polling', () => { + oauthSpy.state.set('polling'); + fixture.detectChanges(); + const status = fixture.nativeElement.querySelector('.polling-status'); + expect(status).toBeTruthy(); + expect(status.textContent).toContain('Waiting for authorization'); + }); + }); + + // --- Connected state --- + + describe('connected state', () => { + beforeEach(() => { + githubSpy.connected.set(true); + githubSpy.config.set({ owner: 'test-org', repo: 'test-repo', branch: 'main', specsPath: 'specs' }); + fixture.detectChanges(); + }); + + it('should show connected view', () => { + const connected = fixture.nativeElement.querySelector('.github-connected'); + expect(connected).toBeTruthy(); + }); + + it('should display owner/repo label', () => { + const label = fixture.nativeElement.querySelector('.connection-label'); + expect(label.textContent).toContain('test-org'); + expect(label.textContent).toContain('test-repo'); + }); + + it('should show Pull Specs button', () => { + const pullBtn = fixture.nativeElement.querySelector('.btn-primary'); + expect(pullBtn.textContent.trim()).toContain('Pull Specs'); + }); + + it('should show Disconnect button', () => { + const buttons = fixture.nativeElement.querySelectorAll('.connected-actions button'); + const disconnectBtn = Array.from(buttons).find( + (b) => (b as HTMLElement).textContent?.trim() === 'Disconnect', + ) as HTMLElement; + expect(disconnectBtn).toBeTruthy(); + }); + + it('Pull Specs button should be disabled while loading', () => { + githubSpy.loading.set(true); + fixture.detectChanges(); + const pullBtn = fixture.nativeElement.querySelector('.btn-primary'); + expect(pullBtn.disabled).toBe(true); + expect(pullBtn.textContent.trim()).toContain('Pulling'); + }); + + it('clicking Disconnect should call github.disconnect()', () => { + const buttons = fixture.nativeElement.querySelectorAll('.connected-actions button'); + const disconnectBtn = Array.from(buttons).find( + (b) => (b as HTMLElement).textContent?.trim() === 'Disconnect', + ) as HTMLElement; + disconnectBtn.click(); + expect(githubSpy.disconnect).toHaveBeenCalled(); + }); + + it('should show Sign Out button when OAuth authenticated', () => { + oauthSpy.authenticated.set(true); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll('.connected-actions button'); + const signOutBtn = Array.from(buttons).find( + (b) => (b as HTMLElement).textContent?.trim() === 'Sign Out', + ) as HTMLElement; + expect(signOutBtn).toBeTruthy(); + }); + + it('should show avatar when OAuth authenticated', () => { + oauthSpy.authenticated.set(true); + oauthSpy.avatarUrl.set('https://example.com/avatar.png'); + oauthSpy.username.set('testuser'); + fixture.detectChanges(); + const avatar = fixture.nativeElement.querySelector('.avatar'); + expect(avatar).toBeTruthy(); + }); + + it('should show status dot when not OAuth authenticated', () => { + oauthSpy.authenticated.set(false); + fixture.detectChanges(); + const dot = fixture.nativeElement.querySelector('.status-dot'); + expect(dot).toBeTruthy(); + }); + }); + + // --- OAuth signed in but not connected --- + + describe('OAuth signed in, not connected', () => { + beforeEach(() => { + oauthSpy.authenticated.set(true); + oauthSpy.state.set('authenticated'); + oauthSpy.username.set('testuser'); + oauthSpy.avatarUrl.set('https://example.com/avatar.png'); + githubSpy.connected.set(false); + fixture.detectChanges(); + }); + + it('should show signed-in view with username', () => { + const label = fixture.nativeElement.querySelector('.connection-label'); + expect(label.textContent).toContain('testuser'); + }); + + it('should show repo browser by default', () => { + const repoBrowser = fixture.nativeElement.querySelector('app-repo-browser'); + expect(repoBrowser).toBeTruthy(); + }); + + it('should show manual form after clicking manual connect', () => { + component.onManualConnect(); + fixture.detectChanges(); + const form = fixture.nativeElement.querySelector('.github-form'); + expect(form).toBeTruthy(); + }); + + it('onRepoBrowserConnected should hide manual form', () => { + component.onManualConnect(); + fixture.detectChanges(); + component.onRepoBrowserConnected(); + fixture.detectChanges(); + expect(component.showManualForm()).toBe(false); + }); + }); + + // --- Connection actions --- + + describe('connection actions', () => { + it('onConnect with PAT mode should call github.connect()', async () => { + oauthSpy.authenticated.set(false); + component.token.set('ghp_test'); + component.owner.set('myorg'); + component.repo.set('myrepo'); + component.branch.set('main'); + component.specsPath.set('specs'); + + await component.onConnect(); + expect(githubSpy.connect).toHaveBeenCalledWith({ + token: 'ghp_test', + owner: 'myorg', + repo: 'myrepo', + branch: 'main', + specsPath: 'specs', + }); + }); + + it('onConnect with OAuth mode should call github.connectWithOAuth()', async () => { + oauthSpy.authenticated.set(true); + component.owner.set('myorg'); + component.repo.set('myrepo'); + component.branch.set('dev'); + component.specsPath.set('docs/specs'); + + await component.onConnect(); + expect(githubSpy.connectWithOAuth).toHaveBeenCalledWith({ + owner: 'myorg', + repo: 'myrepo', + branch: 'dev', + specsPath: 'docs/specs', + }); + }); + + it('onSignIn should call oauth.startDeviceFlow()', async () => { + await component.onSignIn(); + expect(oauthSpy.startDeviceFlow).toHaveBeenCalled(); + }); + + it('onCancelFlow should call oauth.cancelFlow()', () => { + component.onCancelFlow(); + expect(oauthSpy.cancelFlow).toHaveBeenCalled(); + }); + + it('onSignOut should call both github.disconnect() and oauth.signOut()', () => { + component.onSignOut(); + expect(githubSpy.disconnect).toHaveBeenCalled(); + expect(oauthSpy.signOut).toHaveBeenCalled(); + }); + + it('onSignOut should hide form', () => { + component.showForm.set(true); + component.onSignOut(); + expect(component.showForm()).toBe(false); + }); + + it('onDisconnect should call github.disconnect() and hide forms', () => { + component.showForm.set(true); + component.showManualForm.set(true); + component.onDisconnect(); + expect(githubSpy.disconnect).toHaveBeenCalled(); + expect(component.showForm()).toBe(false); + expect(component.showManualForm()).toBe(false); + }); + + it('onPull should call github.pullSpecs() and emit result', async () => { + const mockSpecs = [{ name: 'test.spec.md', content: '# Test', path: 'specs/test.spec.md', sha: 'abc123' }]; + githubSpy.pullSpecs.mockResolvedValue(mockSpecs); + + let emitted: unknown[] | null = null; + component.specsPulled.subscribe((specs) => { + emitted = specs; + }); + + await component.onPull(); + expect(githubSpy.pullSpecs).toHaveBeenCalled(); + expect(emitted).toEqual(mockSpecs); + }); + }); + + // --- Error display --- + + describe('error display', () => { + it('should show OAuth error when state is error', () => { + oauthSpy.state.set('error'); + oauthSpy.error.set('OAuth flow failed'); + fixture.detectChanges(); + const errorDiv = fixture.nativeElement.querySelector('.form-error'); + expect(errorDiv).toBeTruthy(); + expect(errorDiv.textContent).toContain('OAuth flow failed'); + }); + + it('should show GitHub error in PAT form', () => { + component.togglePat(); + githubSpy.error.set('Invalid token'); + fixture.detectChanges(); + const errorDiv = fixture.nativeElement.querySelector('.form-error'); + expect(errorDiv).toBeTruthy(); + expect(errorDiv.textContent).toContain('Invalid token'); + }); + }); + + // --- Accessibility --- + + describe('accessibility', () => { + it('should have aria-label on section', () => { + const section = fixture.nativeElement.querySelector('section[aria-label="GitHub connection"]'); + expect(section).toBeTruthy(); + }); + + it('PAT toggle should have aria-expanded', () => { + const toggle = fixture.nativeElement.querySelector('.github-toggle'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + component.togglePat(); + fixture.detectChanges(); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + }); + + it('error messages should have role="alert"', () => { + oauthSpy.state.set('error'); + oauthSpy.error.set('Test error'); + fixture.detectChanges(); + const alert = fixture.nativeElement.querySelector('[role="alert"]'); + expect(alert).toBeTruthy(); + }); + + it('device flow region should have aria-label', () => { + oauthSpy.state.set('awaiting_code'); + oauthSpy.userCode.set('TEST-CODE'); + fixture.detectChanges(); + const region = fixture.nativeElement.querySelector('[aria-label="Device authorization"]'); + expect(region).toBeTruthy(); + }); + }); +});