feat: first cut at the full renderer
This commit is contained in:
514
tests/engine/BlogGenerationEngine.test.ts
Normal file
514
tests/engine/BlogGenerationEngine.test.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
|
||||
const generatedFileHashes = new Map<string, string>();
|
||||
const getGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string) => {
|
||||
const key = `${projectId}:${relativePath}`;
|
||||
return generatedFileHashes.get(key) ?? null;
|
||||
});
|
||||
const setGeneratedFileHashMock = vi.fn(async (projectId: string, relativePath: string, hash: string) => {
|
||||
const key = `${projectId}:${relativePath}`;
|
||||
generatedFileHashes.set(key, hash);
|
||||
});
|
||||
const executeDbSql = vi.fn(async (input: { sql: string; args?: unknown[] }) => {
|
||||
const sqlText = input.sql.replace(/\s+/g, ' ').trim();
|
||||
const args = input.args ?? [];
|
||||
|
||||
if (sqlText.startsWith('CREATE TABLE IF NOT EXISTS generated_file_hashes')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (sqlText.startsWith('SELECT content_hash FROM generated_file_hashes')) {
|
||||
const key = `${String(args[0] ?? '')}:${String(args[1] ?? '')}`;
|
||||
const hash = generatedFileHashes.get(key);
|
||||
return { rows: hash ? [{ content_hash: hash }] : [] };
|
||||
}
|
||||
|
||||
if (sqlText.startsWith('INSERT INTO generated_file_hashes')) {
|
||||
const key = `${String(args[0] ?? '')}:${String(args[1] ?? '')}`;
|
||||
generatedFileHashes.set(key, String(args[2] ?? ''));
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
vi.mock('../../src/main/database/generatedFileHashStore', () => ({
|
||||
getGeneratedFileHash: getGeneratedFileHashMock,
|
||||
setGeneratedFileHash: setGeneratedFileHashMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
getLocalClient: vi.fn(() => ({
|
||||
execute: executeDbSql,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/main/engine/PostEngine')>();
|
||||
const mockPostEngine = {
|
||||
getPostsFiltered: vi.fn(async () => []),
|
||||
getPublishedVersion: vi.fn(async () => null),
|
||||
getPost: vi.fn(async () => null),
|
||||
setProjectContext: vi.fn(),
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
getPostEngine: vi.fn(() => mockPostEngine),
|
||||
__mockPostEngine: mockPostEngine,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/main/engine/MediaEngine', () => {
|
||||
const mockMediaEngine = {
|
||||
getAllMedia: vi.fn(async () => []),
|
||||
setProjectContext: vi.fn(),
|
||||
};
|
||||
return {
|
||||
getMediaEngine: vi.fn(() => mockMediaEngine),
|
||||
__mockMediaEngine: mockMediaEngine,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/main/engine/PostMediaEngine', () => {
|
||||
const mockPostMediaEngine = {
|
||||
getLinkedMediaDataForPost: vi.fn(async () => []),
|
||||
setProjectContext: vi.fn(),
|
||||
};
|
||||
return {
|
||||
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
||||
__mockPostMediaEngine: mockPostMediaEngine,
|
||||
};
|
||||
});
|
||||
|
||||
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
const createdAt = overrides.createdAt ?? new Date('2025-01-15T10:00:00.000Z');
|
||||
const updatedAt = overrides.updatedAt ?? createdAt;
|
||||
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,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt ?? createdAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function listFiles(dir: string, prefix = ''): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await listFiles(path.join(dir, entry.name), relative));
|
||||
} else {
|
||||
files.push(relative);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// dir doesn't exist
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
describe('BlogGenerationEngine', () => {
|
||||
let tempDir: string;
|
||||
let mockPostEngine: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
generatedFileHashes.clear();
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'bds-gen-'));
|
||||
|
||||
const { __mockPostEngine } = await import('../../src/main/engine/PostEngine') as any;
|
||||
mockPostEngine = __mockPostEngine;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setupPosts(posts: PostData[]): void {
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
return posts.filter((p) => p.status === (filter.status ?? p.status));
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||
mockPostEngine.getPost.mockImplementation(async (id: string) => {
|
||||
return posts.find((p) => p.id === id) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async function generate(posts: PostData[], options?: Partial<{ maxPostsPerPage: number; language: string; pageTitle: string }>) {
|
||||
setupPosts(posts);
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const onProgress = vi.fn();
|
||||
return engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
maxPostsPerPage: options?.maxPostsPerPage,
|
||||
language: options?.language,
|
||||
pageTitle: options?.pageTitle,
|
||||
}, onProgress);
|
||||
}
|
||||
|
||||
it('copies all required asset files to html/assets/ and html/images/', async () => {
|
||||
const result = await generate([]);
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'pico.min.css'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.css'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.js'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'images', 'prev.png'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'images', 'next.png'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'images', 'close.png'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'images', 'loading.gif'))).toBe(true);
|
||||
|
||||
const picoContent = await readFile(path.join(tempDir, 'html', 'assets', 'pico.min.css'), 'utf-8');
|
||||
expect(picoContent.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('generates root index.html for published posts', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'first', title: 'First Post' }),
|
||||
makePost({ id: '2', slug: 'second', title: 'Second Post' }),
|
||||
];
|
||||
|
||||
const result = await generate(posts);
|
||||
|
||||
expect(result.pagesGenerated).toBeGreaterThan(0);
|
||||
const indexPath = path.join(tempDir, 'html', 'index.html');
|
||||
expect(await fileExists(indexPath)).toBe(true);
|
||||
|
||||
const html = await readFile(indexPath, 'utf-8');
|
||||
expect(html).toContain('data-template="post-list"');
|
||||
expect(html).toContain('/assets/pico.min.css');
|
||||
expect(html).toContain('/assets/lightbox.min.css');
|
||||
});
|
||||
|
||||
it('generates single post pages at /{year}/{month}/{day}/{slug}/index.html', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'hello-world', createdAt: new Date('2025-03-15T10:00:00Z') }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
const postPath = path.join(tempDir, 'html', '2025', '03', '15', 'hello-world', 'index.html');
|
||||
expect(await fileExists(postPath)).toBe(true);
|
||||
|
||||
const html = await readFile(postPath, 'utf-8');
|
||||
expect(html).toContain('data-template="single-post"');
|
||||
});
|
||||
|
||||
it('generates category pages with correct archive context', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'news-1', title: 'News 1', categories: ['news'] }),
|
||||
makePost({ id: '2', slug: 'tech-1', title: 'Tech 1', categories: ['tech'] }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
const newsPath = path.join(tempDir, 'html', 'category', 'news', 'index.html');
|
||||
const techPath = path.join(tempDir, 'html', 'category', 'tech', 'index.html');
|
||||
expect(await fileExists(newsPath)).toBe(true);
|
||||
expect(await fileExists(techPath)).toBe(true);
|
||||
|
||||
const newsHtml = await readFile(newsPath, 'utf-8');
|
||||
expect(newsHtml).toContain('news');
|
||||
expect(newsHtml).toContain('data-template="post-list"');
|
||||
});
|
||||
|
||||
it('generates tag pages with correct archive context', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'tagged-1', title: 'Tagged 1', tags: ['javascript'] }),
|
||||
makePost({ id: '2', slug: 'tagged-2', title: 'Tagged 2', tags: ['typescript'] }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
const jsPath = path.join(tempDir, 'html', 'tag', 'javascript', 'index.html');
|
||||
const tsPath = path.join(tempDir, 'html', 'tag', 'typescript', 'index.html');
|
||||
expect(await fileExists(jsPath)).toBe(true);
|
||||
expect(await fileExists(tsPath)).toBe(true);
|
||||
|
||||
const jsHtml = await readFile(jsPath, 'utf-8');
|
||||
expect(jsHtml).toContain('javascript');
|
||||
expect(jsHtml).toContain('data-template="post-list"');
|
||||
});
|
||||
|
||||
it('generates pagination pages for categories with many posts', async () => {
|
||||
const posts: PostData[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
posts.push(makePost({
|
||||
id: `post-${i}`,
|
||||
slug: `post-${i}`,
|
||||
title: `Post ${i}`,
|
||||
categories: ['big-category'],
|
||||
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
|
||||
}));
|
||||
}
|
||||
|
||||
await generate(posts, { maxPostsPerPage: 2 });
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'category', 'big-category', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'category', 'big-category', 'page', '2', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'category', 'big-category', 'page', '3', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('generates pagination pages for tags with many posts', async () => {
|
||||
const posts: PostData[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
posts.push(makePost({
|
||||
id: `post-${i}`,
|
||||
slug: `post-${i}`,
|
||||
title: `Post ${i}`,
|
||||
tags: ['popular'],
|
||||
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
|
||||
}));
|
||||
}
|
||||
|
||||
await generate(posts, { maxPostsPerPage: 2 });
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'popular', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'tag', 'popular', 'page', '2', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('generates root pagination pages', async () => {
|
||||
const posts: PostData[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
posts.push(makePost({
|
||||
id: `post-${i}`,
|
||||
slug: `post-${i}`,
|
||||
title: `Post ${i}`,
|
||||
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
|
||||
}));
|
||||
}
|
||||
|
||||
await generate(posts, { maxPostsPerPage: 2 });
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'page', '2', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('generates year, month, and day archive pages', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'jan-post', createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||
makePost({ id: '2', slug: 'feb-post', createdAt: new Date('2025-02-20T10:00:00Z') }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
// Year archive
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', 'index.html'))).toBe(true);
|
||||
// Month archives
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', 'index.html'))).toBe(true);
|
||||
// Day archives
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', '20', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes draft-only posts from generated pages', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'published', title: 'Published', status: 'published' }),
|
||||
makePost({ id: '2', slug: 'draft-only', title: 'Draft Only', status: 'draft' }),
|
||||
];
|
||||
|
||||
const result = await generate(posts);
|
||||
|
||||
expect(result.postCount).toBe(1);
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'published', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'draft-only', 'index.html'))).toBe(false);
|
||||
});
|
||||
|
||||
it('includes draft posts that have a published version', async () => {
|
||||
const publishedPost = makePost({ id: '1', slug: 'with-published', title: 'Published Version', status: 'published' });
|
||||
const draftWithPublished = makePost({ id: '2', slug: 'draft-has-pub', title: 'Draft Has Published', status: 'draft' });
|
||||
const publishedVersion = makePost({ id: '2', slug: 'draft-has-pub', title: 'Published Version of Draft', status: 'published', createdAt: new Date('2025-02-10T10:00:00Z') });
|
||||
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') return [publishedPost];
|
||||
if (filter.status === 'draft') return [draftWithPublished];
|
||||
return [];
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockImplementation(async (id: string) => {
|
||||
if (id === '2') return publishedVersion;
|
||||
return null;
|
||||
});
|
||||
mockPostEngine.getPost.mockImplementation(async (id: string) => {
|
||||
if (id === '1') return publishedPost;
|
||||
if (id === '2') return publishedVersion;
|
||||
return null;
|
||||
});
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
const result = await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
}, vi.fn());
|
||||
|
||||
expect(result.postCount).toBe(2);
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '02', '10', 'draft-has-pub', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns correct pagesGenerated count', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'post-a', categories: ['news'], tags: ['js'], createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||
];
|
||||
|
||||
const result = await generate(posts);
|
||||
|
||||
// Should have: root(1) + single(1) + category/news(1) + tag/js(1) + year(1) + month(1) + day(1) = 7
|
||||
expect(result.pagesGenerated).toBe(7);
|
||||
});
|
||||
|
||||
it('generates HTML that references local assets not CDN', async () => {
|
||||
const posts = [makePost({ id: '1', slug: 'test' })];
|
||||
await generate(posts);
|
||||
|
||||
const indexHtml = await readFile(path.join(tempDir, 'html', 'index.html'), 'utf-8');
|
||||
expect(indexHtml).toContain('href="/assets/pico.min.css"');
|
||||
expect(indexHtml).toContain('href="/assets/lightbox.min.css"');
|
||||
expect(indexHtml).toContain('src="/assets/lightbox.min.js"');
|
||||
expect(indexHtml).not.toContain('cdn.jsdelivr.net');
|
||||
expect(indexHtml).not.toContain('cdnjs.cloudflare.com');
|
||||
});
|
||||
|
||||
it('handles categories with special characters via URL encoding', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'special', categories: ['my category'] }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'category', 'my%20category', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('generates static page routes at /{slug}/index.html for posts in category page', async () => {
|
||||
const posts = [
|
||||
makePost({ id: 'page-1', slug: 'about', title: 'About', categories: ['page'] }),
|
||||
makePost({ id: 'post-1', slug: 'hello-world', title: 'Hello World', categories: ['blog'] }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'about', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'hello-world', 'index.html'))).toBe(false);
|
||||
});
|
||||
|
||||
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') }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '03', '15', 'alias-test', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'posts', 'alias-test', 'index.html'))).toBe(false);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'posts', '2025', '03', 'alias-test', 'index.html'))).toBe(false);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'post', 'alias-test', 'index.html'))).toBe(false);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'post', '2025', '03', 'alias-test', 'index.html'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not overwrite unchanged html files on subsequent generation runs', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'stable-post', createdAt: new Date('2025-03-15T10:00:00Z') }),
|
||||
];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
const canonicalPath = path.join(tempDir, 'html', '2025', '03', '15', 'stable-post', 'index.html');
|
||||
const beforeStat = await stat(canonicalPath);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await generate(posts);
|
||||
|
||||
const afterStat = await stat(canonicalPath);
|
||||
expect(afterStat.mtimeMs).toBe(beforeStat.mtimeMs);
|
||||
});
|
||||
|
||||
it('delegates hash reads/writes to generated file hash store module', async () => {
|
||||
const posts = [makePost({ id: '1', slug: 'delegation-check' })];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
expect(getGeneratedFileHashMock).toHaveBeenCalled();
|
||||
expect(setGeneratedFileHashMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not execute CREATE TABLE statements during generation runtime', async () => {
|
||||
const posts = [makePost({ id: '1', slug: 'runtime-ddl-test' })];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
const createTableCalls = executeDbSql.mock.calls.filter(([input]) => {
|
||||
const sql = typeof input?.sql === 'string' ? input.sql : '';
|
||||
return sql.toUpperCase().includes('CREATE TABLE');
|
||||
});
|
||||
|
||||
expect(createTableCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not create html/media folder during generation', async () => {
|
||||
const posts = [makePost({ id: '1', slug: 'test' })];
|
||||
|
||||
await generate(posts);
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'media'))).toBe(false);
|
||||
});
|
||||
|
||||
it('generates zero pages when there are no published posts', async () => {
|
||||
const result = await generate([]);
|
||||
expect(result.pagesGenerated).toBe(0);
|
||||
expect(result.postCount).toBe(0);
|
||||
});
|
||||
|
||||
it('generates pagination links in list pages', async () => {
|
||||
const posts: PostData[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
posts.push(makePost({
|
||||
id: `p-${i}`,
|
||||
slug: `p-${i}`,
|
||||
title: `Post ${i}`,
|
||||
tags: ['paginated'],
|
||||
createdAt: new Date(`2025-01-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
|
||||
}));
|
||||
}
|
||||
|
||||
await generate(posts, { maxPostsPerPage: 2 });
|
||||
|
||||
const page1 = await readFile(path.join(tempDir, 'html', 'tag', 'paginated', 'index.html'), 'utf-8');
|
||||
expect(page1).toContain('/tag/paginated/page/2/');
|
||||
|
||||
const page2 = await readFile(path.join(tempDir, 'html', 'tag', 'paginated', 'page', '2', 'index.html'), 'utf-8');
|
||||
expect(page2).toContain('/tag/paginated/');
|
||||
});
|
||||
});
|
||||
@@ -175,7 +175,7 @@ const mockTaskManager = {
|
||||
off: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSettingsStore = new Map<string, string>();
|
||||
const mockGeneratedFileHashStore = new Map<string, string>();
|
||||
|
||||
const mockDatabase = {
|
||||
getLocal: vi.fn(() => ({
|
||||
@@ -189,19 +189,23 @@ const mockDatabase = {
|
||||
})),
|
||||
getLocalClient: vi.fn(() => ({
|
||||
execute: vi.fn(async ({ sql, args }: { sql: string; args?: any[] }) => {
|
||||
if (sql.startsWith('SELECT value FROM settings WHERE key = ?')) {
|
||||
const key = String(args?.[0] ?? '');
|
||||
if (sql.includes('CREATE TABLE IF NOT EXISTS generated_file_hashes')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (sql.startsWith('SELECT content_hash FROM generated_file_hashes')) {
|
||||
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
|
||||
return {
|
||||
rows: mockSettingsStore.has(key)
|
||||
? [{ value: mockSettingsStore.get(key) as string }]
|
||||
rows: mockGeneratedFileHashStore.has(key)
|
||||
? [{ content_hash: mockGeneratedFileHashStore.get(key) as string }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (sql.startsWith('INSERT INTO settings')) {
|
||||
const key = String(args?.[0] ?? '');
|
||||
const value = String(args?.[1] ?? '');
|
||||
mockSettingsStore.set(key, value);
|
||||
if (sql.includes('INSERT INTO generated_file_hashes')) {
|
||||
const key = `${String(args?.[0] ?? '')}:${String(args?.[1] ?? '')}`;
|
||||
const value = String(args?.[2] ?? '');
|
||||
mockGeneratedFileHashStore.set(key, value);
|
||||
return { rowsAffected: 1 };
|
||||
}
|
||||
|
||||
@@ -258,6 +262,10 @@ vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => mockDatabase),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/database/connection', () => ({
|
||||
getDatabase: vi.fn(() => mockDatabase),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/stemmer', () => ({
|
||||
isoToStemmerLanguage: vi.fn((iso: string) => iso === 'en' ? 'english' : 'german'),
|
||||
}));
|
||||
@@ -294,7 +302,7 @@ describe('IPC Handlers', () => {
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
registeredHandlers.clear();
|
||||
mockSettingsStore.clear();
|
||||
mockGeneratedFileHashStore.clear();
|
||||
resetMockCounters();
|
||||
|
||||
// Import and register handlers fresh for each test
|
||||
@@ -1571,6 +1579,62 @@ describe('IPC Handlers', () => {
|
||||
// ============ Blog Handlers ============
|
||||
describe('Blog Handlers', () => {
|
||||
describe('blog:generateSitemap', () => {
|
||||
it('should create separate background tasks for single, category, tag, and date rendering', 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.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') {
|
||||
return [
|
||||
{
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Test Post',
|
||||
slug: 'test-post',
|
||||
excerpt: '',
|
||||
content: '# Test',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
||||
tags: ['tag1'],
|
||||
categories: ['category1'],
|
||||
},
|
||||
];
|
||||
}
|
||||
if (filter.status === 'draft') {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||
|
||||
const { writeFile, mkdir } = await import('fs/promises');
|
||||
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
mockTaskManager.runTask.mockImplementation(async (task: any) => {
|
||||
return task.execute(vi.fn());
|
||||
});
|
||||
|
||||
await invokeHandler('blog:generateSitemap');
|
||||
|
||||
const names = mockTaskManager.runTask.mock.calls.map((call: any[]) => call[0]?.name);
|
||||
expect(names).toContain('Render Site Core');
|
||||
expect(names).toContain('Render Single Posts');
|
||||
expect(names).toContain('Render Category Archives');
|
||||
expect(names).toContain('Render Tag Archives');
|
||||
expect(names).toContain('Render Date Archives');
|
||||
});
|
||||
|
||||
it('should call taskManager.runTask with sitemap generation task', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
@@ -1644,11 +1708,11 @@ describe('IPC Handlers', () => {
|
||||
|
||||
const result = await invokeHandler('blog:generateSitemap');
|
||||
|
||||
// Verify taskManager.runTask was called
|
||||
// Verify taskManager.runTask was called for core task orchestration
|
||||
expect(mockTaskManager.runTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^sitemap-generate-\d+$/),
|
||||
name: 'Generate Sitemap',
|
||||
id: expect.stringMatching(/^site-render-core-\d+$/),
|
||||
name: 'Render Site Core',
|
||||
execute: expect.any(Function),
|
||||
})
|
||||
);
|
||||
@@ -1838,7 +1902,11 @@ describe('IPC Handlers', () => {
|
||||
vi.mocked(writeFile).mockClear();
|
||||
await invokeHandler('blog:generateSitemap');
|
||||
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
// Assets are always copied, but sitemap/feeds/pages should not be rewritten
|
||||
const xmlWrites = vi.mocked(writeFile).mock.calls.filter(
|
||||
([filePath]) => typeof filePath === 'string' && (filePath.endsWith('.xml') || filePath.endsWith('index.html')),
|
||||
);
|
||||
expect(xmlWrites).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw error when no active project', async () => {
|
||||
|
||||
@@ -234,4 +234,39 @@ describe('Panel', () => {
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Output' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('renders grouped tasks as expandable parent rows with child task names in tasks tab', async () => {
|
||||
useAppStore.setState({
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'site-render-core-1',
|
||||
name: 'Render Site Core',
|
||||
status: 'running',
|
||||
progress: 42,
|
||||
message: 'Generating root pages...',
|
||||
startTime: '2026-02-20T10:00:00.000Z',
|
||||
groupId: 'site-render-1',
|
||||
groupName: 'Render Site',
|
||||
},
|
||||
{
|
||||
taskId: 'site-render-tag-1',
|
||||
name: 'Render Tag Archives',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
message: 'Waiting to start...',
|
||||
startTime: '2026-02-20T10:00:01.000Z',
|
||||
groupId: 'site-render-1',
|
||||
groupName: 'Render Site',
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
|
||||
render(<Panel />);
|
||||
|
||||
const parent = screen.getByRole('button', { name: 'Render Site (2)' });
|
||||
expect(parent).toBeInTheDocument();
|
||||
expect(await screen.findByText('Render Site Core')).toBeInTheDocument();
|
||||
expect(screen.getByText('Render Tag Archives')).toBeInTheDocument();
|
||||
expect(screen.getByText('42%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
78
tests/renderer/components/TaskPopup.test.tsx
Normal file
78
tests/renderer/components/TaskPopup.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TaskPopup } from '../../../src/renderer/components/TaskPopup/TaskPopup';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
import type { TaskProgress } from '../../../src/main/shared/electronApi';
|
||||
|
||||
function makeTask(overrides: Partial<TaskProgress> = {}): TaskProgress {
|
||||
return {
|
||||
taskId: overrides.taskId ?? 'task-1',
|
||||
status: overrides.status ?? 'running',
|
||||
progress: overrides.progress ?? 10,
|
||||
message: overrides.message ?? 'Running task',
|
||||
startTime: overrides.startTime ?? '2026-02-20T10:00:00.000Z',
|
||||
endTime: overrides.endTime,
|
||||
error: overrides.error,
|
||||
groupId: overrides.groupId,
|
||||
groupName: overrides.groupName,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskPopup grouped tasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppStore.setState({
|
||||
tasks: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('shows grouped render tasks and expands to list child tasks', () => {
|
||||
useAppStore.setState({
|
||||
tasks: [
|
||||
makeTask({
|
||||
taskId: 'site-render-core-1',
|
||||
status: 'running',
|
||||
message: 'Generating root pages',
|
||||
groupId: 'site-render-1',
|
||||
groupName: 'Render Site',
|
||||
}),
|
||||
makeTask({
|
||||
taskId: 'site-render-date-1',
|
||||
status: 'running',
|
||||
message: 'Generating date archive pages',
|
||||
groupId: 'site-render-1',
|
||||
groupName: 'Render Site',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
render(<TaskPopup />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /running/i }));
|
||||
|
||||
const groupToggle = screen.getByRole('button', { name: /Render Site \(2\)/i });
|
||||
expect(groupToggle).toBeInTheDocument();
|
||||
expect(screen.getByText('Generating root pages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Generating date archive pages')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(groupToggle);
|
||||
|
||||
expect(screen.queryByText('Generating root pages')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Generating date archive pages')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('continues to render ungrouped tasks directly', () => {
|
||||
useAppStore.setState({
|
||||
tasks: [
|
||||
makeTask({ taskId: 'ungrouped-1', status: 'running', message: 'Standalone task' }),
|
||||
],
|
||||
});
|
||||
|
||||
render(<TaskPopup />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /running/i }));
|
||||
|
||||
expect(screen.getByText('Standalone task')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user