Feature/ai post suggestions (#40)

* feat: first cut on ai suggestion system for title and summary

* feat: completion of titling/excerpt/slug-suggestion AI quick action

* feat: feeds use existing excerpts. also documentation.

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-07 09:54:13 +01:00
committed by GitHub
parent 72b21ddba7
commit 9871cb827f
30 changed files with 1270 additions and 245 deletions

View File

@@ -183,4 +183,56 @@ describe('GenerationSitemapFeedService', () => {
expect(result.atomXml).toContain('xml:lang="en"');
expect(result.atomXml).toContain('xml:lang="de"');
});
it('uses excerpt instead of full body in feed entry content when excerpt is available', () => {
const publishedPosts = [
makePost({
id: '1',
slug: 'excerpt-post',
title: 'Excerpt Post',
excerpt: 'Short feed summary.',
content: '# Excerpt Post\n\nVery long body that should not appear in feed content.',
}),
];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: true,
});
expect(result.rssXml).toContain('<content:encoded><![CDATA[<p>Short feed summary.</p>]]></content:encoded>');
expect(result.rssXml).not.toContain('Very long body that should not appear in feed content.');
expect(result.atomXml).toContain('<content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>Short feed summary.</p></div></content>');
expect(result.atomXml).not.toContain('Very long body that should not appear in feed content.');
});
it('falls back to the post body in feed entry content when excerpt is missing', () => {
const publishedPosts = [
makePost({
id: '1',
slug: 'body-post',
title: 'Body Post',
content: '# Body Post\n\nBody paragraph used in feed content.',
}),
];
const result = buildSitemapAndFeeds({
baseUrl: 'https://example.com',
projectName: 'Test Blog',
maxPostsPerPage: 10,
publishedPosts,
publishedListPosts: publishedPosts,
postIndex: buildIndex(publishedPosts),
includeFeeds: true,
});
expect(result.rssXml).toContain('Body paragraph used in feed content.');
expect(result.atomXml).toContain('Body paragraph used in feed content.');
});
});

View File

