Feature/worker threads generation (#43)
* Add worker threads architecture plan for blog generation * fix: tries to optimize rendering, still slow * feat: moved site rendering into web worker * fix: calendar grabs from central data source for calendar * fix: feeds now use blog language content and not canonical content --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -57,6 +57,7 @@ vi.mock('../../src/main/database/generatedFileHashStore', () => ({
|
||||
getGeneratedFileHash: getGeneratedFileHashMock,
|
||||
getGeneratedFileHashRecord: getGeneratedFileHashRecordMock,
|
||||
setGeneratedFileHash: setGeneratedFileHashMock,
|
||||
getAllGeneratedFileHashes: vi.fn(async () => new Map<string, string>()),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
@@ -76,7 +77,7 @@ vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
|
||||
getPostTranslation: vi.fn(async () => null),
|
||||
getPostTranslations: vi.fn(async () => []),
|
||||
setProjectContext: vi.fn(),
|
||||
};
|
||||
} as Record<string, any>;
|
||||
return {
|
||||
...actual,
|
||||
getPostEngine: vi.fn(() => mockPostEngine),
|
||||
@@ -211,6 +212,7 @@ describe('BlogGenerationEngine', () => {
|
||||
options?: Partial<{
|
||||
maxPostsPerPage: number;
|
||||
language: string;
|
||||
blogLanguages: string[];
|
||||
pageTitle: string;
|
||||
picoTheme: string;
|
||||
categorySettings: Record<string, { renderInLists: boolean; showTitle: boolean }>;
|
||||
@@ -229,6 +231,7 @@ describe('BlogGenerationEngine', () => {
|
||||
baseUrl: 'https://example.com',
|
||||
maxPostsPerPage: options?.maxPostsPerPage,
|
||||
language: options?.language,
|
||||
blogLanguages: options?.blogLanguages,
|
||||
pageTitle: options?.pageTitle,
|
||||
picoTheme: options?.picoTheme as any,
|
||||
categorySettings: options?.categorySettings,
|
||||
@@ -2148,6 +2151,154 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(result.postCount).toBe(0);
|
||||
});
|
||||
|
||||
it('language subtree list pages show translated title and excerpt, not canonical language', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: 'de-post-1',
|
||||
slug: 'german-post',
|
||||
title: 'Deutscher Titel',
|
||||
excerpt: 'Deutscher Auszug',
|
||||
content: '# Deutscher Inhalt',
|
||||
language: 'de',
|
||||
categories: ['tech'],
|
||||
createdAt: new Date('2025-06-10T10:00:00Z'),
|
||||
availableLanguages: ['de', 'en'],
|
||||
}),
|
||||
];
|
||||
|
||||
const translationMap = new Map<string, PostTranslationData[]>();
|
||||
translationMap.set('de-post-1', [{
|
||||
id: 'en-trans-1',
|
||||
projectId: 'test',
|
||||
translationFor: 'de-post-1',
|
||||
language: 'en',
|
||||
title: 'English Title',
|
||||
excerpt: 'English excerpt',
|
||||
content: '# English Content',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-06-10T10:00:00Z'),
|
||||
updatedAt: new Date('2025-06-10T10:00:00Z'),
|
||||
publishedAt: new Date('2025-06-10T10:00:00Z'),
|
||||
filePath: '',
|
||||
}]);
|
||||
mockPostEngine.getPublishedTranslationsForRoutePosts = vi.fn().mockResolvedValue(translationMap);
|
||||
|
||||
await generate(posts, { language: 'de', blogLanguages: ['de', 'en'] });
|
||||
|
||||
// /en/ subtree list page should show English title and excerpt
|
||||
const enIndex = await readFile(path.join(tempDir, 'html', 'en', 'index.html'), 'utf-8');
|
||||
expect(enIndex).toContain('English Title');
|
||||
expect(enIndex).toContain('English excerpt');
|
||||
expect(enIndex).not.toContain('Deutscher Titel');
|
||||
expect(enIndex).not.toContain('Deutscher Auszug');
|
||||
|
||||
// Main blog list page should still show German
|
||||
const deIndex = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
|
||||
expect(deIndex).toContain('Deutscher Titel');
|
||||
expect(deIndex).not.toContain('English Title');
|
||||
});
|
||||
|
||||
it('main blog list pages show translated content when canonical language differs from project language', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: 'en-post-1',
|
||||
slug: 'english-post',
|
||||
title: 'English Title',
|
||||
excerpt: 'English excerpt',
|
||||
content: '# English Content',
|
||||
language: 'en',
|
||||
categories: ['tech'],
|
||||
createdAt: new Date('2025-06-10T10:00:00Z'),
|
||||
availableLanguages: ['en', 'de'],
|
||||
}),
|
||||
];
|
||||
|
||||
const translationMap = new Map<string, PostTranslationData[]>();
|
||||
translationMap.set('en-post-1', [{
|
||||
id: 'de-trans-1',
|
||||
projectId: 'test',
|
||||
translationFor: 'en-post-1',
|
||||
language: 'de',
|
||||
title: 'Deutscher Titel',
|
||||
excerpt: 'Deutscher Auszug',
|
||||
content: '# Deutscher Inhalt',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-06-10T10:00:00Z'),
|
||||
updatedAt: new Date('2025-06-10T10:00:00Z'),
|
||||
publishedAt: new Date('2025-06-10T10:00:00Z'),
|
||||
filePath: '',
|
||||
}]);
|
||||
mockPostEngine.getPublishedTranslationsForRoutePosts = vi.fn().mockResolvedValue(translationMap);
|
||||
|
||||
await generate(posts, { language: 'de', blogLanguages: ['de', 'en'] });
|
||||
|
||||
// Main blog (de) should show German translated title, not English canonical
|
||||
const deIndex = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
|
||||
expect(deIndex).toContain('Deutscher Titel');
|
||||
expect(deIndex).not.toContain('English Title');
|
||||
|
||||
// /en/ subtree should show English canonical title
|
||||
const enIndex = await readFile(path.join(tempDir, 'html', 'en', 'index.html'), 'utf-8');
|
||||
expect(enIndex).toContain('English Title');
|
||||
expect(enIndex).not.toContain('Deutscher Titel');
|
||||
});
|
||||
|
||||
it('language subtree RSS and Atom feeds use translated titles and content', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: 'de-post-1',
|
||||
slug: 'german-post',
|
||||
title: 'Deutscher Titel',
|
||||
content: '# Deutscher Inhalt\n\nDeutscher Body Text',
|
||||
language: 'de',
|
||||
categories: ['tech'],
|
||||
createdAt: new Date('2025-06-10T10:00:00Z'),
|
||||
availableLanguages: ['de', 'en'],
|
||||
}),
|
||||
];
|
||||
|
||||
const translationFilePath = path.join(tempDir, 'posts', 'german-post.en.md');
|
||||
await mkdir(path.join(tempDir, 'posts'), { recursive: true });
|
||||
await writeFile(translationFilePath, '---\ntranslationFor: de-post-1\nlanguage: en\ntitle: English Title\n---\n# English Content\n\nEnglish Body Text');
|
||||
|
||||
const translationMap = new Map<string, PostTranslationData[]>();
|
||||
translationMap.set('de-post-1', [{
|
||||
id: 'en-trans-1',
|
||||
projectId: 'test',
|
||||
translationFor: 'de-post-1',
|
||||
language: 'en',
|
||||
title: 'English Title',
|
||||
content: '# English Content\n\nEnglish Body Text',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-06-10T10:00:00Z'),
|
||||
updatedAt: new Date('2025-06-10T10:00:00Z'),
|
||||
publishedAt: new Date('2025-06-10T10:00:00Z'),
|
||||
filePath: translationFilePath,
|
||||
}]);
|
||||
mockPostEngine.getPublishedTranslationsForRoutePosts = vi.fn().mockResolvedValue(translationMap);
|
||||
|
||||
await generate(posts, { language: 'de', blogLanguages: ['de', 'en'] });
|
||||
|
||||
// /en/ RSS feed should use English translated title and content
|
||||
const enRss = await readFile(path.join(tempDir, 'html', 'en', 'rss.xml'), 'utf-8');
|
||||
expect(enRss).toContain('English Title');
|
||||
expect(enRss).not.toContain('Deutscher Titel');
|
||||
expect(enRss).toContain('English Body Text');
|
||||
expect(enRss).not.toContain('Deutscher Body Text');
|
||||
|
||||
// /en/ Atom feed should use English translated title and content
|
||||
const enAtom = await readFile(path.join(tempDir, 'html', 'en', 'atom.xml'), 'utf-8');
|
||||
expect(enAtom).toContain('English Title');
|
||||
expect(enAtom).not.toContain('Deutscher Titel');
|
||||
expect(enAtom).toContain('English Body Text');
|
||||
expect(enAtom).not.toContain('Deutscher Body Text');
|
||||
|
||||
// Root RSS should keep German canonical content
|
||||
const deRss = await readFile(path.join(tempDir, 'html', 'rss.xml'), 'utf-8');
|
||||
expect(deRss).toContain('Deutscher Titel');
|
||||
expect(deRss).not.toContain('English Title');
|
||||
});
|
||||
|
||||
it('generates pagination links in list pages', async () => {
|
||||
const posts: PostData[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
|
||||
471
tests/engine/DataBackedEngines.test.ts
Normal file
471
tests/engine/DataBackedEngines.test.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createDataBackedPostEngine,
|
||||
createDataBackedMediaEngine,
|
||||
createDataBackedPostMediaEngine,
|
||||
} from '../../src/main/engine/DataBackedEngines';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
|
||||
function makePost(overrides: Partial<PostData> & { id: string; slug: string }): PostData {
|
||||
return {
|
||||
projectId: 'proj-1',
|
||||
title: overrides.slug,
|
||||
excerpt: '',
|
||||
content: `content of ${overrides.slug}`,
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-06-15T12:00:00Z'),
|
||||
updatedAt: new Date('2025-06-15T12:00:00Z'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
availableLanguages: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DataBackedPostEngine', () => {
|
||||
const posts: PostData[] = [
|
||||
makePost({ id: '1', slug: 'alpha', tags: ['js', 'node'], categories: ['article'], createdAt: new Date('2025-01-10T00:00:00Z') }),
|
||||
makePost({ id: '2', slug: 'beta', tags: ['python'], categories: ['aside'], createdAt: new Date('2025-02-15T00:00:00Z') }),
|
||||
makePost({ id: '3', slug: 'gamma', tags: ['js'], categories: ['page'], createdAt: new Date('2025-03-20T00:00:00Z') }),
|
||||
makePost({ id: '4', slug: 'delta', tags: ['rust'], categories: ['article'], createdAt: new Date('2024-12-01T00:00:00Z') }),
|
||||
];
|
||||
|
||||
const backlinksMap = new Map([
|
||||
['1', [{ id: '2', title: 'beta', slug: 'beta' }]],
|
||||
]);
|
||||
|
||||
const engine = createDataBackedPostEngine({ allPosts: posts, backlinksMap });
|
||||
|
||||
describe('getPostsFiltered', () => {
|
||||
it('returns all posts when no filter applied', async () => {
|
||||
const result = await engine.getPostsFiltered({});
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('filters by status', async () => {
|
||||
const result = await engine.getPostsFiltered({ status: 'published' });
|
||||
expect(result).toHaveLength(4);
|
||||
|
||||
const drafts = await engine.getPostsFiltered({ status: 'draft' });
|
||||
expect(drafts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters by excludeCategories', async () => {
|
||||
const result = await engine.getPostsFiltered({ excludeCategories: ['page'] });
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.every((p) => !p.categories.includes('page'))).toBe(true);
|
||||
});
|
||||
|
||||
it('filters by categories', async () => {
|
||||
const result = await engine.getPostsFiltered({ categories: ['article'] });
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters by tags (all must match)', async () => {
|
||||
const result = await engine.getPostsFiltered({ tags: ['js'] });
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const both = await engine.getPostsFiltered({ tags: ['js', 'node'] });
|
||||
expect(both).toHaveLength(1);
|
||||
expect(both[0].slug).toBe('alpha');
|
||||
});
|
||||
|
||||
it('filters by year', async () => {
|
||||
const result = await engine.getPostsFiltered({ year: 2025 });
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('filters by year and month', async () => {
|
||||
const result = await engine.getPostsFiltered({ year: 2025, month: 2 });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].slug).toBe('beta');
|
||||
});
|
||||
|
||||
it('filters by date range', async () => {
|
||||
const result = await engine.getPostsFiltered({
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-02-28T23:59:59Z'),
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns sorted by createdAt descending', async () => {
|
||||
const result = await engine.getPostsFiltered({});
|
||||
const dates = result.map((p) => p.createdAt.getTime());
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
expect(dates[i]).toBeLessThanOrEqual(dates[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes translation variants from filtered results', async () => {
|
||||
const canonical = makePost({ id: 'c1', slug: 'hello', content: 'Hello content', language: 'de' });
|
||||
const variant = makePost({
|
||||
id: 'v1',
|
||||
slug: 'hello.en',
|
||||
content: 'Hello EN content',
|
||||
language: 'en',
|
||||
translationSourceSlug: 'hello',
|
||||
} as any);
|
||||
|
||||
const eng = createDataBackedPostEngine({ allPosts: [canonical, variant] });
|
||||
const result = await eng.getPostsFiltered({ status: 'published' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].slug).toBe('hello');
|
||||
});
|
||||
|
||||
it('translation variants remain accessible via findPublishedBySlug', async () => {
|
||||
const canonical = makePost({ id: 'c1', slug: 'hello', content: 'Hello content', language: 'de' });
|
||||
const variant = makePost({
|
||||
id: 'v1',
|
||||
slug: 'hello.en',
|
||||
content: 'Hello EN content',
|
||||
language: 'en',
|
||||
translationSourceSlug: 'hello',
|
||||
} as any);
|
||||
|
||||
const eng = createDataBackedPostEngine({ allPosts: [canonical, variant] });
|
||||
const found = await eng.findPublishedBySlug('hello.en');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe('v1');
|
||||
});
|
||||
|
||||
it('includes resolved posts (slug === translationSourceSlug) in filtered results', async () => {
|
||||
// A resolved post is a canonical post with its title/excerpt replaced by a translation.
|
||||
// Its slug remains the same as translationSourceSlug (e.g., both are "hello").
|
||||
const resolved = makePost({
|
||||
id: 'r1',
|
||||
slug: 'hello',
|
||||
title: 'Hallo (übersetzt)',
|
||||
language: 'en',
|
||||
translationSourceSlug: 'hello',
|
||||
} as any);
|
||||
const unresolved = makePost({
|
||||
id: 'r2',
|
||||
slug: 'world',
|
||||
title: 'Welt',
|
||||
language: 'de',
|
||||
});
|
||||
|
||||
const eng = createDataBackedPostEngine({ allPosts: [resolved, unresolved] });
|
||||
const result = await eng.getPostsFiltered({ status: 'published' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((p) => p.slug).sort()).toEqual(['hello', 'world']);
|
||||
});
|
||||
|
||||
it('lazy-loads translation content for resolved posts returned by getPostsFiltered', async () => {
|
||||
const resolved = makePost({
|
||||
id: 'lz-1',
|
||||
slug: 'lazy-post',
|
||||
title: 'Translated Title',
|
||||
content: '',
|
||||
language: 'en',
|
||||
translationSourceSlug: 'lazy-post',
|
||||
translationFilePath: '/data/posts/2025/06/lazy-post.en.md',
|
||||
} as any);
|
||||
|
||||
const readTranslationSpy = vi.spyOn(await import('../../src/main/engine/postTranslationFileUtils'), 'readPostTranslationFile');
|
||||
readTranslationSpy.mockResolvedValueOnce({
|
||||
translationFor: 'lz-1',
|
||||
language: 'en',
|
||||
title: 'Translated Title',
|
||||
content: 'Lazy loaded translation content!',
|
||||
});
|
||||
|
||||
const eng = createDataBackedPostEngine({
|
||||
allPosts: [resolved],
|
||||
postFilePaths: new Map(),
|
||||
});
|
||||
|
||||
const result = await eng.getPostsFiltered({ status: 'published' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('Lazy loaded translation content!');
|
||||
expect(readTranslationSpy).toHaveBeenCalledWith('/data/posts/2025/06/lazy-post.en.md');
|
||||
|
||||
readTranslationSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookup methods', () => {
|
||||
it('getPublishedVersion returns by id', async () => {
|
||||
const post = await engine.getPublishedVersion('2');
|
||||
expect(post?.slug).toBe('beta');
|
||||
});
|
||||
|
||||
it('getPublishedVersion returns null for unknown id', async () => {
|
||||
expect(await engine.getPublishedVersion('unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('getPost returns by id', async () => {
|
||||
const post = await engine.getPost('3');
|
||||
expect(post?.slug).toBe('gamma');
|
||||
});
|
||||
|
||||
it('hasPublishedVersion checks existence', async () => {
|
||||
expect(await engine.hasPublishedVersion('1')).toBe(true);
|
||||
expect(await engine.hasPublishedVersion('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('findPublishedBySlug returns first match without date filter', async () => {
|
||||
const post = await engine.findPublishedBySlug('alpha');
|
||||
expect(post?.id).toBe('1');
|
||||
});
|
||||
|
||||
it('findPublishedBySlug returns null for unknown slug', async () => {
|
||||
expect(await engine.findPublishedBySlug('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('findPublishedBySlug applies date filter', async () => {
|
||||
const match = await engine.findPublishedBySlug('alpha', { year: 2025, month: 1 });
|
||||
expect(match?.id).toBe('1');
|
||||
|
||||
const noMatch = await engine.findPublishedBySlug('alpha', { year: 2024, month: 1 });
|
||||
expect(noMatch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlinks', () => {
|
||||
it('getLinkedBy returns backlinks for a post', async () => {
|
||||
const links = await engine.getLinkedBy('1');
|
||||
expect(links).toEqual([{ id: '2', title: 'beta', slug: 'beta' }]);
|
||||
});
|
||||
|
||||
it('getLinkedBy returns empty for unlinked post', async () => {
|
||||
const links = await engine.getLinkedBy('3');
|
||||
expect(links).toEqual([]);
|
||||
});
|
||||
|
||||
it('getAllBacklinks returns the full map', async () => {
|
||||
const map = await engine.getAllBacklinks();
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get('1')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translations (stubs)', () => {
|
||||
it('getPostTranslation returns null', async () => {
|
||||
expect(await engine.getPostTranslation('1', 'fr')).toBeNull();
|
||||
});
|
||||
|
||||
it('getPostTranslations returns empty array', async () => {
|
||||
expect(await engine.getPostTranslations('1')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setProjectContext is a no-op', () => {
|
||||
it('does not throw', () => {
|
||||
expect(() => engine.setProjectContext('proj-1', '/data')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublishedVersion with lazy FS content loading', () => {
|
||||
it('reads content from filesystem when post has no content and filePath is available', async () => {
|
||||
const emptyContentPost = makePost({ id: 'fs-1', slug: 'fs-post', content: '' });
|
||||
const postFilePaths = new Map([['fs-1', '/data/posts/2025/06/fs-post.md']]);
|
||||
|
||||
const { readPostFile } = await import('../../src/main/engine/postFileUtils');
|
||||
const readPostFileSpy = vi.spyOn(await import('../../src/main/engine/postFileUtils'), 'readPostFile');
|
||||
readPostFileSpy.mockResolvedValueOnce({
|
||||
id: 'fs-1',
|
||||
title: 'FS Post',
|
||||
slug: 'fs-post',
|
||||
content: 'Content loaded from filesystem!',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-06-15'),
|
||||
updatedAt: new Date('2025-06-15'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
});
|
||||
|
||||
const fsEngine = createDataBackedPostEngine({
|
||||
allPosts: [emptyContentPost],
|
||||
postFilePaths,
|
||||
});
|
||||
|
||||
const result = await fsEngine.getPublishedVersion('fs-1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.content).toBe('Content loaded from filesystem!');
|
||||
expect(readPostFileSpy).toHaveBeenCalledWith('/data/posts/2025/06/fs-post.md');
|
||||
|
||||
readPostFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not read from FS when post already has content', async () => {
|
||||
const postWithContent = makePost({ id: 'has-c', slug: 'has-content', content: 'Already here' });
|
||||
const postFilePaths = new Map([['has-c', '/data/posts/2025/06/has-content.md']]);
|
||||
|
||||
const readPostFileSpy = vi.spyOn(await import('../../src/main/engine/postFileUtils'), 'readPostFile');
|
||||
|
||||
const fsEngine = createDataBackedPostEngine({
|
||||
allPosts: [postWithContent],
|
||||
postFilePaths,
|
||||
});
|
||||
|
||||
const result = await fsEngine.getPublishedVersion('has-c');
|
||||
expect(result!.content).toBe('Already here');
|
||||
expect(readPostFileSpy).not.toHaveBeenCalled();
|
||||
|
||||
readPostFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('reads translation content from translationFilePath', async () => {
|
||||
const translationPost = makePost({ id: 'tr-1', slug: 'post.fr', content: '' });
|
||||
(translationPost as any).translationFilePath = '/data/posts/2025/06/post.fr.md';
|
||||
|
||||
const readTranslationSpy = vi.spyOn(await import('../../src/main/engine/postTranslationFileUtils'), 'readPostTranslationFile');
|
||||
readTranslationSpy.mockResolvedValueOnce({
|
||||
translationFor: 'original-id',
|
||||
language: 'fr',
|
||||
title: 'Post FR',
|
||||
content: 'Contenu fran\u00e7ais!',
|
||||
});
|
||||
|
||||
const fsEngine = createDataBackedPostEngine({
|
||||
allPosts: [translationPost],
|
||||
postFilePaths: new Map(),
|
||||
});
|
||||
|
||||
const result = await fsEngine.getPublishedVersion('tr-1');
|
||||
expect(result!.content).toBe('Contenu fran\u00e7ais!');
|
||||
expect(readTranslationSpy).toHaveBeenCalledWith('/data/posts/2025/06/post.fr.md');
|
||||
|
||||
readTranslationSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPost with lazy FS content loading', () => {
|
||||
it('reads content from filesystem when post has no content', async () => {
|
||||
const emptyContentPost = makePost({ id: 'gp-1', slug: 'gp-post', content: '' });
|
||||
const postFilePaths = new Map([['gp-1', '/data/posts/2025/06/gp-post.md']]);
|
||||
|
||||
const readPostFileSpy = vi.spyOn(await import('../../src/main/engine/postFileUtils'), 'readPostFile');
|
||||
readPostFileSpy.mockResolvedValueOnce({
|
||||
id: 'gp-1',
|
||||
title: 'GP Post',
|
||||
slug: 'gp-post',
|
||||
content: 'Content via getPost!',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-06-15'),
|
||||
updatedAt: new Date('2025-06-15'),
|
||||
tags: [],
|
||||
categories: [],
|
||||
});
|
||||
|
||||
const fsEngine = createDataBackedPostEngine({
|
||||
allPosts: [emptyContentPost],
|
||||
postFilePaths,
|
||||
});
|
||||
|
||||
const result = await fsEngine.getPost('gp-1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.content).toBe('Content via getPost!');
|
||||
expect(readPostFileSpy).toHaveBeenCalledWith('/data/posts/2025/06/gp-post.md');
|
||||
|
||||
readPostFileSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataBackedMediaEngine', () => {
|
||||
const mediaItems: import('../../src/main/engine/MediaEngine').MediaData[] = [
|
||||
{
|
||||
id: 'm1', filename: 'photo.jpg', originalName: 'my-photo.jpg',
|
||||
mimeType: 'image/jpeg', size: 12345, width: 800, height: 600,
|
||||
title: 'Beach', alt: 'Beach photo', caption: 'A sunny beach',
|
||||
author: 'Alice', language: 'en',
|
||||
createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
|
||||
tags: ['nature', 'beach'], linkedPostIds: ['p1', 'p2'], availableLanguages: ['en', 'de'],
|
||||
},
|
||||
{
|
||||
id: 'm2', filename: 'doc.pdf', originalName: 'document.pdf',
|
||||
mimeType: 'application/pdf', size: 99999,
|
||||
createdAt: new Date('2025-02-01'), updatedAt: new Date('2025-02-01'),
|
||||
tags: [], availableLanguages: [],
|
||||
},
|
||||
];
|
||||
|
||||
const engine = createDataBackedMediaEngine(mediaItems);
|
||||
|
||||
it('getAllMedia returns all items with full MediaData fields', async () => {
|
||||
const result = await engine.getAllMedia();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].filename).toBe('photo.jpg');
|
||||
expect(result[0].mimeType).toBe('image/jpeg');
|
||||
expect(result[0].size).toBe(12345);
|
||||
expect(result[0].width).toBe(800);
|
||||
expect(result[0].height).toBe(600);
|
||||
expect(result[0].title).toBe('Beach');
|
||||
expect(result[0].alt).toBe('Beach photo');
|
||||
expect(result[0].caption).toBe('A sunny beach');
|
||||
expect(result[0].author).toBe('Alice');
|
||||
expect(result[0].language).toBe('en');
|
||||
expect(result[0].tags).toEqual(['nature', 'beach']);
|
||||
expect(result[0].linkedPostIds).toEqual(['p1', 'p2']);
|
||||
expect(result[0].availableLanguages).toEqual(['en', 'de']);
|
||||
});
|
||||
|
||||
it('setProjectContext is a no-op', () => {
|
||||
expect(() => engine.setProjectContext('proj-1')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataBackedPostMediaEngine', () => {
|
||||
const mediaItems: import('../../src/main/engine/MediaEngine').MediaData[] = [
|
||||
{
|
||||
id: 'm1', filename: 'photo.jpg', originalName: 'my-photo.jpg',
|
||||
mimeType: 'image/jpeg', size: 12345, width: 800, height: 600,
|
||||
title: 'Beach', alt: 'Beach photo', caption: 'A sunny beach',
|
||||
createdAt: new Date('2025-01-01'), updatedAt: new Date('2025-01-02'),
|
||||
tags: ['nature'], linkedPostIds: ['p1'], availableLanguages: [],
|
||||
},
|
||||
{
|
||||
id: 'm2', filename: 'sunset.png', originalName: 'sunset.png',
|
||||
mimeType: 'image/png', size: 5555,
|
||||
createdAt: new Date('2025-02-01'), updatedAt: new Date('2025-02-01'),
|
||||
tags: [], linkedPostIds: ['p1'], availableLanguages: [],
|
||||
},
|
||||
{
|
||||
id: 'm3', filename: 'doc.pdf', originalName: 'document.pdf',
|
||||
mimeType: 'application/pdf', size: 99999,
|
||||
createdAt: new Date('2025-03-01'), updatedAt: new Date('2025-03-01'),
|
||||
tags: [], availableLanguages: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Post p1 is linked to m1 (sort 0) and m2 (sort 1)
|
||||
const postMediaLinks = new Map<string, Array<{ mediaId: string; sortOrder: number }>>([
|
||||
['p1', [{ mediaId: 'm1', sortOrder: 0 }, { mediaId: 'm2', sortOrder: 1 }]],
|
||||
]);
|
||||
|
||||
const engine = createDataBackedPostMediaEngine({ mediaItems, postMediaLinks });
|
||||
|
||||
it('getLinkedMediaForPost returns links for a post', async () => {
|
||||
const result = await engine.getLinkedMediaForPost('p1');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].mediaId).toBe('m1');
|
||||
expect(result[1].mediaId).toBe('m2');
|
||||
});
|
||||
|
||||
it('getLinkedMediaForPost returns empty for unknown post', async () => {
|
||||
const result = await engine.getLinkedMediaForPost('unknown');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('getLinkedMediaDataForPost returns links with full media data', async () => {
|
||||
const result = await engine.getLinkedMediaDataForPost('p1');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].media.id).toBe('m1');
|
||||
expect(result[0].media.mimeType).toBe('image/jpeg');
|
||||
expect(result[0].media.caption).toBe('A sunny beach');
|
||||
expect(result[1].media.id).toBe('m2');
|
||||
expect(result[1].media.mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('getLinkedMediaDataForPost returns empty for unknown post', async () => {
|
||||
const result = await engine.getLinkedMediaDataForPost('unknown');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('setProjectContext is a no-op', () => {
|
||||
expect(() => engine.setProjectContext('proj-1')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -75,4 +75,65 @@ describe('GenerationPostSnapshotService', () => {
|
||||
expect(result.publishedPosts.map((post) => post.id).sort()).toEqual(['article', 'page']);
|
||||
expect(result.publishedListPosts.map((post) => post.id)).toEqual(['article']);
|
||||
});
|
||||
|
||||
it('uses getPublishedVersionsBulk when available for efficient loading', async () => {
|
||||
const published = makePost({ id: 'pub-1', status: 'published', categories: ['news'] });
|
||||
const draft = makePost({ id: 'draft-1', status: 'draft', categories: ['news'] });
|
||||
const pubSnapshot = makePost({ id: 'pub-1', status: 'published', categories: ['news'], title: 'Snapshot Title' });
|
||||
const draftSnapshot = makePost({ id: 'draft-1', status: 'published', categories: ['news'] });
|
||||
|
||||
const engine = makeEngine([published, draft]);
|
||||
let individualCallCount = 0;
|
||||
engine.getPublishedVersion = async () => {
|
||||
individualCallCount++;
|
||||
return null;
|
||||
};
|
||||
engine.getPublishedVersionsBulk = async (ids: string[]) => {
|
||||
const map = new Map<string, PostData>();
|
||||
if (ids.includes('pub-1')) map.set('pub-1', pubSnapshot);
|
||||
if (ids.includes('draft-1')) map.set('draft-1', draftSnapshot);
|
||||
return map;
|
||||
};
|
||||
|
||||
const result = await loadPublishedGenerationSets(engine, []);
|
||||
|
||||
expect(individualCallCount).toBe(0);
|
||||
expect(result.publishedPosts).toHaveLength(2);
|
||||
expect(result.publishedPosts.find(p => p.id === 'pub-1')?.title).toBe('Snapshot Title');
|
||||
});
|
||||
|
||||
it('uses bulk loading with list-excluded categories filtered in memory', async () => {
|
||||
const article = makePost({ id: 'article', status: 'published', categories: ['article'] });
|
||||
const page = makePost({ id: 'page', status: 'published', categories: ['page'] });
|
||||
const articleSnapshot = makePost({ id: 'article', status: 'published', categories: ['article'], title: 'Article Snap' });
|
||||
const pageSnapshot = makePost({ id: 'page', status: 'published', categories: ['page'], title: 'Page Snap' });
|
||||
|
||||
const engine = makeEngine([article, page]);
|
||||
engine.getPublishedVersionsBulk = async (ids: string[]) => {
|
||||
const map = new Map<string, PostData>();
|
||||
if (ids.includes('article')) map.set('article', articleSnapshot);
|
||||
if (ids.includes('page')) map.set('page', pageSnapshot);
|
||||
return map;
|
||||
};
|
||||
|
||||
const result = await loadPublishedGenerationSets(engine, ['page']);
|
||||
|
||||
expect(result.publishedPosts.map(p => p.id).sort()).toEqual(['article', 'page']);
|
||||
expect(result.publishedListPosts.map(p => p.id)).toEqual(['article']);
|
||||
});
|
||||
|
||||
it('only calls getPostsFiltered twice (published + draft), not four times', async () => {
|
||||
const post = makePost({ id: 'p1', status: 'published' });
|
||||
const engine = makeEngine([post]);
|
||||
let filterCallCount = 0;
|
||||
const originalGetPostsFiltered = engine.getPostsFiltered;
|
||||
engine.getPostsFiltered = async (filter) => {
|
||||
filterCallCount++;
|
||||
return originalGetPostsFiltered(filter);
|
||||
};
|
||||
|
||||
await loadPublishedGenerationSets(engine, ['some-category']);
|
||||
|
||||
expect(filterCallCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,4 +183,59 @@ describe('GenerationRouteRendererFactory', () => {
|
||||
expect(html).toContain('youtube.com/embed/dQw4w9WgXcQ?rel=0');
|
||||
expect(html).toContain('/assets/bds.css');
|
||||
});
|
||||
|
||||
it('produces correct language flags for subtree pages when projectMainLanguage is set', async () => {
|
||||
const post = makePost({
|
||||
id: 'lang-1',
|
||||
slug: 'lang-post',
|
||||
title: 'Sprachbeitrag',
|
||||
content: 'Inhalt des Beitrags.',
|
||||
createdAt: new Date('2025-01-15T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const postEngine = {
|
||||
getPostsFiltered: vi.fn(async () => [post]),
|
||||
getPublishedVersion: vi.fn(async () => null),
|
||||
findPublishedBySlug: vi.fn(async (slug: string) => (slug === post.slug ? post : null)),
|
||||
getPost: vi.fn(async (id: string) => (id === post.id ? post : null)),
|
||||
hasPublishedVersion: vi.fn(async () => false),
|
||||
setProjectContext: vi.fn(),
|
||||
};
|
||||
|
||||
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
options: {
|
||||
projectId: 'project',
|
||||
dataDir: '/tmp',
|
||||
projectName: 'Project',
|
||||
language: 'en',
|
||||
blogLanguages: ['de', 'en'],
|
||||
},
|
||||
projectMainLanguage: 'de',
|
||||
maxPostsPerPage: 50,
|
||||
publishedPostsForLookup: [post],
|
||||
languagePrefix: '/en',
|
||||
engines: {
|
||||
postEngine,
|
||||
mediaEngine: {
|
||||
getAllMedia: vi.fn(async () => []),
|
||||
setProjectContext: vi.fn(),
|
||||
},
|
||||
postMediaEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
getLinkedMediaForPost: vi.fn(async () => []),
|
||||
getLinkedMediaDataForPost: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const html = await renderRoute('/');
|
||||
|
||||
// German flag should point to root (main language), not /de/
|
||||
expect(html).toContain('🇩🇪');
|
||||
expect(html).toContain('🇬🇧');
|
||||
// Main language link should have no prefix (root)
|
||||
expect(html).not.toContain('href="/de');
|
||||
// English flag should show /en prefix
|
||||
expect(html).toContain('/en');
|
||||
});
|
||||
});
|
||||
|
||||
236
tests/engine/GenerationWorkerData.test.ts
Normal file
236
tests/engine/GenerationWorkerData.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
serializePostData,
|
||||
deserializePostData,
|
||||
serializeMediaItem,
|
||||
deserializeMediaItem,
|
||||
serializeBlogGenerationOptions,
|
||||
serializePostMap,
|
||||
deserializePostMap,
|
||||
serializeDateMap,
|
||||
deserializeDateMap,
|
||||
} from '../../src/main/engine/GenerationWorkerData';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
|
||||
function makePost(overrides: Partial<PostData> & { id: string; slug: string }): PostData {
|
||||
return {
|
||||
projectId: 'proj-1',
|
||||
title: overrides.slug,
|
||||
excerpt: 'short',
|
||||
content: `body of ${overrides.slug}`,
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-06-15T12:00:00Z'),
|
||||
updatedAt: new Date('2025-06-15T14:00:00Z'),
|
||||
tags: ['a', 'b'],
|
||||
categories: ['article'],
|
||||
availableLanguages: ['en'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PostData serialization', () => {
|
||||
it('round-trips a basic post', () => {
|
||||
const post = makePost({ id: '1', slug: 'hello' });
|
||||
const serialized = serializePostData(post);
|
||||
const deserialized = deserializePostData(serialized);
|
||||
|
||||
expect(deserialized.id).toBe('1');
|
||||
expect(deserialized.slug).toBe('hello');
|
||||
expect(deserialized.createdAt).toBeInstanceOf(Date);
|
||||
expect(deserialized.createdAt.toISOString()).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(deserialized.updatedAt).toBeInstanceOf(Date);
|
||||
expect(deserialized.tags).toEqual(['a', 'b']);
|
||||
expect(deserialized.categories).toEqual(['article']);
|
||||
expect(deserialized.content).toBe('body of hello');
|
||||
});
|
||||
|
||||
it('round-trips publishedAt', () => {
|
||||
const post = makePost({ id: '2', slug: 'pub', publishedAt: new Date('2025-07-01T00:00:00Z') });
|
||||
const result = deserializePostData(serializePostData(post));
|
||||
expect(result.publishedAt).toBeInstanceOf(Date);
|
||||
expect(result.publishedAt?.toISOString()).toBe('2025-07-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('round-trips undefined publishedAt', () => {
|
||||
const post = makePost({ id: '3', slug: 'nopub' });
|
||||
const result = deserializePostData(serializePostData(post));
|
||||
expect(result.publishedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves translation variant fields', () => {
|
||||
const post = makePost({ id: '4', slug: 'hello.fr' });
|
||||
(post as any).translationSourceSlug = 'hello';
|
||||
(post as any).translationCanonicalLanguage = 'en';
|
||||
(post as any).translationFilePath = '/data/translations/hello.fr.md';
|
||||
|
||||
const result = deserializePostData(serializePostData(post));
|
||||
expect((result as any).translationSourceSlug).toBe('hello');
|
||||
expect((result as any).translationCanonicalLanguage).toBe('en');
|
||||
expect((result as any).translationFilePath).toBe('/data/translations/hello.fr.md');
|
||||
});
|
||||
|
||||
it('handles post with Date already as string (defensive)', () => {
|
||||
const post = makePost({ id: '5', slug: 'strdate' });
|
||||
(post as any).createdAt = '2025-01-01T00:00:00.000Z';
|
||||
const serialized = serializePostData(post);
|
||||
expect(serialized.createdAt).toBe('2025-01-01T00:00:00.000Z');
|
||||
|
||||
const deserialized = deserializePostData(serialized);
|
||||
expect(deserialized.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaItem serialization', () => {
|
||||
it('round-trips a media item with all fields', () => {
|
||||
const media = {
|
||||
id: 'm1',
|
||||
filename: 'photo.webp',
|
||||
originalName: 'My Photo.webp',
|
||||
mimeType: 'image/webp',
|
||||
size: 54321,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
title: 'Sunset',
|
||||
alt: 'A beautiful sunset',
|
||||
caption: 'Taken at the beach',
|
||||
author: 'Bob',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-03-01T10:00:00Z'),
|
||||
updatedAt: new Date('2025-03-02T10:00:00Z'),
|
||||
tags: ['nature', 'sunset'],
|
||||
linkedPostIds: ['p1', 'p2'],
|
||||
availableLanguages: ['en', 'de'],
|
||||
};
|
||||
const serialized = serializeMediaItem(media);
|
||||
expect(serialized.createdAt).toBe('2025-03-01T10:00:00.000Z');
|
||||
expect(serialized.updatedAt).toBe('2025-03-02T10:00:00.000Z');
|
||||
expect(serialized.mimeType).toBe('image/webp');
|
||||
expect(serialized.size).toBe(54321);
|
||||
expect(serialized.width).toBe(1920);
|
||||
expect(serialized.title).toBe('Sunset');
|
||||
expect(serialized.tags).toEqual(['nature', 'sunset']);
|
||||
expect(serialized.linkedPostIds).toEqual(['p1', 'p2']);
|
||||
|
||||
const deserialized = deserializeMediaItem(serialized);
|
||||
expect(deserialized.createdAt).toBeInstanceOf(Date);
|
||||
expect(deserialized.updatedAt).toBeInstanceOf(Date);
|
||||
expect(deserialized.filename).toBe('photo.webp');
|
||||
expect(deserialized.originalName).toBe('My Photo.webp');
|
||||
expect(deserialized.mimeType).toBe('image/webp');
|
||||
expect(deserialized.size).toBe(54321);
|
||||
expect(deserialized.width).toBe(1920);
|
||||
expect(deserialized.height).toBe(1080);
|
||||
expect(deserialized.title).toBe('Sunset');
|
||||
expect(deserialized.alt).toBe('A beautiful sunset');
|
||||
expect(deserialized.caption).toBe('Taken at the beach');
|
||||
expect(deserialized.author).toBe('Bob');
|
||||
expect(deserialized.language).toBe('en');
|
||||
expect(deserialized.tags).toEqual(['nature', 'sunset']);
|
||||
expect(deserialized.linkedPostIds).toEqual(['p1', 'p2']);
|
||||
expect(deserialized.availableLanguages).toEqual(['en', 'de']);
|
||||
});
|
||||
|
||||
it('round-trips a media item with minimal fields', () => {
|
||||
const media = {
|
||||
id: 'm2',
|
||||
filename: 'doc.pdf',
|
||||
originalName: 'Document.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 999,
|
||||
createdAt: new Date('2025-04-01T00:00:00Z'),
|
||||
updatedAt: new Date('2025-04-01T00:00:00Z'),
|
||||
tags: [],
|
||||
availableLanguages: [],
|
||||
};
|
||||
const deserialized = deserializeMediaItem(serializeMediaItem(media));
|
||||
expect(deserialized.mimeType).toBe('application/pdf');
|
||||
expect(deserialized.width).toBeUndefined();
|
||||
expect(deserialized.title).toBeUndefined();
|
||||
expect(deserialized.linkedPostIds).toBeUndefined();
|
||||
expect(deserialized.tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlogGenerationOptions serialization', () => {
|
||||
it('strips fields not needed by worker', () => {
|
||||
const serialized = serializeBlogGenerationOptions({
|
||||
projectId: 'p1',
|
||||
projectName: 'My Blog',
|
||||
projectDescription: 'A blog',
|
||||
dataDir: '/data',
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
pageTitle: 'My Blog',
|
||||
maxPostsPerPage: 50,
|
||||
picoTheme: undefined,
|
||||
sections: ['single'],
|
||||
});
|
||||
|
||||
expect(serialized.projectId).toBe('p1');
|
||||
expect(serialized.baseUrl).toBe('https://example.com');
|
||||
expect(serialized.blogLanguages).toEqual(['en', 'fr']);
|
||||
// pageTitle, maxPostsPerPage, sections are not in serialized
|
||||
expect((serialized as any).sections).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostMap serialization', () => {
|
||||
it('round-trips a Map<string, PostData[]>', () => {
|
||||
const post1 = makePost({ id: '1', slug: 'a' });
|
||||
const post2 = makePost({ id: '2', slug: 'b' });
|
||||
const map = new Map<string, PostData[]>([
|
||||
['tag-js', [post1, post2]],
|
||||
['tag-py', [post2]],
|
||||
]);
|
||||
|
||||
const serialized = serializePostMap(map);
|
||||
expect(serialized).toHaveLength(2);
|
||||
expect(serialized[0][0]).toBe('tag-js');
|
||||
expect(serialized[0][1]).toHaveLength(2);
|
||||
|
||||
const deserialized = deserializePostMap(serialized);
|
||||
expect(deserialized.size).toBe(2);
|
||||
expect(deserialized.get('tag-js')).toHaveLength(2);
|
||||
expect(deserialized.get('tag-js')![0].createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('round-trips a Map<number, PostData[]>', () => {
|
||||
const post1 = makePost({ id: '1', slug: 'a' });
|
||||
const map = new Map<number, PostData[]>([
|
||||
[2025, [post1]],
|
||||
]);
|
||||
|
||||
const serialized = serializePostMap(map);
|
||||
const deserialized = deserializePostMap(serialized);
|
||||
expect(deserialized.get(2025)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DateMap serialization', () => {
|
||||
it('round-trips a Map<number, Date>', () => {
|
||||
const map = new Map<number, Date>([
|
||||
[2024, new Date('2024-01-01')],
|
||||
[2025, new Date('2025-01-01')],
|
||||
]);
|
||||
|
||||
const serialized = serializeDateMap(map);
|
||||
expect(serialized).toHaveLength(2);
|
||||
expect(typeof serialized[0][1]).toBe('string');
|
||||
|
||||
const deserialized = deserializeDateMap(serialized);
|
||||
expect(deserialized.size).toBe(2);
|
||||
expect(deserialized.get(2024)).toBeInstanceOf(Date);
|
||||
expect(deserialized.get(2025)).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('round-trips a Map<string, Date>', () => {
|
||||
const map = new Map<string, Date>([
|
||||
['2025/01', new Date('2025-01-15')],
|
||||
]);
|
||||
|
||||
const serialized = serializeDateMap(map);
|
||||
const deserialized = deserializeDateMap(serialized);
|
||||
expect(deserialized.get('2025/01')).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
274
tests/engine/GenerationWorkerPool.test.ts
Normal file
274
tests/engine/GenerationWorkerPool.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { GenerationWorkerPool, type WorkerLike, type WorkerFactory } from '../../src/main/engine/GenerationWorkerPool';
|
||||
import type { GenerationWorkerTask, WorkerOutboundMessage } from '../../src/main/engine/GenerationWorkerData';
|
||||
|
||||
function makeTask(taskId: string, section: 'single' | 'category' | 'tag' | 'date' = 'single'): GenerationWorkerTask {
|
||||
return {
|
||||
taskId,
|
||||
section,
|
||||
posts: [],
|
||||
lookupPosts: [],
|
||||
mediaItems: [],
|
||||
backlinksMap: {},
|
||||
options: {
|
||||
projectId: 'proj-1',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: '/data',
|
||||
baseUrl: 'https://example.com',
|
||||
},
|
||||
maxPostsPerPage: 50,
|
||||
htmlDir: '/data/html',
|
||||
hashMapEntries: [],
|
||||
postFilePathEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createMockWorkerFactory(
|
||||
responses: Map<string, WorkerOutboundMessage[]>,
|
||||
): WorkerFactory {
|
||||
return (_workerPath: string, workerData: GenerationWorkerTask): WorkerLike => {
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
const worker: WorkerLike = {
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
listeners.set(event, existing);
|
||||
},
|
||||
async terminate() {
|
||||
return 0;
|
||||
},
|
||||
removeAllListeners() {
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
|
||||
// Simulate async message delivery
|
||||
setTimeout(() => {
|
||||
const taskMessages = responses.get(workerData.taskId) ?? [
|
||||
{ type: 'result', taskId: workerData.taskId, pagesGenerated: 0 },
|
||||
];
|
||||
for (const msg of taskMessages) {
|
||||
const messageListeners = listeners.get('message') ?? [];
|
||||
for (const listener of messageListeners) {
|
||||
listener(msg);
|
||||
}
|
||||
}
|
||||
}, 1);
|
||||
|
||||
return worker;
|
||||
};
|
||||
}
|
||||
|
||||
describe('GenerationWorkerPool', () => {
|
||||
it('returns zero pages for empty task list', async () => {
|
||||
const pool = new GenerationWorkerPool({ maxWorkers: 2 });
|
||||
const result = await pool.runTasks([], vi.fn());
|
||||
expect(result.pagesGenerated).toBe(0);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('runs a single task and reports result', async () => {
|
||||
const responses = new Map<string, WorkerOutboundMessage[]>([
|
||||
['task-1', [{ type: 'result', taskId: 'task-1', pagesGenerated: 42 }]],
|
||||
]);
|
||||
|
||||
const pool = new GenerationWorkerPool(
|
||||
{ maxWorkers: 2 },
|
||||
createMockWorkerFactory(responses),
|
||||
);
|
||||
|
||||
const progress = vi.fn();
|
||||
const result = await pool.runTasks([makeTask('task-1')], progress);
|
||||
|
||||
expect(result.pagesGenerated).toBe(42);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('merges results from multiple tasks', async () => {
|
||||
const responses = new Map<string, WorkerOutboundMessage[]>([
|
||||
['task-1', [{ type: 'result', taskId: 'task-1', pagesGenerated: 10 }]],
|
||||
['task-2', [{ type: 'result', taskId: 'task-2', pagesGenerated: 20 }]],
|
||||
['task-3', [{ type: 'result', taskId: 'task-3', pagesGenerated: 30 }]],
|
||||
]);
|
||||
|
||||
const pool = new GenerationWorkerPool(
|
||||
{ maxWorkers: 2 },
|
||||
createMockWorkerFactory(responses),
|
||||
);
|
||||
|
||||
const result = await pool.runTasks(
|
||||
[makeTask('task-1'), makeTask('task-2'), makeTask('task-3')],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(result.pagesGenerated).toBe(60);
|
||||
});
|
||||
|
||||
it('collects progress messages', async () => {
|
||||
const responses = new Map<string, WorkerOutboundMessage[]>([
|
||||
['task-1', [
|
||||
{ type: 'progress', taskId: 'task-1', message: 'Page 1' },
|
||||
{ type: 'progress', taskId: 'task-1', message: 'Page 2' },
|
||||
{ type: 'result', taskId: 'task-1', pagesGenerated: 2 },
|
||||
]],
|
||||
]);
|
||||
|
||||
const pool = new GenerationWorkerPool(
|
||||
{ maxWorkers: 1 },
|
||||
createMockWorkerFactory(responses),
|
||||
);
|
||||
|
||||
const progress = vi.fn();
|
||||
await pool.runTasks([makeTask('task-1')], progress);
|
||||
|
||||
expect(progress).toHaveBeenCalledWith('Page 1');
|
||||
expect(progress).toHaveBeenCalledWith('Page 2');
|
||||
});
|
||||
|
||||
it('collects errors from failed tasks', async () => {
|
||||
const responses = new Map<string, WorkerOutboundMessage[]>([
|
||||
['task-1', [{ type: 'error', taskId: 'task-1', error: 'Render failed' }]],
|
||||
['task-2', [{ type: 'result', taskId: 'task-2', pagesGenerated: 5 }]],
|
||||
]);
|
||||
|
||||
const pool = new GenerationWorkerPool(
|
||||
{ maxWorkers: 2 },
|
||||
createMockWorkerFactory(responses),
|
||||
);
|
||||
|
||||
const result = await pool.runTasks(
|
||||
[makeTask('task-1'), makeTask('task-2')],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(result.pagesGenerated).toBe(5);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].taskId).toBe('task-1');
|
||||
expect(result.errors[0].error).toBe('Render failed');
|
||||
});
|
||||
|
||||
it('handles worker crash via error event', async () => {
|
||||
const factory: WorkerFactory = (_workerPath, workerData): WorkerLike => {
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
const worker: WorkerLike = {
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
listeners.set(event, existing);
|
||||
},
|
||||
async terminate() {
|
||||
return 1;
|
||||
},
|
||||
removeAllListeners() {
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
const errorListeners = listeners.get('error') ?? [];
|
||||
for (const listener of errorListeners) {
|
||||
listener(new Error('Worker crashed'));
|
||||
}
|
||||
}, 1);
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
const pool = new GenerationWorkerPool({ maxWorkers: 1 }, factory);
|
||||
const result = await pool.runTasks([makeTask('crash-task')], vi.fn());
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].error).toBe('Worker crashed');
|
||||
});
|
||||
|
||||
it('collects hashUpdates from worker results', async () => {
|
||||
const responses = new Map<string, WorkerOutboundMessage[]>([
|
||||
['task-1', [{ type: 'result', taskId: 'task-1', pagesGenerated: 3, hashUpdates: [
|
||||
{ relativePath: 'index.html', hash: 'aaa' },
|
||||
{ relativePath: 'page/2/index.html', hash: 'bbb' },
|
||||
] }]],
|
||||
['task-2', [{ type: 'result', taskId: 'task-2', pagesGenerated: 2, hashUpdates: [
|
||||
{ relativePath: 'tags/index.html', hash: 'ccc' },
|
||||
] }]],
|
||||
]);
|
||||
|
||||
const pool = new GenerationWorkerPool(
|
||||
{ maxWorkers: 2 },
|
||||
createMockWorkerFactory(responses),
|
||||
);
|
||||
|
||||
const result = await pool.runTasks(
|
||||
[makeTask('task-1'), makeTask('task-2')],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(result.pagesGenerated).toBe(5);
|
||||
expect(result.hashUpdates).toHaveLength(3);
|
||||
expect(result.hashUpdates).toContainEqual({ relativePath: 'index.html', hash: 'aaa' });
|
||||
expect(result.hashUpdates).toContainEqual({ relativePath: 'page/2/index.html', hash: 'bbb' });
|
||||
expect(result.hashUpdates).toContainEqual({ relativePath: 'tags/index.html', hash: 'ccc' });
|
||||
});
|
||||
|
||||
it('returns empty hashUpdates when workers report errors', async () => {
|
||||
const responses = new Map<string, WorkerOutboundMessage[]>([
|
||||
['task-1', [{ type: 'error', taskId: 'task-1', error: 'boom' }]],
|
||||
]);
|
||||
|
||||
const pool = new GenerationWorkerPool(
|
||||
{ maxWorkers: 1 },
|
||||
createMockWorkerFactory(responses),
|
||||
);
|
||||
|
||||
const result = await pool.runTasks([makeTask('task-1')], vi.fn());
|
||||
|
||||
expect(result.hashUpdates).toHaveLength(0);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('respects maxWorkers concurrency', async () => {
|
||||
let peakConcurrent = 0;
|
||||
let currentConcurrent = 0;
|
||||
|
||||
const factory: WorkerFactory = (_workerPath, workerData): WorkerLike => {
|
||||
currentConcurrent++;
|
||||
if (currentConcurrent > peakConcurrent) peakConcurrent = currentConcurrent;
|
||||
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
const worker: WorkerLike = {
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(listener);
|
||||
listeners.set(event, existing);
|
||||
},
|
||||
async terminate() {
|
||||
currentConcurrent--;
|
||||
return 0;
|
||||
},
|
||||
removeAllListeners() {
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
const messageListeners = listeners.get('message') ?? [];
|
||||
for (const listener of messageListeners) {
|
||||
listener({ type: 'result', taskId: workerData.taskId, pagesGenerated: 1 });
|
||||
}
|
||||
}, 5);
|
||||
|
||||
return worker;
|
||||
};
|
||||
|
||||
const pool = new GenerationWorkerPool({ maxWorkers: 2 }, factory);
|
||||
const result = await pool.runTasks(
|
||||
[makeTask('t1'), makeTask('t2'), makeTask('t3'), makeTask('t4')],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(result.pagesGenerated).toBe(4);
|
||||
expect(peakConcurrent).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user