From a38954101cf4bb33e0bb2a8bddf5f2e141592f0a Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 27 Feb 2026 21:15:03 +0100 Subject: [PATCH] fix: some smaller pieces still missing --- .../TemplatesView/TemplatesView.tsx | 4 +- tests/ipc/handlers.test.ts | 18 +- .../renderer/components/SettingsView.test.tsx | 101 +++++++ .../components/SidebarTemplates.test.tsx | 281 ++++++++++++++++++ tests/renderer/components/TagsView.test.tsx | 97 +++++- .../components/TemplatesView.test.tsx | 8 +- tests/renderer/navigation/tabPolicy.test.ts | 31 ++ 7 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 tests/renderer/components/SidebarTemplates.test.tsx diff --git a/src/renderer/components/TemplatesView/TemplatesView.tsx b/src/renderer/components/TemplatesView/TemplatesView.tsx index 4d38127..4f77d33 100644 --- a/src/renderer/components/TemplatesView/TemplatesView.tsx +++ b/src/renderer/components/TemplatesView/TemplatesView.tsx @@ -18,8 +18,8 @@ const UI_DATE_LOCALE: Record = { const toTemplateSlug = (value: string) => { const normalized = value .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); return normalized || 'template'; }; diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 0a14096..cebab7a 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -602,7 +602,9 @@ describe('IPC Handlers', () => { mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([ { status: 'modified', path: 'scripts/transform.py' }, ]); - mockGitEngine.getChangedTemplateFilesBetween.mockResolvedValue([]); + mockGitEngine.getChangedTemplateFilesBetween.mockResolvedValue([ + { status: 'added', path: 'templates/custom_post.liquid' }, + ]); mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({ created: 1, updated: 1, @@ -615,6 +617,12 @@ describe('IPC Handlers', () => { deleted: 0, processedFiles: 1, }); + mockTemplateEngine.reconcileTemplatesFromGitChanges.mockResolvedValue({ + created: 1, + updated: 0, + deleted: 0, + processedFiles: 1, + }); const result = await invokeHandler('git:pull', '/repo'); @@ -623,6 +631,7 @@ describe('IPC Handlers', () => { expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo'); expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head'); expect(mockGitEngine.getChangedScriptFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head'); + expect(mockGitEngine.getChangedTemplateFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head'); expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).toHaveBeenCalledWith('/repo', [ { status: 'modified', path: 'posts/2026/02/existing.md' }, { status: 'added', path: 'posts/2026/02/new-post.md' }, @@ -630,6 +639,9 @@ describe('IPC Handlers', () => { expect(mockScriptEngine.reconcileScriptsFromGitChanges).toHaveBeenCalledWith('/repo', [ { status: 'modified', path: 'scripts/transform.py' }, ]); + expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).toHaveBeenCalledWith('/repo', [ + { status: 'added', path: 'templates/custom_post.liquid' }, + ]); expect(result).toEqual({ success: true }); }); @@ -642,8 +654,10 @@ describe('IPC Handlers', () => { expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo'); expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled(); expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled(); + expect(mockGitEngine.getChangedTemplateFilesBetween).not.toHaveBeenCalled(); expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled(); expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled(); + expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).not.toHaveBeenCalled(); expect(result).toEqual({ success: false, code: 'conflict' }); }); @@ -658,8 +672,10 @@ describe('IPC Handlers', () => { expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo'); expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled(); expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled(); + expect(mockGitEngine.getChangedTemplateFilesBetween).not.toHaveBeenCalled(); expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled(); expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled(); + expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).not.toHaveBeenCalled(); expect(result).toEqual({ success: true }); }); }); diff --git a/tests/renderer/components/SettingsView.test.tsx b/tests/renderer/components/SettingsView.test.tsx index ccc2166..d88dad9 100644 --- a/tests/renderer/components/SettingsView.test.tsx +++ b/tests/renderer/components/SettingsView.test.tsx @@ -182,6 +182,107 @@ describe('SettingsView Diff Preferences', () => { expect(rebuildScriptsMock).toHaveBeenCalledTimes(1); }); + it('triggers templates rebuild from data maintenance section', async () => { + const rebuildTemplatesMock = vi.fn().mockResolvedValue(undefined); + (window as any).electronAPI = { + ...(window as any).electronAPI, + templates: { + ...(window as any).electronAPI?.templates, + getEnabledByKind: vi.fn().mockResolvedValue([]), + rebuildFromFiles: rebuildTemplatesMock, + }, + }; + + render(); + + const rebuildTemplatesButton = await screen.findByRole('button', { name: /rebuild templates/i }); + fireEvent.click(rebuildTemplatesButton); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(rebuildTemplatesMock).toHaveBeenCalledTimes(1); + }); + + it('renders category template dropdowns populated with enabled templates', async () => { + (window as any).electronAPI = { + ...(window as any).electronAPI, + templates: { + ...(window as any).electronAPI?.templates, + getEnabledByKind: vi.fn().mockImplementation((kind: string) => { + if (kind === 'post') { + return Promise.resolve([ + { slug: 'custom_post', title: 'Custom Post' }, + ]); + } + if (kind === 'list') { + return Promise.resolve([ + { slug: 'custom_list', title: 'Custom List' }, + ]); + } + return Promise.resolve([]); + }), + }, + }; + + render(); + + const postTemplateSelect = await screen.findByLabelText(/article post template/i); + expect(postTemplateSelect).toBeInTheDocument(); + + await vi.waitFor(() => { + const options = postTemplateSelect.querySelectorAll('option'); + const optionTexts = Array.from(options).map((o) => o.textContent); + expect(optionTexts).toContain('Custom Post'); + }); + + const listTemplateSelect = screen.getByLabelText(/article list template/i); + expect(listTemplateSelect).toBeInTheDocument(); + + await vi.waitFor(() => { + const options = listTemplateSelect.querySelectorAll('option'); + const optionTexts = Array.from(options).map((o) => o.textContent); + expect(optionTexts).toContain('Custom List'); + }); + }); + + it('persists category template selection via project metadata update', async () => { + (window as any).electronAPI = { + ...(window as any).electronAPI, + templates: { + ...(window as any).electronAPI?.templates, + getEnabledByKind: vi.fn().mockImplementation((kind: string) => { + if (kind === 'post') { + return Promise.resolve([ + { slug: 'custom_post', title: 'Custom Post' }, + ]); + } + return Promise.resolve([]); + }), + }, + }; + + render(); + + const postTemplateSelect = await screen.findByLabelText(/article post template/i); + + await vi.waitFor(() => { + const options = postTemplateSelect.querySelectorAll('option'); + expect(Array.from(options).map((o) => o.textContent)).toContain('Custom Post'); + }); + + fireEvent.change(postTemplateSelect, { target: { value: 'custom_post' } }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + categoryMetadata: expect.objectContaining({ + article: expect.objectContaining({ postTemplateSlug: 'custom_post' }), + }), + }) + ); + }); + it('persists category settings changes via project metadata update', async () => { render(); diff --git a/tests/renderer/components/SidebarTemplates.test.tsx b/tests/renderer/components/SidebarTemplates.test.tsx new file mode 100644 index 0000000..5f2ca3b --- /dev/null +++ b/tests/renderer/components/SidebarTemplates.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { act, render, screen, fireEvent } from '@testing-library/react'; +import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar'; +import { useAppStore } from '../../../src/renderer/store'; + +describe('Sidebar templates list behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + + const listeners = new Map void>>(); + (window as any).addEventListener = vi.fn((type: string, listener: (event: Event) => void) => { + if (!listeners.has(type)) { + listeners.set(type, new Set()); + } + listeners.get(type)?.add(listener); + }); + (window as any).removeEventListener = vi.fn((type: string, listener: (event: Event) => void) => { + listeners.get(type)?.delete(listener); + }); + (window as any).dispatchEvent = vi.fn((event: Event) => { + listeners.get(event.type)?.forEach((listener) => listener(event)); + return true; + }); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + templates: { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn().mockResolvedValue({ deleted: true }), + get: vi.fn(), + getAll: vi.fn().mockResolvedValue([ + { + id: 'template-1', + projectId: 'default', + slug: 'custom_post', + title: 'Custom Post', + kind: 'post', + enabled: true, + version: 1, + filePath: '/tmp/custom_post.liquid', + content: '
{{ post.title }}
', + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + ]), + getEnabledByKind: vi.fn().mockResolvedValue([]), + validate: vi.fn(), + rebuildFromFiles: vi.fn(), + }, + }; + + useAppStore.setState({ + activeView: 'templates', + sidebarVisible: true, + tabs: [], + activeTabId: null, + }); + }); + + it('opens a transient template tab on single click', async () => { + const { container } = render(); + + const templateRow = await screen.findByRole('button', { name: 'Custom Post' }); + expect(templateRow).toHaveClass('chat-list-item'); + expect(container.querySelector('.chat-item-date')).not.toBeNull(); + fireEvent.click(templateRow); + + expect(useAppStore.getState().tabs).toEqual([ + { + type: 'templates', + id: 'template-1', + isTransient: true, + }, + ]); + expect(useAppStore.getState().activeTabId).toBe('template-1'); + }); + + it('renders templates section title and create button', async () => { + render(); + + expect(screen.getByText('TEMPLATES')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'New Template' })).toBeInTheDocument(); + }); + + it('shows loading state while templates are being fetched', () => { + (window as any).electronAPI.templates.getAll = vi.fn().mockImplementation( + () => new Promise(() => {}), + ); + + render(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('shows empty state with create action when no templates exist', async () => { + (window as any).electronAPI.templates.getAll = vi.fn().mockResolvedValue([]); + + render(); + + expect(await screen.findByText('No templates yet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create a template' })).toBeInTheDocument(); + }); + + it('creates a new template from the create button and opens it pinned', async () => { + const createMock = vi.fn().mockResolvedValue({ + id: 'template-new', + projectId: 'default', + slug: 'new_template', + title: 'New Template', + kind: 'post', + enabled: true, + version: 1, + filePath: '/tmp/new_template.liquid', + content: '', + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }); + + (window as any).electronAPI.templates.create = createMock; + + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'New Template' })); + + await vi.waitFor(() => { + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'New Template', + kind: 'post', + content: '', + enabled: true, + }), + ); + }); + + await vi.waitFor(() => { + expect(useAppStore.getState().tabs).toEqual([ + { + type: 'templates', + id: 'template-new', + isTransient: false, + }, + ]); + expect(useAppStore.getState().activeTabId).toBe('template-new'); + }); + }); + + it('opens a pinned template tab on double click', async () => { + render(); + + const templateRow = await screen.findByRole('button', { name: 'Custom Post' }); + fireEvent.doubleClick(templateRow); + + expect(useAppStore.getState().tabs).toEqual([ + { + type: 'templates', + id: 'template-1', + isTransient: false, + }, + ]); + expect(useAppStore.getState().activeTabId).toBe('template-1'); + }); + + it('deletes a template from sidebar action', async () => { + const deleteMock = vi.fn().mockResolvedValue({ deleted: true }); + (window as any).electronAPI.templates.delete = deleteMock; + + useAppStore.setState({ + tabs: [{ type: 'templates', id: 'template-1', isTransient: false }], + activeTabId: 'template-1', + }); + + render(); + + const deleteButton = await screen.findByTitle('Delete template'); + fireEvent.click(deleteButton); + + await vi.waitFor(() => { + expect(deleteMock).toHaveBeenCalledWith('template-1'); + expect(useAppStore.getState().tabs).toEqual([]); + }); + }); + + it('refreshes templates list when templates-changed event is emitted', async () => { + const getAllMock = vi + .fn() + .mockResolvedValueOnce([ + { + id: 'template-1', + projectId: 'default', + slug: 'custom_post', + title: 'Custom Post', + kind: 'post', + enabled: true, + version: 1, + filePath: '/tmp/custom_post.liquid', + content: '
{{ post.title }}
', + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + ]) + .mockResolvedValueOnce([ + { + id: 'template-1', + projectId: 'default', + slug: 'renamed_template', + title: 'Renamed Template', + kind: 'post', + enabled: true, + version: 2, + filePath: '/tmp/custom_post.liquid', + content: '
{{ post.title }}
', + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:01:00.000Z', + }, + ]); + + (window as any).electronAPI.templates.getAll = getAllMock; + + render(); + + await screen.findByRole('button', { name: 'Custom Post' }); + window.dispatchEvent(new CustomEvent('bds:templates-changed')); + + expect(await screen.findByRole('button', { name: 'Renamed Template' })).toBeInTheDocument(); + }); + + it('reloads templates when active project context becomes available after mount', async () => { + const getAllMock = vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 'template-1', + projectId: 'project-1', + slug: 'custom_post', + title: 'Custom Post', + kind: 'post', + enabled: true, + version: 1, + filePath: '/tmp/custom_post.liquid', + content: '
{{ post.title }}
', + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + ]); + + (window as any).electronAPI.templates.getAll = getAllMock; + + useAppStore.setState({ + activeProject: null, + activeView: 'templates', + sidebarVisible: true, + tabs: [], + activeTabId: null, + }); + + render(); + + expect(await screen.findByText('No templates yet')).toBeInTheDocument(); + + act(() => { + useAppStore.setState({ + activeProject: { + id: 'project-1', + name: 'Project 1', + slug: 'project-1', + dataPath: '/tmp/project-1', + isActive: true, + createdAt: '2026-02-22T00:00:00.000Z', + updatedAt: '2026-02-22T00:00:00.000Z', + }, + }); + }); + + expect(await screen.findByRole('button', { name: 'Custom Post' })).toBeInTheDocument(); + expect(getAllMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/renderer/components/TagsView.test.tsx b/tests/renderer/components/TagsView.test.tsx index 0333751..57ee8ce 100644 --- a/tests/renderer/components/TagsView.test.tsx +++ b/tests/renderer/components/TagsView.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, act } from '@testing-library/react'; +import { render, act, screen, fireEvent } from '@testing-library/react'; import { TagsView } from '../../../src/renderer/components/TagsView/TagsView'; describe('TagsView subscriptions', () => { @@ -61,3 +61,98 @@ describe('TagsView subscriptions', () => { }); }); }); + +describe('TagsView template dropdown', () => { + beforeEach(() => { + const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn()); + + (window as any).electronAPI = { + ...(window as any).electronAPI, + tags: { + getWithCounts: vi.fn().mockResolvedValue([ + { name: 'javascript', count: 5 }, + ]), + getAll: vi.fn().mockResolvedValue([ + { id: 'tag-1', name: 'javascript', color: null, postTemplateSlug: null }, + ]), + create: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn(), + rename: vi.fn(), + merge: vi.fn(), + syncFromPosts: vi.fn(), + }, + templates: { + getEnabledByKind: vi.fn().mockResolvedValue([ + { slug: 'custom_post', title: 'Custom Post' }, + ]), + }, + on: onMock, + }; + }); + + it('loads post templates and shows them in tag edit dropdown', async () => { + render(); + + // Wait for tags to load and click the tag to select it + const tagButton = await screen.findByText('javascript'); + fireEvent.click(tagButton); + + // Click the edit button to enter edit mode + const editButton = await screen.findByRole('button', { name: /edit/i }); + fireEvent.click(editButton); + + // Verify template dropdown appears with the loaded template + await vi.waitFor(() => { + const label = screen.getByText('Post Template'); + const fieldDiv = label.closest('.tagsview-field')!; + const select = fieldDiv.querySelector('select')!; + const options = Array.from(select.querySelectorAll('option')); + const optionTexts = options.map((o) => o.textContent); + expect(optionTexts).toContain('Custom Post'); + }); + }); + + it('saves tag template selection via update IPC call', async () => { + const updateMock = vi.fn().mockResolvedValue(undefined); + (window as any).electronAPI.tags.update = updateMock; + + render(); + + // Select the tag + const tagButton = await screen.findByText('javascript'); + fireEvent.click(tagButton); + + // Enter edit mode + const editButton = await screen.findByRole('button', { name: /edit/i }); + fireEvent.click(editButton); + + // Wait for template dropdown to populate + await vi.waitFor(() => { + const label = screen.getByText('Post Template'); + const fieldDiv = label.closest('.tagsview-field')!; + const select = fieldDiv.querySelector('select')!; + const options = Array.from(select.querySelectorAll('option')); + expect(options.map((o) => o.textContent)).toContain('Custom Post'); + }); + + // Select a template + const label = screen.getByText('Post Template'); + const fieldDiv = label.closest('.tagsview-field')!; + const templateSelect = fieldDiv.querySelector('select')!; + fireEvent.change(templateSelect, { target: { value: 'custom_post' } }); + + // Save + const saveButton = screen.getByRole('button', { name: /save/i }); + fireEvent.click(saveButton); + + await vi.waitFor(() => { + expect(updateMock).toHaveBeenCalledWith( + 'tag-1', + expect.objectContaining({ + postTemplateSlug: 'custom_post', + }), + ); + }); + }); +}); diff --git a/tests/renderer/components/TemplatesView.test.tsx b/tests/renderer/components/TemplatesView.test.tsx index 9e870d6..121262e 100644 --- a/tests/renderer/components/TemplatesView.test.tsx +++ b/tests/renderer/components/TemplatesView.test.tsx @@ -27,12 +27,12 @@ vi.mock('@monaco-editor/react', () => ({ const mockTemplate = { id: 'template-1', projectId: 'default', - slug: 'custom-post', + slug: 'custom_post', title: 'Custom Post', kind: 'post' as const, enabled: true, version: 1, - filePath: '/tmp/custom-post.liquid', + filePath: '/tmp/custom_post.liquid', content: '
{{ post.title }}
', createdAt: '2026-02-22T00:00:00.000Z', updatedAt: '2026-02-22T00:00:00.000Z', @@ -67,7 +67,7 @@ describe('TemplatesView', () => { await vi.waitFor(() => { expect(titleInput.value).toBe('Custom Post'); - expect(slugInput.value).toBe('custom-post'); + expect(slugInput.value).toBe('custom_post'); }); expect(kindSelect.value).toBe('post'); expect(enabledInput.checked).toBe(true); @@ -125,7 +125,7 @@ describe('TemplatesView', () => { 'template-1', expect.objectContaining({ title: 'Custom Post', - slug: 'custom-post', + slug: 'custom_post', kind: 'list', enabled: false, content: '
{{ post.title }}
', diff --git a/tests/renderer/navigation/tabPolicy.test.ts b/tests/renderer/navigation/tabPolicy.test.ts index a56cc13..317c7a3 100644 --- a/tests/renderer/navigation/tabPolicy.test.ts +++ b/tests/renderer/navigation/tabPolicy.test.ts @@ -6,6 +6,7 @@ import { getGitDiffFileTabSpec, getImportTabSpec, getScriptTabSpec, + getTemplateTabSpec, parseGitDiffTabId, openChatTab, getSingletonToolTabSpec, @@ -14,6 +15,7 @@ import { openGitDiffFileTab, openImportTab, openScriptTab, + openTemplateTab, openSingletonToolTab, } from '../../../src/renderer/navigation/tabPolicy'; @@ -163,4 +165,33 @@ describe('tabPolicy', () => { { type: 'git-diff', id: 'git-diff:commit:def456', isTransient: false }, ]); }); + + it('provides canonical template tab spec for preview and pin intents', () => { + expect(getTemplateTabSpec('template-1', 'preview')).toEqual({ + type: 'templates', + id: 'template-1', + isTransient: true, + }); + + expect(getTemplateTabSpec('template-1', 'pin')).toEqual({ + type: 'templates', + id: 'template-1', + isTransient: false, + }); + }); + + it('opens template tabs from shared policy', () => { + const opened: Array<{ type: string; id: string; isTransient: boolean }> = []; + const openTab = (tab: { type: string; id: string; isTransient: boolean }) => { + opened.push(tab); + }; + + openTemplateTab(openTab, 'template-preview', 'preview'); + openTemplateTab(openTab, 'template-pin', 'pin'); + + expect(opened).toEqual([ + { type: 'templates', id: 'template-preview', isTransient: true }, + { type: 'templates', id: 'template-pin', isTransient: false }, + ]); + }); });