""" 匯出模組單元測試 對應 TDD.md - UT-EXP-01: PDF 輸出完整性 驗證重點: - 字型嵌入與 DPI 驗證 - 各種格式的輸出品質 Version: 1.0.0 DocID: TDD-UT-EXP-001 Related: SDD-API-003 (POST /export) """ import pytest import os from pathlib import Path from datetime import datetime from backend.schemas import ExportOptions, ExportFormat, Event, TimelineConfig from backend.export import ( FileNameSanitizer, ExportEngine, TimelineExporter, ExportError, create_metadata ) from backend.renderer import TimelineRenderer # 測試輸出目錄 TEST_OUTPUT_DIR = Path(__file__).parent.parent / "temp_output" @pytest.fixture def setup_output_dir(): """建立測試輸出目錄""" TEST_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) yield TEST_OUTPUT_DIR # 清理測試檔案 if TEST_OUTPUT_DIR.exists(): for file in TEST_OUTPUT_DIR.glob("*"): try: file.unlink() except: pass @pytest.fixture def sample_figure(): """建立範例 Plotly 圖表""" events = [ Event(id="1", title="Event 1", start=datetime(2024, 1, 1)), Event(id="2", title="Event 2", start=datetime(2024, 1, 5)), Event(id="3", title="Event 3", start=datetime(2024, 1, 10)) ] renderer = TimelineRenderer() result = renderer.render(events, TimelineConfig()) import plotly.graph_objects as go fig = go.Figure(data=result.data.get('data', []), layout=result.layout) return fig class TestFileNameSanitizer: """檔名淨化器測試""" def test_sanitize_normal_name(self): """測試正常檔名""" result = FileNameSanitizer.sanitize("my_timeline_2024") assert result == "my_timeline_2024" def test_sanitize_illegal_chars(self): """測試移除非法字元""" result = FileNameSanitizer.sanitize("my2024:test") assert '<' not in result assert '>' not in result assert ':' not in result def test_sanitize_reserved_name(self): """測試保留字處理""" result = FileNameSanitizer.sanitize("CON") assert result == "_CON" def test_sanitize_long_name(self): """測試過長檔名""" long_name = "a" * 300 result = FileNameSanitizer.sanitize(long_name) assert len(result) <= FileNameSanitizer.MAX_LENGTH def test_sanitize_empty_name(self): """測試空檔名""" result = FileNameSanitizer.sanitize("") assert result == "timeline" def test_sanitize_trailing_spaces(self): """測試移除尾部空格和點""" result = FileNameSanitizer.sanitize("test. ") assert not result.endswith('.') assert not result.endswith(' ') class TestExportEngine: """匯出引擎測試""" def test_export_engine_initialization(self): """測試匯出引擎初始化""" engine = ExportEngine() assert engine is not None assert engine.filename_sanitizer is not None def test_export_pdf_basic(self, sample_figure, setup_output_dir): """測試基本 PDF 匯出""" engine = ExportEngine() output_path = setup_output_dir / "test.pdf" options = ExportOptions(fmt=ExportFormat.PDF, dpi=300) result = engine.export(sample_figure, output_path, options) assert result.exists() assert result.suffix == '.pdf' assert result.stat().st_size > 0 def test_export_png_basic(self, sample_figure, setup_output_dir): """測試基本 PNG 匯出""" engine = ExportEngine() output_path = setup_output_dir / "test.png" options = ExportOptions(fmt=ExportFormat.PNG, dpi=300) result = engine.export(sample_figure, output_path, options) assert result.exists() assert result.suffix == '.png' assert result.stat().st_size > 0 def test_export_svg_basic(self, sample_figure, setup_output_dir): """測試基本 SVG 匯出""" engine = ExportEngine() output_path = setup_output_dir / "test.svg" options = ExportOptions(fmt=ExportFormat.SVG) result = engine.export(sample_figure, output_path, options) assert result.exists() assert result.suffix == '.svg' assert result.stat().st_size > 0 def test_export_png_with_transparency(self, sample_figure, setup_output_dir): """測試 PNG 透明背景""" engine = ExportEngine() output_path = setup_output_dir / "transparent.png" options = ExportOptions( fmt=ExportFormat.PNG, transparent_background=True ) result = engine.export(sample_figure, output_path, options) assert result.exists() assert result.suffix == '.png' def test_export_custom_dimensions(self, sample_figure, setup_output_dir): """測試自訂尺寸""" engine = ExportEngine() output_path = setup_output_dir / "custom_size.png" options = ExportOptions( fmt=ExportFormat.PNG, width=1280, height=720 ) result = engine.export(sample_figure, output_path, options) assert result.exists() def test_export_high_dpi(self, sample_figure, setup_output_dir): """測試高 DPI 匯出""" engine = ExportEngine() output_path = setup_output_dir / "high_dpi.png" options = ExportOptions(fmt=ExportFormat.PNG, dpi=600) result = engine.export(sample_figure, output_path, options) assert result.exists() # 高 DPI 檔案應該較大 assert result.stat().st_size > 0 def test_export_creates_directory(self, sample_figure, setup_output_dir): """測試自動建立目錄""" engine = ExportEngine() nested_path = setup_output_dir / "subdir" / "test.pdf" options = ExportOptions(fmt=ExportFormat.PDF) result = engine.export(sample_figure, nested_path, options) assert result.exists() assert result.parent.exists() def test_export_filename_sanitization(self, sample_figure, setup_output_dir): """測試檔名淨化""" engine = ExportEngine() output_path = setup_output_dir / "testname.pdf" options = ExportOptions(fmt=ExportFormat.PDF) result = engine.export(sample_figure, output_path, options) assert result.exists() assert '<' not in result.name assert '>' not in result.name class TestTimelineExporter: """時間軸匯出器測試""" def test_exporter_initialization(self): """測試匯出器初始化""" exporter = TimelineExporter() assert exporter is not None assert exporter.export_engine is not None def test_export_from_plotly_json(self, setup_output_dir): """測試從 Plotly JSON 匯出""" # 先渲染出 Plotly JSON events = [Event(id="1", title="Test", start=datetime(2024, 1, 1))] renderer = TimelineRenderer() result = renderer.render(events, TimelineConfig()) exporter = TimelineExporter() output_path = setup_output_dir / "from_json.pdf" options = ExportOptions(fmt=ExportFormat.PDF) exported = exporter.export_from_plotly_json( result.data, result.layout, output_path, options ) assert exported.exists() assert exported.suffix == '.pdf' def test_export_to_directory_with_default_name(self, setup_output_dir): """測試匯出至目錄並自動命名""" events = [Event(id="1", title="Test", start=datetime(2024, 1, 1))] renderer = TimelineRenderer() result = renderer.render(events, TimelineConfig()) exporter = TimelineExporter() options = ExportOptions(fmt=ExportFormat.PNG) exported = exporter.export_from_plotly_json( result.data, result.layout, setup_output_dir, options, filename_prefix="my_timeline" ) assert exported.exists() assert "my_timeline" in exported.name assert exported.suffix == '.png' def test_generate_default_filename(self): """測試生成預設檔名""" exporter = TimelineExporter() filename = exporter.generate_default_filename(ExportFormat.PDF) assert "timeline_" in filename assert filename.endswith('.pdf') def test_generate_default_filename_format(self): """測試預設檔名格式""" exporter = TimelineExporter() for fmt in [ExportFormat.PDF, ExportFormat.PNG, ExportFormat.SVG]: filename = exporter.generate_default_filename(fmt) assert filename.endswith(f'.{fmt.value}') assert filename.startswith('timeline_') class TestExportErrorHandling: """匯出錯誤處理測試""" def test_export_to_readonly_location(self, sample_figure, tmp_path): """測試寫入唯讀位置""" # 建立唯讀目錄(在 Windows 上這個測試可能需要調整) readonly_dir = tmp_path / "readonly" readonly_dir.mkdir() # 在某些系統上可能無法真正設定唯讀,所以這個測試可能會跳過 # 這裡主要測試錯誤處理機制存在 engine = ExportEngine() output_path = readonly_dir / "test.pdf" options = ExportOptions(fmt=ExportFormat.PDF) try: # 嘗試匯出 result = engine.export(sample_figure, output_path, options) # 如果成功,清理檔案 if result.exists(): result.unlink() except ExportError: # 預期的錯誤 pass def test_export_empty_timeline(self, setup_output_dir): """測試匯出空白時間軸""" # 建立空白時間軸 renderer = TimelineRenderer() result = renderer.render([], TimelineConfig()) exporter = TimelineExporter() output_path = setup_output_dir / "empty.pdf" options = ExportOptions(fmt=ExportFormat.PDF) # 應該不會崩潰,能生成空白圖檔 exported = exporter.export_from_plotly_json( result.data, result.layout, output_path, options ) assert exported.exists() class TestExportMetadata: """匯出元資料測試""" def test_create_metadata_default(self): """測試建立預設元資料""" metadata = create_metadata() assert 'Title' in metadata assert 'Creator' in metadata assert 'Producer' in metadata assert 'CreationDate' in metadata assert 'TimeLine Designer' in metadata['Title'] def test_create_metadata_custom_title(self): """測試自訂標題元資料""" metadata = create_metadata(title="My Project Timeline") assert metadata['Title'] == "My Project Timeline" assert 'TimeLine Designer' in metadata['Creator'] class TestExportFileFormats: """匯出檔案格式測試""" def test_pdf_file_format(self, sample_figure, setup_output_dir): """測試 PDF 檔案格式正確""" engine = ExportEngine() output_path = setup_output_dir / "test.pdf" options = ExportOptions(fmt=ExportFormat.PDF) result = engine.export(sample_figure, output_path, options) # 檢查檔案開頭是否為 PDF 標記 with open(result, 'rb') as f: header = f.read(5) assert header == b'%PDF-' def test_png_file_format(self, sample_figure, setup_output_dir): """測試 PNG 檔案格式正確""" engine = ExportEngine() output_path = setup_output_dir / "test.png" options = ExportOptions(fmt=ExportFormat.PNG) result = engine.export(sample_figure, output_path, options) # 檢查 PNG 檔案簽名 with open(result, 'rb') as f: header = f.read(8) assert header == b'\x89PNG\r\n\x1a\n' def test_svg_file_format(self, sample_figure, setup_output_dir): """測試 SVG 檔案格式正確""" engine = ExportEngine() output_path = setup_output_dir / "test.svg" options = ExportOptions(fmt=ExportFormat.SVG) result = engine.export(sample_figure, output_path, options) # 檢查 SVG 內容 with open(result, 'r', encoding='utf-8') as f: content = f.read() assert ' 1000 # 至少 1KB def test_full_workflow_all_formats(self, setup_output_dir): """測試所有格式的完整流程""" events = [Event(id="1", title="Test", start=datetime(2024, 1, 1))] renderer = TimelineRenderer() result = renderer.render(events, TimelineConfig()) exporter = TimelineExporter() for fmt in [ExportFormat.PDF, ExportFormat.PNG, ExportFormat.SVG]: options = ExportOptions(fmt=fmt) exported = exporter.export_from_plotly_json( result.data, result.layout, setup_output_dir / f"all_formats.{fmt.value}", options ) assert exported.exists() assert exported.suffix == f'.{fmt.value}' if __name__ == "__main__": pytest.main([__file__, "-v"])