Feature/post media translations (#42)
* chore: updated todo with translation ideas * feat: first take at the implementation of translations * fix: small addition for the translation feature * feat: support language switching in the editor and preview * feat: better handling of long bodies by not running them through a json envelope * fix: unknown macros have better fallback * feat: api for python to get translations * fix: strip dumb prefix of content in translation * feat: extend meta diff for translations * feat: hook up translations to rebuild-from-disk * feat: generation of the website prefers project language, falling back to canonical language * fix: crashes during rendering * feat: translation validation report * fix: made the translation validation actually work * chore: reorganization of menu * fix: some topics cleanup * chore: updated doc * feat: translations for media * feat: more aligned in UI/UX * feat: edit translations possible * chore: added full multi-language todo * chore: updated todo for clarity * feat: implementation of full multi-linguality * fix: page creation creates pages * fix: flags on every page * fix: better prompt * feat: made MCP server aware of language content * feat: python tools for translations * fix: better fill-in-translations * fix: better prompt for translation. maybe. * fix: losing posts from search due to translation process * fix: translation validation handles in-db content and fill-in of missing translations fixed to flush * fix: faster scanning for infilling of missing translations * chore: updated agent instructions * feat: calendar and tag cloud respect current language now * fix: retries going up * fix: got metadata-diff and rebuild into sync * fix: extended meta-diff for timestamps * fix: made website validation look at translated content, too * fix: multi-lingual search * chore: refactor Editor.tsx into two separate editors * feat: do language detection when no explicit language given --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, readdir, stat, mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
import type { PostData, PostTranslationData } from '../../src/main/engine/PostEngine';
|
||||
import { resolveUiLanguageFromSystemLocale } from '../../src/main/shared/i18n';
|
||||
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
|
||||
import { createPreviewBackedGenerationRouteRenderer } from '../../src/main/engine/GenerationRouteRendererFactory';
|
||||
|
||||
const generatedFileHashes = new Map<string, string>();
|
||||
const generatedFileUpdatedAt = new Map<string, number>();
|
||||
@@ -72,6 +73,8 @@ vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
|
||||
getPostsFiltered: vi.fn(async () => []),
|
||||
getPublishedVersion: vi.fn(async () => null),
|
||||
getPost: vi.fn(async () => null),
|
||||
getPostTranslation: vi.fn(async () => null),
|
||||
getPostTranslations: vi.fn(async () => []),
|
||||
setProjectContext: vi.fn(),
|
||||
};
|
||||
return {
|
||||
@@ -115,11 +118,13 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
content: overrides.content ?? '# Test\n\nBody text',
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt ?? createdAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
availableLanguages: overrides.availableLanguages ?? (overrides.language ? [overrides.language] : []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,6 +202,8 @@ describe('BlogGenerationEngine', () => {
|
||||
mockPostEngine.getPost.mockImplementation(async (id: string) => {
|
||||
return posts.find((p) => p.id === id) ?? null;
|
||||
});
|
||||
mockPostEngine.getPostTranslation.mockResolvedValue(null);
|
||||
mockPostEngine.getPostTranslations.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
async function generate(
|
||||
@@ -682,11 +689,122 @@ describe('BlogGenerationEngine', () => {
|
||||
const monthArchivePath = path.join(tempDir, 'html', '2020', '02', 'index.html');
|
||||
const monthHtml = await readFile(monthArchivePath, 'utf-8');
|
||||
|
||||
expect(monthHtml).toContain('<html lang="fr">');
|
||||
expect(monthHtml).toContain('<html lang="fr"');
|
||||
expect(monthHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
|
||||
expect(monthHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
|
||||
});
|
||||
|
||||
it('renders canonical single-post route with project main language content when available', async () => {
|
||||
const canonicalPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nCanonical body',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
|
||||
setupPosts([canonicalPost]);
|
||||
mockPostEngine.getPostTranslation.mockImplementation(async (postId: string, language: string) => {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'fr',
|
||||
}, vi.fn());
|
||||
|
||||
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
|
||||
|
||||
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('post-1', 'fr');
|
||||
expect(canonicalHtml).toContain('<html lang="fr"');
|
||||
expect(canonicalHtml).toContain('Bonjour le monde');
|
||||
expect(canonicalHtml).toContain('Corps FR');
|
||||
expect(canonicalHtml).not.toContain('Canonical body');
|
||||
});
|
||||
|
||||
it('preview-backed generation route renderer prefers project main language content on canonical single-post routes', async () => {
|
||||
const canonicalPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nCanonical body',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
|
||||
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
options: {
|
||||
projectId: 'test',
|
||||
dataDir: tempDir,
|
||||
projectName: 'Test Blog',
|
||||
language: 'fr',
|
||||
},
|
||||
maxPostsPerPage: 50,
|
||||
publishedPostsForLookup: [canonicalPost],
|
||||
engines: {
|
||||
postEngine: {
|
||||
getPostsFiltered: mockPostEngine.getPostsFiltered,
|
||||
getPublishedVersion: mockPostEngine.getPublishedVersion,
|
||||
getPost: mockPostEngine.getPost,
|
||||
getPostTranslation: vi.fn(async (postId: string, language: string) => {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
} satisfies PostTranslationData;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
hasPublishedVersion: mockPostEngine.hasPublishedVersion,
|
||||
setProjectContext: mockPostEngine.setProjectContext,
|
||||
},
|
||||
mediaEngine: mockMediaEngine,
|
||||
postMediaEngine: mockPostMediaEngine,
|
||||
},
|
||||
});
|
||||
|
||||
const html = await renderRoute('/2025/01/15/hello-world');
|
||||
|
||||
expect(html).not.toBeNull();
|
||||
expect(html).toContain('<html lang="fr"');
|
||||
expect(html).toContain('Bonjour le monde');
|
||||
expect(html).toContain('Corps FR');
|
||||
expect(html).not.toContain('Canonical body');
|
||||
});
|
||||
|
||||
it('excludes draft-only posts from generated pages', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'published', title: 'Published', status: 'published' }),
|
||||
@@ -1221,6 +1339,121 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
||||
});
|
||||
|
||||
it('generates published translation pages with alternate links and sitemap entries', async () => {
|
||||
const sourcePost = makePost({
|
||||
id: '1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nEnglish body',
|
||||
language: 'en',
|
||||
availableLanguages: ['en', 'fr'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
const translationsByPostId = new Map<string, PostTranslationData[]>([
|
||||
['1', [{
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: '1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
}]],
|
||||
]);
|
||||
|
||||
setupPosts([sourcePost]);
|
||||
mockPostEngine.getPostTranslations.mockImplementation(async (postId: string) => translationsByPostId.get(postId) ?? []);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn());
|
||||
|
||||
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
|
||||
const translationHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'), 'utf-8');
|
||||
const sitemap = await readFile(path.join(tempDir, 'html', 'sitemap.xml'), 'utf-8');
|
||||
|
||||
expect(canonicalHtml).toContain('hreflang="fr"');
|
||||
expect(canonicalHtml).toContain('href="/2025/01/15/hello-world.fr"');
|
||||
expect(translationHtml).toContain('<html lang="fr"');
|
||||
expect(translationHtml).toContain('Bonjour le monde');
|
||||
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world.fr/</loc>');
|
||||
});
|
||||
|
||||
it('preserves post engine method binding when loading published translations', async () => {
|
||||
const sourcePost = makePost({
|
||||
id: '1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nEnglish body',
|
||||
language: 'en',
|
||||
availableLanguages: ['en', 'fr'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
const translationsByPostId = new Map<string, PostTranslationData[]>([
|
||||
['1', [{
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: '1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
}]],
|
||||
]);
|
||||
|
||||
const postEngine = {
|
||||
translationsByPostId,
|
||||
setProjectContext: vi.fn(),
|
||||
async getPostsFiltered(filter: { status?: string }) {
|
||||
return filter.status === 'published' ? [sourcePost] : [];
|
||||
},
|
||||
async getPublishedVersion() {
|
||||
return null;
|
||||
},
|
||||
async getPost(postId: string) {
|
||||
return postId === sourcePost.id ? sourcePost : null;
|
||||
},
|
||||
async getPostTranslations(postId: string) {
|
||||
return this.translationsByPostId.get(postId) ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(postEngine as any, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await expect(engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn())).resolves.toMatchObject({
|
||||
postCount: 1,
|
||||
});
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('applies validation by generating only missing category and tag routes', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'ordered-post', categories: ['news'], tags: ['ordered-tag'], createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||
@@ -1617,6 +1850,49 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'hello-world', 'index.html'))).toBe(false);
|
||||
});
|
||||
|
||||
it('generates translated static page routes for published page translations', async () => {
|
||||
const pagePost = makePost({
|
||||
id: 'page-1',
|
||||
slug: 'tag-cloud',
|
||||
title: 'Tag Cloud',
|
||||
categories: ['page'],
|
||||
language: 'en',
|
||||
availableLanguages: ['en', 'de'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
|
||||
setupPosts([pagePost]);
|
||||
mockPostEngine.getPostTranslations.mockResolvedValue([{
|
||||
id: 'translation-page-1-de',
|
||||
projectId: 'default',
|
||||
translationFor: 'page-1',
|
||||
language: 'de',
|
||||
title: 'Schlagwortwolke',
|
||||
excerpt: 'Zusammenfassung DE',
|
||||
content: '# Schlagwortwolke\n\nInhalt DE',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'tag-cloud.de.md'),
|
||||
}]);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn());
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud.de', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('generates canonical post routes only and does not generate aliases', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'alias-test', createdAt: new Date('2025-03-15T10:00:00Z') }),
|
||||
@@ -1705,6 +1981,167 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'media'))).toBe(false);
|
||||
});
|
||||
|
||||
it('validateSite reports missing language subtree pages and does not flag them as extra', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'lang-post',
|
||||
title: 'Language Post',
|
||||
categories: ['news'],
|
||||
tags: ['lang-tag'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
// Generate only main language pages
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn());
|
||||
|
||||
// Validate with blogLanguages including fr - should report missing fr pages
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.missingUrlPaths).toContain('/fr');
|
||||
expect(report.missingUrlPaths).toContain('/fr/2025/01/15/lang-post');
|
||||
expect(report.missingUrlPaths).toContain('/fr/category/news');
|
||||
expect(report.missingUrlPaths).toContain('/fr/tag/lang-tag');
|
||||
expect(report.extraUrlPaths).not.toContain('/fr');
|
||||
});
|
||||
|
||||
it('validateSite reports no missing language pages after full multi-language generation', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'multilang-post',
|
||||
title: 'Multi Lang Post',
|
||||
categories: ['news'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.missingUrlPaths).toEqual([]);
|
||||
expect(report.extraUrlPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('applyValidation renders missing language subtree pages', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'apply-lang-post',
|
||||
title: 'Apply Lang Post',
|
||||
categories: ['news'],
|
||||
tags: ['apply-lang-tag'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
const result = await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, {
|
||||
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||
sitemapChanged: false,
|
||||
missingUrlPaths: ['/fr/category/news', '/fr/tag/apply-lang-tag'],
|
||||
extraUrlPaths: [],
|
||||
updatedPostUrlPaths: [],
|
||||
expectedUrlCount: 2,
|
||||
existingHtmlUrlCount: 0,
|
||||
}, vi.fn());
|
||||
|
||||
expect(result.renderedUrlCount).toBeGreaterThan(0);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'category', 'news', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'tag', 'apply-lang-tag', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('validateSite excludes doNotTranslate posts from language subtree expected urls', async () => {
|
||||
const translatablePost = makePost({
|
||||
id: '1',
|
||||
slug: 'translatable',
|
||||
title: 'Translatable',
|
||||
categories: ['news'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
const dntPost = makePost({
|
||||
id: '2',
|
||||
slug: 'no-translate',
|
||||
title: 'Do Not Translate',
|
||||
categories: ['news'],
|
||||
createdAt: new Date('2025-01-16T10:00:00Z'),
|
||||
doNotTranslate: true,
|
||||
} as any);
|
||||
setupPosts([translatablePost, dntPost]);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.missingUrlPaths).toEqual([]);
|
||||
// The dnt post's single page should NOT be expected in /fr/ subtree
|
||||
expect(report.extraUrlPaths).not.toContain('/fr/2025/01/16/no-translate');
|
||||
});
|
||||
|
||||
it('generates zero pages when there are no published posts', async () => {
|
||||
const result = await generate([]);
|
||||
expect(result.pagesGenerated).toBe(0);
|
||||
|
||||
@@ -27,6 +27,8 @@ function createMockPostEngine() {
|
||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
|
||||
getPostTranslation: vi.fn().mockResolvedValue(null),
|
||||
getPostTranslations: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1198,6 +1200,81 @@ describe('MCPServer', () => {
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('read_post_by_slug with language returns translation content', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
|
||||
const translation = { id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English content', excerpt: 'English excerpt', status: 'published', createdAt: new Date(), updatedAt: new Date() };
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getPostTranslation.mockResolvedValue(translation);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'hallo-welt', language: 'en' }, {}) as { content: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.post.title).toBe('Hello World');
|
||||
expect(parsed.post.content).toBe('English content');
|
||||
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
|
||||
});
|
||||
|
||||
it('read_post_by_slug with canonical language returns original post', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'hallo-welt', language: 'de' }, {}) as { content: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.post.title).toBe('Hallo Welt');
|
||||
expect(parsed.post.content).toBe('Deutscher Inhalt');
|
||||
expect(mockPostEngine.getPostTranslation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('read_post_by_slug returns error when translation not found', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutsch', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de'], createdAt: new Date(), updatedAt: new Date() };
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getPostTranslation.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'hallo-welt', language: 'fr' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('fr');
|
||||
});
|
||||
|
||||
// ── get_post_translations tool ──────────────────────────────────
|
||||
|
||||
it('registers get_post_translations tool', () => {
|
||||
const mcpServer = server.createMcpServer();
|
||||
expect(hasRegistered(mcpServer, '_registeredTools', 'get_post_translations')).toBe(true);
|
||||
});
|
||||
|
||||
it('get_post_translations returns all translations for a post', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', language: 'de', status: 'published', tags: [], categories: [], createdAt: new Date(), updatedAt: new Date() };
|
||||
const translations = [
|
||||
{ id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English', status: 'published', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 't2', translationFor: 'p1', language: 'fr', title: 'Bonjour le Monde', content: 'French', status: 'published', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getPostTranslations.mockResolvedValue(translations);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'get_post_translations');
|
||||
const result = await tool.handler({ slug: 'hallo-welt' }, {}) as { content: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.translations).toHaveLength(2);
|
||||
expect(parsed.translations[0].language).toBe('en');
|
||||
expect(parsed.translations[1].language).toBe('fr');
|
||||
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('get_post_translations returns error for nonexistent slug', async () => {
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'get_post_translations');
|
||||
const result = await tool.handler({ slug: 'no-such-slug' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt handler behavior ────────────────────────────────────────
|
||||
|
||||
491
tests/engine/MediaTranslationSystem.test.ts
Normal file
491
tests/engine/MediaTranslationSystem.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaEngine } from '../../src/main/engine/MediaEngine';
|
||||
import { media, mediaTranslations } from '../../src/main/database/schema';
|
||||
|
||||
const mockMedia = new Map<string, any>();
|
||||
const mockTranslations = new Map<string, any>();
|
||||
const mockFiles = new Map<string, string>();
|
||||
|
||||
function resetData(): void {
|
||||
mockMedia.clear();
|
||||
mockTranslations.clear();
|
||||
mockFiles.clear();
|
||||
}
|
||||
|
||||
function getTableRows(table: unknown): any[] {
|
||||
if (table === media) {
|
||||
return Array.from(mockMedia.values());
|
||||
}
|
||||
if (table === mediaTranslations) {
|
||||
return Array.from(mockTranslations.values());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractEqValue(predicate: unknown): string | undefined {
|
||||
const chunks = (predicate as any)?.queryChunks;
|
||||
if (!Array.isArray(chunks)) return undefined;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk?.value !== undefined && typeof chunk.value === 'string') {
|
||||
return chunk.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createSelectChain() {
|
||||
let selectedTable: unknown;
|
||||
let filterValue: string | undefined;
|
||||
|
||||
return {
|
||||
from: vi.fn().mockImplementation(function from(table: unknown) {
|
||||
selectedTable = table;
|
||||
return this;
|
||||
}),
|
||||
where: vi.fn().mockImplementation(function where(predicate: unknown) {
|
||||
filterValue = extractEqValue(predicate);
|
||||
return this;
|
||||
}),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockImplementation(async () => {
|
||||
const rows = getTableRows(selectedTable);
|
||||
if (filterValue) {
|
||||
return rows.filter((row) =>
|
||||
row.id === filterValue ||
|
||||
row.translationFor === filterValue ||
|
||||
row.projectId === filterValue
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}),
|
||||
get: vi.fn().mockImplementation(async () => {
|
||||
const rows = getTableRows(selectedTable);
|
||||
if (filterValue) {
|
||||
return rows.find((row) =>
|
||||
row.id === filterValue ||
|
||||
row.translationFor === filterValue ||
|
||||
row.projectId === filterValue
|
||||
);
|
||||
}
|
||||
return rows[0];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createInsertChain(table: unknown) {
|
||||
return {
|
||||
values: vi.fn(async (value: any) => {
|
||||
const rows = Array.isArray(value) ? value : [value];
|
||||
for (const row of rows) {
|
||||
if (table === media) {
|
||||
mockMedia.set(row.id, row);
|
||||
} else if (table === mediaTranslations) {
|
||||
mockTranslations.set(row.id, row);
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createUpdateChain(table: unknown) {
|
||||
return {
|
||||
set: vi.fn().mockImplementation((value: Record<string, unknown>) => ({
|
||||
where: vi.fn(async (predicate: unknown) => {
|
||||
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
|
||||
if (!targetMap || targetMap.size === 0) return;
|
||||
const targetId = extractEqValue(predicate);
|
||||
if (targetId && targetMap.has(targetId)) {
|
||||
const existing = targetMap.get(targetId);
|
||||
targetMap.set(targetId, { ...existing, ...value });
|
||||
}
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeleteChain(table: unknown) {
|
||||
return {
|
||||
where: vi.fn(async (predicate: unknown) => {
|
||||
const targetId = extractEqValue(predicate);
|
||||
if (targetId) {
|
||||
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
|
||||
if (targetMap) {
|
||||
// Try direct key match first
|
||||
if (targetMap.has(targetId)) {
|
||||
targetMap.delete(targetId);
|
||||
} else {
|
||||
// Filter by translationFor (cascade delete pattern)
|
||||
for (const [key, row] of targetMap.entries()) {
|
||||
if ((row as any).translationFor === targetId || (row as any).mediaId === targetId) {
|
||||
targetMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const mockLocalDb = {
|
||||
select: vi.fn(() => createSelectChain()),
|
||||
insert: vi.fn((table: unknown) => createInsertChain(table)),
|
||||
update: vi.fn((table: unknown) => createUpdateChain(table)),
|
||||
delete: vi.fn((table: unknown) => createDeleteChain(table)),
|
||||
};
|
||||
|
||||
const mockLocalClient = {
|
||||
execute: vi.fn(async () => ({ rows: [] })),
|
||||
};
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
getLocal: vi.fn(() => mockLocalDb),
|
||||
getLocalClient: vi.fn(() => mockLocalClient),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
access: vi.fn(async (filePath: string) => {
|
||||
if (!mockFiles.has(filePath)) {
|
||||
const error = new Error('ENOENT');
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
const content = mockFiles.get(filePath);
|
||||
if (content == null) {
|
||||
const error = new Error('ENOENT');
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return content;
|
||||
}),
|
||||
readdir: vi.fn(async () => []),
|
||||
rename: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
writeFile: vi.fn(async (filePath: string, content: string) => {
|
||||
mockFiles.set(filePath, content);
|
||||
}),
|
||||
copyFile: vi.fn(async () => {}),
|
||||
stat: vi.fn(async () => ({ size: 1024 })),
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => {
|
||||
let counter = 1;
|
||||
return {
|
||||
v4: vi.fn(() => `uuid-${counter++}`),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/electron-test'),
|
||||
},
|
||||
}));
|
||||
|
||||
function seedMediaItem(overrides: Partial<any> = {}): any {
|
||||
const id = overrides.id || 'media-1';
|
||||
const item = {
|
||||
id,
|
||||
projectId: 'project-1',
|
||||
filename: `${id}.jpg`,
|
||||
originalName: 'photo.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
title: 'A photo',
|
||||
alt: 'Alt text',
|
||||
caption: 'Photo caption',
|
||||
author: 'Author',
|
||||
language: null,
|
||||
filePath: `/tmp/project-1/media/2024/01/${id}.jpg`,
|
||||
sidecarPath: `/tmp/project-1/media/2024/01/${id}.jpg.meta`,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
checksum: 'abc123',
|
||||
tags: '[]',
|
||||
...overrides,
|
||||
};
|
||||
mockMedia.set(id, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
describe('Media translation system', () => {
|
||||
let engine: MediaEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetData();
|
||||
engine = new MediaEngine();
|
||||
engine.setProjectContext('project-1', '/tmp/project-1');
|
||||
});
|
||||
|
||||
describe('getMediaTranslation', () => {
|
||||
it('returns null when no translation exists', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.getMediaTranslation('media-1', 'fr');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a translation when one exists', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-1', {
|
||||
id: 'trans-1',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.getMediaTranslation('media-1', 'fr');
|
||||
expect(result).toMatchObject({
|
||||
id: 'trans-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaTranslations', () => {
|
||||
it('returns all translations for a media item', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
mockTranslations.set('trans-de', {
|
||||
id: 'trans-de',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Ein Foto',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.getMediaTranslations('media-1');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(t => t.language).sort()).toEqual(['de', 'fr']);
|
||||
});
|
||||
|
||||
it('returns empty array when no translations exist', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.getMediaTranslations('media-1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertMediaTranslation', () => {
|
||||
it('creates a new translation', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
|
||||
const result = await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
expect(result.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates an existing translation instead of creating duplicates', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
|
||||
const first = await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Titre v1',
|
||||
});
|
||||
const second = await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Titre v2',
|
||||
alt: 'Alt v2',
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
const translations = await engine.getMediaTranslations('media-1');
|
||||
expect(translations).toHaveLength(1);
|
||||
expect(translations[0].title).toBe('Titre v2');
|
||||
});
|
||||
|
||||
it('rejects translations whose language matches the canonical media language', async () => {
|
||||
seedMediaItem({ language: 'de' });
|
||||
|
||||
await expect(
|
||||
engine.upsertMediaTranslation('media-1', 'DE', {
|
||||
title: 'Titel',
|
||||
})
|
||||
).rejects.toThrow('Translation language must differ from canonical media language');
|
||||
});
|
||||
|
||||
it('rejects translations for non-existent media', async () => {
|
||||
await expect(
|
||||
engine.upsertMediaTranslation('nonexistent', 'fr', { title: 'Test' })
|
||||
).rejects.toThrow('Media item not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMediaTranslation', () => {
|
||||
it('deletes an existing translation', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.deleteMediaTranslation('media-1', 'fr');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when translation does not exist', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.deleteMediaTranslation('media-1', 'fr');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availableLanguages on media', () => {
|
||||
it('includes canonical language and translation languages', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.getMedia('media-1');
|
||||
expect(result?.availableLanguages).toEqual(['en', 'fr']);
|
||||
});
|
||||
|
||||
it('returns empty array when no language is set and no translations exist', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.getMedia('media-1');
|
||||
expect(result?.availableLanguages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translated sidecar I/O', () => {
|
||||
it('writes a translated sidecar file with language suffix', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
|
||||
// Verify a sidecar file was written at the .fr.meta path
|
||||
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.fr.meta';
|
||||
expect(mockFiles.has(sidecarPath)).toBe(true);
|
||||
const content = mockFiles.get(sidecarPath)!;
|
||||
expect(content).toContain('language: fr');
|
||||
expect(content).toContain('title: "Une photo"');
|
||||
expect(content).toContain('alt: "Texte alt"');
|
||||
});
|
||||
|
||||
it('reads a translated sidecar file', async () => {
|
||||
const sidecarContent = [
|
||||
'---',
|
||||
'translationFor: media-1',
|
||||
'language: de',
|
||||
'title: "Ein Foto"',
|
||||
'alt: "Alt-Text"',
|
||||
'caption: "Bildunterschrift"',
|
||||
'---',
|
||||
].join('\n');
|
||||
mockFiles.set('/tmp/project-1/media/2024/01/media-1.jpg.de.meta', sidecarContent);
|
||||
|
||||
const result = await engine.readTranslatedSidecarFile(
|
||||
'/tmp/project-1/media/2024/01/media-1.jpg.de.meta'
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Ein Foto',
|
||||
alt: 'Alt-Text',
|
||||
caption: 'Bildunterschrift',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-existent sidecar', async () => {
|
||||
const result = await engine.readTranslatedSidecarFile(
|
||||
'/tmp/nonexistent.fr.meta'
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical sidecar includes language', () => {
|
||||
it('includes language field in sidecar when set on media', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
// Trigger a sidecar write via updateMedia
|
||||
await engine.updateMedia('media-1', { language: 'en' });
|
||||
|
||||
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.meta';
|
||||
if (mockFiles.has(sidecarPath)) {
|
||||
const content = mockFiles.get(sidecarPath)!;
|
||||
expect(content).toContain('language: en');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMedia cascades to translations', () => {
|
||||
it('deletes all translations when media is deleted', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
await engine.deleteMedia('media-1');
|
||||
|
||||
// Translations should be cleaned up
|
||||
expect(mockTranslations.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -123,12 +123,19 @@ vi.mock('gray-matter', () => ({
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
let value = line.slice(colonIndex + 1).trim();
|
||||
let value: any = line.slice(colonIndex + 1).trim();
|
||||
|
||||
// Parse arrays
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = JSON.parse(value.replace(/'/g, '"'));
|
||||
}
|
||||
// Parse booleans
|
||||
else if (value === 'true') {
|
||||
value = true;
|
||||
}
|
||||
else if (value === 'false') {
|
||||
value = false;
|
||||
}
|
||||
// Parse strings
|
||||
else if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
@@ -160,13 +167,17 @@ vi.mock('../../src/main/engine/TaskManager', () => ({
|
||||
|
||||
// Track the mock function for PostEngine.syncPublishedPostFile
|
||||
const mockSyncPublishedPostFile = vi.fn(async () => true);
|
||||
const mockSyncPublishedPostTranslationFile = vi.fn(async () => true);
|
||||
const mockImportOrphanFile = vi.fn(async () => ({ id: 'imported-id', title: 'Imported' }));
|
||||
const mockImportOrphanTranslationFile = vi.fn(async () => ({ id: 'imported-translation-id', title: 'Imported translation' }));
|
||||
|
||||
// Mock PostEngine
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({
|
||||
syncPublishedPostFile: mockSyncPublishedPostFile,
|
||||
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
|
||||
importOrphanFile: mockImportOrphanFile,
|
||||
importOrphanTranslationFile: mockImportOrphanTranslationFile,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -179,10 +190,23 @@ describe('MetadataDiffEngine', () => {
|
||||
mockPostsGetQueue = [];
|
||||
mockFileData.clear();
|
||||
mockAllPostsRows = [];
|
||||
mockSyncPublishedPostFile.mockClear();
|
||||
mockImportOrphanFile.mockClear();
|
||||
mockLocalDb.select.mockImplementation(() => createSelectChain(mockAllPostsRows));
|
||||
mockLocalDb.update.mockImplementation(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
}) as any);
|
||||
mockSyncPublishedPostFile.mockReset().mockResolvedValue(true);
|
||||
mockSyncPublishedPostTranslationFile.mockReset().mockResolvedValue(true);
|
||||
mockImportOrphanFile.mockReset().mockResolvedValue({ id: 'imported-id', title: 'Imported' });
|
||||
mockImportOrphanTranslationFile.mockReset().mockResolvedValue({ id: 'imported-translation-id', title: 'Imported translation' });
|
||||
resetMockCounters();
|
||||
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile, importOrphanFile: mockImportOrphanFile } as any);
|
||||
engine = new MetadataDiffEngine({
|
||||
syncPublishedPostFile: mockSyncPublishedPostFile,
|
||||
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
|
||||
importOrphanFile: mockImportOrphanFile,
|
||||
importOrphanTranslationFile: mockImportOrphanTranslationFile,
|
||||
} as any);
|
||||
engine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
@@ -498,38 +522,48 @@ Content here`);
|
||||
|
||||
describe('scanAllPublishedPosts', () => {
|
||||
it('should scan all published posts and return differences', async () => {
|
||||
// Mock the raw SQL query that returns published posts
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: 'post-1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
file_path: '/mock/userData/posts/2024/01/post-1.md',
|
||||
tags: '["new-tag"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
title: 'Post 2',
|
||||
slug: 'post-2',
|
||||
file_path: '/mock/userData/posts/2024/01/post-2.md',
|
||||
tags: '["tag1"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock the second query that gets ALL post file paths (for orphan detection)
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
|
||||
],
|
||||
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
|
||||
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 'post-1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
file_path: '/mock/userData/posts/2024/01/post-1.md',
|
||||
tags: '["new-tag"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
title: 'Post 2',
|
||||
slug: 'post-2',
|
||||
file_path: '/mock/userData/posts/2024/01/post-2.md',
|
||||
tags: '["tag1"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM posts')) {
|
||||
return {
|
||||
rows: [
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM post_translations')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
// Queue the posts for sequential .get() calls in comparePostMetadata
|
||||
@@ -545,6 +579,7 @@ Content here`);
|
||||
categories: '["cat1"]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
@@ -557,6 +592,7 @@ Content here`);
|
||||
categories: '["cat1"]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -734,6 +770,93 @@ Content`);
|
||||
// Draft post file should NOT be flagged as orphan since it's in the DB
|
||||
expect(result.orphanFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include published translation metadata differences in the scan', async () => {
|
||||
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
|
||||
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 'translation-1',
|
||||
translation_for: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Hallo Welt',
|
||||
excerpt: 'DB excerpt',
|
||||
file_path: '/mock/posts/2024/01/post-1.de.md',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM posts')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM post_translations')) {
|
||||
return { rows: [{ file_path: '/mock/posts/2024/01/post-1.de.md' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
let selectCall = 0;
|
||||
mockLocalDb.select.mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
selectCall += 1;
|
||||
if (selectCall === 1) return Promise.resolve(undefined);
|
||||
if (selectCall === 2) {
|
||||
return Promise.resolve({
|
||||
id: 'translation-1',
|
||||
projectId: 'test-project',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Hallo Welt',
|
||||
excerpt: 'DB excerpt',
|
||||
content: null,
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
filePath: '/mock/posts/2024/01/post-1.de.md',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Hello World',
|
||||
slug: 'post-1',
|
||||
status: 'published',
|
||||
filePath: '/mock/posts/2024/01/post-1.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
});
|
||||
}),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockFileData.set('/mock/posts/2024/01/post-1.de.md', `---
|
||||
translationFor: post-1
|
||||
language: de
|
||||
title: "Hallo Welt"
|
||||
excerpt: "File excerpt"
|
||||
---
|
||||
Translated content`);
|
||||
|
||||
const result = await engine.scanAllPublishedPosts((current, total) => {}, '/mock/posts');
|
||||
|
||||
expect(result.totalScanned).toBe(1);
|
||||
expect(result.postsWithDifferences).toBe(1);
|
||||
expect(result.differences).toHaveLength(1);
|
||||
expect(result.differences[0].postId).toBe('translation-1');
|
||||
expect(result.differences[0].differences.excerpt).toEqual({ dbValue: 'DB excerpt', fileValue: 'File excerpt' });
|
||||
expect(result.orphanFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importOrphanFiles', () => {
|
||||
@@ -778,6 +901,22 @@ Content`);
|
||||
expect(progressCalls[0][0]).toBe(5);
|
||||
expect(progressCalls[0][1]).toBe(5);
|
||||
});
|
||||
|
||||
it('should import orphan translation files into the translations table path', async () => {
|
||||
mockFileData.set('/posts/2024/01/post.de.md', `---
|
||||
translationFor: post-1
|
||||
language: de
|
||||
title: "Hallo Welt"
|
||||
excerpt: "Translated excerpt"
|
||||
---
|
||||
Translated content`);
|
||||
|
||||
const result = await engine.importOrphanFiles(['/posts/2024/01/post.de.md']);
|
||||
|
||||
expect(result).toEqual({ success: 1, failed: 0 });
|
||||
expect(mockImportOrphanTranslationFile).toHaveBeenCalledWith('/posts/2024/01/post.de.md');
|
||||
expect(mockImportOrphanFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupDifferencesByField', () => {
|
||||
@@ -856,12 +995,26 @@ Content`);
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockRejectedValueOnce(new Error('sync failure'));
|
||||
mockSyncPublishedPostTranslationFile
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await engine.syncDbToFile(['post-1', 'post-2', 'post-3']);
|
||||
|
||||
expect(result).toEqual({ success: 1, failed: 2 });
|
||||
expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should fall back to syncing published translation files when the post file sync does not apply', async () => {
|
||||
mockSyncPublishedPostFile.mockResolvedValueOnce(false);
|
||||
mockSyncPublishedPostTranslationFile.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await engine.syncDbToFile(['translation-1']);
|
||||
|
||||
expect(result).toEqual({ success: 1, failed: 0 });
|
||||
expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('translation-1');
|
||||
expect(mockSyncPublishedPostTranslationFile).toHaveBeenCalledWith('translation-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFileToDb', () => {
|
||||
@@ -1019,6 +1172,49 @@ Content here`);
|
||||
expect(result).toEqual({ success: 1, failed: 2 });
|
||||
expect(mockLocalDb.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should sync published translation metadata from file to database', async () => {
|
||||
let selectCall = 0;
|
||||
mockLocalDb.select.mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
selectCall += 1;
|
||||
if (selectCall === 1) return Promise.resolve(undefined);
|
||||
return Promise.resolve({
|
||||
id: 'translation-1',
|
||||
projectId: 'test-project',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Hallo Welt',
|
||||
excerpt: 'DB excerpt',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/post-1.de.md',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
});
|
||||
}),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/post-1.de.md', `---
|
||||
translationFor: post-1
|
||||
language: de
|
||||
title: "Hallo Datei"
|
||||
excerpt: "File excerpt"
|
||||
---
|
||||
Translated content`);
|
||||
|
||||
await engine.syncFileToDb(['translation-1'], 'title');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.title).toBe('Hallo Datei');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Media diff tests ──
|
||||
@@ -1383,4 +1579,595 @@ Content here`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparePostMetadata – new fields', () => {
|
||||
it('should detect doNotTranslate differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-dnt',
|
||||
projectId: 'test-project',
|
||||
title: 'DNT Post',
|
||||
slug: 'dnt-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/dnt-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
doNotTranslate: true,
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-dnt', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/dnt-post.md', `---
|
||||
id: post-dnt
|
||||
projectId: test-project
|
||||
title: "DNT Post"
|
||||
slug: dnt-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-dnt');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.doNotTranslate).toBeDefined();
|
||||
expect(result?.differences.doNotTranslate?.dbValue).toBe(true);
|
||||
expect(result?.differences.doNotTranslate?.fileValue).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect templateSlug differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-tpl',
|
||||
projectId: 'test-project',
|
||||
title: 'Template Post',
|
||||
slug: 'template-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/template-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
templateSlug: 'blog-default',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-tpl', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/template-post.md', `---
|
||||
id: post-tpl
|
||||
projectId: test-project
|
||||
title: "Template Post"
|
||||
slug: template-post
|
||||
status: published
|
||||
templateSlug: old-template
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-tpl');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.templateSlug).toBeDefined();
|
||||
expect(result?.differences.templateSlug?.dbValue).toBe('blog-default');
|
||||
expect(result?.differences.templateSlug?.fileValue).toBe('old-template');
|
||||
});
|
||||
|
||||
it('should detect status differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-status',
|
||||
projectId: 'test-project',
|
||||
title: 'Archived Post',
|
||||
slug: 'archived-post',
|
||||
status: 'archived',
|
||||
filePath: '/mock/userData/posts/2024/01/archived-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-status', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/archived-post.md', `---
|
||||
id: post-status
|
||||
projectId: test-project
|
||||
title: "Archived Post"
|
||||
slug: archived-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-status');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.status).toBeDefined();
|
||||
expect(result?.differences.status?.dbValue).toBe('archived');
|
||||
expect(result?.differences.status?.fileValue).toBe('published');
|
||||
});
|
||||
|
||||
it('should show no differences when new fields match', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-match',
|
||||
projectId: 'test-project',
|
||||
title: 'Matching Post',
|
||||
slug: 'matching-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/matching-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
doNotTranslate: false,
|
||||
templateSlug: null,
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-match', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/matching-post.md', `---
|
||||
id: post-match
|
||||
projectId: test-project
|
||||
title: "Matching Post"
|
||||
slug: matching-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-match');
|
||||
expect(result?.hasDifferences).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFileToDb – new fields', () => {
|
||||
it('should sync doNotTranslate from file to database', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
doNotTranslate: true
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'doNotTranslate');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.doNotTranslate).toBe(true);
|
||||
});
|
||||
|
||||
it('should sync templateSlug from file to database', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
templateSlug: my-template
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'templateSlug');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.templateSlug).toBe('my-template');
|
||||
});
|
||||
|
||||
it('should sync status from file to database', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: archived
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'status');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.status).toBe('archived');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupDifferencesByField – new fields', () => {
|
||||
it('should include new fields in group labels', () => {
|
||||
const diffs: PostMetadataDiff[] = [
|
||||
{
|
||||
postId: 'p1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
hasDifferences: true,
|
||||
differences: {
|
||||
doNotTranslate: { dbValue: true, fileValue: false },
|
||||
templateSlug: { dbValue: 'tpl-a', fileValue: 'tpl-b' },
|
||||
status: { dbValue: 'published', fileValue: 'archived' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groups = engine.groupDifferencesByField(diffs);
|
||||
|
||||
const fieldNames = groups.map(g => g.field);
|
||||
expect(fieldNames).toContain('doNotTranslate');
|
||||
expect(fieldNames).toContain('templateSlug');
|
||||
expect(fieldNames).toContain('status');
|
||||
|
||||
const dntGroup = groups.find(g => g.field === 'doNotTranslate');
|
||||
expect(dntGroup?.label).toBe('Do Not Translate');
|
||||
const tplGroup = groups.find(g => g.field === 'templateSlug');
|
||||
expect(tplGroup?.label).toBe('Template');
|
||||
const statusGroup = groups.find(g => g.field === 'status');
|
||||
expect(statusGroup?.label).toBe('Status');
|
||||
});
|
||||
|
||||
it('should include timestamp fields in group labels', () => {
|
||||
const diffs: PostMetadataDiff[] = [
|
||||
{
|
||||
postId: 'p1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
hasDifferences: true,
|
||||
differences: {
|
||||
createdAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-02-15T00:00:00.000Z' },
|
||||
updatedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-03-01T00:00:00.000Z' },
|
||||
publishedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groups = engine.groupDifferencesByField(diffs);
|
||||
|
||||
const fieldNames = groups.map(g => g.field);
|
||||
expect(fieldNames).toContain('createdAt');
|
||||
expect(fieldNames).toContain('updatedAt');
|
||||
expect(fieldNames).toContain('publishedAt');
|
||||
|
||||
expect(groups.find(g => g.field === 'createdAt')?.label).toBe('Created At');
|
||||
expect(groups.find(g => g.field === 'updatedAt')?.label).toBe('Updated At');
|
||||
expect(groups.find(g => g.field === 'publishedAt')?.label).toBe('Published At');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparePostMetadata – timestamp diffs', () => {
|
||||
it('should detect createdAt differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-ts',
|
||||
projectId: 'test-project',
|
||||
title: 'Timestamp Post',
|
||||
slug: 'timestamp-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/timestamp-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
};
|
||||
mockPosts.set('post-ts', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/timestamp-post.md', `---
|
||||
id: post-ts
|
||||
projectId: test-project
|
||||
title: "Timestamp Post"
|
||||
slug: timestamp-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-06-01T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-ts');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.createdAt).toBeDefined();
|
||||
expect(result?.differences.createdAt?.fileValue).toContain('2024-06-01');
|
||||
});
|
||||
|
||||
it('should detect publishedAt differences when DB has it but file does not', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-pa',
|
||||
projectId: 'test-project',
|
||||
title: 'Published At Post',
|
||||
slug: 'published-at-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-at-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
};
|
||||
mockPosts.set('post-pa', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-at-post.md', `---
|
||||
id: post-pa
|
||||
projectId: test-project
|
||||
title: "Published At Post"
|
||||
slug: published-at-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-pa');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.publishedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show no timestamp differences when they match at second precision', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-eq',
|
||||
projectId: 'test-project',
|
||||
title: 'Equal Post',
|
||||
slug: 'equal-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/equal-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T12:30:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T12:30:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T12:30:00.000Z'),
|
||||
};
|
||||
mockPosts.set('post-eq', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/equal-post.md', `---
|
||||
id: post-eq
|
||||
projectId: test-project
|
||||
title: "Equal Post"
|
||||
slug: equal-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T12:30:00.000Z
|
||||
updatedAt: 2024-01-15T12:30:00.000Z
|
||||
publishedAt: 2024-01-15T12:30:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-eq');
|
||||
expect(result?.hasDifferences).toBe(false);
|
||||
expect(result?.differences.createdAt).toBeUndefined();
|
||||
expect(result?.differences.updatedAt).toBeUndefined();
|
||||
expect(result?.differences.publishedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFileToDb – timestamp fields', () => {
|
||||
it('should sync createdAt from file to database using file value', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-06-01T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'createdAt');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.createdAt).toBeInstanceOf(Date);
|
||||
expect(setCall.createdAt.toISOString()).toContain('2024-06-01');
|
||||
// Should NOT auto-set updatedAt when syncing a timestamp field
|
||||
expect(setCall.updatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should auto-set updatedAt when syncing a non-timestamp field', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
tags: ["new-tag"]
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'tags');
|
||||
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
// Should auto-set updatedAt for non-timestamp field syncs
|
||||
expect(setCall.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareMediaMetadata – language diff', () => {
|
||||
let mediaEngine: MetadataDiffEngine;
|
||||
const mockReadSidecarFile = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mediaEngine = new MetadataDiffEngine(
|
||||
undefined,
|
||||
{ readSidecarFile: mockReadSidecarFile, getMedia: vi.fn(), updateMedia: vi.fn() } as any,
|
||||
);
|
||||
mediaEngine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
it('should detect media language differences', async () => {
|
||||
mockPostsGetQueue = [{
|
||||
id: 'media-1',
|
||||
projectId: 'test-project',
|
||||
originalName: 'photo.jpg',
|
||||
filePath: '/mock/media/photo.jpg',
|
||||
title: 'Photo',
|
||||
alt: 'A photo',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: 'en',
|
||||
tags: '[]',
|
||||
}];
|
||||
|
||||
mockReadSidecarFile.mockResolvedValueOnce({
|
||||
id: 'media-1',
|
||||
originalName: 'photo.jpg',
|
||||
title: 'Photo',
|
||||
alt: 'A photo',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: 'fr',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const result = await mediaEngine.compareMediaMetadata('media-1');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.language).toBeDefined();
|
||||
expect(result?.differences.language?.dbValue).toBe('en');
|
||||
expect(result?.differences.language?.fileValue).toBe('fr');
|
||||
});
|
||||
|
||||
it('should not flag language when both are empty', async () => {
|
||||
mockPostsGetQueue = [{
|
||||
id: 'media-2',
|
||||
projectId: 'test-project',
|
||||
originalName: 'photo.jpg',
|
||||
filePath: '/mock/media/photo.jpg',
|
||||
title: 'Photo',
|
||||
alt: '',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: null,
|
||||
tags: '[]',
|
||||
}];
|
||||
|
||||
mockReadSidecarFile.mockResolvedValueOnce({
|
||||
title: 'Photo',
|
||||
alt: '',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: '',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const result = await mediaEngine.compareMediaMetadata('media-2');
|
||||
expect(result?.differences.language).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('replaceAllMacrosAsync', () => {
|
||||
expect(result).toContain('<aside class="custom-box">Custom Content</aside>');
|
||||
});
|
||||
|
||||
it('returns empty string for unknown macros without Python renderer', async () => {
|
||||
it('preserves unknown macros without Python renderer', async () => {
|
||||
const result = await replaceAllMacrosAsync(
|
||||
'Before [[unknown_macro]] After',
|
||||
'post-1',
|
||||
@@ -120,10 +120,10 @@ describe('replaceAllMacrosAsync', () => {
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before After');
|
||||
expect(result).toBe('Before [[unknown_macro]] After');
|
||||
});
|
||||
|
||||
it('returns empty string for unmatched Python macros', async () => {
|
||||
it('preserves unmatched Python macros', async () => {
|
||||
const mockRenderer: PythonMacroRendererContract = {
|
||||
getEnabledMacroScripts: vi.fn().mockResolvedValue([]),
|
||||
renderMacro: vi.fn(),
|
||||
@@ -139,7 +139,7 @@ describe('replaceAllMacrosAsync', () => {
|
||||
mockRenderer,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before After');
|
||||
expect(result).toBe('Before [[nonexistent_macro]] After');
|
||||
expect(mockRenderer.renderMacro).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -186,7 +186,21 @@ describe('replaceAllMacrosAsync', () => {
|
||||
mockRenderer,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before After');
|
||||
expect(result).toBe('Before [[my_macro]] After');
|
||||
});
|
||||
|
||||
it('preserves the original unknown macro tag including params', async () => {
|
||||
const result = await replaceAllMacrosAsync(
|
||||
'Before [[unknown_macro title="Hello" count="2"]] After',
|
||||
'post-1',
|
||||
[],
|
||||
null,
|
||||
[],
|
||||
'en',
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before [[unknown_macro title="Hello" count="2"]] After');
|
||||
});
|
||||
|
||||
it('does not look up Python scripts when all macros are built-in', async () => {
|
||||
@@ -244,6 +258,73 @@ describe('replaceAllMacrosAsync', () => {
|
||||
expect(call.cacheKey).toBe('ctx-script:2');
|
||||
});
|
||||
|
||||
it('passes languagePrefix and translations in Python macro context', async () => {
|
||||
const mockRenderer: PythonMacroRendererContract = {
|
||||
getEnabledMacroScripts: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'lang-script',
|
||||
slug: 'lang_test',
|
||||
entrypoint: 'render',
|
||||
content: 'def render(ctx, post): return {"html": "ok"}',
|
||||
version: 1,
|
||||
},
|
||||
] satisfies PythonMacroScript[]),
|
||||
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
|
||||
};
|
||||
|
||||
await replaceAllMacrosAsync(
|
||||
'[[lang_test]]',
|
||||
'post-1',
|
||||
[],
|
||||
null,
|
||||
[],
|
||||
'fr',
|
||||
mockRenderer,
|
||||
null,
|
||||
'/fr',
|
||||
);
|
||||
|
||||
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
const parsedContext = JSON.parse(call.contextJson);
|
||||
|
||||
expect(parsedContext.env.languagePrefix).toBe('/fr');
|
||||
expect(parsedContext.env.mainLanguage).toBe('fr');
|
||||
expect(parsedContext.env.translations).toBeDefined();
|
||||
expect(typeof parsedContext.env.translations).toBe('object');
|
||||
expect(parsedContext.env.translations['render.archive']).toBe('Archives');
|
||||
});
|
||||
|
||||
it('passes empty languagePrefix when not provided', async () => {
|
||||
const mockRenderer: PythonMacroRendererContract = {
|
||||
getEnabledMacroScripts: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'no-prefix-script',
|
||||
slug: 'no_prefix',
|
||||
entrypoint: 'render',
|
||||
content: 'def render(ctx, post): return {"html": "ok"}',
|
||||
version: 1,
|
||||
},
|
||||
] satisfies PythonMacroScript[]),
|
||||
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
|
||||
};
|
||||
|
||||
await replaceAllMacrosAsync(
|
||||
'[[no_prefix]]',
|
||||
'post-1',
|
||||
[],
|
||||
null,
|
||||
[],
|
||||
'en',
|
||||
mockRenderer,
|
||||
);
|
||||
|
||||
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
const parsedContext = JSON.parse(call.contextJson);
|
||||
|
||||
expect(parsedContext.env.languagePrefix).toBe('');
|
||||
expect(parsedContext.env.translations).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns unchanged text when there are no macros', async () => {
|
||||
const content = 'Just plain text with no macros';
|
||||
const result = await replaceAllMacrosAsync(content, '', [], null, [], 'en', null);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { PostEngine, PostData } from '../../src/main/engine/PostEngine';
|
||||
import { postTranslations } from '../../src/main/database/schema';
|
||||
import { resetMockCounters } from '../utils/factories';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
@@ -1838,6 +1839,71 @@ Content`);
|
||||
expect(insertedProjects).toHaveLength(1);
|
||||
expect(insertedProjects[0]).toBe('current-project-id');
|
||||
});
|
||||
|
||||
it('should rebuild published translation files into the translations table', async () => {
|
||||
const fs = await import('fs/promises');
|
||||
const insertedRows: Array<{ table: unknown; data: any }> = [];
|
||||
|
||||
vi.mocked(mockLocalDb.insert).mockImplementation((table: unknown) => ({
|
||||
values: vi.fn((data: any) => {
|
||||
insertedRows.push({ table, data });
|
||||
if (data && data.id) {
|
||||
mockPosts.set(data.id, data);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}) as any);
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||||
mockDirent('source-post.md'),
|
||||
mockDirent('source-post.de.md'),
|
||||
] as any);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
|
||||
if (filePath.includes('source-post.md') && !filePath.includes('source-post.de.md')) {
|
||||
return `---
|
||||
id: source-post-id
|
||||
projectId: default
|
||||
title: Source Post
|
||||
slug: source-post
|
||||
status: published
|
||||
language: en
|
||||
createdAt: 2024-01-01T00:00:00.000Z
|
||||
updatedAt: 2024-01-02T00:00:00.000Z
|
||||
publishedAt: 2024-01-02T00:00:00.000Z
|
||||
tags: []
|
||||
categories: []
|
||||
---
|
||||
Canonical content`;
|
||||
}
|
||||
|
||||
if (filePath.includes('source-post.de.md')) {
|
||||
return `---
|
||||
translationFor: source-post-id
|
||||
language: de
|
||||
title: Quellbeitrag
|
||||
excerpt: Deutsche Zusammenfassung
|
||||
---
|
||||
Deutscher Inhalt`;
|
||||
}
|
||||
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await postEngine.rebuildDatabaseFromFiles();
|
||||
|
||||
const translationInsert = insertedRows.find((row) => row.table === postTranslations);
|
||||
expect(translationInsert).toBeDefined();
|
||||
expect(translationInsert?.data).toMatchObject({
|
||||
projectId: 'default',
|
||||
translationFor: 'source-post-id',
|
||||
language: 'de',
|
||||
title: 'Quellbeitrag',
|
||||
excerpt: 'Deutsche Zusammenfassung',
|
||||
status: 'published',
|
||||
filePath: expect.stringContaining('source-post.de.md'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date-based folder structure', () => {
|
||||
@@ -3787,4 +3853,198 @@ Body.`);
|
||||
expect(result!.slug).toBe('existing-slug-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FTS translation indexing', () => {
|
||||
it('should include translation content in FTS index when updating a post', async () => {
|
||||
// Arrange: set up a post with a German translation
|
||||
postEngine.setMainLanguage('en');
|
||||
postEngine.setSearchLanguage('english');
|
||||
|
||||
// Mock getTranslationRowsForPost to return a translation
|
||||
const translationRow = {
|
||||
id: 'trans-1',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'German Title Häuser',
|
||||
excerpt: 'German Excerpt',
|
||||
content: 'German draft content Haus',
|
||||
status: 'draft',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
publishedAt: null,
|
||||
filePath: '',
|
||||
checksum: null,
|
||||
};
|
||||
|
||||
// getAllTranslationRows returns all translations for the current project
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.all = vi.fn().mockResolvedValue([translationRow]);
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
all: vi.fn().mockResolvedValue([translationRow]),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockExecuteArgs = [];
|
||||
|
||||
// Act
|
||||
await postEngine.updateFTSIndex({
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'English Title',
|
||||
content: 'English content about houses',
|
||||
excerpt: 'Summary',
|
||||
tags: ['test'],
|
||||
categories: ['blog'],
|
||||
});
|
||||
|
||||
// Assert: the FTS insert should contain both English and German stemmed content
|
||||
const ftsInsert = mockExecuteArgs.find((q: { sql: string }) =>
|
||||
q.sql.includes('INSERT INTO posts_fts'),
|
||||
);
|
||||
expect(ftsInsert).toBeDefined();
|
||||
const indexedContent = ftsInsert.args[2] as string;
|
||||
// English content should be stemmed with English stemmer
|
||||
expect(indexedContent).toContain('hous'); // "houses" stemmed in English
|
||||
// German content should be stemmed with German stemmer
|
||||
expect(indexedContent).toContain('haus'); // "Haus/Häuser" stemmed in German
|
||||
});
|
||||
|
||||
it('should re-index FTS when a translation is created', async () => {
|
||||
// Arrange: source post exists
|
||||
const sourcePost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Source Post',
|
||||
slug: 'source-post',
|
||||
excerpt: null,
|
||||
content: 'Source content',
|
||||
status: 'draft',
|
||||
author: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
publishedAt: null,
|
||||
filePath: null,
|
||||
checksum: null,
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
language: 'en',
|
||||
translationOfId: null,
|
||||
templateSlug: null,
|
||||
doNotTranslate: 0,
|
||||
version: 1,
|
||||
stemmedTitle: '',
|
||||
stemmedContent: '',
|
||||
};
|
||||
|
||||
let selectCallCount = 0;
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
selectCallCount++;
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue(selectCallCount <= 2 ? sourcePost : undefined),
|
||||
all: vi.fn().mockResolvedValue(selectCallCount <= 2 ? [sourcePost] : []),
|
||||
});
|
||||
chain.all = vi.fn().mockResolvedValue([]);
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockExecuteArgs = [];
|
||||
|
||||
// Act: create a French translation
|
||||
await postEngine.upsertPostTranslation('post-1', 'fr', {
|
||||
title: 'Titre Français',
|
||||
content: 'Contenu en français avec des maisons',
|
||||
});
|
||||
|
||||
// Assert: FTS should have been updated (at least one INSERT INTO posts_fts)
|
||||
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
|
||||
q.sql.includes('INSERT INTO posts_fts'),
|
||||
);
|
||||
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should stem search query with multiple languages for cross-language matching', async () => {
|
||||
postEngine.setMainLanguage('en');
|
||||
postEngine.setSearchLanguage('english');
|
||||
|
||||
// Mock translations with German language to simulate a project with translations
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.all = vi.fn().mockResolvedValue([
|
||||
{ id: 'trans-1', projectId: 'test-project', translationFor: 'post-1', language: 'de', title: 'T', content: 'C', status: 'draft', createdAt: new Date(), updatedAt: new Date(), publishedAt: null, filePath: '', checksum: null },
|
||||
]);
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue({ id: 'post-1', title: 'Found', slug: 'found', excerpt: null, tags: '[]', categories: '[]' }),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [{ id: 'post-1' }] });
|
||||
|
||||
await postEngine.searchPosts('Häuser');
|
||||
|
||||
// Verify the FTS MATCH query was called
|
||||
const matchCall = mockLocalClient.execute.mock.calls[0]?.[0] as { sql: string; args: any[] };
|
||||
expect(matchCall.sql).toContain('MATCH');
|
||||
|
||||
// The query should contain stems from multiple languages combined with OR
|
||||
const matchArg = matchCall.args[1] as string;
|
||||
expect(matchArg).toBeDefined();
|
||||
});
|
||||
|
||||
it('should rebuild FTS index including translation content', async () => {
|
||||
postEngine.setMainLanguage('en');
|
||||
postEngine.setSearchLanguage('english');
|
||||
|
||||
const translationRow = {
|
||||
id: 'trans-1',
|
||||
projectId: 'test-project',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Deutscher Titel',
|
||||
excerpt: null,
|
||||
content: 'Deutscher Inhalt',
|
||||
status: 'draft',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
publishedAt: null,
|
||||
filePath: '',
|
||||
checksum: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockResolvedValue([
|
||||
{ id: 'post-1', projectId: 'test-project', title: 'English Post', content: 'English content', tags: '[]', categories: '[]', language: 'en' },
|
||||
]),
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
chain.all = vi.fn().mockResolvedValue([translationRow]);
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockExecuteArgs = [];
|
||||
await postEngine.rebuildFTSIndex();
|
||||
|
||||
// Verify FTS was populated
|
||||
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
|
||||
q.sql.includes('INSERT INTO posts_fts'),
|
||||
);
|
||||
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The indexed content should include German translation content
|
||||
const insertContent = ftsInserts[0]?.args?.[2] as string;
|
||||
expect(insertContent).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1194
tests/engine/PostTranslationSystem.test.ts
Normal file
1194
tests/engine/PostTranslationSystem.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ type PostEngineLike = {
|
||||
};
|
||||
|
||||
type SettingsEngineLike = {
|
||||
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string } | null>;
|
||||
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string; blogLanguages?: string[] } | null>;
|
||||
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
@@ -463,7 +464,6 @@ describe('PreviewServer', () => {
|
||||
postMediaEngine,
|
||||
settingsEngine: settingsEngine as any,
|
||||
menuEngine,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
|
||||
});
|
||||
|
||||
@@ -618,6 +618,142 @@ describe('PreviewServer', () => {
|
||||
expect(draftHtml).not.toContain('Published body');
|
||||
});
|
||||
|
||||
it('serves translated draft content for single post route when lang query param is provided', async () => {
|
||||
const draftPost = makePost({
|
||||
id: 'post-2',
|
||||
slug: 'shared-slug',
|
||||
title: 'Draft Title',
|
||||
content: 'Draft-only body',
|
||||
status: 'draft',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([draftPost]),
|
||||
async getPostTranslation(postId: string, language: string) {
|
||||
if (postId === 'post-2' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-2-fr',
|
||||
translationFor: 'post-2',
|
||||
language: 'fr',
|
||||
title: 'Titre brouillon',
|
||||
excerpt: 'Resume brouillon',
|
||||
content: 'Contenu brouillon traduit',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
|
||||
publishedAt: null,
|
||||
filePath: 'posts/shared-slug.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const draftResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug?draft=true&postId=post-2&lang=fr`);
|
||||
expect(draftResponse.status).toBe(200);
|
||||
const draftHtml = await draftResponse.text();
|
||||
expect(draftHtml).toContain('Titre brouillon');
|
||||
expect(draftHtml).toContain('Contenu brouillon traduit');
|
||||
expect(draftHtml).toContain('<html lang="fr"');
|
||||
expect(draftHtml).not.toContain('Draft-only body');
|
||||
});
|
||||
|
||||
it('prefers project main language content for canonical single post route and falls back to canonical content when unavailable', async () => {
|
||||
const publishedPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'shared-slug',
|
||||
title: 'Published Title',
|
||||
content: 'Published body',
|
||||
status: 'published',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([publishedPost]),
|
||||
async getPostTranslation(postId: string, language: string) {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Titre publie',
|
||||
excerpt: 'Resume FR',
|
||||
content: 'Contenu FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
|
||||
publishedAt: new Date('2025-01-03T11:00:00.000Z'),
|
||||
filePath: 'posts/shared-slug.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const translatedResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
|
||||
expect(translatedResponse.status).toBe(200);
|
||||
const translatedHtml = await translatedResponse.text();
|
||||
expect(translatedHtml).toContain('<html lang="fr"');
|
||||
expect(translatedHtml).toContain('Titre publie');
|
||||
expect(translatedHtml).toContain('Contenu FR');
|
||||
expect(translatedHtml).not.toContain('Published body');
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([publishedPost]),
|
||||
async getPostTranslation() {
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const fallbackResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
|
||||
expect(fallbackResponse.status).toBe(200);
|
||||
const fallbackHtml = await fallbackResponse.text();
|
||||
expect(fallbackHtml).toContain('Published Title');
|
||||
expect(fallbackHtml).toContain('Published body');
|
||||
expect(fallbackHtml).not.toContain('Contenu FR');
|
||||
});
|
||||
|
||||
it('uses selected pico theme stylesheet from project metadata', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
@@ -674,11 +810,87 @@ describe('PreviewServer', () => {
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain('<html lang="en" data-theme="dark">');
|
||||
expect(html).toContain('<html lang="en" data-language-prefix="" data-theme="dark">');
|
||||
expect(html).toContain('href="/assets/pico.green.min.css"');
|
||||
expect(html).toContain('/assets/bds.css');
|
||||
});
|
||||
|
||||
it('renders language switcher with flags on style preview when blogLanguages configured', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return {
|
||||
maxPostsPerPage: 50,
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: ['en', 'de'],
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const response = await fetch(`${server.getBaseUrl()}/__style-preview`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain('class="language-switcher"');
|
||||
expect(html).toContain('🇬🇧');
|
||||
expect(html).toContain('🇩🇪');
|
||||
});
|
||||
|
||||
it('includes mainLanguage in language switcher even when not listed in blogLanguages', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return {
|
||||
maxPostsPerPage: 50,
|
||||
mainLanguage: 'de',
|
||||
blogLanguages: ['en'],
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
// Check style-preview route
|
||||
const styleResponse = await fetch(`${server.getBaseUrl()}/__style-preview`);
|
||||
expect(styleResponse.status).toBe(200);
|
||||
const styleHtml = await styleResponse.text();
|
||||
expect(styleHtml).toContain('class="language-switcher"');
|
||||
expect(styleHtml).toContain('🇩🇪');
|
||||
expect(styleHtml).toContain('🇬🇧');
|
||||
|
||||
// Check root route
|
||||
const rootResponse = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(rootResponse.status).toBe(200);
|
||||
const rootHtml = await rootResponse.text();
|
||||
expect(rootHtml).toContain('class="language-switcher"');
|
||||
expect(rootHtml).toContain('🇩🇪');
|
||||
expect(rootHtml).toContain('🇬🇧');
|
||||
|
||||
// Check language-prefixed route (/en/)
|
||||
const enResponse = await fetch(`${server.getBaseUrl()}/en/`);
|
||||
expect(enResponse.status).toBe(200);
|
||||
const enHtml = await enResponse.text();
|
||||
expect(enHtml).toContain('class="language-switcher"');
|
||||
expect(enHtml).toContain('🇩🇪');
|
||||
expect(enHtml).toContain('🇬🇧');
|
||||
});
|
||||
|
||||
it('limits list routes to 50 posts', async () => {
|
||||
const posts = Array.from({ length: 60 }).map((_, index) =>
|
||||
makePost({
|
||||
@@ -1179,7 +1391,7 @@ describe('PreviewServer', () => {
|
||||
await server.start(0);
|
||||
|
||||
const monthPageHtml = await (await fetch(`${server.getBaseUrl()}/2020/2/`)).text();
|
||||
expect(monthPageHtml).toContain('<html lang="fr">');
|
||||
expect(monthPageHtml).toContain('<html lang="fr"');
|
||||
expect(monthPageHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
|
||||
expect(monthPageHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
|
||||
});
|
||||
@@ -1579,7 +1791,7 @@ describe('PreviewServer', () => {
|
||||
const response = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain('<html lang="de">');
|
||||
expect(html).toContain('<html lang="de"');
|
||||
});
|
||||
|
||||
it('initializes metadata before reading language when supported by settings engine', async () => {
|
||||
@@ -1610,7 +1822,7 @@ describe('PreviewServer', () => {
|
||||
const response = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain('<html lang="fr">');
|
||||
expect(html).toContain('<html lang="fr"');
|
||||
});
|
||||
|
||||
it('falls back to active project name in page title when metadata is unavailable', async () => {
|
||||
@@ -1692,7 +1904,7 @@ describe('PreviewServer', () => {
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
@@ -2068,4 +2280,59 @@ describe('PreviewServer', () => {
|
||||
expect(html).toContain('data-template="not-found"');
|
||||
expect(html).toContain('class="not-found"');
|
||||
});
|
||||
|
||||
it('returns 503 after stop is called', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
const baseUrl = server.getBaseUrl();
|
||||
|
||||
const okResponse = await fetch(`${baseUrl}/`);
|
||||
expect(okResponse.status).toBe(200);
|
||||
|
||||
await server.stop();
|
||||
|
||||
await expect(fetch(`${baseUrl}/`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('includes translation variant slugs in rewrite context using batch method', async () => {
|
||||
const publishedPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'hello',
|
||||
status: 'published',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-02-15T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const getPublishedTranslationLanguagesByPost = vi.fn(async () => {
|
||||
const map = new Map<string, string[]>();
|
||||
map.set('post-1', ['de', 'fr']);
|
||||
return map;
|
||||
});
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([publishedPost]),
|
||||
getPublishedTranslationLanguagesByPost,
|
||||
} as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const response = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(getPublishedTranslationLanguagesByPost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
@@ -50,8 +51,8 @@ function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData |
|
||||
result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
if ((filter as any).excludeCategories && (filter as any).excludeCategories.length > 0) {
|
||||
result = result.filter((post) => !(filter as any).excludeCategories.some((category: string) => post.categories.includes(category)));
|
||||
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
|
||||
result = result.filter((post) => !filter.excludeCategories!.some((category: string) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
if (filter.year !== undefined) {
|
||||
@@ -116,6 +117,118 @@ describe('SharedSnapshotService', () => {
|
||||
expect(result?.id).toBe('draft-1');
|
||||
});
|
||||
|
||||
it('returns a translated draft variant when preview language is provided', async () => {
|
||||
const draft = makePost({ id: 'draft-1', slug: 'my-post', status: 'draft', language: 'en', title: 'Canonical title', content: 'Canonical body', createdAt: new Date('2025-03-21T10:00:00.000Z') });
|
||||
const engine = {
|
||||
...makeEngine([draft]),
|
||||
async getPostTranslation(postId: string, language: string) {
|
||||
if (postId === 'draft-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1',
|
||||
translationFor: 'draft-1',
|
||||
language: 'fr',
|
||||
title: 'Titre traduit',
|
||||
excerpt: 'Resume traduit',
|
||||
content: 'Contenu traduit',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2025-03-21T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
|
||||
publishedAt: null,
|
||||
filePath: 'posts/my-post.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as SharedSnapshotPostEngine & {
|
||||
getPostTranslation: (postId: string, language: string) => Promise<any>;
|
||||
};
|
||||
|
||||
const result = await findSinglePostBySlug(
|
||||
engine,
|
||||
'my-post',
|
||||
{ useDraftContent: true, draftPostId: 'draft-1', lang: 'fr' },
|
||||
{ year: 2025, month: 3, day: 21 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 'translation-1',
|
||||
slug: 'my-post.fr',
|
||||
language: 'fr',
|
||||
title: 'Titre traduit',
|
||||
excerpt: 'Resume traduit',
|
||||
content: 'Contenu traduit',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers project main language content and falls back to canonical language when no translation exists', async () => {
|
||||
const published = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'my-post',
|
||||
status: 'published',
|
||||
language: 'en',
|
||||
title: 'Canonical title',
|
||||
content: 'Canonical body',
|
||||
createdAt: new Date('2025-03-21T10:00:00.000Z'),
|
||||
});
|
||||
const getPostTranslation = vi.fn(async (postId: string, language: string) => {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Titre principal',
|
||||
excerpt: 'Resume principal',
|
||||
content: 'Contenu principal',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-03-21T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
|
||||
publishedAt: new Date('2025-03-21T11:00:00.000Z'),
|
||||
filePath: 'posts/my-post.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const engine = {
|
||||
...makeEngine([published]),
|
||||
getPostTranslation,
|
||||
} as SharedSnapshotPostEngine & {
|
||||
getPostTranslation: (postId: string, language: string) => Promise<any>;
|
||||
};
|
||||
|
||||
const translated = await findSinglePostBySlug(
|
||||
engine,
|
||||
'my-post',
|
||||
{ preferredLanguage: 'fr' },
|
||||
{ year: 2025, month: 3, day: 21 },
|
||||
);
|
||||
|
||||
expect(translated).toMatchObject({
|
||||
id: 'translation-1',
|
||||
slug: 'my-post.fr',
|
||||
language: 'fr',
|
||||
title: 'Titre principal',
|
||||
content: 'Contenu principal',
|
||||
});
|
||||
|
||||
const fallback = await findSinglePostBySlug(
|
||||
engine,
|
||||
'my-post',
|
||||
{ preferredLanguage: 'de' },
|
||||
{ year: 2025, month: 3, day: 21 },
|
||||
);
|
||||
|
||||
expect(fallback).toMatchObject({
|
||||
id: 'post-1',
|
||||
slug: 'my-post',
|
||||
language: 'en',
|
||||
title: 'Canonical title',
|
||||
content: 'Canonical body',
|
||||
});
|
||||
expect(getPostTranslation).toHaveBeenNthCalledWith(1, 'post-1', 'fr');
|
||||
expect(getPostTranslation).toHaveBeenNthCalledWith(2, 'post-1', 'de');
|
||||
});
|
||||
|
||||
it('uses findPublishedBySlug shortcut when present', async () => {
|
||||
const post = makePost({ id: 'x1', slug: 'shortcut', status: 'published' });
|
||||
const engine = makeEngine([post]);
|
||||
|
||||
@@ -92,4 +92,49 @@ describe('ValidationApplyPlannerService', () => {
|
||||
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
|
||||
expect(targeted.requestRootRoutes).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies language-prefixed missing paths into per-language plans', () => {
|
||||
const plan = planMissingValidationPaths(
|
||||
[
|
||||
'/fr/',
|
||||
'/fr/page/2',
|
||||
'/fr/category/news',
|
||||
'/fr/tag/dev',
|
||||
'/fr/2025/01/15/my-post',
|
||||
'/fr/2025',
|
||||
'/fr/2025/01',
|
||||
'/fr/about',
|
||||
'/de/',
|
||||
'/de/category/tech',
|
||||
],
|
||||
['fr', 'de'],
|
||||
);
|
||||
|
||||
expect(plan.requiresFallbackSectionRender).toBe(false);
|
||||
expect(plan.requestRootRoutes).toBe(false);
|
||||
|
||||
const frPlan = plan.languagePlans.get('fr');
|
||||
expect(frPlan).toBeDefined();
|
||||
expect(frPlan!.requestRootRoutes).toBe(true);
|
||||
expect(Array.from(frPlan!.requestedCategories)).toEqual(['news']);
|
||||
expect(Array.from(frPlan!.requestedTags)).toEqual(['dev']);
|
||||
expect(frPlan!.requestedPostRoutes).toEqual([
|
||||
{ year: 2025, month: 1, day: 15, slug: 'my-post' },
|
||||
]);
|
||||
expect(Array.from(frPlan!.requestedYears)).toContain(2025);
|
||||
expect(Array.from(frPlan!.requestedYearMonths)).toContain('2025/01');
|
||||
expect(Array.from(frPlan!.requestedPageSlugs)).toEqual(['about']);
|
||||
|
||||
const dePlan = plan.languagePlans.get('de');
|
||||
expect(dePlan).toBeDefined();
|
||||
expect(dePlan!.requestRootRoutes).toBe(true);
|
||||
expect(Array.from(dePlan!.requestedCategories)).toEqual(['tech']);
|
||||
});
|
||||
|
||||
it('treats unknown prefixes as page slugs when no languages specified', () => {
|
||||
const plan = planMissingValidationPaths(['/fr/category/news', '/fr/']);
|
||||
|
||||
expect(plan.languagePlans.size).toBe(0);
|
||||
expect(plan.requiresFallbackSectionRender).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ function createMockDeps() {
|
||||
|
||||
const postEngine = {
|
||||
getPost: vi.fn(),
|
||||
upsertPostTranslation: vi.fn(),
|
||||
} as any;
|
||||
|
||||
return { chatEngine, providers, mediaEngine, postEngine };
|
||||
@@ -182,3 +183,283 @@ describe('OneShotTasks.analyzePost', () => {
|
||||
expect(deps.providers.resolveModel).toHaveBeenCalledWith('custom-model-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OneShotTasks.translatePost', () => {
|
||||
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('instructs the AI to leave fenced code blocks untranslated', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Resume"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Intro\n\n```ts\nconst label = "Hello";\n```',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
system: expect.stringContaining('Leave text inside fenced code blocks untranslated.'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('translates markdown body outside a JSON envelope', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Intro\n\n```ts\nconst config = { hello: "world" };\n```\n\nEnd.',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: 'Bonjour',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Resume"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
|
||||
} as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
system: expect.stringContaining('keys title and excerpt'),
|
||||
}));
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
system: expect.stringContaining('Return ONLY the translated Markdown body'),
|
||||
}));
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the raw markdown body without a leading content label', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Heading\n\nBody text',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: '# Titre\n\nTexte',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Resume"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: '# Titre\n\nTexte',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
prompt: '# Heading\n\nBody text',
|
||||
}));
|
||||
});
|
||||
|
||||
it('strips an accidental leading content label from translated markdown', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Mein Beitrag',
|
||||
excerpt: 'Zusammenfassung',
|
||||
content: '# Uberschrift\n\nText',
|
||||
language: 'de',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Heading\n\nText',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"My Post","excerpt":"Summary"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'content:\n\n# Heading\n\nText',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'en');
|
||||
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'en', {
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Heading\n\nText',
|
||||
});
|
||||
});
|
||||
|
||||
it('instructs the AI to translate only — never invent or add content', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Hello\n\nWorld',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: '# Bonjour\n\nMonde',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"Mon Post","excerpt":"Resume"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '# Bonjour\n\nMonde' } as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
expect(contentSystemPrompt).toMatch(/do not invent|do not add|only translate/i);
|
||||
expect(contentSystemPrompt).toMatch(/macro|shortcode|non-translatable/i);
|
||||
});
|
||||
|
||||
it('instructs the AI to keep markdown link text unchanged and translate surrounding prose', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'USearch Library',
|
||||
excerpt: '',
|
||||
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - drin was drauf steht. Eine Library.',
|
||||
language: 'de',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'USearch Library',
|
||||
excerpt: '',
|
||||
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"USearch Library","excerpt":""}' } as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'en');
|
||||
|
||||
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
expect(contentSystemPrompt).toMatch(/link text/i);
|
||||
expect(contentSystemPrompt).toMatch(/url/i);
|
||||
});
|
||||
|
||||
it('falls back to source title and excerpt when metadata JSON is invalid', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Intro',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Bonjour',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: 'not valid json',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Bonjour',
|
||||
} as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Bonjour',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes status published when autoPublish is set', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Hello world',
|
||||
language: 'en',
|
||||
status: 'published',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Bonjour le monde',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Résumé"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Bonjour le monde',
|
||||
} as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr', { autoPublish: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Bonjour le monde',
|
||||
status: 'published',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
386
tests/engine/ai/languageDetectionBeforeTranslation.test.ts
Normal file
386
tests/engine/ai/languageDetectionBeforeTranslation.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
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) => key),
|
||||
}));
|
||||
|
||||
import { OneShotTasks } 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 = {
|
||||
getMedia: vi.fn(),
|
||||
updateMedia: vi.fn(),
|
||||
upsertMediaTranslation: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const postEngine = {
|
||||
getPost: vi.fn(),
|
||||
updatePost: vi.fn(),
|
||||
upsertPostTranslation: vi.fn(),
|
||||
} as any;
|
||||
|
||||
return { chatEngine, providers, mediaEngine, postEngine };
|
||||
}
|
||||
|
||||
describe('translatePost: auto-detect language when not set', () => {
|
||||
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('detects and persists language before translating when post has no language', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Mein Beitrag',
|
||||
excerpt: 'Zusammenfassung',
|
||||
content: 'Dies ist ein deutscher Blogbeitrag über verschiedene Themen.',
|
||||
language: undefined, // No language set
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.updatePost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
language: 'de',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'This is a German blog post about various topics.',
|
||||
});
|
||||
|
||||
// First call: language detection response
|
||||
// Then: metadata translation response
|
||||
// Then: content translation response
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
|
||||
.mockResolvedValueOnce({ text: '{"title":"My Post","excerpt":"Summary"}' } as any) // metadata translation
|
||||
.mockResolvedValueOnce({ text: 'This is a German blog post about various topics.' } as any); // content translation
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'en');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Language detection should have been called (first generateText call)
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(3);
|
||||
// Language should have been persisted
|
||||
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'de' });
|
||||
// Translation prompts should use detected language 'de', not fallback 'en'
|
||||
const metadataCall = mockGenerateText.mock.calls[1][0];
|
||||
expect((metadataCall as any).system).toContain('de');
|
||||
});
|
||||
|
||||
it('skips detection when post has explicit language set', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'This is an English blog post.',
|
||||
language: 'en', // Explicitly set
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Article',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Ceci est un article de blog en anglais.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any) // metadata translation
|
||||
.mockResolvedValueOnce({ text: 'Ceci est un article de blog en anglais.' } as any); // content translation
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Only 2 calls: metadata + content translation, NO language detection
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
// updatePost should NOT have been called for language
|
||||
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds with fallback when language detection fails', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Some content here.',
|
||||
language: undefined,
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Article',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Du contenu ici.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: 'garbage response' } as any) // language detection fails
|
||||
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any)
|
||||
.mockResolvedValueOnce({ text: 'Du contenu ici.' } as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// updatePost should NOT have been called since detection failed
|
||||
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
|
||||
// Should still proceed with translation (3 calls total: detection + 2 translation)
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('uses detected language in translation prompts', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Mon Article',
|
||||
excerpt: '',
|
||||
content: 'Ceci est un article français.',
|
||||
language: undefined,
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'fr' });
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'My Article',
|
||||
excerpt: '',
|
||||
content: 'This is a French article.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "fr"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"My Article","excerpt":""}' } as any)
|
||||
.mockResolvedValueOnce({ text: 'This is a French article.' } as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'en');
|
||||
|
||||
// The translation prompt should use 'fr' as source language
|
||||
const metadataSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
const contentSystemPrompt = mockGenerateText.mock.calls[2][0].system as string;
|
||||
expect(metadataSystemPrompt).toContain('from fr to en');
|
||||
expect(contentSystemPrompt).toContain('from fr to en');
|
||||
});
|
||||
|
||||
it('treats null language the same as undefined (no language)', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Test Post',
|
||||
excerpt: '',
|
||||
content: 'Some content.',
|
||||
language: null, // null, not undefined
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'en' });
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Test',
|
||||
excerpt: '',
|
||||
content: 'Inhalt.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"Test","excerpt":""}' } as any)
|
||||
.mockResolvedValueOnce({ text: 'Inhalt.' } as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'de');
|
||||
|
||||
// Should have called detection since language is null
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(3);
|
||||
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'en' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateMediaMetadata: auto-detect language when not set', () => {
|
||||
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('detects and persists language before translating when media has no language', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Sonnenuntergang am Meer',
|
||||
alt: 'Ein wunderschöner Sonnenuntergang',
|
||||
caption: 'Aufgenommen im Sommer',
|
||||
language: undefined,
|
||||
});
|
||||
deps.mediaEngine.updateMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
language: 'de',
|
||||
});
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'en',
|
||||
title: 'Sunset at Sea',
|
||||
alt: 'A beautiful sunset',
|
||||
caption: 'Taken in summer',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
|
||||
.mockResolvedValueOnce({ text: '{"title":"Sunset at Sea","alt":"A beautiful sunset","caption":"Taken in summer"}' } as any); // translation
|
||||
|
||||
const result = await tasks.translateMediaMetadata('media-1', 'en');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'de' });
|
||||
// Translation prompt should use detected language 'de'
|
||||
const translationCall = mockGenerateText.mock.calls[1][0];
|
||||
expect((translationCall as any).system).toContain('de');
|
||||
});
|
||||
|
||||
it('skips detection when media has explicit language set', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Sunset at Sea',
|
||||
alt: 'A beautiful sunset',
|
||||
caption: 'Taken in summer',
|
||||
language: 'en',
|
||||
});
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Sonnenuntergang',
|
||||
alt: 'Schön',
|
||||
caption: 'Im Sommer',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"Sonnenuntergang","alt":"Schön","caption":"Im Sommer"}' } as any);
|
||||
|
||||
const result = await tasks.translateMediaMetadata('media-1', 'de');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(1);
|
||||
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds with fallback when media language detection fails', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Test Image',
|
||||
alt: 'Alt text',
|
||||
caption: 'Caption',
|
||||
language: undefined,
|
||||
});
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Testbild',
|
||||
alt: 'Alt',
|
||||
caption: 'Beschriftung',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: 'garbage' } as any) // detection fails
|
||||
.mockResolvedValueOnce({ text: '{"title":"Testbild","alt":"Alt","caption":"Beschriftung"}' } as any);
|
||||
|
||||
const result = await tasks.translateMediaMetadata('media-1', 'de');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses detected language in media translation prompts', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Tramonto sul mare',
|
||||
alt: 'Un bel tramonto',
|
||||
caption: 'Fotografato in estate',
|
||||
language: undefined,
|
||||
});
|
||||
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'it' });
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'en',
|
||||
title: 'Sunset',
|
||||
alt: 'Beautiful',
|
||||
caption: 'Summer',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "it"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"Sunset","alt":"Beautiful","caption":"Summer"}' } as any);
|
||||
|
||||
await tasks.translateMediaMetadata('media-1', 'en');
|
||||
|
||||
const translationSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
expect(translationSystemPrompt).toContain('from it to en');
|
||||
});
|
||||
|
||||
it('treats null language the same as undefined (no language)', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Test',
|
||||
alt: 'Alt',
|
||||
caption: null,
|
||||
language: null,
|
||||
});
|
||||
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'en' });
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Test',
|
||||
alt: 'Alt',
|
||||
caption: null,
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"Test","alt":"Alt","caption":null}' } as any);
|
||||
|
||||
await tasks.translateMediaMetadata('media-1', 'de');
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'en' });
|
||||
});
|
||||
});
|
||||
116
tests/engine/ai/retryWithBackoff.test.ts
Normal file
116
tests/engine/ai/retryWithBackoff.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { retryWithBackoff } from '../../../src/main/engine/ai/retry';
|
||||
|
||||
describe('retryWithBackoff', () => {
|
||||
it('returns immediately on success (no retries)', async () => {
|
||||
const fn = vi.fn(async () => ({ success: true, value: 42 }));
|
||||
|
||||
const result = await retryWithBackoff(fn);
|
||||
|
||||
expect(result).toEqual({ success: true, value: 42 });
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries up to maxRetries times with exponential delays on failure', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn(async () => ({ success: false, error: 'rate limited' }));
|
||||
|
||||
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
|
||||
|
||||
// Initial call happens immediately
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry 1 after 5s
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Retry 2 after 10s
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Retry 3 after 20s
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
expect(fn).toHaveBeenCalledTimes(4);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ success: false, error: 'rate limited' });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('stops retrying once the function succeeds', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn()
|
||||
.mockResolvedValueOnce({ success: false, error: 'fail' })
|
||||
.mockResolvedValueOnce({ success: true, value: 'ok' });
|
||||
|
||||
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
|
||||
|
||||
// Initial call fails
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry 1 after 5s — succeeds
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ success: true, value: 'ok' });
|
||||
// Should not retry further
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses default 3 retries with 5s base delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn(async () => ({ success: false }));
|
||||
|
||||
const promise = retryWithBackoff(fn);
|
||||
|
||||
// Initial + 3 retries = 4 total calls
|
||||
await vi.advanceTimersByTimeAsync(0); // initial
|
||||
await vi.advanceTimersByTimeAsync(5000); // retry 1 (5s)
|
||||
await vi.advanceTimersByTimeAsync(10000); // retry 2 (10s)
|
||||
await vi.advanceTimersByTimeAsync(20000); // retry 3 (20s)
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledTimes(4);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('applies exponential doubling: 5s, 10s, 20s', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const delays: number[] = [];
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
|
||||
const fn = vi.fn(async () => ({ success: false }));
|
||||
|
||||
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
|
||||
await promise;
|
||||
|
||||
// Extract the delay values passed to setTimeout for our retries
|
||||
const timeoutCalls = setTimeoutSpy.mock.calls
|
||||
.filter(([, ms]) => typeof ms === 'number' && ms >= 5000)
|
||||
.map(([, ms]) => ms);
|
||||
|
||||
expect(timeoutCalls).toContain(5000);
|
||||
expect(timeoutCalls).toContain(10000);
|
||||
expect(timeoutCalls).toContain(20000);
|
||||
|
||||
setTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -24,4 +24,12 @@ describe('main/shared locale completeness', () => {
|
||||
expect(localeKeys, `Locale ${locale} is missing or has extra keys`).toEqual(englishKeys);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes a native-menu label for validate translations', () => {
|
||||
expect((en as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((de as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((fr as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((itLocale as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((es as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveSupportedRenderLanguage,
|
||||
resolveRenderLanguageFromProjectPreferences,
|
||||
translateRender,
|
||||
getRenderTranslations,
|
||||
} from '../../src/main/shared/i18n';
|
||||
|
||||
describe('render i18n', () => {
|
||||
@@ -24,4 +25,17 @@ describe('render i18n', () => {
|
||||
expect(translateRender('es', 'render.pagination.older')).toBe('más antiguo');
|
||||
expect(translateRender('fr', 'missing.key')).toBe('missing.key');
|
||||
});
|
||||
|
||||
it('returns full translation map for a language', () => {
|
||||
const translations = getRenderTranslations('de');
|
||||
expect(translations).toBeDefined();
|
||||
expect(typeof translations).toBe('object');
|
||||
expect(translations['render.pagination.newer']).toBe('neuer');
|
||||
expect(translations['render.archive']).toBe('Archiv');
|
||||
});
|
||||
|
||||
it('falls back to English for unsupported languages', () => {
|
||||
const translations = getRenderTranslations('en');
|
||||
expect(translations['render.archive']).toBe('Archive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ const mockPostEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
updatePost: vi.fn().mockResolvedValue(null),
|
||||
deletePost: vi.fn().mockResolvedValue(true),
|
||||
publishPost: vi.fn().mockResolvedValue(null),
|
||||
publishPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'fr' }),
|
||||
getPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'en', content: 'hello' }),
|
||||
getPostTranslations: vi.fn().mockResolvedValue([{ id: 'tr1', language: 'en' }]),
|
||||
discardChanges: vi.fn().mockResolvedValue(null),
|
||||
hasPublishedVersion: vi.fn().mockResolvedValue(false),
|
||||
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -431,6 +434,21 @@ describe('invokeMainProcessPythonApi', () => {
|
||||
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('test', undefined);
|
||||
});
|
||||
|
||||
it('routes posts.publishTranslation to postEngine.publishPostTranslation', async () => {
|
||||
await invokeMainProcessPythonApi('posts.publishTranslation', { postId: 'p1', language: 'fr' });
|
||||
expect(mockPostEngine.publishPostTranslation).toHaveBeenCalledWith('p1', 'fr');
|
||||
});
|
||||
|
||||
it('routes posts.getTranslation to postEngine.getPostTranslation', async () => {
|
||||
await invokeMainProcessPythonApi('posts.getTranslation', { postId: 'p1', language: 'en' });
|
||||
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
|
||||
});
|
||||
|
||||
it('routes posts.getTranslations to postEngine.getPostTranslations', async () => {
|
||||
await invokeMainProcessPythonApi('posts.getTranslations', { postId: 'p1' });
|
||||
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('handles null args gracefully (normalizes to empty record)', async () => {
|
||||
await expect(
|
||||
invokeMainProcessPythonApi('posts.get', null as unknown as Record<string, unknown>),
|
||||
|
||||
@@ -1221,6 +1221,7 @@ describe('main bootstrap preview behavior', () => {
|
||||
createPost,
|
||||
setProjectContext: vi.fn(),
|
||||
setSearchLanguage: vi.fn(),
|
||||
setMainLanguage: vi.fn(),
|
||||
}; }),
|
||||
}));
|
||||
|
||||
|
||||
483
tests/engine/multilinguality.test.ts
Normal file
483
tests/engine/multilinguality.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
import {
|
||||
applyLanguagePrefixToHtml,
|
||||
PageRenderer,
|
||||
type HtmlRewriteContext,
|
||||
} from '../../src/main/engine/PageRenderer';
|
||||
import {
|
||||
buildSitemapAndFeeds,
|
||||
buildMultiLanguageSitemap,
|
||||
type GenerationPostIndexLike,
|
||||
} from '../../src/main/engine/GenerationSitemapFeedService';
|
||||
|
||||
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
const createdAt = overrides.createdAt ?? new Date('2025-03-08T10:00:00.000Z');
|
||||
return {
|
||||
id: overrides.id ?? 'post-1',
|
||||
projectId: overrides.projectId ?? 'default',
|
||||
title: overrides.title ?? 'Test Post',
|
||||
slug: overrides.slug ?? 'test-post',
|
||||
excerpt: overrides.excerpt,
|
||||
content: overrides.content ?? '# Test\n\nBody text',
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language ?? 'en',
|
||||
createdAt,
|
||||
updatedAt: overrides.updatedAt ?? createdAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildIndex(posts: PostData[]): GenerationPostIndexLike {
|
||||
const postsByCategory = new Map<string, PostData[]>();
|
||||
const postsByTag = new Map<string, PostData[]>();
|
||||
const postsByYear = new Map<number, PostData[]>();
|
||||
const postsByYearMonth = new Map<string, PostData[]>();
|
||||
const postsByYearMonthDay = new Map<string, PostData[]>();
|
||||
|
||||
for (const post of posts) {
|
||||
for (const category of (post.categories ?? [])) {
|
||||
postsByCategory.set(category, [...(postsByCategory.get(category) ?? []), post]);
|
||||
}
|
||||
for (const tag of (post.tags ?? [])) {
|
||||
postsByTag.set(tag, [...(postsByTag.get(tag) ?? []), post]);
|
||||
}
|
||||
const year = post.createdAt.getFullYear();
|
||||
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(post.createdAt.getDate()).padStart(2, '0');
|
||||
postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]);
|
||||
postsByYearMonth.set(`${year}/${month}`, [...(postsByYearMonth.get(`${year}/${month}`) ?? []), post]);
|
||||
postsByYearMonthDay.set(`${year}/${month}/${day}`, [...(postsByYearMonthDay.get(`${year}/${month}/${day}`) ?? []), post]);
|
||||
}
|
||||
|
||||
return { postsByCategory, postsByTag, postsByYear, postsByYearMonth, postsByYearMonthDay };
|
||||
}
|
||||
|
||||
describe('applyLanguagePrefixToHtml', () => {
|
||||
it('prefixes internal hrefs with language prefix', () => {
|
||||
const html = '<a href="/2025/03/08/my-post/">Post</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
|
||||
});
|
||||
|
||||
it('does not prefix media or asset paths', () => {
|
||||
const html = '<img src="/media/2025/03/photo.jpg" /><link href="/assets/bds.css" />';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it('does not double-prefix already prefixed hrefs', () => {
|
||||
const html = '<a href="/de/2025/03/08/my-post/">Post</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
|
||||
});
|
||||
|
||||
it('prefixes root href', () => {
|
||||
const html = '<a href="/">Home</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/fr');
|
||||
expect(result).toBe('<a href="/fr/">Home</a>');
|
||||
});
|
||||
|
||||
it('prefixes category and tag hrefs', () => {
|
||||
const html = '<a href="/category/tech/">Tech</a><a href="/tag/js/">JS</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/es');
|
||||
expect(result).toBe('<a href="/es/category/tech/">Tech</a><a href="/es/tag/js/">JS</a>');
|
||||
});
|
||||
|
||||
it('returns html unchanged when prefix is empty', () => {
|
||||
const html = '<a href="/some/path">Link</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '');
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it('prefixes pagination hrefs', () => {
|
||||
const html = '<a href="/page/2">Next</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe('<a href="/de/page/2">Next</a>');
|
||||
});
|
||||
|
||||
it('handles both single and double quotes', () => {
|
||||
const html = `<a href='/tag/foo/'>Foo</a><a href="/tag/bar/">Bar</a>`;
|
||||
const result = applyLanguagePrefixToHtml(html, '/it');
|
||||
expect(result).toBe(`<a href='/it/tag/foo/'>Foo</a><a href="/it/tag/bar/">Bar</a>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data-language-prefix on html tag', () => {
|
||||
it('renders data-language-prefix on post-list html tag', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'fr',
|
||||
language_prefix: '/fr',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('data-language-prefix="/fr"');
|
||||
});
|
||||
|
||||
it('renders empty data-language-prefix for main language', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
language_prefix: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('data-language-prefix=""');
|
||||
});
|
||||
|
||||
it('renders data-language-prefix on single-post html tag', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderSinglePost(
|
||||
makePost({ content: 'Hello world' }),
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
page_title: 'Blog',
|
||||
language: 'de',
|
||||
language_prefix: '/de',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('data-language-prefix="/de"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feed language filtering', () => {
|
||||
it('includes feedLanguage element in RSS when specified', () => {
|
||||
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts: posts,
|
||||
publishedListPosts: posts,
|
||||
postIndex: buildIndex(posts),
|
||||
includeFeeds: true,
|
||||
feedLanguage: 'de',
|
||||
});
|
||||
|
||||
expect(result.rssXml).toContain('<language>de</language>');
|
||||
});
|
||||
|
||||
it('includes xml:lang in Atom feed when feedLanguage is specified', () => {
|
||||
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts: posts,
|
||||
publishedListPosts: posts,
|
||||
postIndex: buildIndex(posts),
|
||||
includeFeeds: true,
|
||||
feedLanguage: 'fr',
|
||||
});
|
||||
|
||||
expect(result.atomXml).toContain('xml:lang="fr"');
|
||||
});
|
||||
|
||||
it('omits language elements when feedLanguage is not specified', () => {
|
||||
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts: posts,
|
||||
publishedListPosts: posts,
|
||||
postIndex: buildIndex(posts),
|
||||
includeFeeds: true,
|
||||
});
|
||||
|
||||
expect(result.rssXml).not.toContain('<language>');
|
||||
expect(result.atomXml).not.toMatch(/<feed[^>]+xml:lang=/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMultiLanguageSitemap', () => {
|
||||
it('generates hreflang links for translatable posts in all languages', () => {
|
||||
const post = makePost({ id: '1', slug: 'hello', title: 'Hello' });
|
||||
const postIndex = buildIndex([post]);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'de'],
|
||||
translatablePosts: [post],
|
||||
doNotTranslatePosts: [],
|
||||
publishedListPosts: [post],
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
expect(sitemap).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
|
||||
expect(sitemap).toContain('hreflang="en"');
|
||||
expect(sitemap).toContain('hreflang="de"');
|
||||
expect(sitemap).toContain('hreflang="x-default"');
|
||||
// Main language post URL is unprefixed
|
||||
expect(sitemap).toContain('href="https://example.com/2025/03/08/hello/"');
|
||||
// Alternative language post URL is prefixed
|
||||
expect(sitemap).toContain('href="https://example.com/de/2025/03/08/hello/"');
|
||||
});
|
||||
|
||||
it('generates hreflang links only for main language for doNotTranslate posts', () => {
|
||||
const dntPost = makePost({ id: '2', slug: 'private-note', title: 'Private' });
|
||||
(dntPost as PostData & { doNotTranslate?: boolean }).doNotTranslate = true;
|
||||
const postIndex = buildIndex([dntPost]);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'de', 'fr'],
|
||||
translatablePosts: [],
|
||||
doNotTranslatePosts: [dntPost],
|
||||
publishedListPosts: [dntPost],
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
// The doNotTranslate post URL entry should exist
|
||||
expect(sitemap).toContain('https://example.com/2025/03/08/private-note/');
|
||||
// But it should NOT have de or fr hreflang links for this specific post
|
||||
const postUrlBlock = sitemap.split('<url>').find((block) => block.includes('private-note'));
|
||||
expect(postUrlBlock).toBeDefined();
|
||||
expect(postUrlBlock).toContain('hreflang="en"');
|
||||
expect(postUrlBlock).not.toContain('hreflang="de"');
|
||||
expect(postUrlBlock).not.toContain('hreflang="fr"');
|
||||
});
|
||||
|
||||
it('includes root page and pagination in all languages', () => {
|
||||
const posts = Array.from({ length: 15 }, (_, i) =>
|
||||
makePost({
|
||||
id: `p-${i}`,
|
||||
slug: `post-${i}`,
|
||||
createdAt: new Date(`2025-03-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
|
||||
}),
|
||||
);
|
||||
const postIndex = buildIndex(posts);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'de'],
|
||||
translatablePosts: posts,
|
||||
doNotTranslatePosts: [],
|
||||
publishedListPosts: posts,
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
// Root page has both languages
|
||||
expect(sitemap).toContain('https://example.com/');
|
||||
expect(sitemap).toContain('https://example.com/de/');
|
||||
// Pagination page 2
|
||||
expect(sitemap).toContain('https://example.com/page/2');
|
||||
expect(sitemap).toContain('https://example.com/de/page/2');
|
||||
});
|
||||
|
||||
it('includes archive, category, and tag URLs in all languages', () => {
|
||||
const post = makePost({
|
||||
id: '1', slug: 'tagged', tags: ['javascript'], categories: ['tutorial'],
|
||||
});
|
||||
const postIndex = buildIndex([post]);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'fr'],
|
||||
translatablePosts: [post],
|
||||
doNotTranslatePosts: [],
|
||||
publishedListPosts: [post],
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
expect(sitemap).toContain('https://example.com/category/tutorial');
|
||||
expect(sitemap).toContain('https://example.com/fr/category/tutorial');
|
||||
expect(sitemap).toContain('https://example.com/tag/javascript');
|
||||
expect(sitemap).toContain('https://example.com/fr/tag/javascript');
|
||||
expect(sitemap).toContain('https://example.com/2025/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language switcher in templates', () => {
|
||||
it('renders language switcher badges when blog_languages has multiple entries', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map([['test-post', '/2025/03/08/test-post']]),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: true,
|
||||
routeKind: 'date',
|
||||
archiveContext: { kind: 'root' },
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
blog_languages: [
|
||||
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true },
|
||||
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: false },
|
||||
],
|
||||
current_language: 'en',
|
||||
language_prefix: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('class="language-switcher"');
|
||||
expect(html).toContain('class="language-switcher-badge language-switcher-badge-current"');
|
||||
expect(html).toContain('🇬🇧');
|
||||
expect(html).toContain('href="/de"');
|
||||
expect(html).toContain('🇩🇪');
|
||||
});
|
||||
|
||||
it('does not render language switcher when blog_languages has one or zero entries', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
blog_languages: [{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true }],
|
||||
current_language: 'en',
|
||||
language_prefix: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).not.toContain('class="language-switcher"');
|
||||
});
|
||||
|
||||
it('renders language switcher in single post template', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderSinglePost(
|
||||
makePost({ content: 'Hello world' }),
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
blog_languages: [
|
||||
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: false },
|
||||
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: true },
|
||||
],
|
||||
current_language: 'de',
|
||||
language_prefix: '/de',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('class="language-switcher"');
|
||||
expect(html).toContain('aria-current="true"');
|
||||
expect(html).toContain('🇩🇪');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Per-language feed links in head', () => {
|
||||
it('renders language-prefixed feed links in head partial', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'de',
|
||||
language_prefix: '/de',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/de/rss.xml"');
|
||||
expect(html).toContain('href="/de/atom.xml"');
|
||||
});
|
||||
|
||||
it('renders unprefixed feed links when no language prefix', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/rss.xml"');
|
||||
expect(html).toContain('href="/atom.xml"');
|
||||
});
|
||||
});
|
||||
66
tests/engine/normalizeTranslatedMarkdownBody.test.ts
Normal file
66
tests/engine/normalizeTranslatedMarkdownBody.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeTranslatedMarkdownBody } from '../../src/main/engine/ai/tasks';
|
||||
|
||||
describe('normalizeTranslatedMarkdownBody', () => {
|
||||
const sourceContent = '# Hello\n\nThis is the source content.';
|
||||
|
||||
it('strips English "content:" prefix', () => {
|
||||
const input = 'content:\n\n# Hallo\n\nDies ist der Inhalt.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
|
||||
'# Hallo\n\nDies ist der Inhalt.',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips German "inhalt:" prefix', () => {
|
||||
const input = 'Inhalt:\n\nDer Inhalt hier.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
|
||||
'Der Inhalt hier.',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips French "contenu:" prefix', () => {
|
||||
const input = 'Contenu:\n\nLe contenu ici.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
|
||||
'Le contenu ici.',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips Italian "contenuto:" prefix', () => {
|
||||
const input = 'Contenuto:\n\nIl contenuto qui.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
|
||||
'Il contenuto qui.',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips Spanish "contenido:" prefix', () => {
|
||||
const input = 'Contenido:\n\nEl contenido aqui.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
|
||||
'El contenido aqui.',
|
||||
);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const input = 'CONTENT:\n\nBody text.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(
|
||||
'Body text.',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves content when source also has the prefix', () => {
|
||||
const sourceWithPrefix = 'content:\n\nSource body.';
|
||||
const input = 'Inhalt:\n\nTranslated body.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceWithPrefix)).toBe(
|
||||
'Inhalt:\n\nTranslated body.',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for empty/whitespace input', () => {
|
||||
expect(normalizeTranslatedMarkdownBody('', sourceContent)).toBe('');
|
||||
expect(normalizeTranslatedMarkdownBody(' \n ', sourceContent)).toBe('');
|
||||
});
|
||||
|
||||
it('returns content unchanged when no prefix is present', () => {
|
||||
const input = '# Normal markdown\n\nBody text.';
|
||||
expect(normalizeTranslatedMarkdownBody(input, sourceContent)).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,9 @@ const mockPostEngine = {
|
||||
on: vi.fn(),
|
||||
setProjectContext: vi.fn(),
|
||||
setSearchLanguage: vi.fn(),
|
||||
setMainLanguage: vi.fn(),
|
||||
validateTranslations: vi.fn(),
|
||||
fixInvalidTranslations: vi.fn(),
|
||||
reconcilePublishedPostsFromGitChanges: vi.fn(),
|
||||
createPost: vi.fn(),
|
||||
updatePost: vi.fn(),
|
||||
@@ -67,6 +70,7 @@ const mockPostEngine = {
|
||||
getLinksTo: vi.fn(),
|
||||
getLinkedBy: vi.fn(),
|
||||
rebuildLinks: vi.fn(),
|
||||
getPostTranslations: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockMediaEngine = {
|
||||
@@ -88,6 +92,7 @@ const mockMediaEngine = {
|
||||
getThumbnailDataUrl: vi.fn(),
|
||||
regenerateMissingThumbnails: vi.fn(),
|
||||
getRelativePath: vi.fn(),
|
||||
getMediaTranslations: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProjectEngine = {
|
||||
@@ -911,6 +916,7 @@ describe('IPC Handlers', () => {
|
||||
|
||||
expect(mockPostEngine.setSearchLanguage).toHaveBeenCalledWith('german');
|
||||
expect(mockMediaEngine.setSearchLanguage).toHaveBeenCalledWith('german');
|
||||
expect(mockPostEngine.setMainLanguage).toHaveBeenCalledWith('de');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1025,6 +1031,19 @@ describe('IPC Handlers', () => {
|
||||
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
|
||||
expect(result).toBe('http://127.0.0.1:4123/2026/02/16/my-post?draft=true&postId=post-1');
|
||||
});
|
||||
|
||||
it('should include lang in draft preview URL when provided', async () => {
|
||||
mockPostEngine.getPost.mockResolvedValue(createMockPost({
|
||||
id: 'post-1',
|
||||
slug: 'my-post',
|
||||
createdAt: new Date('2026-02-16T12:00:00.000Z'),
|
||||
}));
|
||||
|
||||
const result = await invokeHandler('posts:getPreviewUrl', 'post-1', { draft: true, lang: 'fr' });
|
||||
|
||||
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
|
||||
expect(result).toBe('http://127.0.0.1:4123/2026/02/16/my-post?draft=true&postId=post-1&lang=fr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('posts:getAll', () => {
|
||||
@@ -2767,6 +2786,244 @@ describe('IPC Handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('blog:validateTranslations', () => {
|
||||
it('should run translation validation via taskManager.runTask', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com',
|
||||
});
|
||||
mockPostEngine.validateTranslations.mockResolvedValue({
|
||||
checkedDatabaseRowCount: 1,
|
||||
checkedFilesystemFileCount: 1,
|
||||
invalidDatabaseRows: [],
|
||||
invalidFilesystemFiles: [],
|
||||
});
|
||||
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
return task.execute(vi.fn());
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:validateTranslations');
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
checkedDatabaseRowCount: 1,
|
||||
checkedFilesystemFileCount: 1,
|
||||
}));
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Validate Translations',
|
||||
execute: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(mockPostEngine.validateTranslations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('blog:fixInvalidTranslations', () => {
|
||||
it('should run fix via taskManager.runTask', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com',
|
||||
});
|
||||
mockPostEngine.fixInvalidTranslations.mockResolvedValue({
|
||||
deletedDatabaseRows: 2,
|
||||
deletedFiles: 1,
|
||||
flushedTranslations: 0,
|
||||
});
|
||||
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
return task.execute(vi.fn());
|
||||
});
|
||||
|
||||
const report = {
|
||||
checkedDatabaseRowCount: 5,
|
||||
checkedFilesystemFileCount: 3,
|
||||
invalidDatabaseRows: [
|
||||
{ issue: 'same-language-as-canonical', translationFor: 'post-1', translationLanguage: 'de', translationId: 'tr-1' },
|
||||
{ issue: 'same-language-as-canonical', translationFor: 'post-2', translationLanguage: 'de', translationId: 'tr-2' },
|
||||
],
|
||||
invalidFilesystemFiles: [
|
||||
{ issue: 'same-language-as-canonical', translationFor: 'post-1', translationLanguage: 'de', filePath: '/tmp/file.de.md' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await invokeHandler('blog:fixInvalidTranslations', report);
|
||||
|
||||
expect(result).toEqual({ deletedDatabaseRows: 2, deletedFiles: 1, flushedTranslations: 0 });
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Fix Invalid Translations',
|
||||
execute: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(mockPostEngine.fixInvalidTranslations).toHaveBeenCalledWith(report);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blog:fillMissingTranslations', () => {
|
||||
it('should return taskStarted false when only main language is configured', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: ['en'],
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:fillMissingTranslations');
|
||||
|
||||
expect(result).toEqual({ taskStarted: false });
|
||||
expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return taskStarted false when no blog languages configured', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: [],
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:fillMissingTranslations');
|
||||
|
||||
expect(result).toEqual({ taskStarted: false });
|
||||
expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return taskStarted false when metadata has no blogLanguages', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
mainLanguage: 'en',
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:fillMissingTranslations');
|
||||
|
||||
expect(result).toEqual({ taskStarted: false });
|
||||
expect(mockPostEngine.getPostsFiltered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start task immediately and scan inside the task', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: ['en', 'fr', 'de'],
|
||||
});
|
||||
|
||||
mockTaskManager.runTask.mockResolvedValue(undefined);
|
||||
|
||||
const result = await invokeHandler('blog:fillMissingTranslations');
|
||||
|
||||
expect(result).toEqual({ taskStarted: true });
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Fill missing translations',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include media scanning inside the task', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
});
|
||||
|
||||
mockTaskManager.runTask.mockResolvedValue(undefined);
|
||||
|
||||
const result = await invokeHandler('blog:fillMissingTranslations');
|
||||
|
||||
expect(result).toEqual({ taskStarted: true });
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledTimes(1);
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Fill missing translations',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip posts marked as doNotTranslate', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
});
|
||||
|
||||
const post1 = createMockPost({ id: 'post-1', title: 'Test Post', language: 'en', doNotTranslate: true });
|
||||
// missingTranslationLanguage queries return the post (it IS missing fr),
|
||||
// but the handler filters it out due to doNotTranslate
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: any) => {
|
||||
if (filter.missingTranslationLanguage) {
|
||||
return filter.missingTranslationLanguage === 'fr' ? [post1] : [];
|
||||
}
|
||||
return [post1]; // all published (for media scanning)
|
||||
});
|
||||
mockPostMediaEngine.getLinkedMediaForPost.mockResolvedValue([]);
|
||||
|
||||
const onProgress = vi.fn();
|
||||
let taskDone: Promise<void> | undefined;
|
||||
mockTaskManager.runTask.mockImplementation((task: any) => {
|
||||
taskDone = task.execute(onProgress);
|
||||
return taskDone;
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:fillMissingTranslations');
|
||||
await taskDone;
|
||||
|
||||
expect(result).toEqual({ taskStarted: true });
|
||||
// Task completes with "up to date" since doNotTranslate posts are skipped
|
||||
expect(onProgress).toHaveBeenCalledWith(100, 'All translations are up to date');
|
||||
});
|
||||
|
||||
it('should complete with nothing to do when all translations exist', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
});
|
||||
|
||||
const post1 = createMockPost({ id: 'post-1', title: 'Test Post', language: 'en' });
|
||||
// missingTranslationLanguage queries return empty (all translations exist)
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: any) => {
|
||||
if (filter.missingTranslationLanguage) {
|
||||
return [];
|
||||
}
|
||||
return [post1]; // all published (for media scanning)
|
||||
});
|
||||
mockPostMediaEngine.getLinkedMediaForPost.mockResolvedValue([]);
|
||||
|
||||
const onProgress = vi.fn();
|
||||
let taskDone: Promise<void> | undefined;
|
||||
mockTaskManager.runTask.mockImplementation((task: any) => {
|
||||
taskDone = task.execute(onProgress);
|
||||
return taskDone;
|
||||
});
|
||||
|
||||
const result = await invokeHandler('blog:fillMissingTranslations');
|
||||
await taskDone;
|
||||
|
||||
expect(result).toEqual({ taskStarted: true });
|
||||
expect(onProgress).toHaveBeenCalledWith(100, 'All translations are up to date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('blog:applyValidation', () => {
|
||||
it('should run apply via taskManager.runTask', async () => {
|
||||
const mockProject = createMockProject({ id: 'test-project', dataPath: '/mock/data' });
|
||||
|
||||
@@ -124,7 +124,7 @@ vi.mock('../../../src/renderer/components/Toast', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/Editor';
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/PostEditor';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const INITIAL_CONTENT = '# Hello World\n\nThis is the original content.';
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('../../../src/renderer/components/MetadataDiffPanel', () => ({ MetadataD
|
||||
vi.mock('../../../src/renderer/components/GitDiffView/GitDiffView', () => ({ GitDiffView: () => null }));
|
||||
vi.mock('../../../src/renderer/components/DocumentationView/DocumentationView', () => ({ DocumentationView: () => null }));
|
||||
vi.mock('../../../src/renderer/components/SiteValidationView', () => ({ SiteValidationView: () => null }));
|
||||
vi.mock('../../../src/renderer/components/TranslationValidationView', () => ({ TranslationValidationView: () => null }));
|
||||
vi.mock('../../../src/renderer/components/InsertModal', () => ({ InsertModal: () => null }));
|
||||
vi.mock('../../../src/renderer/components/AISuggestionsModal/AISuggestionsModal', () => ({
|
||||
AISuggestionsModal: () => null,
|
||||
|
||||
402
tests/renderer/components/EditorMediaQuickActions.test.tsx
Normal file
402
tests/renderer/components/EditorMediaQuickActions.test.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, act, fireEvent, within } from '@testing-library/react';
|
||||
|
||||
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/MilkdownEditor', () => ({
|
||||
MilkdownEditor: ({ content, onChange }: { content: string; onChange: (value: string) => void }) => (
|
||||
<textarea data-testid="milkdown-editor" value={content} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
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: () => null,
|
||||
}));
|
||||
vi.mock('../../../src/renderer/components/Toast', () => ({
|
||||
showToast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { MediaEditor } from '../../../src/renderer/components/Editor/MediaEditor';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const createMedia = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'media-1',
|
||||
title: 'Test Image',
|
||||
alt: 'A test image',
|
||||
caption: 'Photo caption',
|
||||
originalName: 'test.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
tags: [],
|
||||
author: '',
|
||||
language: 'en',
|
||||
availableLanguages: [],
|
||||
projectId: 'project-1',
|
||||
filePath: 'media/test.jpg',
|
||||
createdAt: new Date('2026-02-16T12:00:00.000Z'),
|
||||
updatedAt: new Date('2026-02-16T12:00:00.000Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Media editor quick-actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const neverSettles = new Promise<never>(() => {});
|
||||
|
||||
(window as any).electronAPI ??= {};
|
||||
(window as any).electronAPI.media ??= {};
|
||||
(window as any).electronAPI.meta ??= {};
|
||||
(window as any).electronAPI.chat ??= {};
|
||||
(window as any).electronAPI.postMedia ??= {};
|
||||
|
||||
(window as any).addEventListener = vi.fn();
|
||||
(window as any).removeEventListener = vi.fn();
|
||||
(window as any).dispatchEvent = vi.fn();
|
||||
(window as any).electronAPI.on = vi.fn(() => () => {});
|
||||
|
||||
(window as any).electronAPI.media.getTranslations = vi.fn().mockResolvedValue([]);
|
||||
(window as any).electronAPI.media.getTranslation = vi.fn().mockResolvedValue(null);
|
||||
(window as any).electronAPI.media.deleteTranslation = vi.fn().mockResolvedValue(undefined);
|
||||
(window as any).electronAPI.media.upsertTranslation = vi.fn().mockImplementation(async (_id: string, lang: string, data: Record<string, unknown>) => ({
|
||||
id: 'trans-1', projectId: 'project-1', translationFor: _id, language: lang, ...data,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
}));
|
||||
(window as any).electronAPI.media.update = vi.fn().mockImplementation(async (_id: string, payload: Record<string, unknown>) => ({
|
||||
...createMedia(),
|
||||
...payload,
|
||||
}));
|
||||
(window as any).electronAPI.media.replaceFileDialog = vi.fn().mockReturnValue(neverSettles);
|
||||
(window as any).electronAPI.meta.getProjectMetadata = vi.fn().mockResolvedValue({ mainLanguage: 'en' });
|
||||
(window as any).electronAPI.postMedia.getForMedia = vi.fn().mockResolvedValue([]);
|
||||
(window as any).electronAPI.chat.analyzeMediaImage = vi.fn().mockResolvedValue({ success: true, title: 'AI Title', alt: 'AI alt', caption: 'AI caption' });
|
||||
(window as any).electronAPI.chat.detectMediaLanguage = vi.fn().mockResolvedValue({ success: true, language: 'de' });
|
||||
(window as any).electronAPI.chat.translateMediaMetadata = vi.fn().mockResolvedValue({ success: true });
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
media: [createMedia()],
|
||||
posts: [],
|
||||
dirtyPosts: new Set<string>(),
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows quick-actions button for image media', async () => {
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
expect(ui.getByRole('button', { name: /Quick Actions/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows quick-actions button for non-image media', async () => {
|
||||
useAppStore.setState({
|
||||
media: [createMedia({ mimeType: 'application/pdf', width: undefined, height: undefined })],
|
||||
});
|
||||
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
expect(ui.getByRole('button', { name: /Quick Actions/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AI analysis, detect language, and translate items in the quick-actions menu', async () => {
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Quick Actions/i }));
|
||||
});
|
||||
|
||||
expect(ui.getByText('AI: Generate Title, Alt & Caption')).toBeInTheDocument();
|
||||
expect(ui.getByText('Detect Language')).toBeInTheDocument();
|
||||
expect(ui.getByText('Translate to...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides AI analysis item for non-image media but shows detect + translate', async () => {
|
||||
useAppStore.setState({
|
||||
media: [createMedia({ mimeType: 'application/pdf', width: undefined, height: undefined })],
|
||||
});
|
||||
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Quick Actions/i }));
|
||||
});
|
||||
|
||||
expect(ui.queryByText('AI: Generate Title, Alt & Caption')).toBeNull();
|
||||
expect(ui.getByText('Detect Language')).toBeInTheDocument();
|
||||
expect(ui.getByText('Translate to...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls detectMediaLanguage and updates the language dropdown on detect', async () => {
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Quick Actions/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByText('Detect Language'));
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.chat.detectMediaLanguage).toHaveBeenCalledWith(
|
||||
'Test Image', 'A test image', 'Photo caption'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a translation modal from quick-actions and translates on confirm', async () => {
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Quick Actions/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByText('Translate to...'));
|
||||
});
|
||||
|
||||
expect(ui.getByRole('heading', { name: 'Translations' })).toBeInTheDocument();
|
||||
expect(ui.getByText('Select target language')).toBeInTheDocument();
|
||||
expect(ui.getByText(/Current language/i)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(ui.getByLabelText('Select target language'), { target: { value: 'fr' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: 'Translate to…' }));
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.chat.translateMediaMetadata).toHaveBeenCalledWith('media-1', 'fr');
|
||||
});
|
||||
|
||||
it('disables translate quick-action when no language is set', async () => {
|
||||
useAppStore.setState({
|
||||
media: [createMedia({ language: '' })],
|
||||
});
|
||||
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Quick Actions/i }));
|
||||
});
|
||||
|
||||
const translateButton = ui.getByText('Translate to...').closest('button');
|
||||
expect(translateButton).toBeDisabled();
|
||||
});
|
||||
|
||||
describe('edit translation modal', () => {
|
||||
const translationFixture = {
|
||||
id: 'trans-1',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Titre français',
|
||||
alt: 'Alt français',
|
||||
caption: 'Légende française',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
it('opens edit modal when clicking a translation title', async () => {
|
||||
(window as any).electronAPI.media.getTranslations = vi.fn().mockResolvedValue([translationFixture]);
|
||||
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
const translationTitle = ui.getByText(/Titre français/);
|
||||
expect(translationTitle).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(translationTitle);
|
||||
});
|
||||
|
||||
expect(ui.getByRole('heading', { name: /Edit Translation/ })).toBeInTheDocument();
|
||||
expect((ui.getByDisplayValue('Titre français') as HTMLInputElement)).toBeInTheDocument();
|
||||
expect((ui.getByDisplayValue('Alt français') as HTMLInputElement)).toBeInTheDocument();
|
||||
expect((ui.getByDisplayValue('Légende française') as HTMLTextAreaElement)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes edit modal on cancel', async () => {
|
||||
(window as any).electronAPI.media.getTranslations = vi.fn().mockResolvedValue([translationFixture]);
|
||||
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
await act(async () => { fireEvent.click(ui.getByText(/Titre français/)); });
|
||||
|
||||
expect(ui.getByRole('heading', { name: /Edit Translation/ })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Cancel/i }));
|
||||
});
|
||||
|
||||
expect(ui.queryByRole('heading', { name: /Edit Translation/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('saves translation via upsertTranslation and shows success toast', async () => {
|
||||
(window as any).electronAPI.media.getTranslations = vi.fn().mockResolvedValue([translationFixture]);
|
||||
const { showToast } = await import('../../../src/renderer/components/Toast');
|
||||
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
await act(async () => { fireEvent.click(ui.getByText(/Titre français/)); });
|
||||
|
||||
const modal = ui.getByText(/Edit Translation/).closest('.translation-modal')!;
|
||||
const modalUi = within(modal as HTMLElement);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(modalUi.getByRole('button', { name: /Save/i }));
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.media.upsertTranslation).toHaveBeenCalledWith(
|
||||
'media-1', 'fr', { title: 'Titre français', alt: 'Alt français', caption: 'Légende française' }
|
||||
);
|
||||
expect(showToast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows error toast when save fails', async () => {
|
||||
(window as any).electronAPI.media.getTranslations = vi.fn().mockResolvedValue([translationFixture]);
|
||||
(window as any).electronAPI.media.upsertTranslation = vi.fn().mockRejectedValue(new Error('DB Error'));
|
||||
const { showToast } = await import('../../../src/renderer/components/Toast');
|
||||
|
||||
const view = render(<MediaEditor mediaId="media-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
await act(async () => { fireEvent.click(ui.getByText(/Titre français/)); });
|
||||
|
||||
const modal = ui.getByText(/Edit Translation/).closest('.translation-modal')!;
|
||||
const modalUi = within(modal as HTMLElement);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(modalUi.getByRole('button', { name: /Save/i }));
|
||||
});
|
||||
|
||||
expect(showToast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ vi.mock('../../../src/renderer/components/Toast', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/Editor';
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/PostEditor';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const createPost = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -262,4 +262,26 @@ describe('Editor metadata collapse', () => {
|
||||
|
||||
expect(container.querySelector('.editor-excerpt-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows translation flags in the metadata toggle header even when collapsed', async () => {
|
||||
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: 'Existing Post' }));
|
||||
|
||||
const { container } = render(<PostEditor postId="post-1" />);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Metadata is collapsed for existing posts
|
||||
expect(container.querySelector('.editor-header-row')).toBeNull();
|
||||
|
||||
// Translation flags should be visible in the header line, not inside metadata
|
||||
const toggleHeader = container.querySelector('.metadata-toggle-header');
|
||||
expect(toggleHeader).not.toBeNull();
|
||||
|
||||
const flagsInHeader = toggleHeader!.querySelector('.editor-translations-flags');
|
||||
expect(flagsInHeader).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, act, fireEvent, screen } from '@testing-library/react';
|
||||
import { render, act, fireEvent, within } from '@testing-library/react';
|
||||
|
||||
let lastSuggestionFields: Array<{ key: string; label: string; currentValue: string; suggestedValue?: string; disabled?: boolean; warning?: string }> = [];
|
||||
const menuListeners = new Map<string, () => void | Promise<void>>();
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: () => <div data-testid="monaco-editor" />,
|
||||
@@ -93,6 +94,11 @@ vi.mock('../../../src/renderer/components/Lightbox', () => ({
|
||||
Lightbox: () => null,
|
||||
useMarkdownImages: () => [],
|
||||
}));
|
||||
vi.mock('../../../src/renderer/components/MilkdownEditor', () => ({
|
||||
MilkdownEditor: ({ content, onChange }: { content: string; onChange: (value: string) => void }) => (
|
||||
<textarea data-testid="milkdown-editor" value={content} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
vi.mock('../../../src/renderer/components/PostLinks', () => ({ PostLinks: () => null }));
|
||||
vi.mock('../../../src/renderer/components/LinkedMediaPanel', () => ({ LinkedMediaPanel: () => null }));
|
||||
vi.mock('../../../src/renderer/components/ErrorModal', () => ({ ErrorModal: () => null }));
|
||||
@@ -128,7 +134,7 @@ vi.mock('../../../src/renderer/components/Toast', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/Editor';
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/PostEditor';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const createPost = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -154,10 +160,26 @@ const createPost = (overrides: Record<string, unknown> = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createTranslation = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour',
|
||||
excerpt: 'Resume',
|
||||
content: 'Contenu',
|
||||
status: 'draft' as const,
|
||||
createdAt: new Date('2026-02-16T12:00:00.000Z'),
|
||||
updatedAt: new Date('2026-02-16T12:00:00.000Z'),
|
||||
publishedAt: null,
|
||||
filePath: 'posts/test-post.fr.md',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Editor AI post suggestions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
lastSuggestionFields = [];
|
||||
menuListeners.clear();
|
||||
const neverSettles = new Promise<never>(() => {});
|
||||
|
||||
(window as any).electronAPI ??= {};
|
||||
@@ -168,9 +190,23 @@ describe('Editor AI post suggestions', () => {
|
||||
|
||||
(window as any).addEventListener = vi.fn();
|
||||
(window as any).removeEventListener = vi.fn();
|
||||
(window as any).dispatchEvent = vi.fn();
|
||||
(window as any).electronAPI.on = vi.fn((event: string, handler: () => void | Promise<void>) => {
|
||||
menuListeners.set(event, handler);
|
||||
return () => {
|
||||
menuListeners.delete(event);
|
||||
};
|
||||
});
|
||||
|
||||
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
|
||||
(window as any).electronAPI.posts.getTranslations = vi.fn().mockResolvedValue([]);
|
||||
(window as any).electronAPI.posts.getTranslation = vi.fn().mockResolvedValue(null);
|
||||
(window as any).electronAPI.posts.publishTranslation = vi.fn().mockResolvedValue(createPost());
|
||||
(window as any).electronAPI.posts.upsertTranslation = vi.fn().mockImplementation(async (postId: string, language: string, data: Record<string, string>) =>
|
||||
createTranslation({ translationFor: postId, language, ...data, status: 'draft' })
|
||||
);
|
||||
(window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/preview');
|
||||
(window as any).electronAPI.posts.publish = vi.fn().mockResolvedValue(createPost({ status: 'published' }));
|
||||
(window as any).electronAPI.posts.update = vi.fn().mockImplementation(async (_postId: string, payload: Record<string, string>) => ({
|
||||
...createPost(),
|
||||
...payload,
|
||||
@@ -184,6 +220,9 @@ describe('Editor AI post suggestions', () => {
|
||||
excerpt: 'A concise summary.',
|
||||
slug: 'better-title',
|
||||
});
|
||||
(window as any).electronAPI.chat.translatePost = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: { id: 'project-1', name: 'Test', path: '/tmp/test' } as any,
|
||||
@@ -201,7 +240,8 @@ describe('Editor AI post suggestions', () => {
|
||||
publishedAt: new Date('2026-02-16T12:00:00.000Z'),
|
||||
}));
|
||||
|
||||
render(<PostEditor postId="post-1" />);
|
||||
const view = render(<PostEditor postId="post-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -210,11 +250,11 @@ describe('Editor AI post suggestions', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
|
||||
fireEvent.click(ui.getByRole('button', { name: '⚡ Quick Actions' }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
|
||||
fireEvent.click(ui.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
|
||||
});
|
||||
|
||||
const slugField = lastSuggestionFields.find((field) => field.key === 'slug');
|
||||
@@ -225,7 +265,8 @@ describe('Editor AI post suggestions', () => {
|
||||
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" />);
|
||||
const view = render(<PostEditor postId="post-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -234,15 +275,15 @@ describe('Editor AI post suggestions', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '⚡ Quick Actions' }));
|
||||
fireEvent.click(ui.getByRole('button', { name: '⚡ Quick Actions' }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
|
||||
fireEvent.click(ui.getByRole('button', { name: /AI: Suggest Title, Summary & Slug/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'apply-suggestions' }));
|
||||
fireEvent.click(ui.getByRole('button', { name: 'apply-suggestions' }));
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.posts.update).toHaveBeenCalledWith(
|
||||
@@ -250,4 +291,203 @@ describe('Editor AI post suggestions', () => {
|
||||
expect.objectContaining({ title: 'Better Title', slug: 'better-title' })
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a translation modal from quick actions and creates the translation on confirm', async () => {
|
||||
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: '' }));
|
||||
|
||||
const view = render(<PostEditor postId="post-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(ui.queryByRole('button', { name: 'Translate to...' })).toBeNull();
|
||||
expect(ui.queryByText('Select target language')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: '⚡ Quick Actions' }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Translate to\.\.\./i }));
|
||||
});
|
||||
|
||||
expect(ui.getByRole('heading', { name: 'Translations' })).toBeInTheDocument();
|
||||
expect(ui.getByText('Select target language')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(ui.getByLabelText('Select target language'), { target: { value: 'fr' } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: 'Translate to...' }));
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.chat.translatePost).toHaveBeenCalledWith('post-1', 'fr');
|
||||
});
|
||||
|
||||
it('renders available translations as compact flag indicators in metadata', async () => {
|
||||
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({ title: '' }));
|
||||
(window as any).electronAPI.posts.getTranslations = vi.fn().mockResolvedValue([
|
||||
createTranslation(),
|
||||
createTranslation({
|
||||
id: 'translation-2',
|
||||
language: 'de',
|
||||
title: 'Hallo',
|
||||
status: 'published',
|
||||
filePath: 'posts/test-post.de.md',
|
||||
}),
|
||||
]);
|
||||
|
||||
const view = render(<PostEditor postId="post-1" />);
|
||||
const ui = within(view.container);
|
||||
const { container } = view;
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('.editor-translations-panel')).toBeNull();
|
||||
expect(container.querySelector('.metadata-toggle-header .editor-translations-flags')).not.toBeNull();
|
||||
expect(ui.getByLabelText('French (Draft)')).toBeInTheDocument();
|
||||
expect(ui.getByLabelText('German (Published)')).toBeInTheDocument();
|
||||
expect(container.querySelector('.editor-translations-flags')).not.toBeNull();
|
||||
expect(container.querySelector('.editor-translation-actions')).toBeNull();
|
||||
expect(container.querySelector('.editor-translation-language')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches the active editing language with flags and saves translated title excerpt and content to that language', async () => {
|
||||
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({
|
||||
language: 'en',
|
||||
title: 'Hello world',
|
||||
excerpt: 'Canonical excerpt',
|
||||
content: 'Canonical content',
|
||||
}));
|
||||
(window as any).electronAPI.posts.getTranslations = vi.fn().mockResolvedValue([
|
||||
createTranslation({ language: 'fr', title: 'Bonjour', excerpt: 'Resume', content: 'Contenu' }),
|
||||
]);
|
||||
|
||||
const view = render(<PostEditor postId="post-1" />);
|
||||
const ui = within(view.container);
|
||||
const { container } = view;
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Metadata/i }));
|
||||
});
|
||||
|
||||
const flags = container.querySelectorAll('.metadata-toggle-header .editor-translation-flag');
|
||||
expect(flags).toHaveLength(2);
|
||||
expect(ui.getByDisplayValue('Hello world')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByLabelText('French (Draft)'));
|
||||
});
|
||||
|
||||
expect(ui.getByDisplayValue('Bonjour')).toBeInTheDocument();
|
||||
expect((ui.getByLabelText('French (Draft)') as HTMLElement).className).toContain('active');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Excerpt/i }));
|
||||
});
|
||||
|
||||
expect(ui.getByDisplayValue('Resume')).toBeInTheDocument();
|
||||
expect(ui.getByTestId('milkdown-editor')).toHaveValue('Contenu');
|
||||
|
||||
const titleInput = container.querySelector('#post-editor-post-1-title') as HTMLInputElement;
|
||||
const excerptInput = container.querySelector('#post-editor-post-1-excerpt') as HTMLTextAreaElement;
|
||||
const contentInput = ui.getByTestId('milkdown-editor') as HTMLTextAreaElement;
|
||||
|
||||
const setTextValue = (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
const prototype = Object.getPrototypeOf(element);
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
|
||||
fireEvent.focus(element);
|
||||
valueSetter?.call(element, value);
|
||||
fireEvent.input(element, { target: { value } });
|
||||
fireEvent.change(element, { target: { value } });
|
||||
fireEvent.blur(element);
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
setTextValue(titleInput, 'Salut modifie');
|
||||
setTextValue(excerptInput, 'Resume modifie');
|
||||
setTextValue(contentInput, 'Contenu modifie');
|
||||
});
|
||||
|
||||
expect(ui.getByDisplayValue('Salut modifie')).toBeInTheDocument();
|
||||
expect(ui.getByDisplayValue('Resume modifie')).toBeInTheDocument();
|
||||
expect(ui.getByTestId('milkdown-editor')).toHaveValue('Contenu modifie');
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: 'Publish' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.posts.upsertTranslation).toHaveBeenCalledWith(
|
||||
'post-1',
|
||||
'fr',
|
||||
expect.objectContaining({
|
||||
title: 'Salut modifie',
|
||||
excerpt: 'Resume modifie',
|
||||
content: 'Contenu modifie',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requests preview URL with lang when a translation is the active editing language', async () => {
|
||||
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost({
|
||||
language: 'en',
|
||||
title: 'Hello world',
|
||||
content: 'Canonical content',
|
||||
}));
|
||||
(window as any).electronAPI.posts.getTranslations = vi.fn().mockResolvedValue([
|
||||
createTranslation({ language: 'fr', title: 'Bonjour', content: 'Contenu' }),
|
||||
]);
|
||||
|
||||
const view = render(<PostEditor postId="post-1" />);
|
||||
const ui = within(view.container);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: /Metadata/i }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByLabelText('French (Draft)'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(ui.getByRole('button', { name: 'Preview (Read-only)' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect((window as any).electronAPI.posts.getPreviewUrl).toHaveBeenLastCalledWith('post-1', {
|
||||
draft: true,
|
||||
lang: 'fr',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -124,7 +124,7 @@ vi.mock('../../../src/renderer/components/Toast', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/Editor';
|
||||
import { PostEditor } from '../../../src/renderer/components/Editor/PostEditor';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
const createPost = () => ({
|
||||
|
||||
@@ -63,6 +63,17 @@ describe('Help menu documentation entry', () => {
|
||||
expect(APP_MENU_ACTION_EVENT_MAP.validateSite).toBe('menu:validateSite');
|
||||
});
|
||||
|
||||
it('includes Validate Translations action in Blog menu', () => {
|
||||
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||
|
||||
expect(blogGroup).toBeDefined();
|
||||
expect(blogGroup?.items.some((item) => item.action === 'validateTranslations')).toBe(true);
|
||||
});
|
||||
|
||||
it('maps Validate Translations to a renderer menu event', () => {
|
||||
expect(APP_MENU_ACTION_EVENT_MAP.validateTranslations).toBe('menu:validateTranslations');
|
||||
});
|
||||
|
||||
it('includes Regenerate Calendar action in Blog menu', () => {
|
||||
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||
|
||||
@@ -96,4 +107,15 @@ describe('Help menu documentation entry', () => {
|
||||
it('maps Edit Menu to a renderer menu event', () => {
|
||||
expect(APP_MENU_ACTION_EVENT_MAP.editMenu).toBe('menu:editMenu');
|
||||
});
|
||||
|
||||
it('includes Fill Missing Translations action in Blog menu', () => {
|
||||
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||
|
||||
expect(blogGroup).toBeDefined();
|
||||
expect(blogGroup?.items.some((item) => item.action === 'fillMissingTranslations')).toBe(true);
|
||||
});
|
||||
|
||||
it('maps Fill Missing Translations to a renderer menu event', () => {
|
||||
expect(APP_MENU_ACTION_EVENT_MAP.fillMissingTranslations).toBe('menu:fillMissingTranslations');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('editorRouting', () => {
|
||||
documentation: 'documentation',
|
||||
'api-documentation': 'api-documentation',
|
||||
'site-validation': 'site-validation',
|
||||
'translation-validation': 'translation-validation',
|
||||
scripts: 'scripts',
|
||||
templates: 'templates',
|
||||
'find-duplicates': 'find-duplicates',
|
||||
|
||||
@@ -40,6 +40,24 @@ describe('postCreation', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('passes provided categories to createPost', async () => {
|
||||
const createPost = vi.fn().mockResolvedValue({ id: 'page-1' });
|
||||
const setSelectedPost = vi.fn();
|
||||
|
||||
await createAndFocusPost({
|
||||
createPost,
|
||||
setSelectedPost,
|
||||
categories: ['page'],
|
||||
});
|
||||
|
||||
expect(createPost).toHaveBeenCalledWith({
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: ['page'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null and reports errors', async () => {
|
||||
const createPost = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const setSelectedPost = vi.fn();
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('tabPolicy', () => {
|
||||
expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('site-validation')).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
||||
expect(getSingletonToolTabSpec('translation-validation')).toEqual({ type: 'translation-validation', id: 'translation-validation', isTransient: false });
|
||||
});
|
||||
|
||||
it('opens singleton tool tabs using canonical tab spec', () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TranslationValidationReport } from '../../../src/main/shared/electronApi';
|
||||
import {
|
||||
getPersistedTranslationValidationReport,
|
||||
persistTranslationValidationReport,
|
||||
} from '../../../src/renderer/navigation/translationValidationPersistence';
|
||||
|
||||
const report: TranslationValidationReport = {
|
||||
checkedDatabaseRowCount: 2,
|
||||
checkedFilesystemFileCount: 3,
|
||||
invalidDatabaseRows: [
|
||||
{
|
||||
issue: 'same-language-as-canonical',
|
||||
translationId: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
canonicalLanguage: 'de',
|
||||
translationLanguage: 'de',
|
||||
title: 'Hallo Welt',
|
||||
},
|
||||
],
|
||||
invalidFilesystemFiles: [
|
||||
{
|
||||
issue: 'missing-source-post',
|
||||
translationFor: 'missing-post',
|
||||
translationLanguage: 'it',
|
||||
filePath: '/tmp/project/posts/orphan.it.md',
|
||||
title: 'Ciao',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('translationValidationPersistence', () => {
|
||||
it('persists and loads translation validation report by project', () => {
|
||||
persistTranslationValidationReport('project-1', report);
|
||||
|
||||
expect(getPersistedTranslationValidationReport('project-1')).toEqual(report);
|
||||
});
|
||||
|
||||
it('returns null when project has no persisted report', () => {
|
||||
expect(getPersistedTranslationValidationReport('missing-project')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -59,10 +59,10 @@ describe('pythonApiContractV1', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes analyzeMediaImage and detectPostLanguage from chat namespace', () => {
|
||||
it('exposes one-shot translation from chat namespace', () => {
|
||||
const methodNames = listPythonApiMethodNames();
|
||||
const chatMethods = methodNames.filter((m) => m.startsWith('chat.'));
|
||||
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage', 'chat.analyzePost']);
|
||||
expect(chatMethods).toEqual(['chat.analyzeMediaImage', 'chat.detectPostLanguage', 'chat.analyzePost', 'chat.translatePost', 'chat.detectMediaLanguage', 'chat.translateMediaMetadata']);
|
||||
});
|
||||
|
||||
it('documents chat.analyzeMediaImage contract with mediaId and language params', () => {
|
||||
@@ -77,9 +77,21 @@ describe('pythonApiContractV1', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('documents chat.translatePost contract with postId and targetLanguage params', () => {
|
||||
expect(getPythonApiMethodContract('chat.translatePost')).toEqual({
|
||||
method: 'chat.translatePost',
|
||||
description: 'Translate a post into a target language and save it as a translation draft.',
|
||||
params: [
|
||||
{ name: 'postId', type: 'string', required: true },
|
||||
{ name: 'targetLanguage', type: 'string', required: true },
|
||||
],
|
||||
returns: 'PostTranslationResult',
|
||||
});
|
||||
});
|
||||
|
||||
it('contains semantic version metadata for compatibility checks', () => {
|
||||
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||
version: '1.13.0',
|
||||
version: '1.15.0',
|
||||
generatedAt: expect.any(String),
|
||||
});
|
||||
});
|
||||
@@ -116,6 +128,7 @@ describe('generatePythonApiModuleV1', () => {
|
||||
expect(moduleCode).toContain('class ChatApi:');
|
||||
expect(moduleCode).toContain('async def analyze_media_image(self, media_id, language=None):');
|
||||
expect(moduleCode).toContain('async def detect_post_language(self, title, content):');
|
||||
expect(moduleCode).toContain('async def translate_post(self, post_id, target_language):');
|
||||
});
|
||||
|
||||
it('escapes python keyword method names to valid identifiers', () => {
|
||||
|
||||
@@ -55,6 +55,27 @@ describe('invokePythonApiMethodV1', () => {
|
||||
expect(getProjectMetadata).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('invokes chat.translatePost via electronAPI with validated args', async () => {
|
||||
const translatePost = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
translation: { id: 'tr-1', language: 'de' },
|
||||
});
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
chat: {
|
||||
translatePost,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(invokePythonApiMethodV1('chat.translatePost', { postId: 'p1', targetLanguage: 'de' })).resolves.toEqual({
|
||||
success: true,
|
||||
translation: { id: 'tr-1', language: 'de' },
|
||||
});
|
||||
expect(translatePost).toHaveBeenCalledWith('p1', 'de');
|
||||
});
|
||||
|
||||
it('rejects unknown methods and malformed args', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
|
||||
@@ -85,6 +85,10 @@ Object.defineProperty(globalThis, 'window', {
|
||||
search: vi.fn(),
|
||||
getUrl: vi.fn(),
|
||||
getFilePath: vi.fn(),
|
||||
getTranslation: vi.fn(),
|
||||
getTranslations: vi.fn(),
|
||||
upsertTranslation: vi.fn(),
|
||||
deleteTranslation: vi.fn(),
|
||||
},
|
||||
sync: {
|
||||
checkAvailability: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user