fix: some smaller pieces still missing
This commit is contained in:
@@ -18,8 +18,8 @@ const UI_DATE_LOCALE: Record<string, string> = {
|
|||||||
const toTemplateSlug = (value: string) => {
|
const toTemplateSlug = (value: string) => {
|
||||||
const normalized = value
|
const normalized = value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
.replace(/^-+|-+$/g, '');
|
.replace(/^_+|_+$/g, '');
|
||||||
return normalized || 'template';
|
return normalized || 'template';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -602,7 +602,9 @@ describe('IPC Handlers', () => {
|
|||||||
mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([
|
mockGitEngine.getChangedScriptFilesBetween.mockResolvedValue([
|
||||||
{ status: 'modified', path: 'scripts/transform.py' },
|
{ status: 'modified', path: 'scripts/transform.py' },
|
||||||
]);
|
]);
|
||||||
mockGitEngine.getChangedTemplateFilesBetween.mockResolvedValue([]);
|
mockGitEngine.getChangedTemplateFilesBetween.mockResolvedValue([
|
||||||
|
{ status: 'added', path: 'templates/custom_post.liquid' },
|
||||||
|
]);
|
||||||
mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({
|
mockPostEngine.reconcilePublishedPostsFromGitChanges.mockResolvedValue({
|
||||||
created: 1,
|
created: 1,
|
||||||
updated: 1,
|
updated: 1,
|
||||||
@@ -615,6 +617,12 @@ describe('IPC Handlers', () => {
|
|||||||
deleted: 0,
|
deleted: 0,
|
||||||
processedFiles: 1,
|
processedFiles: 1,
|
||||||
});
|
});
|
||||||
|
mockTemplateEngine.reconcileTemplatesFromGitChanges.mockResolvedValue({
|
||||||
|
created: 1,
|
||||||
|
updated: 0,
|
||||||
|
deleted: 0,
|
||||||
|
processedFiles: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await invokeHandler('git:pull', '/repo');
|
const result = await invokeHandler('git:pull', '/repo');
|
||||||
|
|
||||||
@@ -623,6 +631,7 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo');
|
expect(mockGitEngine.getHeadCommit).toHaveBeenNthCalledWith(2, '/repo');
|
||||||
expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
expect(mockGitEngine.getChangedPostFilesBetween).toHaveBeenCalledWith('/repo', 'before-head', 'after-head');
|
||||||
expect(mockGitEngine.getChangedScriptFilesBetween).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', [
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||||
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
{ status: 'modified', path: 'posts/2026/02/existing.md' },
|
||||||
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
{ status: 'added', path: 'posts/2026/02/new-post.md' },
|
||||||
@@ -630,6 +639,9 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
expect(mockScriptEngine.reconcileScriptsFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||||
{ status: 'modified', path: 'scripts/transform.py' },
|
{ status: 'modified', path: 'scripts/transform.py' },
|
||||||
]);
|
]);
|
||||||
|
expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).toHaveBeenCalledWith('/repo', [
|
||||||
|
{ status: 'added', path: 'templates/custom_post.liquid' },
|
||||||
|
]);
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -642,8 +654,10 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||||
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
||||||
|
expect(mockGitEngine.getChangedTemplateFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ success: false, code: 'conflict' });
|
expect(result).toEqual({ success: false, code: 'conflict' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,8 +672,10 @@ describe('IPC Handlers', () => {
|
|||||||
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
expect(mockGitEngine.pull).toHaveBeenCalledWith('/repo');
|
||||||
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedPostFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
expect(mockGitEngine.getChangedScriptFilesBetween).not.toHaveBeenCalled();
|
||||||
|
expect(mockGitEngine.getChangedTemplateFilesBetween).not.toHaveBeenCalled();
|
||||||
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockPostEngine.reconcilePublishedPostsFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
expect(mockScriptEngine.reconcileScriptsFromGitChanges).not.toHaveBeenCalled();
|
||||||
|
expect(mockTemplateEngine.reconcileTemplatesFromGitChanges).not.toHaveBeenCalled();
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,6 +182,107 @@ describe('SettingsView Diff Preferences', () => {
|
|||||||
expect(rebuildScriptsMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('persists category settings changes via project metadata update', async () => {
|
||||||
render(<SettingsView />);
|
render(<SettingsView />);
|
||||||
|
|
||||||
|
|||||||
281
tests/renderer/components/SidebarTemplates.test.tsx
Normal file
281
tests/renderer/components/SidebarTemplates.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
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';
|
import { TagsView } from '../../../src/renderer/components/TagsView/TagsView';
|
||||||
|
|
||||||
describe('TagsView subscriptions', () => {
|
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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ vi.mock('@monaco-editor/react', () => ({
|
|||||||
const mockTemplate = {
|
const mockTemplate = {
|
||||||
id: 'template-1',
|
id: 'template-1',
|
||||||
projectId: 'default',
|
projectId: 'default',
|
||||||
slug: 'custom-post',
|
slug: 'custom_post',
|
||||||
title: 'Custom Post',
|
title: 'Custom Post',
|
||||||
kind: 'post' as const,
|
kind: 'post' as const,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
version: 1,
|
version: 1,
|
||||||
filePath: '/tmp/custom-post.liquid',
|
filePath: '/tmp/custom_post.liquid',
|
||||||
content: '<main>{{ post.title }}</main>',
|
content: '<main>{{ post.title }}</main>',
|
||||||
createdAt: '2026-02-22T00:00:00.000Z',
|
createdAt: '2026-02-22T00:00:00.000Z',
|
||||||
updatedAt: '2026-02-22T00:00:00.000Z',
|
updatedAt: '2026-02-22T00:00:00.000Z',
|
||||||
@@ -67,7 +67,7 @@ describe('TemplatesView', () => {
|
|||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(titleInput.value).toBe('Custom Post');
|
expect(titleInput.value).toBe('Custom Post');
|
||||||
expect(slugInput.value).toBe('custom-post');
|
expect(slugInput.value).toBe('custom_post');
|
||||||
});
|
});
|
||||||
expect(kindSelect.value).toBe('post');
|
expect(kindSelect.value).toBe('post');
|
||||||
expect(enabledInput.checked).toBe(true);
|
expect(enabledInput.checked).toBe(true);
|
||||||
@@ -125,7 +125,7 @@ describe('TemplatesView', () => {
|
|||||||
'template-1',
|
'template-1',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: 'Custom Post',
|
title: 'Custom Post',
|
||||||
slug: 'custom-post',
|
slug: 'custom_post',
|
||||||
kind: 'list',
|
kind: 'list',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
content: '<main>{{ post.title }}</main>',
|
content: '<main>{{ post.title }}</main>',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getGitDiffFileTabSpec,
|
getGitDiffFileTabSpec,
|
||||||
getImportTabSpec,
|
getImportTabSpec,
|
||||||
getScriptTabSpec,
|
getScriptTabSpec,
|
||||||
|
getTemplateTabSpec,
|
||||||
parseGitDiffTabId,
|
parseGitDiffTabId,
|
||||||
openChatTab,
|
openChatTab,
|
||||||
getSingletonToolTabSpec,
|
getSingletonToolTabSpec,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
openGitDiffFileTab,
|
openGitDiffFileTab,
|
||||||
openImportTab,
|
openImportTab,
|
||||||
openScriptTab,
|
openScriptTab,
|
||||||
|
openTemplateTab,
|
||||||
openSingletonToolTab,
|
openSingletonToolTab,
|
||||||
} from '../../../src/renderer/navigation/tabPolicy';
|
} from '../../../src/renderer/navigation/tabPolicy';
|
||||||
|
|
||||||
@@ -163,4 +165,33 @@ describe('tabPolicy', () => {
|
|||||||
{ type: 'git-diff', id: 'git-diff:commit:def456', isTransient: false },
|
{ 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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user