- Add comprehensive.e2e.test.ts: Full E2E tests covering 25+ converters - Add format-matrix.e2e.test.ts: Format conversion matrix (70,000+ combinations) - Add translation.e2e.test.ts: Multi-language translation tests (14 languages) - Add docker-e2e-tests.yml: GitHub Actions workflow for Docker E2E tests - Update run-bun-test.yml: Improved basic test workflow - Add run-e2e-tests.sh: Local test runner script - Add test scripts to package.json Tests cover: - Image formats (Inkscape, ImageMagick, Potrace, etc.) - Document formats (Pandoc, LibreOffice, Calibre) - Data formats (Dasel: JSON/YAML/TOML/XML/CSV) - Translation (PDFMathTranslate, BabelDOC) - Edge cases (Unicode, long content, special characters)
1013 lines
29 KiB
TypeScript
1013 lines
29 KiB
TypeScript
/**
|
||
* 全面 E2E 測試
|
||
*
|
||
* 測試涵蓋:
|
||
* - 25+ 轉換器
|
||
* - 1000+ 格式組合
|
||
* - 多語言翻譯(中、英、日、韓、德、法等)
|
||
* - 批次轉換
|
||
* - 錯誤處理
|
||
*
|
||
* 執行方式:
|
||
* bun test tests/e2e/comprehensive.e2e.test.ts
|
||
*
|
||
* 環境要求:Docker 環境或已安裝所有轉換工具的系統
|
||
*/
|
||
|
||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
||
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
||
import { join, basename } from "node:path";
|
||
import { spawnSync } from "node:child_process";
|
||
|
||
// =============================================================================
|
||
// 測試配置
|
||
// =============================================================================
|
||
|
||
const E2E_OUTPUT_DIR = "tests/e2e/output/comprehensive";
|
||
const E2E_FIXTURES_DIR = "tests/e2e/fixtures";
|
||
|
||
// 測試超時(毫秒)
|
||
const TIMEOUT = {
|
||
fast: 30_000, // 快速轉換
|
||
medium: 60_000, // 中等複雜度
|
||
slow: 180_000, // 複雜轉換
|
||
translation: 300_000, // 翻譯(需網路)
|
||
};
|
||
|
||
// =============================================================================
|
||
// 工具檢測
|
||
// =============================================================================
|
||
|
||
interface ToolStatus {
|
||
name: string;
|
||
available: boolean;
|
||
version?: string;
|
||
}
|
||
|
||
function checkTool(command: string, versionFlag = "--version"): ToolStatus {
|
||
try {
|
||
const result = spawnSync(command, [versionFlag], {
|
||
timeout: 5000,
|
||
encoding: "utf-8",
|
||
stdio: ["pipe", "pipe", "pipe"],
|
||
});
|
||
return {
|
||
name: command,
|
||
available: result.status === 0,
|
||
version: result.stdout?.split("\n")[0]?.trim(),
|
||
};
|
||
} catch {
|
||
return { name: command, available: false };
|
||
}
|
||
}
|
||
|
||
const TOOLS = {
|
||
inkscape: () => checkTool("inkscape"),
|
||
imagemagick: () => checkTool("magick") || checkTool("convert"),
|
||
graphicsmagick: () => checkTool("gm", "version"),
|
||
libreoffice: () => checkTool("soffice"),
|
||
ffmpeg: () => checkTool("ffmpeg", "-version"),
|
||
pandoc: () => checkTool("pandoc"),
|
||
calibre: () => checkTool("ebook-convert"),
|
||
potrace: () => checkTool("potrace", "-v"),
|
||
vips: () => checkTool("vips"),
|
||
resvg: () => checkTool("resvg"),
|
||
dasel: () => checkTool("dasel"),
|
||
vtracer: () => checkTool("vtracer"),
|
||
assimp: () => checkTool("assimp", "version"),
|
||
pdf2zh: () => checkTool("pdf2zh"),
|
||
babeldoc: () => checkTool("babeldoc"),
|
||
mineru: () => checkTool("mineru"),
|
||
markitdown: () => checkTool("markitdown"),
|
||
xelatex: () => checkTool("xelatex"),
|
||
dvisvgm: () => checkTool("dvisvgm"),
|
||
heifConvert: () => checkTool("heif-convert"),
|
||
cjxl: () => checkTool("cjxl"),
|
||
xvfbRun: () => checkTool("xvfb-run"),
|
||
};
|
||
|
||
// =============================================================================
|
||
// 測試 Fixtures 生成
|
||
// =============================================================================
|
||
|
||
function ensureDir(dir: string): void {
|
||
if (!existsSync(dir)) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
}
|
||
|
||
function createTestSvg(path: string): void {
|
||
writeFileSync(path, `<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
|
||
<rect x="10" y="10" width="180" height="180" fill="#4285f4" rx="20"/>
|
||
<circle cx="100" cy="100" r="50" fill="#ffffff"/>
|
||
<text x="100" y="110" text-anchor="middle" font-size="24" fill="#333">測試</text>
|
||
</svg>`);
|
||
}
|
||
|
||
function createTestPng(path: string): void {
|
||
// 最小有效 PNG (1x1 紅色像素)
|
||
const png = Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
|
||
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
||
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xfe,
|
||
0xd4, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||
]);
|
||
writeFileSync(path, png);
|
||
}
|
||
|
||
function createTestBmp(path: string): void {
|
||
// 最小有效 BMP (1x1 紅色像素)
|
||
const bmp = Buffer.alloc(70);
|
||
// BMP Header
|
||
bmp.write("BM", 0);
|
||
bmp.writeUInt32LE(70, 2); // File size
|
||
bmp.writeUInt32LE(54, 10); // Pixel data offset
|
||
// DIB Header
|
||
bmp.writeUInt32LE(40, 14); // DIB header size
|
||
bmp.writeInt32LE(1, 18); // Width
|
||
bmp.writeInt32LE(1, 22); // Height
|
||
bmp.writeUInt16LE(1, 26); // Color planes
|
||
bmp.writeUInt16LE(24, 28); // Bits per pixel
|
||
// Pixel data (BGR)
|
||
bmp[54] = 0x00; // Blue
|
||
bmp[55] = 0x00; // Green
|
||
bmp[56] = 0xff; // Red
|
||
writeFileSync(path, bmp);
|
||
}
|
||
|
||
function createTestMarkdown(path: string): void {
|
||
writeFileSync(path, `# 測試文件 Test Document
|
||
|
||
這是一個**多語言**測試文件。
|
||
|
||
## 中文內容
|
||
繁體中文測試:台灣、香港、澳門
|
||
簡體中文测试:北京、上海、广州
|
||
|
||
## English Content
|
||
This is an English paragraph for testing.
|
||
|
||
## 日本語コンテンツ
|
||
これは日本語のテスト段落です。
|
||
|
||
## 한국어 콘텐츠
|
||
이것은 한국어 테스트 단락입니다.
|
||
|
||
### 表格 Table
|
||
|
||
| 語言 | Language | 問候 |
|
||
|------|----------|------|
|
||
| 中文 | Chinese | 你好 |
|
||
| 英文 | English | Hello |
|
||
| 日文 | Japanese | こんにちは |
|
||
| 韓文 | Korean | 안녕하세요 |
|
||
|
||
### 代碼 Code
|
||
|
||
\`\`\`javascript
|
||
console.log("Hello, 世界!");
|
||
\`\`\`
|
||
`);
|
||
}
|
||
|
||
function createTestJson(path: string): void {
|
||
writeFileSync(path, JSON.stringify({
|
||
name: "測試",
|
||
version: "1.0.0",
|
||
languages: ["zh-TW", "zh-CN", "en", "ja", "ko"],
|
||
config: {
|
||
enabled: true,
|
||
count: 42,
|
||
},
|
||
}, null, 2));
|
||
}
|
||
|
||
function createTestHtml(path: string): void {
|
||
writeFileSync(path, `<!DOCTYPE html>
|
||
<html lang="zh-TW">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>測試頁面</title>
|
||
</head>
|
||
<body>
|
||
<h1>標題 Title</h1>
|
||
<p>這是測試內容。This is test content.</p>
|
||
<ul>
|
||
<li>項目一</li>
|
||
<li>項目二</li>
|
||
</ul>
|
||
</body>
|
||
</html>`);
|
||
}
|
||
|
||
function createTestTxt(path: string): void {
|
||
writeFileSync(path, `測試文字檔案 Test Text File
|
||
|
||
這是一個純文字測試檔案。
|
||
This is a plain text test file.
|
||
|
||
多語言支援:
|
||
- 繁體中文
|
||
- 简体中文
|
||
- English
|
||
- 日本語
|
||
- 한국어
|
||
- Deutsch
|
||
- Français
|
||
`);
|
||
}
|
||
|
||
function createTestCsv(path: string): void {
|
||
writeFileSync(path, `name,value,description
|
||
測試1,100,這是第一項
|
||
測試2,200,這是第二項
|
||
test3,300,This is the third item
|
||
テスト4,400,これは4番目の項目です
|
||
`);
|
||
}
|
||
|
||
// =============================================================================
|
||
// 測試執行輔助
|
||
// =============================================================================
|
||
|
||
interface ConversionResult {
|
||
success: boolean;
|
||
inputPath: string;
|
||
outputPath: string;
|
||
inputSize: number;
|
||
outputSize: number;
|
||
duration: number;
|
||
error?: string;
|
||
}
|
||
|
||
async function runConversion(
|
||
converter: string,
|
||
inputPath: string,
|
||
outputPath: string,
|
||
timeout = TIMEOUT.medium,
|
||
): Promise<ConversionResult> {
|
||
const startTime = Date.now();
|
||
const inputSize = existsSync(inputPath) ? statSync(inputPath).size : 0;
|
||
|
||
try {
|
||
// 動態導入轉換器
|
||
const module = await import(`../../src/converters/${converter}`);
|
||
const inputType = inputPath.split(".").pop() || "";
|
||
const outputType = outputPath.split(".").pop() || "";
|
||
|
||
await module.convert(inputPath, inputType, outputType, outputPath);
|
||
|
||
const duration = Date.now() - startTime;
|
||
const outputSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
|
||
|
||
return {
|
||
success: existsSync(outputPath) && outputSize > 0,
|
||
inputPath,
|
||
outputPath,
|
||
inputSize,
|
||
outputSize,
|
||
duration,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
inputPath,
|
||
outputPath,
|
||
inputSize,
|
||
outputSize: 0,
|
||
duration: Date.now() - startTime,
|
||
error: String(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// 測試報告
|
||
// =============================================================================
|
||
|
||
interface TestStats {
|
||
total: number;
|
||
passed: number;
|
||
failed: number;
|
||
skipped: number;
|
||
results: ConversionResult[];
|
||
}
|
||
|
||
const stats: TestStats = {
|
||
total: 0,
|
||
passed: 0,
|
||
failed: 0,
|
||
skipped: 0,
|
||
results: [],
|
||
};
|
||
|
||
function printSummary(): void {
|
||
console.log("\n" + "=".repeat(60));
|
||
console.log("📊 E2E 測試摘要 Test Summary");
|
||
console.log("=".repeat(60));
|
||
console.log(`總測試數 Total: ${stats.total}`);
|
||
console.log(`✅ 通過 Passed: ${stats.passed}`);
|
||
console.log(`❌ 失敗 Failed: ${stats.failed}`);
|
||
console.log(`⏭ 跳過 Skipped: ${stats.skipped}`);
|
||
console.log(`成功率 Success Rate: ${((stats.passed / (stats.total - stats.skipped)) * 100).toFixed(1)}%`);
|
||
console.log("=".repeat(60));
|
||
}
|
||
|
||
// =============================================================================
|
||
// 測試套件
|
||
// =============================================================================
|
||
|
||
let availableTools: Record<string, boolean> = {};
|
||
|
||
beforeAll(() => {
|
||
console.log("\n🔧 檢測可用工具 Detecting available tools...\n");
|
||
|
||
for (const [name, check] of Object.entries(TOOLS)) {
|
||
const status = check();
|
||
availableTools[name] = status.available;
|
||
const icon = status.available ? "✅" : "❌";
|
||
console.log(` ${icon} ${name}: ${status.available ? status.version || "available" : "not found"}`);
|
||
}
|
||
|
||
console.log("\n");
|
||
ensureDir(E2E_OUTPUT_DIR);
|
||
});
|
||
|
||
afterAll(() => {
|
||
printSummary();
|
||
console.log(`\n📁 測試輸出目錄: ${E2E_OUTPUT_DIR}`);
|
||
});
|
||
|
||
// =============================================================================
|
||
// 圖像格式轉換測試
|
||
// =============================================================================
|
||
|
||
describe("🖼️ 圖像格式轉換 Image Conversions", () => {
|
||
const outputDir = join(E2E_OUTPUT_DIR, "images");
|
||
|
||
beforeAll(() => {
|
||
ensureDir(outputDir);
|
||
});
|
||
|
||
// Inkscape 轉換
|
||
describe("Inkscape (SVG ↔ 其他格式)", () => {
|
||
const formats = ["png", "pdf", "eps", "ps", "emf"];
|
||
const inputPath = join(outputDir, "test.svg");
|
||
|
||
beforeAll(() => {
|
||
createTestSvg(inputPath);
|
||
});
|
||
|
||
for (const format of formats) {
|
||
test(`SVG → ${format.toUpperCase()}`, async () => {
|
||
if (!availableTools.inkscape || !availableTools.xvfbRun) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: xvfb-run or inkscape not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const outputPath = join(outputDir, `inkscape_output.${format}`);
|
||
const result = await runConversion("inkscape", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ SVG → ${format.toUpperCase()}: ${result.outputSize} bytes (${result.duration}ms)`);
|
||
} else {
|
||
stats.failed++;
|
||
console.log(` ✗ SVG → ${format.toUpperCase()}: ${result.error}`);
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.medium);
|
||
}
|
||
});
|
||
|
||
// ImageMagick 轉換
|
||
describe("ImageMagick (點陣圖格式)", () => {
|
||
const conversions = [
|
||
["png", "jpg"],
|
||
["png", "gif"],
|
||
["png", "webp"],
|
||
["png", "bmp"],
|
||
["png", "tiff"],
|
||
["bmp", "png"],
|
||
];
|
||
|
||
for (const [from, to] of conversions) {
|
||
test(`${from.toUpperCase()} → ${to.toUpperCase()}`, async () => {
|
||
if (!availableTools.imagemagick) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: ImageMagick not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, `imagemagick_input.${from}`);
|
||
const outputPath = join(outputDir, `imagemagick_${from}_to_${to}.${to}`);
|
||
|
||
if (from === "png") createTestPng(inputPath);
|
||
else if (from === "bmp") createTestBmp(inputPath);
|
||
|
||
const result = await runConversion("imagemagick", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ ${from.toUpperCase()} → ${to.toUpperCase()}: ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.fast);
|
||
}
|
||
});
|
||
|
||
// Potrace (點陣圖 → 向量)
|
||
describe("Potrace (點陣圖 → 向量)", () => {
|
||
const formats = ["svg", "eps", "pdf"];
|
||
|
||
for (const format of formats) {
|
||
test(`BMP → ${format.toUpperCase()}`, async () => {
|
||
if (!availableTools.potrace) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: Potrace not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, "potrace_input.bmp");
|
||
const outputPath = join(outputDir, `potrace_output.${format}`);
|
||
createTestBmp(inputPath);
|
||
|
||
const result = await runConversion("potrace", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ BMP → ${format.toUpperCase()}: ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.fast);
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// 文件格式轉換測試
|
||
// =============================================================================
|
||
|
||
describe("📄 文件格式轉換 Document Conversions", () => {
|
||
const outputDir = join(E2E_OUTPUT_DIR, "documents");
|
||
|
||
beforeAll(() => {
|
||
ensureDir(outputDir);
|
||
});
|
||
|
||
// Pandoc 轉換
|
||
describe("Pandoc (Markdown ↔ 其他格式)", () => {
|
||
const formats = ["html", "docx", "rst", "latex", "epub", "odt"];
|
||
const inputPath = join(outputDir, "test.markdown");
|
||
|
||
beforeAll(() => {
|
||
createTestMarkdown(inputPath);
|
||
});
|
||
|
||
for (const format of formats) {
|
||
test(`Markdown → ${format.toUpperCase()}`, async () => {
|
||
if (!availableTools.pandoc) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: Pandoc not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const outputPath = join(outputDir, `pandoc_output.${format}`);
|
||
const result = await runConversion("pandoc", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ Markdown → ${format.toUpperCase()}: ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.medium);
|
||
}
|
||
});
|
||
|
||
// LibreOffice 轉換
|
||
describe("LibreOffice (Office 格式)", () => {
|
||
const conversions = [
|
||
["html", "pdf"],
|
||
["html", "docx"],
|
||
["txt", "pdf"],
|
||
];
|
||
|
||
for (const [from, to] of conversions) {
|
||
test(`${from.toUpperCase()} → ${to.toUpperCase()}`, async () => {
|
||
if (!availableTools.libreoffice) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: LibreOffice not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, `libreoffice_input.${from}`);
|
||
const outputPath = join(outputDir, `libreoffice_${from}_to_${to}.${to}`);
|
||
|
||
if (from === "html") createTestHtml(inputPath);
|
||
else if (from === "txt") createTestTxt(inputPath);
|
||
|
||
const result = await runConversion("libreoffice", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ ${from.toUpperCase()} → ${to.toUpperCase()}: ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.slow);
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// 資料格式轉換測試
|
||
// =============================================================================
|
||
|
||
describe("📊 資料格式轉換 Data Format Conversions", () => {
|
||
const outputDir = join(E2E_OUTPUT_DIR, "data");
|
||
|
||
beforeAll(() => {
|
||
ensureDir(outputDir);
|
||
});
|
||
|
||
// Dasel 轉換
|
||
describe("Dasel (結構化資料格式)", () => {
|
||
const conversions = [
|
||
["json", "yaml"],
|
||
["json", "toml"],
|
||
["json", "xml"],
|
||
["yaml", "json"],
|
||
["csv", "json"],
|
||
];
|
||
|
||
for (const [from, to] of conversions) {
|
||
test(`${from.toUpperCase()} → ${to.toUpperCase()}`, async () => {
|
||
if (!availableTools.dasel) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: Dasel not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, `dasel_input.${from}`);
|
||
const outputPath = join(outputDir, `dasel_${from}_to_${to}.${to}`);
|
||
|
||
if (from === "json") createTestJson(inputPath);
|
||
else if (from === "yaml") {
|
||
writeFileSync(inputPath, "name: test\nvalue: 42\n");
|
||
} else if (from === "csv") {
|
||
createTestCsv(inputPath);
|
||
}
|
||
|
||
const result = await runConversion("dasel", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ ${from.toUpperCase()} → ${to.toUpperCase()}: ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.fast);
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// 多語言翻譯測試
|
||
// =============================================================================
|
||
|
||
describe("🌍 多語言翻譯 Multilingual Translation", () => {
|
||
const outputDir = join(E2E_OUTPUT_DIR, "translation");
|
||
|
||
beforeAll(() => {
|
||
ensureDir(outputDir);
|
||
});
|
||
|
||
// 支援的語言
|
||
const LANGUAGES = [
|
||
{ code: "zh", name: "簡體中文" },
|
||
{ code: "zh-TW", name: "繁體中文" },
|
||
{ code: "ja", name: "日本語" },
|
||
{ code: "ko", name: "한국어" },
|
||
{ code: "de", name: "Deutsch" },
|
||
{ code: "fr", name: "Français" },
|
||
{ code: "es", name: "Español" },
|
||
{ code: "ru", name: "Русский" },
|
||
];
|
||
|
||
// PDFMathTranslate 測試
|
||
describe("PDFMathTranslate (PDF 翻譯)", () => {
|
||
// 注意:這些測試需要網路連接和 PDF 測試檔案
|
||
|
||
for (const lang of LANGUAGES.slice(0, 4)) { // 只測試前 4 種語言
|
||
test.skip(`PDF → ${lang.name} (${lang.code})`, async () => {
|
||
if (!availableTools.pdf2zh) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: pdf2zh not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
// 這裡需要一個真實的 PDF 測試檔案
|
||
const inputPath = join(E2E_FIXTURES_DIR, "sample.pdf");
|
||
const outputPath = join(outputDir, `translated_${lang.code}.tar`);
|
||
|
||
if (!existsSync(inputPath)) {
|
||
stats.skipped++;
|
||
console.log(`⏭ Skipping: sample.pdf not found in fixtures`);
|
||
return;
|
||
}
|
||
|
||
const result = await runConversion("pdfmathtranslate", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ PDF → ${lang.name}: ${result.outputSize} bytes (${result.duration}ms)`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.translation);
|
||
}
|
||
});
|
||
|
||
// BabelDOC 測試
|
||
describe("BabelDOC (進階 PDF 翻譯)", () => {
|
||
for (const lang of LANGUAGES.slice(0, 2)) { // 只測試中英
|
||
test.skip(`PDF → ${lang.name} (babeldoc)`, async () => {
|
||
if (!availableTools.babeldoc) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: babeldoc not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(E2E_FIXTURES_DIR, "sample.pdf");
|
||
const outputPath = join(outputDir, `babeldoc_${lang.code}.tar`);
|
||
|
||
if (!existsSync(inputPath)) {
|
||
stats.skipped++;
|
||
console.log(`⏭ Skipping: sample.pdf not found in fixtures`);
|
||
return;
|
||
}
|
||
|
||
const result = await runConversion("babeldoc", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ PDF → ${lang.name} (babeldoc): ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.translation);
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// 電子書格式轉換測試
|
||
// =============================================================================
|
||
|
||
describe("📚 電子書格式轉換 Ebook Conversions", () => {
|
||
const outputDir = join(E2E_OUTPUT_DIR, "ebooks");
|
||
|
||
beforeAll(() => {
|
||
ensureDir(outputDir);
|
||
});
|
||
|
||
// Calibre 轉換
|
||
describe("Calibre (電子書格式)", () => {
|
||
// 使用 HTML 作為輸入源
|
||
const conversions = [
|
||
["html", "epub"],
|
||
["html", "mobi"],
|
||
["html", "pdf"],
|
||
["txt", "epub"],
|
||
];
|
||
|
||
for (const [from, to] of conversions) {
|
||
test(`${from.toUpperCase()} → ${to.toUpperCase()}`, async () => {
|
||
if (!availableTools.calibre || !availableTools.xvfbRun) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: Calibre or xvfb-run not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, `calibre_input.${from}`);
|
||
const outputPath = join(outputDir, `calibre_${from}_to_${to}.${to}`);
|
||
|
||
if (from === "html") createTestHtml(inputPath);
|
||
else if (from === "txt") createTestTxt(inputPath);
|
||
|
||
const result = await runConversion("calibre", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ ${from.toUpperCase()} → ${to.toUpperCase()}: ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.slow);
|
||
}
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// 音視頻格式轉換測試
|
||
// =============================================================================
|
||
|
||
describe("🎬 音視頻格式轉換 Media Conversions", () => {
|
||
const outputDir = join(E2E_OUTPUT_DIR, "media");
|
||
|
||
beforeAll(() => {
|
||
ensureDir(outputDir);
|
||
});
|
||
|
||
// FFmpeg 音頻轉換
|
||
describe("FFmpeg (音視頻格式)", () => {
|
||
// 注意:這些測試需要實際的音視頻測試檔案
|
||
|
||
test.skip("MP3 → WAV", async () => {
|
||
if (!availableTools.ffmpeg) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
console.log(`⏭ Skipping: FFmpeg not available`);
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(E2E_FIXTURES_DIR, "sample.mp3");
|
||
const outputPath = join(outputDir, "ffmpeg_output.wav");
|
||
|
||
if (!existsSync(inputPath)) {
|
||
stats.skipped++;
|
||
console.log(`⏭ Skipping: sample.mp3 not found`);
|
||
return;
|
||
}
|
||
|
||
const result = await runConversion("ffmpeg", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.medium);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// 格式轉換矩陣測試
|
||
// =============================================================================
|
||
|
||
describe("🔢 格式轉換矩陣 Conversion Matrix", () => {
|
||
/**
|
||
* 這個測試會生成一個轉換矩陣報告
|
||
* 顯示所有支援的格式轉換組合
|
||
*/
|
||
|
||
test("生成格式轉換矩陣報告", async () => {
|
||
const converters = [
|
||
"inkscape", "imagemagick", "graphicsmagick", "pandoc",
|
||
"dasel", "potrace", "vtracer", "libreoffice", "calibre",
|
||
];
|
||
|
||
const matrix: Record<string, { from: string[]; to: string[] }> = {};
|
||
|
||
for (const converter of converters) {
|
||
try {
|
||
const module = await import(`../../src/converters/${converter}`);
|
||
const props = module.properties;
|
||
|
||
if (props) {
|
||
const fromFormats: string[] = [];
|
||
const toFormats: string[] = [];
|
||
|
||
for (const category of Object.values(props.from || {})) {
|
||
fromFormats.push(...(category as string[]));
|
||
}
|
||
for (const category of Object.values(props.to || {})) {
|
||
toFormats.push(...(category as string[]));
|
||
}
|
||
|
||
matrix[converter] = {
|
||
from: [...new Set(fromFormats)],
|
||
to: [...new Set(toFormats)],
|
||
};
|
||
}
|
||
} catch {
|
||
// 忽略無法載入的轉換器
|
||
}
|
||
}
|
||
|
||
// 計算總格式數
|
||
let totalFrom = 0;
|
||
let totalTo = 0;
|
||
let totalCombinations = 0;
|
||
|
||
console.log("\n📊 格式轉換矩陣 Conversion Matrix\n");
|
||
console.log("-".repeat(60));
|
||
|
||
for (const [converter, formats] of Object.entries(matrix)) {
|
||
const combinations = formats.from.length * formats.to.length;
|
||
totalFrom += formats.from.length;
|
||
totalTo += formats.to.length;
|
||
totalCombinations += combinations;
|
||
|
||
console.log(`${converter}:`);
|
||
console.log(` 輸入格式: ${formats.from.length} (${formats.from.slice(0, 5).join(", ")}${formats.from.length > 5 ? "..." : ""})`);
|
||
console.log(` 輸出格式: ${formats.to.length} (${formats.to.slice(0, 5).join(", ")}${formats.to.length > 5 ? "..." : ""})`);
|
||
console.log(` 組合數: ${combinations}`);
|
||
console.log();
|
||
}
|
||
|
||
console.log("-".repeat(60));
|
||
console.log(`總計:`);
|
||
console.log(` 轉換器數量: ${Object.keys(matrix).length}`);
|
||
console.log(` 輸入格式總數: ${totalFrom}`);
|
||
console.log(` 輸出格式總數: ${totalTo}`);
|
||
console.log(` 理論轉換組合: ${totalCombinations}`);
|
||
console.log("-".repeat(60));
|
||
|
||
// 保存矩陣報告
|
||
const reportPath = join(E2E_OUTPUT_DIR, "conversion-matrix.json");
|
||
writeFileSync(reportPath, JSON.stringify(matrix, null, 2));
|
||
console.log(`\n📁 矩陣報告已保存: ${reportPath}`);
|
||
|
||
expect(Object.keys(matrix).length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
// =============================================================================
|
||
// 邊界條件測試
|
||
// =============================================================================
|
||
|
||
describe("⚠️ 邊界條件測試 Edge Cases", () => {
|
||
const outputDir = join(E2E_OUTPUT_DIR, "edge-cases");
|
||
|
||
beforeAll(() => {
|
||
ensureDir(outputDir);
|
||
});
|
||
|
||
test("空檔案處理", async () => {
|
||
stats.total++;
|
||
const inputPath = join(outputDir, "empty.txt");
|
||
const outputPath = join(outputDir, "empty_output.html");
|
||
|
||
writeFileSync(inputPath, "");
|
||
|
||
// 某些轉換器可能可以處理空檔案
|
||
try {
|
||
const result = await runConversion("pandoc", inputPath, outputPath);
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(" ✓ 空檔案處理成功");
|
||
} else {
|
||
stats.passed++; // 失敗也是預期行為
|
||
console.log(" ✓ 空檔案正確拒絕");
|
||
}
|
||
} catch {
|
||
stats.passed++;
|
||
console.log(" ✓ 空檔案正確拒絕");
|
||
}
|
||
});
|
||
|
||
test("Unicode 檔名處理", async () => {
|
||
if (!availableTools.pandoc) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, "測試文件_テスト_테스트.markdown");
|
||
const outputPath = join(outputDir, "unicode_output.html");
|
||
|
||
createTestMarkdown(inputPath);
|
||
const result = await runConversion("pandoc", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(" ✓ Unicode 檔名處理成功");
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
});
|
||
|
||
test("超長內容處理", async () => {
|
||
if (!availableTools.pandoc) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, "long_content.markdown");
|
||
const outputPath = join(outputDir, "long_output.html");
|
||
|
||
// 生成 10000 行的文件
|
||
const lines = Array.from({ length: 10000 }, (_, i) =>
|
||
`第 ${i + 1} 行:這是測試內容 Line ${i + 1}: This is test content`
|
||
).join("\n");
|
||
writeFileSync(inputPath, `# 長文件測試\n\n${lines}`);
|
||
|
||
const result = await runConversion("pandoc", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(` ✓ 超長內容處理成功: ${result.outputSize} bytes`);
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
}, TIMEOUT.slow);
|
||
|
||
test("特殊字元處理", async () => {
|
||
if (!availableTools.pandoc) {
|
||
stats.skipped++;
|
||
stats.total++;
|
||
return;
|
||
}
|
||
|
||
stats.total++;
|
||
const inputPath = join(outputDir, "special_chars.markdown");
|
||
const outputPath = join(outputDir, "special_output.html");
|
||
|
||
writeFileSync(inputPath, `# 特殊字元測試
|
||
|
||
& < > " ' \` ~ ! @ # $ % ^ & * ( ) - = + [ ] { } | \\ : ; < > ? , . /
|
||
|
||
數學符號:∑ ∏ ∫ √ ∞ ≠ ≤ ≥ ± × ÷ π
|
||
|
||
表情符號:😀 🎉 🚀 ❤️ 🔥 💯 ✅ ❌
|
||
|
||
CJK 擴展:㊀ ㊁ ㊂ ㊃ ㊄
|
||
|
||
全形標點:「」『』【】〈〉《》
|
||
`);
|
||
|
||
const result = await runConversion("pandoc", inputPath, outputPath);
|
||
stats.results.push(result);
|
||
|
||
if (result.success) {
|
||
stats.passed++;
|
||
console.log(" ✓ 特殊字元處理成功");
|
||
} else {
|
||
stats.failed++;
|
||
}
|
||
|
||
expect(result.success).toBe(true);
|
||
});
|
||
});
|