Skip to content

Commit a9fe50d

Browse files
corvid-agentclaude
andauthored
test: add unit tests for EditorPageComponent (#55)
* test: add unit tests for EditorPageComponent Add 55 unit tests covering all EditorPageComponent behavior: - editor-page.spec.ts: init, tab switching, validation, frontmatter/section changes, navigation, filename editing (26 tests) - editor-page-signals.spec.ts: computed properties — canCreatePR, editableSections, activeEditableSection, knownModules, body, frontmatter, rebuildBody (17 tests) - editor-page-computed.spec.ts: ngOnInit edge case, onExport, onCreatePR including error handling and PR description content (12 tests) - editor-page-test-utils.ts: shared TestHarness, makeSpec factory, setupTestBed with mocked services Fixes document.createElement spy leak that caused happy-dom "rootElement.setAttribute is not a function" in subsequent tests. Closes #49 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: exclude test utility files from production build Add `src/**/*-test-utils.ts` to tsconfig.app.json exclude list so test helper files that reference vitest globals don't break the production build or e2e tests. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent 5db7dde commit a9fe50d

File tree

5 files changed

+835
-1
lines changed

5 files changed

+835
-1
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Unit tests for EditorPageComponent — export, PR creation, and ngOnInit edge cases.
3+
*
4+
* Split from editor-page.spec.ts to stay under happy-dom's per-file
5+
* DOM element limit (~27 TestBed.createComponent calls).
6+
*/
7+
import { type SpecFrontmatter, type SpecSection } from '../../models/spec.model';
8+
import { makeSpec, type TestHarness, setupTestBed } from './editor-page-test-utils';
9+
10+
// ══════════════════════════════════════════════════════════════════════════
11+
// ngOnInit edge case (requires separate TestBed with different routeId)
12+
// ══════════════════════════════════════════════════════════════════════════
13+
14+
describe('EditorPageComponent — ngOnInit edge case', () => {
15+
it('should not call selectSpec for id=0', async () => {
16+
const h = await setupTestBed({ routeId: '0' });
17+
h.page.ngOnInit();
18+
19+
await vi.waitFor(() => {
20+
expect(h.storeSpy.selectSpec).not.toHaveBeenCalled();
21+
});
22+
});
23+
});
24+
25+
// ══════════════════════════════════════════════════════════════════════════
26+
// Export
27+
// ══════════════════════════════════════════════════════════════════════════
28+
29+
describe('EditorPageComponent — onExport', () => {
30+
let h: TestHarness;
31+
32+
beforeEach(async () => {
33+
h = await setupTestBed();
34+
});
35+
36+
afterEach(() => {
37+
vi.restoreAllMocks();
38+
});
39+
40+
it('should call store.exportSpec with spec id', async () => {
41+
h.activeSpecSignal.set(makeSpec({ id: 42 }));
42+
43+
const clickSpy = vi.fn();
44+
const origCreateElement = document.createElement.bind(document);
45+
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
46+
if (tag === 'a') {
47+
return { set href(_: string) {}, set download(_: string) {}, click: clickSpy } as unknown as HTMLAnchorElement;
48+
}
49+
return origCreateElement(tag);
50+
});
51+
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test');
52+
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
53+
54+
await h.page.onExport();
55+
56+
expect(h.storeSpy.exportSpec).toHaveBeenCalledWith(42);
57+
expect(clickSpy).toHaveBeenCalled();
58+
});
59+
60+
it('should return early when spec has no id', async () => {
61+
h.activeSpecSignal.set(makeSpec({ id: undefined }));
62+
63+
await h.page.onExport();
64+
65+
expect(h.storeSpy.exportSpec).not.toHaveBeenCalled();
66+
});
67+
68+
it('should return early when exportSpec returns no content', async () => {
69+
h.activeSpecSignal.set(makeSpec({ id: 1 }));
70+
h.storeSpy.exportSpec.mockResolvedValue(null);
71+
72+
const clickSpy = vi.fn();
73+
const origCreateElement = document.createElement.bind(document);
74+
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
75+
if (tag === 'a') {
76+
return { click: clickSpy } as unknown as HTMLAnchorElement;
77+
}
78+
return origCreateElement(tag);
79+
});
80+
81+
await h.page.onExport();
82+
83+
expect(clickSpy).not.toHaveBeenCalled();
84+
});
85+
});
86+
87+
// ══════════════════════════════════════════════════════════════════════════
88+
// PR creation
89+
// ══════════════════════════════════════════════════════════════════════════
90+
91+
describe('EditorPageComponent — onCreatePR', () => {
92+
let h: TestHarness;
93+
94+
beforeEach(async () => {
95+
h = await setupTestBed();
96+
});
97+
98+
it('should create PR and set prUrl on success', async () => {
99+
h.activeSpecSignal.set(makeSpec({
100+
id: 1,
101+
filepath: 'specs/auth.spec.md',
102+
githubSha: 'abc123',
103+
}));
104+
105+
await h.page.onCreatePR();
106+
107+
expect(h.githubSpy.createSpecPR).toHaveBeenCalledWith(
108+
'specs/auth.spec.md',
109+
expect.any(String),
110+
'abc123',
111+
expect.stringContaining('update auth.spec.md'),
112+
expect.any(String),
113+
);
114+
expect(h.page.prUrl()).toBe('https://github.com/owner/repo/pull/42');
115+
});
116+
117+
it('should set prLoading during PR creation', async () => {
118+
let loadingDuringCall = false;
119+
h.githubSpy.createSpecPR.mockImplementation(async () => {
120+
loadingDuringCall = h.page.prLoading() as boolean;
121+
return { html_url: 'https://github.com/owner/repo/pull/1' };
122+
});
123+
124+
h.activeSpecSignal.set(makeSpec({ id: 1, filepath: 'specs/test.spec.md' }));
125+
126+
await h.page.onCreatePR();
127+
128+
expect(loadingDuringCall).toBe(true);
129+
expect(h.page.prLoading()).toBe(false);
130+
});
131+
132+
it('should set github error on failure', async () => {
133+
h.githubSpy.createSpecPR.mockRejectedValue(new Error('API rate limit exceeded'));
134+
135+
h.activeSpecSignal.set(makeSpec({ id: 1, filepath: 'specs/test.spec.md' }));
136+
137+
await h.page.onCreatePR();
138+
139+
expect(h.githubSpy._error()).toBe('API rate limit exceeded');
140+
expect(h.page.prLoading()).toBe(false);
141+
});
142+
143+
it('should handle non-Error exceptions', async () => {
144+
h.githubSpy.createSpecPR.mockRejectedValue('string error');
145+
146+
h.activeSpecSignal.set(makeSpec({ id: 1, filepath: 'specs/test.spec.md' }));
147+
148+
await h.page.onCreatePR();
149+
150+
expect(h.githubSpy._error()).toBe('PR creation failed');
151+
});
152+
153+
it('should return early when spec has no id', async () => {
154+
h.activeSpecSignal.set(makeSpec({ id: undefined }));
155+
156+
await h.page.onCreatePR();
157+
158+
expect(h.githubSpy.createSpecPR).not.toHaveBeenCalled();
159+
});
160+
161+
it('should return early when spec has no filepath', async () => {
162+
h.activeSpecSignal.set(makeSpec({ id: 1, filepath: undefined }));
163+
164+
await h.page.onCreatePR();
165+
166+
expect(h.githubSpy.createSpecPR).not.toHaveBeenCalled();
167+
});
168+
169+
it('should clear prUrl before new PR creation', async () => {
170+
h.page.prUrl.set('https://old-url');
171+
172+
h.activeSpecSignal.set(makeSpec({ id: 1, filepath: 'specs/test.spec.md' }));
173+
174+
await h.page.onCreatePR();
175+
176+
expect(h.page.prUrl()).toBe('https://github.com/owner/repo/pull/42');
177+
});
178+
179+
it('should include module name and version in PR description', async () => {
180+
h.activeSpecSignal.set(makeSpec({
181+
id: 1,
182+
filepath: 'specs/auth.spec.md',
183+
frontmatter: {
184+
module: 'auth-service',
185+
version: 3,
186+
status: 'active',
187+
files: [],
188+
db_tables: [],
189+
depends_on: [],
190+
},
191+
}));
192+
193+
await h.page.onCreatePR();
194+
195+
const description = h.githubSpy.createSpecPR.mock.calls[0][4] as string;
196+
expect(description).toContain('auth-service');
197+
expect(description).toContain('v3');
198+
});
199+
});
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Unit tests for EditorPageComponent — computed properties & rebuildBody.
3+
*
4+
* Split from editor-page.spec.ts to stay under happy-dom's per-file
5+
* DOM element limit (~27 TestBed.createComponent calls).
6+
*
7+
* Tests cover:
8+
* - Computed: canCreatePR, editableSections, activeEditableSection
9+
* - Computed: knownModules, body, frontmatter
10+
* - rebuildBody (tested through section changes)
11+
*/
12+
import { type SpecFrontmatter, type SpecSection } from '../../models/spec.model';
13+
import { makeSpec, type TestHarness, setupTestBed } from './editor-page-test-utils';
14+
15+
describe('EditorPageComponent — computed properties', () => {
16+
let h: TestHarness;
17+
18+
beforeEach(async () => {
19+
h = await setupTestBed();
20+
});
21+
22+
describe('canCreatePR', () => {
23+
it('should return true when github is connected and spec has filepath', () => {
24+
h.githubSpy.connected.set(true);
25+
h.activeSpecSignal.set(makeSpec({ filepath: 'specs/test.spec.md' }));
26+
27+
expect(h.page.canCreatePR()).toBe(true);
28+
});
29+
30+
it('should return false when github is not connected', () => {
31+
h.githubSpy.connected.set(false);
32+
h.activeSpecSignal.set(makeSpec({ filepath: 'specs/test.spec.md' }));
33+
34+
expect(h.page.canCreatePR()).toBe(false);
35+
});
36+
37+
it('should return false when spec has no filepath', () => {
38+
h.githubSpy.connected.set(true);
39+
h.activeSpecSignal.set(makeSpec({ filepath: undefined }));
40+
41+
expect(h.page.canCreatePR()).toBe(false);
42+
});
43+
44+
it('should return false when no spec is loaded', () => {
45+
h.githubSpy.connected.set(true);
46+
h.activeSpecSignal.set(null);
47+
48+
expect(h.page.canCreatePR()).toBe(false);
49+
});
50+
});
51+
52+
describe('editableSections', () => {
53+
it('should filter out level-1 sections', () => {
54+
h.activeSpecSignal.set(makeSpec({
55+
sections: [
56+
{ heading: 'Title', level: 1, content: '' },
57+
{ heading: 'Purpose', level: 2, content: 'text' },
58+
{ heading: 'Sub', level: 3, content: 'nested' },
59+
],
60+
}));
61+
62+
const editables = h.page.editableSections();
63+
expect(editables).toHaveLength(2);
64+
expect(editables[0].heading).toBe('Purpose');
65+
expect(editables[1].heading).toBe('Sub');
66+
});
67+
68+
it('should return empty array when no spec', () => {
69+
h.activeSpecSignal.set(null);
70+
71+
expect(h.page.editableSections()).toEqual([]);
72+
});
73+
});
74+
75+
describe('activeEditableSection', () => {
76+
it('should return null when activeSectionIndex is -1', () => {
77+
h.activeSpecSignal.set(makeSpec());
78+
h.page.activeSectionIndex.set(-1);
79+
80+
expect(h.page.activeEditableSection()).toBeNull();
81+
});
82+
83+
it('should return the correct section for valid index', () => {
84+
h.activeSpecSignal.set(makeSpec());
85+
h.page.activeSectionIndex.set(0);
86+
87+
const section = h.page.activeEditableSection();
88+
expect(section).not.toBeNull();
89+
expect(section!.heading).toBe('Purpose');
90+
});
91+
92+
it('should return null for out-of-range index', () => {
93+
h.activeSpecSignal.set(makeSpec());
94+
h.page.activeSectionIndex.set(99);
95+
96+
expect(h.page.activeEditableSection()).toBeNull();
97+
});
98+
});
99+
100+
describe('knownModules', () => {
101+
it('should extract and sort module names from all specs', () => {
102+
h.allSpecsSignal.set([
103+
makeSpec({ frontmatter: { ...makeSpec().frontmatter, module: 'zebra' } }),
104+
makeSpec({ frontmatter: { ...makeSpec().frontmatter, module: 'alpha' } }),
105+
makeSpec({ frontmatter: { ...makeSpec().frontmatter, module: 'middle' } }),
106+
]);
107+
108+
expect(h.page.knownModules()).toEqual(['alpha', 'middle', 'zebra']);
109+
});
110+
111+
it('should filter out empty module names', () => {
112+
h.allSpecsSignal.set([
113+
makeSpec({ frontmatter: { ...makeSpec().frontmatter, module: 'valid' } }),
114+
makeSpec({ frontmatter: { ...makeSpec().frontmatter, module: '' } }),
115+
]);
116+
117+
expect(h.page.knownModules()).toEqual(['valid']);
118+
});
119+
});
120+
121+
describe('body', () => {
122+
it('should return spec body', () => {
123+
h.activeSpecSignal.set(makeSpec({ body: '# Test\n\nContent' }));
124+
125+
expect(h.page.body()).toBe('# Test\n\nContent');
126+
});
127+
128+
it('should return empty string when no spec', () => {
129+
h.activeSpecSignal.set(null);
130+
131+
expect(h.page.body()).toBe('');
132+
});
133+
});
134+
135+
describe('frontmatter', () => {
136+
it('should return spec frontmatter', () => {
137+
const fm: SpecFrontmatter = { module: 'test', version: 2, status: 'active', files: ['a.ts'], db_tables: [], depends_on: [] };
138+
h.activeSpecSignal.set(makeSpec({ frontmatter: fm }));
139+
140+
expect(h.page.frontmatter()).toEqual(fm);
141+
});
142+
143+
it('should return default frontmatter when no spec', () => {
144+
h.activeSpecSignal.set(null);
145+
146+
const fm = h.page.frontmatter();
147+
expect(fm.module).toBe('');
148+
expect(fm.version).toBe(1);
149+
expect(fm.status).toBe('draft');
150+
});
151+
});
152+
153+
// ── rebuildBody (private, tested through section changes) ─────────────
154+
155+
describe('rebuildBody (via section changes)', () => {
156+
it('should call parser.sectionsToBody with title and non-title sections', () => {
157+
h.activeSpecSignal.set(makeSpec());
158+
h.page.activeSectionIndex.set(0);
159+
160+
h.page.onSectionContentChange('New content');
161+
162+
expect(h.parserSpy.sectionsToBody).toHaveBeenCalled();
163+
const [title, sections] = h.parserSpy.sectionsToBody.mock.calls[0] as [string, SpecSection[]];
164+
expect(title).toBe('auth-service');
165+
expect(sections.every((s: SpecSection) => s.level >= 2)).toBe(true);
166+
});
167+
168+
it('should update store with rebuilt body and sections', () => {
169+
h.parserSpy.sectionsToBody.mockReturnValue('## Purpose\n\nUpdated content');
170+
171+
h.activeSpecSignal.set(makeSpec());
172+
h.page.activeSectionIndex.set(0);
173+
174+
h.page.onSectionContentChange('Updated content');
175+
176+
expect(h.storeSpy.updateActiveSpec).toHaveBeenCalledWith(
177+
expect.objectContaining({
178+
body: expect.any(String),
179+
sections: expect.any(Array),
180+
}),
181+
);
182+
});
183+
});
184+
});

0 commit comments

Comments
 (0)