fix: hooked up python API for templates
This commit is contained in:
312
API.md
312
API.md
@@ -1,6 +1,6 @@
|
||||
# API Documentation
|
||||
|
||||
Contract version: 1.7.0
|
||||
Contract version: 1.8.0
|
||||
|
||||
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
||||
|
||||
@@ -21,6 +21,7 @@ project = await bds.meta.get_project_metadata()
|
||||
- [posts](#posts)
|
||||
- [media](#media)
|
||||
- [scripts](#scripts)
|
||||
- [templates](#templates)
|
||||
- [tasks](#tasks)
|
||||
- [app](#app)
|
||||
- [meta](#meta)
|
||||
@@ -1949,6 +1950,295 @@ None
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
## templates
|
||||
|
||||
**Module APIs**
|
||||
|
||||
- [templates.create](#templatescreate)
|
||||
- [templates.update](#templatesupdate)
|
||||
- [templates.delete](#templatesdelete)
|
||||
- [templates.get](#templatesget)
|
||||
- [templates.getAll](#templatesgetall)
|
||||
- [templates.getEnabledByKind](#templatesgetenabledbykind)
|
||||
- [templates.validate](#templatesvalidate)
|
||||
- [templates.rebuildFromFiles](#templatesrebuildfromfiles)
|
||||
|
||||
### templates.create
|
||||
|
||||
Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).
|
||||
|
||||
**Parameters**
|
||||
|
||||
- data (dict, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `TemplateData`
|
||||
- Data structures: `TemplateData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.create(data={})
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'slug': 'value',
|
||||
'title': 'value',
|
||||
'kind': 'post',
|
||||
'enabled': False,
|
||||
'version': 0,
|
||||
'filePath': 'value',
|
||||
'content': 'value',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
### templates.update
|
||||
|
||||
Update template by id. data may include any of: title, kind, content, slug, enabled.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- id (str, required)
|
||||
- data (dict, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `TemplateData | null`
|
||||
- Nullability: Returns `None` when no matching value exists.
|
||||
- Data structures: `TemplateData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.update(id='id-1', data={})
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
None # or
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'slug': 'value',
|
||||
'title': 'value',
|
||||
'kind': 'post',
|
||||
'enabled': False,
|
||||
'version': 0,
|
||||
'filePath': 'value',
|
||||
'content': 'value',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
### templates.delete
|
||||
|
||||
Delete template by id.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- id (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `boolean`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.delete(id='id-1')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
True
|
||||
```
|
||||
|
||||
### templates.get
|
||||
|
||||
Fetch template by id.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- id (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `TemplateData | null`
|
||||
- Nullability: Returns `None` when no matching value exists.
|
||||
- Data structures: `TemplateData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.get(id='id-1')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
None # or
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'slug': 'value',
|
||||
'title': 'value',
|
||||
'kind': 'post',
|
||||
'enabled': False,
|
||||
'version': 0,
|
||||
'filePath': 'value',
|
||||
'content': 'value',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
### templates.getAll
|
||||
|
||||
Fetch all templates.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- None
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `TemplateData[]`
|
||||
- Data structures: `TemplateData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.get_all()
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
[
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'slug': 'value',
|
||||
'title': 'value',
|
||||
'kind': 'post',
|
||||
'enabled': False,
|
||||
'version': 0,
|
||||
'filePath': 'value',
|
||||
'content': 'value',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### templates.getEnabledByKind
|
||||
|
||||
Fetch enabled templates filtered by kind.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- kind (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `TemplateData[]`
|
||||
- Data structures: `TemplateData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.get_enabled_by_kind(kind='kind')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
[
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'slug': 'value',
|
||||
'title': 'value',
|
||||
'kind': 'post',
|
||||
'enabled': False,
|
||||
'version': 0,
|
||||
'filePath': 'value',
|
||||
'content': 'value',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### templates.validate
|
||||
|
||||
Validate Liquid template syntax.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- content (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `{ valid: boolean; errors: string[] }`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.validate(content='content')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
{}
|
||||
```
|
||||
|
||||
### templates.rebuildFromFiles
|
||||
|
||||
Rebuild templates from files.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- None
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `void`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.templates.rebuild_from_files()
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
None
|
||||
```
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
## tasks
|
||||
|
||||
**Module APIs**
|
||||
@@ -3579,6 +3869,26 @@ Script definition for Python macros, utilities, and transforms.
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
### TemplateData
|
||||
|
||||
Liquid template definition for posts, lists, not-found pages, and partials.
|
||||
|
||||
**Fields**
|
||||
|
||||
- id (`string`, required): Unique template identifier.
|
||||
- projectId (`string`, required): Owning project id.
|
||||
- slug (`string`, required): Stable template slug.
|
||||
- title (`string`, required): Human-readable template title.
|
||||
- kind (`'post' | 'list' | 'not-found' | 'partial'`, required): Template category.
|
||||
- enabled (`boolean`, required): Whether template is enabled.
|
||||
- version (`number`, required): Incrementing template version.
|
||||
- filePath (`string`, required): Filesystem path to template file.
|
||||
- content (`string`, required): Liquid template source code.
|
||||
- createdAt (`string`, required): Creation timestamp (ISO string).
|
||||
- updatedAt (`string`, required): Last update timestamp (ISO string).
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
### TaskProgress
|
||||
|
||||
Task queue status object for long-running operations.
|
||||
|
||||
@@ -129,6 +129,15 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
||||
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
|
||||
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
|
||||
|
||||
method('templates.create', 'Create template. data must include: title (str), kind ("post"|"list"|"not-found"|"partial"), content (str). Optional: slug (str), enabled (bool).', [requiredObject('data')], 'TemplateData'),
|
||||
method('templates.update', 'Update template by id. data may include any of: title, kind, content, slug, enabled.', [requiredString('id'), requiredObject('data')], 'TemplateData | null'),
|
||||
method('templates.delete', 'Delete template by id.', [requiredString('id')], 'boolean'),
|
||||
method('templates.get', 'Fetch template by id.', [requiredString('id')], 'TemplateData | null'),
|
||||
method('templates.getAll', 'Fetch all templates.', [], 'TemplateData[]'),
|
||||
method('templates.getEnabledByKind', 'Fetch enabled templates filtered by kind.', [requiredString('kind')], 'TemplateData[]'),
|
||||
method('templates.validate', 'Validate Liquid template syntax.', [requiredString('content')], '{ valid: boolean; errors: string[] }'),
|
||||
method('templates.rebuildFromFiles', 'Rebuild templates from files.', [], 'void'),
|
||||
|
||||
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
|
||||
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
|
||||
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
|
||||
@@ -276,6 +285,23 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'TemplateData',
|
||||
description: 'Liquid template definition for posts, lists, not-found pages, and partials.',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', required: true, description: 'Unique template identifier.' },
|
||||
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
|
||||
{ name: 'slug', type: 'string', required: true, description: 'Stable template slug.' },
|
||||
{ name: 'title', type: 'string', required: true, description: 'Human-readable template title.' },
|
||||
{ name: 'kind', type: "'post' | 'list' | 'not-found' | 'partial'", required: true, description: 'Template category.' },
|
||||
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether template is enabled.' },
|
||||
{ name: 'version', type: 'number', required: true, description: 'Incrementing template version.' },
|
||||
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to template file.' },
|
||||
{ name: 'content', type: 'string', required: true, description: 'Liquid template source code.' },
|
||||
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'TaskProgress',
|
||||
description: 'Task queue status object for long-running operations.',
|
||||
@@ -370,7 +396,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
];
|
||||
|
||||
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||
version: '1.7.0',
|
||||
version: '1.8.0',
|
||||
generatedAt: '2026-02-27T00:00:00.000Z',
|
||||
methods: METHODS_V1,
|
||||
dataStructures: DATA_STRUCTURES_V1,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
23
tests/renderer/components/TemplatesView.styles.test.ts
Normal file
23
tests/renderer/components/TemplatesView.styles.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
211
tests/renderer/components/TemplatesView.test.tsx
Normal file
211
tests/renderer/components/TemplatesView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user