v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
440
tests/unit/test_export.py
Normal file
440
tests/unit/test_export.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
匯出模組單元測試
|
||||
|
||||
對應 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("my<timeline>2024: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 / "test<invalid>name.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 '<svg' in content or '<?xml' in content
|
||||
|
||||
|
||||
class TestExportIntegration:
|
||||
"""匯出整合測試"""
|
||||
|
||||
def test_full_workflow_pdf(self, setup_output_dir):
|
||||
"""測試完整 PDF 匯出流程"""
|
||||
# 1. 建立事件
|
||||
events = [
|
||||
Event(id="1", title="專案啟動", start=datetime(2024, 1, 1)),
|
||||
Event(id="2", title="需求分析", start=datetime(2024, 1, 5)),
|
||||
Event(id="3", title="開發階段", start=datetime(2024, 1, 10))
|
||||
]
|
||||
|
||||
# 2. 渲染時間軸
|
||||
renderer = TimelineRenderer()
|
||||
config = TimelineConfig(direction='horizontal', theme='modern')
|
||||
result = renderer.render(events, config)
|
||||
|
||||
# 3. 匯出為 PDF
|
||||
exporter = TimelineExporter()
|
||||
options = ExportOptions(fmt=ExportFormat.PDF, dpi=300)
|
||||
exported = exporter.export_from_plotly_json(
|
||||
result.data,
|
||||
result.layout,
|
||||
setup_output_dir / "workflow_test.pdf",
|
||||
options
|
||||
)
|
||||
|
||||
# 4. 驗證結果
|
||||
assert exported.exists()
|
||||
assert exported.stat().st_size > 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"])
|
||||
Reference in New Issue
Block a user