fix: hooked up python API for templates

This commit is contained in:
2026-02-27 20:31:25 +01:00
parent f3364999ee
commit 6c2d2c48bf
7 changed files with 764 additions and 3 deletions

View File

@@ -343,4 +343,50 @@ describe('TemplateEngine', () => {
expect(created.slug).toBe('my_custom_slug');
});
});
describe('getTemplateBySlug', () => {
it('retrieves an enabled template by exact slug', async () => {
await templateEngine.createTemplate({
title: 'Custom Post',
kind: 'post',
content: '<main>Post</main>',
enabled: true,
});
const found = await templateEngine.getTemplateBySlug('custom_post');
expect(found).not.toBeNull();
expect(found?.slug).toBe('custom_post');
expect(found?.title).toBe('Custom Post');
});
it('matches slugs case-insensitively', async () => {
await templateEngine.createTemplate({
title: 'Custom Post',
kind: 'post',
content: '<main>Post</main>',
enabled: true,
});
const found = await templateEngine.getTemplateBySlug('CUSTOM_POST');
expect(found).not.toBeNull();
expect(found?.slug).toBe('custom_post');
});
it('returns null for non-existent slug', async () => {
const found = await templateEngine.getTemplateBySlug('does_not_exist');
expect(found).toBeNull();
});
it('does not return disabled templates', async () => {
await templateEngine.createTemplate({
title: 'Disabled Template',
kind: 'post',
content: '<main>Disabled</main>',
enabled: false,
});
const found = await templateEngine.getTemplateBySlug('disabled_template');
expect(found).toBeNull();
});
});
});

View File