@@ -1028,6 +1028,40 @@ Original content`);
expect(result?.slug).toBe('new-title');
});
it('should honor explicit slug when title and slug both change on a never-published draft', async () => {
const created = await postEngine.createPost({ title: 'Original Title' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'draft',
content: created.content || '',
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
publishedAt: null,
}),
});
return chain;
});
const result = await postEngine.updatePost(created.id, {
title: 'New Title',
slug: 'custom-ai-slug',
});
expect(result).not.toBeNull();
expect(result?.slug).toBe('custom-ai-slug');
});
it('should NOT auto-update slug when title changes on a previously published post', async () => {
const created = await postEngine.createPost({ title: 'Published Post' });
@@ -1059,6 +1093,37 @@ Original content`);
expect(result?.slug).toBe('published-post'); // slug preserved
});
it('should ignore explicit slug changes on a previously published post', async () => {
const created = await postEngine.createPost({ title: 'Published Post' });
vi.mocked(mockLocalDb.select).mockImplementation(() => {
const chain = createSelectChain();
chain.where = vi.fn().mockReturnValue({
...chain,
get: vi.fn().mockResolvedValue({
id: created.id,
projectId: created.projectId,
title: created.title,
slug: created.slug,
status: 'draft',
content: created.content || '',
filePath: '',
tags: '[]',
categories: '[]',
createdAt: created.createdAt,
updatedAt: created.updatedAt,
publishedAt: new Date('2025-01-01'),
}),
});
return chain;
});
const result = await postEngine.updatePost(created.id, { slug: 'new-slug' });
expect(result).not.toBeNull();
expect(result?.slug).toBe('published-post');
});
it('should allow empty title and use untitled as slug base', async () => {
const created = await postEngine.createPost({ title: '' });
expect(created.title).toBe('');

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the AI SDK generateText before importing OneShotTasks
vi.mock('ai', () => ({
generateText: vi.fn(),
}));
// Mock i18n
vi.mock('../../../src/main/shared/i18n', () => ({
resolveSupportedRenderLanguage: vi.fn((lang: string) => lang),
translateRender: vi.fn((_lang: string, key: string) => {
const prompts: Record<string, string> = {
'ai.postAnalysis.system': 'You are a blog editor assistant. Analyze the blog post and suggest improvements. Return JSON with "title", "excerpt", "slug". Respond in en.',
'ai.postAnalysis.user': 'Analyze this blog post.',
};
return prompts[key] || key;
}),
}));
import { OneShotTasks, type PostAnalysisResult } from '../../../src/main/engine/ai/tasks';
import { generateText } from 'ai';
const mockGenerateText = vi.mocked(generateText);
function createMockDeps() {
const chatEngine = {
getSetting: vi.fn().mockResolvedValue(null),
} as any;
const providers = {
detectModelProvider: vi.fn().mockReturnValue('opencode'),
isProviderKeySet: vi.fn().mockReturnValue(true),
getOpencodeKey: vi.fn().mockReturnValue('test-key'),
getMistralKey: vi.fn().mockReturnValue(null),
resolveModel: vi.fn().mockReturnValue('mock-model'),
isOfflineMode: vi.fn().mockReturnValue(false),
isOllamaModel: vi.fn().mockReturnValue(false),
isLmstudioModel: vi.fn().mockReturnValue(false),
getFirstKnownLocalModelId: vi.fn().mockReturnValue(null),
} as any;
const mediaEngine = {} as any;
const postEngine = {
getPost: vi.fn(),
} as any;
return { chatEngine, providers, mediaEngine, postEngine };
}
describe('OneShotTasks.analyzePost', () => {
let deps: ReturnType<typeof createMockDeps>;
let tasks: OneShotTasks;
beforeEach(() => {
vi.clearAllMocks();
deps = createMockDeps();
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
});
it('returns title, excerpt, and slug from AI response', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'My Post',
slug: 'my-post',
excerpt: '',
content: 'This is the content of my blog post about testing.',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "Better Title", "excerpt": "A concise summary of the post.", "slug": "better-title"}',
} as any);
const result: PostAnalysisResult = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(true);
expect(result.title).toBe('Better Title');
expect(result.excerpt).toBe('A concise summary of the post.');
expect(result.slug).toBe('better-title');
expect(deps.postEngine.getPost).toHaveBeenCalledWith('post-1');
});
it('returns error when post is not found', async () => {
deps.postEngine.getPost.mockResolvedValue(null);
const result = await tasks.analyzePost('nonexistent', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Post not found');
});
it('returns error when post has no content', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: '',
slug: 'post-1',
content: '',
status: 'draft',
});
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Post has no content to analyze');
});
it('returns error when no API key is configured', async () => {
deps.providers.getOpencodeKey.mockReturnValue(null);
deps.providers.getMistralKey.mockReturnValue(null);
deps.providers.isProviderKeySet.mockReturnValue(false);
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content here',
status: 'draft',
});
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('API key');
});
it('sanitizes slug to lowercase with hyphens only', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
slug: 'test',
content: 'Content here',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "New Title", "excerpt": "Summary text.", "slug": "Some Weird Slug!"}',
} as any);
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(true);
expect(result.slug).toMatch(/^[a-z0-9-]+$/);
});
it('handles AI response parse errors gracefully', async () => {
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: 'not valid json at all',
} as any);
const result = await tasks.analyzePost('post-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid response format from AI');
});
it('uses title model when configured', async () => {
deps.chatEngine.getSetting.mockResolvedValue('custom-model-id');
deps.providers.detectModelProvider.mockReturnValue('opencode');
deps.providers.isProviderKeySet.mockReturnValue(true);
deps.postEngine.getPost.mockResolvedValue({
id: 'post-1',
title: 'Test',
content: 'Content',
status: 'draft',
});
mockGenerateText.mockResolvedValue({
text: '{"title": "T", "excerpt": "E", "slug": "t"}',
} as any);
await tasks.analyzePost('post-1', 'en');
expect(deps.chatEngine.getSetting).toHaveBeenCalledWith('chat_title_model');
expect(deps.providers.resolveModel).toHaveBeenCalledWith('custom-model-id');
});
});

View File

