- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
441 lines
14 KiB
Python
441 lines
14 KiB
Python
"""
|
|
匯出模組單元測試
|
|
|
|
對應 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"])
|