fix: some smaller pieces still missing

This commit is contained in:
2026-02-27 21:15:03 +01:00
parent 696b79c5d7
commit a38954101c
7 changed files with 532 additions and 8 deletions

View File

@@ -18,8 +18,8 @@ const UI_DATE_LOCALE: Record<string, string> = {
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';
};

View File

@@ -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 });
});
});

View File

@@ -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(<SettingsView />);
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(<SettingsView />);
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(<SettingsView />);
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(<SettingsView />);

View File

@@ -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<string, Set<(event: Event) => 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: '<main>{{ post.title }}</main>',
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(<Sidebar />);
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(<Sidebar />);
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(<Sidebar />);
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(<Sidebar />);
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(<Sidebar />);
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(<Sidebar />);
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(<Sidebar />);
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: '<main>{{ post.title }}</main>',
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: '<main>{{ post.title }}</main>',
createdAt: '2026-02-22T00:00:00.000Z',
updatedAt: '2026-02-22T00:01:00.000Z',
},
]);
(window as any).electronAPI.templates.getAll = getAllMock;
render(<Sidebar />);
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: '<main>{{ post.title }}</main>',
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(<Sidebar />);
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);
});
});

View File

@@ -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(<TagsView />);
// 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(<TagsView />);
// 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',
}),
);
});
});
});

View File

@@ -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: '<main>{{ post.title }}</main>',
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: '<main>{{ post.title }}</main>',

View File

@@ -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 },
]);
});
});