@@ -1,19 +1,13 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { AISuggestionsModal, type AISuggestions } from '../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal';
import { AISuggestionsModal, type SuggestionField } from '../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal';
const currentValues = {
title: 'Existing title',
alt: 'Existing alt',
caption: '',
};
const baseSuggestions: AISuggestions = {
title: 'Suggested title',
alt: 'Suggested alt',
caption: 'Suggested caption',
};
const mediaFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: 'Existing title', suggestedValue: 'Suggested title' },
{ key: 'alt', label: 'Alt Text', currentValue: 'Existing alt', suggestedValue: 'Suggested alt' },
{ key: 'caption', label: 'Caption', currentValue: '', suggestedValue: 'Suggested caption' },
];
describe('AISuggestionsModal', () => {
it('shows suggested fields and applies only selected values', () => {
@@ -23,8 +17,10 @@ describe('AISuggestionsModal', () => {
<AISuggestionsModal
isOpen
isLoading={false}
suggestions={baseSuggestions}
currentValues={currentValues}
fields={mediaFields}
modalTitle="AI Image Analysis"
loadingText="Analyzing image..."
emptyText="No suggestions."
onConfirm={onConfirm}
onClose={vi.fn()}
/>
@@ -37,8 +33,10 @@ describe('AISuggestionsModal', () => {
const applyButton = screen.getByRole('button', { name: 'Apply Selected' });
const [titleCheckbox, altCheckbox, captionCheckbox] = screen.getAllByRole('checkbox') as HTMLInputElement[];
// Fields with existing values should be unchecked
expect(titleCheckbox.checked).toBe(false);
expect(altCheckbox.checked).toBe(false);
// Field with empty current value should be checked
expect(captionCheckbox.checked).toBe(true);
expect(applyButton).not.toBeDisabled();
@@ -57,18 +55,78 @@ describe('AISuggestionsModal', () => {
});
it('hides apply button when no suggestions are available', () => {
const emptyFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: '', suggestedValue: undefined },
];
render(
<AISuggestionsModal
isOpen
isLoading={false}
suggestions={{}}
currentValues={{ title: '', alt: '', caption: '' }}
fields={emptyFields}
modalTitle="AI Analysis"
loadingText="Analyzing..."
emptyText="No suggestions were generated."
onConfirm={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.queryByRole('button', { name: 'Apply Selected' })).toBeNull();
expect(screen.getByText('No suggestions were generated for this image.')).toBeTruthy();
expect(screen.getByText('No suggestions were generated.')).toBeTruthy();
});
it('shows custom modal title and loading text', () => {
render(
<AISuggestionsModal
isOpen
isLoading={true}
fields={[]}
modalTitle="AI Post Analysis"
loadingText="Analyzing post…"
emptyText="No suggestions."
onConfirm={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('AI Post Analysis')).toBeTruthy();
expect(screen.getByText('Analyzing post…')).toBeTruthy();
});
it('works with post analysis fields (title, excerpt, slug)', () => {
const postFields: SuggestionField[] = [
{ key: 'title', label: 'Title', currentValue: 'My Post', suggestedValue: 'Better Title' },
{ key: 'excerpt', label: 'Summary / Excerpt', currentValue: '', suggestedValue: 'A concise summary.' },
{ key: 'slug', label: 'Slug', currentValue: 'my-post', suggestedValue: 'better-title' },
];
const onConfirm = vi.fn();
render(
<AISuggestionsModal
isOpen
isLoading={false}
fields={postFields}
modalTitle="AI Post Analysis"
loadingText="Analyzing post…"
emptyText="No suggestions."
onConfirm={onConfirm}
onClose={vi.fn()}
/>
);
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
// title has value → unchecked; excerpt empty → checked; slug has value → unchecked
expect(checkboxes[0].checked).toBe(false); // title
expect(checkboxes[1].checked).toBe(true); // excerpt
expect(checkboxes[2].checked).toBe(false); // slug
// Apply only the excerpt (pre-selected)
fireEvent.click(screen.getByRole('button', { name: 'Apply Selected' }));
expect(onConfirm).toHaveBeenCalledWith({
excerpt: 'A concise summary.',
});
});
});

View File

@@ -229,4 +229,37 @@ describe('Editor metadata collapse', () => {
});
expect(container.querySelector('.editor-header-row')).toBeNull();
});
it('keeps excerpt panel collapsed by default and toggles it independently', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: '' }));
const { container } = render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(container.querySelector('.editor-header-row')).not.toBeNull();
expect(container.querySelector('.editor-excerpt-panel')).toBeNull();
const excerptToggle = Array.from(container.querySelectorAll('.metadata-toggle')).find((node) =>
node.textContent?.includes('Excerpt')
);
expect(excerptToggle).not.toBeNull();
expect(excerptToggle?.classList.contains('expanded')).toBe(false);
await act(async () => {
fireEvent.click(excerptToggle!);
});
expect(container.querySelector('.editor-excerpt-panel')).not.toBeNull();
await act(async () => {
fireEvent.click(excerptToggle!);
});
expect(container.querySelector('.editor-excerpt-panel')).toBeNull();
});
});