@@ -2786,6 +2786,151 @@ describe('IPC Handlers', () => {
});
});
// ============ Template Handlers ============
describe('Template Handlers', () => {
describe('templates:create', () => {
it('should call TemplateEngine.createTemplate with payload', async () => {
const payload = {
title: 'Custom Post',
kind: 'post',
content: '<html>{{ post.title }}</html>',
};
const expected = {
id: 'template-1',
projectId: 'default',
...payload,
slug: 'custom_post',
enabled: true,
version: 1,
filePath: '/mock/userData/projects/default/templates/custom_post.liquid',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockTemplateEngine.createTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:create', payload);
expect(mockTemplateEngine.createTemplate).toHaveBeenCalledWith(payload);
expect(result).toEqual(expected);
});
});
describe('templates:update', () => {
it('should call TemplateEngine.updateTemplate with id and updates', async () => {
const updates = { title: 'Updated Template', content: '<html>{{ post.content }}</html>' };
const expected = {
id: 'template-1',
projectId: 'default',
slug: 'updated_template',
title: 'Updated Template',
kind: 'post',
enabled: true,
version: 2,
filePath: '/mock/userData/projects/default/templates/updated_template.liquid',
content: '<html>{{ post.content }}</html>',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockTemplateEngine.updateTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:update', 'template-1', updates);
expect(mockTemplateEngine.updateTemplate).toHaveBeenCalledWith('template-1', updates);
expect(result).toEqual(expected);
});
});
describe('templates:delete', () => {
it('should call TemplateEngine.deleteTemplate with id', async () => {
mockTemplateEngine.deleteTemplate.mockResolvedValue(true);
const result = await invokeHandler('templates:delete', 'template-1');
expect(mockTemplateEngine.deleteTemplate).toHaveBeenCalledWith('template-1');
expect(result).toBe(true);
});
});
describe('templates:get', () => {
it('should call TemplateEngine.getTemplate with id', async () => {
const expected = {
id: 'template-1',
projectId: 'default',
slug: 'custom_post',
title: 'Custom Post',
kind: 'post',
enabled: true,
version: 1,
filePath: '/mock/userData/projects/default/templates/custom_post.liquid',
content: '<html>{{ post.title }}</html>',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockTemplateEngine.getTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:get', 'template-1');
expect(mockTemplateEngine.getTemplate).toHaveBeenCalledWith('template-1');
expect(result).toEqual(expected);
});
});
describe('templates:getAll', () => {
it('should call TemplateEngine.getAllTemplates', async () => {
const expected = [{ id: 'template-1' }, { id: 'template-2' }];
mockTemplateEngine.getAllTemplates.mockResolvedValue(expected);
const result = await invokeHandler('templates:getAll');
expect(mockTemplateEngine.getAllTemplates).toHaveBeenCalled();
expect(result).toEqual(expected);
});
});
describe('templates:getEnabledByKind', () => {
it('should call TemplateEngine.getEnabledTemplatesByKind with kind', async () => {
const expected = [{ id: 'template-1', kind: 'post' }];
mockTemplateEngine.getEnabledTemplatesByKind.mockResolvedValue(expected);
const result = await invokeHandler('templates:getEnabledByKind', 'post');
expect(mockTemplateEngine.getEnabledTemplatesByKind).toHaveBeenCalledWith('post');
expect(result).toEqual(expected);
});
});
describe('templates:validate', () => {
it('should call TemplateEngine.validateTemplate with content', async () => {
const expected = { valid: true, errors: [] };
mockTemplateEngine.validateTemplate.mockResolvedValue(expected);
const result = await invokeHandler('templates:validate', '<html>{{ post.title }}</html>');
expect(mockTemplateEngine.validateTemplate).toHaveBeenCalledWith('<html>{{ post.title }}</html>');
expect(result).toEqual(expected);
});
});
describe('templates:rebuildFromFiles', () => {
it('should set project context and trigger TemplateEngine rebuild', async () => {
mockProjectEngine.getActiveProject.mockResolvedValue({
id: 'project-1',
dataPath: '/external/data',
});
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
mockTemplateEngine.rebuildDatabaseFromFiles.mockResolvedValue(undefined);
const result = await invokeHandler('templates:rebuildFromFiles');
expect(mockTemplateEngine.setProjectContext).toHaveBeenCalledWith('project-1', '/resolved/project-data');
expect(mockTemplateEngine.rebuildDatabaseFromFiles).toHaveBeenCalled();
expect(result).toBe(true);
});
});
});
// ============ Error Handling ============
describe('Error Handling', () => {
it('should silently handle "Database is closing" errors', async () => {

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('TemplatesView styles', () => {
const cssPath = path.resolve(
__dirname,
'../../../src/renderer/components/TemplatesView/TemplatesView.css'
);
it('uses full editor area layout for the templates container', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.templates-view\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
});
it('keeps editor and monaco stretched to fill available space', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.templates-editor\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
expect(css).toMatch(/\.templates-monaco\s*\{[^}]*flex:\s*1;[^}]*min-height:\s*0;[^}]*\}/s);
});
});

View File

@@ -0,0 +1,211 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TemplatesView } from '../../../src/renderer/components/TemplatesView/TemplatesView';
import { useAppStore } from '../../../src/renderer/store';
const monacoPropsSpy = vi.fn();
vi.mock('@monaco-editor/react', () => ({
default: (props: {
value?: string;
defaultValue?: string;
onChange?: (value?: string) => void;
language?: string;
}) => {
monacoPropsSpy(props);
return (
<textarea
aria-label="Template Content"
defaultValue={props.defaultValue ?? props.value ?? ''}
onChange={(event) => props.onChange?.(event.target.value)}
/>
);
},
}));
const mockTemplate = {
id: 'template-1',
projectId: 'default',
slug: 'custom-post',
title: 'Custom Post',
kind: 'post' as const,
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',
};
describe('TemplatesView', () => {
beforeEach(() => {
vi.clearAllMocks();
(window as any).electronAPI = {
...(window as any).electronAPI,
templates: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn().mockResolvedValue({ ...mockTemplate }),
getAll: vi.fn().mockResolvedValue([]),
getEnabledByKind: vi.fn().mockResolvedValue([]),
validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }),
rebuildFromFiles: vi.fn(),
},
};
});
it('loads template and displays metadata fields', async () => {
render(<TemplatesView templateId="template-1" />);
const titleInput = await screen.findByLabelText('Title') as HTMLInputElement;
const slugInput = screen.getByLabelText('Slug') as HTMLInputElement;
const kindSelect = screen.getByLabelText('Kind') as HTMLSelectElement;
const enabledInput = screen.getByLabelText('Enabled') as HTMLInputElement;
await vi.waitFor(() => {
expect(titleInput.value).toBe('Custom Post');
expect(slugInput.value).toBe('custom-post');
});
expect(kindSelect.value).toBe('post');
expect(enabledInput.checked).toBe(true);
expect(screen.getByText(/Created:/)).toBeInTheDocument();
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
it('loads template content into Monaco editor with html language', async () => {
render(<TemplatesView templateId="template-1" />);
await vi.waitFor(() => {
const textarea = screen.getByLabelText('Template Content') as HTMLTextAreaElement;
expect(textarea.value).toContain('{{ post.title }}');
});
expect(monacoPropsSpy).toHaveBeenCalledWith(
expect.objectContaining({
language: 'html',
}),
);
});
it('saves template metadata via update IPC call', async () => {
const updateMock = vi.fn().mockResolvedValue({
...mockTemplate,
kind: 'list',
enabled: false,
version: 2,
updatedAt: '2026-02-22T00:01:00.000Z',
});
(window as any).electronAPI.templates.update = updateMock;
render(<TemplatesView templateId="template-1" />);
const kindSelect = screen.getByLabelText('Kind');
const enabledInput = screen.getByLabelText('Enabled');
await vi.waitFor(() => {
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Custom Post');
});
fireEvent.change(kindSelect, { target: { value: 'list' } });
fireEvent.click(enabledInput);
await vi.waitFor(() => {
expect((kindSelect as HTMLSelectElement).value).toBe('list');
expect((enabledInput as HTMLInputElement).checked).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: 'Save Template' }));
await vi.waitFor(() => {
expect(updateMock).toHaveBeenCalledWith(
'template-1',
expect.objectContaining({
title: 'Custom Post',
slug: 'custom-post',
kind: 'list',
enabled: false,
content: '<main>{{ post.title }}</main>',
}),
);
});
});
it('validates before saving and blocks save on invalid syntax', async () => {
const validateMock = vi.fn().mockResolvedValue({
valid: false,
errors: ['unexpected end of tag'],
});
const updateMock = vi.fn();
(window as any).electronAPI.templates.validate = validateMock;
(window as any).electronAPI.templates.update = updateMock;
render(<TemplatesView templateId="template-1" />);
await vi.waitFor(() => {
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Custom Post');
});
// Trigger hasChanges via kind change (select events work reliably)
fireEvent.change(screen.getByLabelText('Kind'), { target: { value: 'list' } });
await vi.waitFor(() => {
expect((screen.getByLabelText('Kind') as HTMLSelectElement).value).toBe('list');
});
fireEvent.click(screen.getByRole('button', { name: 'Save Template' }));
await vi.waitFor(() => {
expect(validateMock).toHaveBeenCalled();
});
expect(updateMock).not.toHaveBeenCalled();
});
it('validates template content via validate button', async () => {
const validateMock = vi.fn().mockResolvedValue({ valid: true, errors: [] });
(window as any).electronAPI.templates.validate = validateMock;
render(<TemplatesView templateId="template-1" />);
await screen.findByLabelText('Title');
fireEvent.click(screen.getByRole('button', { name: 'Validate' }));
await vi.waitFor(() => {
expect(validateMock).toHaveBeenCalledWith('<main>{{ post.title }}</main>');
});
});
it('deletes template and closes tab', async () => {
const deleteMock = vi.fn().mockResolvedValue(true);
(window as any).electronAPI.templates.delete = deleteMock;
useAppStore.setState({
tabs: [{ type: 'templates', id: 'template-1', isTransient: false }],
activeTabId: 'template-1',
});
render(<TemplatesView templateId="template-1" />);
fireEvent.click(await screen.findByRole('button', { name: 'Delete Template' }));
await vi.waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith('template-1');
expect(useAppStore.getState().tabs).toEqual([]);
});
});
it('disables save button when no changes exist', async () => {
render(<TemplatesView templateId="template-1" />);
await screen.findByLabelText('Title');
await vi.waitFor(() => {
const saveButton = screen.getByRole('button', { name: 'Save Template' });
expect(saveButton).toBeDisabled();
});
});
});

View File

@@ -67,7 +67,7 @@ describe('pythonApiContractV1', () => {
it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.7.0',
version: '1.8.0',
generatedAt: expect.any(String),
});
});