View File

@@ -0,0 +1,253 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, act, fireEvent, screen } from '@testing-library/react';
let lastSuggestionFields: Array<{ key: string; label: string; currentValue: string; suggestedValue?: string; disabled?: boolean; warning?: string }> = [];
vi.mock('@monaco-editor/react', () => ({
default: () => <div data-testid="monaco-editor" />,
}));
vi.mock('@milkdown/kit/core', () => {
const makeChain = () => {
const chain = {
config: (callback: (ctx: { set: () => void; get: () => { markdownUpdated: () => void } }) => void) => {
callback({
set: () => {},
get: () => ({ markdownUpdated: () => {} }),
});
return chain;
},
use: () => chain,
};
return chain;
};
return {
Editor: { make: makeChain },
defaultValueCtx: Symbol('defaultValueCtx'),
editorViewCtx: Symbol('editorViewCtx'),
rootCtx: Symbol('rootCtx'),
remarkStringifyOptionsCtx: Symbol('remarkStringifyOptionsCtx'),
remarkPluginsCtx: Symbol('remarkPluginsCtx'),
};
});
vi.mock('@milkdown/kit/preset/commonmark', () => ({
commonmark: {},
toggleStrongCommand: { key: 'toggleStrong' },
toggleEmphasisCommand: { key: 'toggleEmphasis' },
wrapInBlockquoteCommand: { key: 'wrapInBlockquote' },
wrapInBulletListCommand: { key: 'wrapInBulletList' },
wrapInOrderedListCommand: { key: 'wrapInOrderedList' },
insertHrCommand: { key: 'insertHr' },
toggleInlineCodeCommand: { key: 'toggleInlineCode' },
insertImageCommand: { key: 'insertImage' },
toggleLinkCommand: { key: 'toggleLink' },
}));
vi.mock('@milkdown/kit/preset/gfm', () => ({
gfm: {},
toggleStrikethroughCommand: { key: 'toggleStrike' },
}));
vi.mock('@milkdown/kit/plugin/history', () => ({
history: {},
undoCommand: { key: 'undo' },
redoCommand: { key: 'redo' },
}));
vi.mock('@milkdown/kit/plugin/listener', () => ({
listener: {},
listenerCtx: Symbol('listenerCtx'),
}));
vi.mock('@milkdown/kit/plugin/clipboard', () => ({ clipboard: {} }));
vi.mock('@milkdown/kit/plugin/trailing', () => ({ trailing: {} }));
vi.mock('@milkdown/kit/plugin/indent', () => ({ indent: {} }));
vi.mock('@milkdown/kit/plugin/cursor', () => ({ cursor: {} }));
vi.mock('@milkdown/kit/utils', () => ({
$node: () => ({}),
$inputRule: () => ({}),
$remark: () => ({}),
$prose: () => ({}),
replaceAll: () => () => {},
callCommand: () => () => {},
}));
vi.mock('@milkdown/react', () => ({
Milkdown: () => <div data-testid="milkdown" />,
MilkdownProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useInstance: () => [false, () => ({ action: (action: unknown) => {
if (typeof action === 'function') {
action({ get: () => ({}) });
}
} })] as const,
useEditor: (factory: (root: Node) => unknown) => {
factory(document.createElement('div'));
},
}));
vi.mock('../../../src/renderer/components/Lightbox', () => ({
Lightbox: () => null,
useMarkdownImages: () => [],
}));
vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null }));
vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null }));
vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null }));
vi.mock('../../../src/renderer/components/ConfirmDeleteModal', () => ({ ConfirmDeleteModal: () => null }));
vi.mock('../../../src/renderer/components/SettingsView', () => ({ SettingsView: () => null }));
vi.mock('../../../src/renderer/components/TagsView', () => ({ TagsView: () => null }));
vi.mock('../../../src/renderer/components/TagInput', () => ({ TagInput: () => null }));
vi.mock('../../../src/renderer/components/ChatPanel', () => ({ ChatPanel: () => null }));
vi.mock('../../../src/renderer/components/ImportAnalysisView', () => ({ ImportAnalysisView: () => null }));
vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataDiffPanel: () => null }));
vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null }));
vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null }));
vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({
AISuggestionsModal: ({ isOpen, fields, onConfirm }: { isOpen: boolean; fields: typeof lastSuggestionFields; onConfirm: (values: Record<string, string>) => void }) => {
if (!isOpen) return null;
lastSuggestionFields = fields;
return (
<div data-testid="ai-suggestions-modal">
{fields.map((field) => (
<div key={field.key} data-testid={`field-${field.key}`} data-disabled={field.disabled ? 'true' : 'false'}>
{field.label}
</div>
))}
<button onClick={() => onConfirm({ title: 'Better Title', slug: 'better-title' })}>apply-suggestions</button>
</div>
);
},
}));
vi.mock('../../../src/renderer/components/Toast', () => ({
showToast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { PostEditor } from '../../../src/renderer/components/Editor/Editor';
import { useAppStore } from '../../../src/renderer/store';
const createPost = (overrides: Record<string, unknown> = {}) => ({
id: 'post-1',
title: 'Test Post',
content: 'Some content',
excerpt: '',
slug: 'test-post',
status: 'draft' as const,
tags: [],
categories: ['article'],
featuredImage: null,
publishedAt: null,
createdAt: new Date('2026-02-16T12:00:00.000Z'),
updatedAt: new Date('2026-02-16T12:00:00.000Z'),
author: undefined,
metadata: {},
seoTitle: undefined,
seoDescription: undefined,
canonicalUrl: undefined,
projectId: 'project-1',
filePath: 'posts/test-post.md',
...overrides,
});
describe('Editor AI post suggestions', () => {
beforeEach(() => {
vi.clearAllMocks();
lastSuggestionFields = [];
const neverSettles = new Promise<never>(() => {});
(window as any).electronAPI ??= {};
(window as any).electronAPI.posts ??= {};
(window as any).electronAPI.meta ??= {};
(window as any).electronAPI.chat ??= {};
(window as any).electronAPI.templates ??= {};
(window as any).addEventListener = vi.fn();
(window as any).removeEventListener = vi.fn();
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/preview');
(window as any).electronAPI.posts.update = vi.fn().mockImplementation(async (_postId: string, payload: Record<string, string>) => ({
...createPost(),
...payload,
}));
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
(window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockResolvedValue({ mainLanguage: 'en' });
(window as any).electronAPI.templates.getEnabledByKind = vi.fn().mockResolvedValue([]);
(window as any).electronAPI.chat.analyzePost = vi.fn().mockResolvedValue({
success: true,
title: 'Better Title',
excerpt: 'A concise summary.',
slug: 'better-title',
});
useAppStore.setState({
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
preferredEditorMode: 'wysiwyg',
posts: [],
media: [],
dirtyPosts: new Set<string>(),
isLoading: false,
});
});
it('passes a disabled slug suggestion for published posts', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({
status: 'published',
publishedAt: new Date('2026-02-16T12:00:00.000Z'),
}));
render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
});
const slugField = lastSuggestionFields.find((field) => field.key === 'slug');
expect(slugField).toBeDefined();
expect(slugField?.disabled).toBe(true);
});
it('submits the AI slug for a never-published draft when applying suggestions', async () => {
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost());
render(<PostEditor postId="post-1" />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'apply-suggestions' }));
});
expect((window as any).electronAPI.posts.update).toHaveBeenCalledWith(
'post-1',
expect.objectContaining({ title: 'Better Title', slug: 'better-title' })
);
});
});

View File

@@ -62,7 +62,7 @@ describe('pythonApiContractV1', () => {
it('exposes analyzeMediaImage and detectPostLanguage from chat namespace', () => {
const methodNames = listPythonApiMethodNames();
const chatMethods = methodNames.filter((m) => m.startsWith('chat.'));
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage']);
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage', 'chat.analyzePost']);
});
it('documents chat.analyzeMediaImage contract with mediaId and language params', () => {
@@ -79,7 +79,7 @@ describe('pythonApiContractV1', () => {
it('contains semantic version metadata for compatibility checks', () => {
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
version: '1.12.0',
version: '1.13.0',
generatedAt: expect.any(String),
});
});