v9.5: 實作標籤完全不重疊算法

- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-11-06 11:35:29 +08:00
commit 2d37d23bcf
83 changed files with 22971 additions and 0 deletions

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Unit Tests Package"""

440
tests/unit/test_export.py Normal file
View 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"])

245
tests/unit/test_importer.py Normal file
View File

@@ -0,0 +1,245 @@
"""
CSV/XLSX 匯入模組單元測試
對應 TDD.md - UT-IMP-01: 匯入 CSV 欄位解析
驗證重點:
- 欄位自動對應
- 格式容錯
- 錯誤處理
Version: 1.0.0
DocID: TDD-UT-IMP-001
Related: SDD-API-001 (POST /import)
"""
import pytest
import os
from pathlib import Path
from datetime import datetime
from backend.schemas import Event, ImportResult, EventType
from backend.importer import CSVImporter, FieldMapper, DateParser, ColorValidator
# 測試資料路徑
FIXTURES_DIR = Path(__file__).parent.parent / "fixtures"
SAMPLE_CSV = FIXTURES_DIR / "sample_events.csv"
INVALID_CSV = FIXTURES_DIR / "invalid_dates.csv"
class TestFieldMapper:
"""欄位映射器測試"""
def test_map_english_fields(self):
"""測試英文欄位映射"""
headers = ['id', 'title', 'start', 'end', 'group', 'description', 'color']
mapping = FieldMapper.map_fields(headers)
assert mapping['id'] == 'id'
assert mapping['title'] == 'title'
assert mapping['start'] == 'start'
assert mapping['end'] == 'end'
def test_map_chinese_fields(self):
"""測試中文欄位映射"""
headers = ['編號', '標題', '開始', '結束', '群組']
mapping = FieldMapper.map_fields(headers)
assert mapping['id'] == '編號'
assert mapping['title'] == '標題'
assert mapping['start'] == '開始'
def test_validate_missing_fields(self):
"""測試缺少必要欄位驗證"""
mapping = {'id': 'id', 'title': 'title'} # 缺少 start
missing = FieldMapper.validate_required_fields(mapping)
assert 'start' in missing
class TestDateParser:
"""日期解析器測試"""
def test_parse_standard_format(self):
"""測試標準日期格式"""
result = DateParser.parse('2024-01-01 09:00:00')
assert result == datetime(2024, 1, 1, 9, 0, 0)
def test_parse_date_only(self):
"""測試僅日期格式"""
result = DateParser.parse('2024-01-01')
assert result.year == 2024
assert result.month == 1
assert result.day == 1
def test_parse_slash_format(self):
"""測試斜線格式"""
result = DateParser.parse('2024/01/01')
assert result.year == 2024
def test_parse_invalid_date(self):
"""測試無效日期"""
result = DateParser.parse('invalid-date')
assert result is None
def test_parse_empty_string(self):
"""測試空字串"""
result = DateParser.parse('')
assert result is None
class TestColorValidator:
"""顏色驗證器測試"""
def test_validate_valid_hex(self):
"""測試有效的 HEX 顏色"""
result = ColorValidator.validate('#3B82F6')
assert result == '#3B82F6'
def test_validate_hex_without_hash(self):
"""測試不含 # 的 HEX 顏色"""
result = ColorValidator.validate('3B82F6')
assert result == '#3B82F6'
def test_validate_invalid_color(self):
"""測試無效顏色,應返回預設顏色"""
result = ColorValidator.validate('invalid')
assert result.startswith('#')
assert len(result) == 7
def test_validate_empty_color(self):
"""測試空顏色,應返回預設顏色"""
result = ColorValidator.validate('', 0)
assert result == ColorValidator.DEFAULT_COLORS[0]
class TestCSVImporter:
"""CSV 匯入器測試類別"""
def test_import_valid_csv(self):
"""
UT-IMP-01-001: 測試匯入有效的 CSV 檔案
預期結果:
- 成功解析所有行
- 欄位正確對應
- 日期格式正確轉換
"""
importer = CSVImporter()
result = importer.import_file(str(SAMPLE_CSV))
assert result.success is True
assert result.imported_count == 6
assert len(result.events) == 6
assert result.events[0].title == "專案啟動"
assert isinstance(result.events[0].start, datetime)
def test_import_with_invalid_dates(self):
"""
UT-IMP-01-003: 測試日期格式錯誤的 CSV
預期結果:
- 部分成功匯入
- 錯誤行記錄在 errors 列表中
"""
importer = CSVImporter()
result = importer.import_file(str(INVALID_CSV))
assert result.success is True
assert len(result.errors) > 0
# 應該有錯誤但不會完全失敗
def test_import_nonexistent_file(self):
"""測試匯入不存在的檔案"""
importer = CSVImporter()
result = importer.import_file('nonexistent.csv')
assert result.success is False
assert len(result.errors) > 0
assert result.imported_count == 0
def test_field_auto_mapping(self):
"""
UT-IMP-01-005: 測試欄位自動對應功能
測試不同的欄位名稱變體是否能正確對應
"""
# 建立臨時測試 CSV
test_csv = FIXTURES_DIR / "test_mapping.csv"
with open(test_csv, 'w', encoding='utf-8') as f:
f.write("ID,Title,Start\n")
f.write("1,Test Event,2024-01-01\n")
importer = CSVImporter()
result = importer.import_file(str(test_csv))
assert result.success is True
assert len(result.events) == 1
assert result.events[0].id == "1"
assert result.events[0].title == "Test Event"
# 清理
if test_csv.exists():
test_csv.unlink()
def test_color_format_validation(self):
"""
UT-IMP-01-007: 測試顏色格式驗證
預期結果:
- 有效的 HEX 顏色被接受
- 無效的顏色格式使用預設值
"""
importer = CSVImporter()
result = importer.import_file(str(SAMPLE_CSV))
assert result.success is True
# 所有事件都應該有有效的顏色
for event in result.events:
assert event.color.startswith('#')
assert len(event.color) == 7
def test_import_empty_csv(self):
"""測試匯入空白 CSV"""
# 建立空白測試 CSV
empty_csv = FIXTURES_DIR / "empty.csv"
with open(empty_csv, 'w', encoding='utf-8') as f:
f.write("")
importer = CSVImporter()
result = importer.import_file(str(empty_csv))
assert result.success is False
assert "" in str(result.errors[0])
# 清理
if empty_csv.exists():
empty_csv.unlink()
def test_date_format_tolerance(self):
"""
UT-IMP-01-006: 測試日期格式容錯
測試多種日期格式是否能正確解析
"""
# 建立測試 CSV with various date formats
test_csv = FIXTURES_DIR / "test_dates.csv"
with open(test_csv, 'w', encoding='utf-8') as f:
f.write("id,title,start\n")
f.write("1,Event1,2024-01-01\n")
f.write("2,Event2,2024/01/02\n")
f.write("3,Event3,2024-01-03 10:00:00\n")
importer = CSVImporter()
result = importer.import_file(str(test_csv))
assert result.success is True
assert result.imported_count == 3
assert all(isinstance(e.start, datetime) for e in result.events)
# 清理
if test_csv.exists():
test_csv.unlink()
if __name__ == "__main__":
pytest.main([__file__, "-v"])

255
tests/unit/test_renderer.py Normal file
View File

@@ -0,0 +1,255 @@
"""
時間軸渲染模組單元測試
對應 TDD.md:
- UT-REN-01: 時間刻度演算法
- UT-REN-02: 節點避碰演算法
Version: 1.0.0
DocID: TDD-UT-REN-001
"""
import pytest
from datetime import datetime, timedelta
from backend.schemas import Event, TimelineConfig, RenderResult, EventType
from backend.renderer import (
TimeScaleCalculator, CollisionResolver, ThemeManager,
TimelineRenderer, TimeUnit
)
class TestTimeScaleCalculator:
"""時間刻度演算法測試"""
def test_calculate_time_range(self):
"""測試時間範圍計算"""
events = [
Event(id="1", title="E1", start=datetime(2024, 1, 1)),
Event(id="2", title="E2", start=datetime(2024, 1, 10))
]
start, end = TimeScaleCalculator.calculate_time_range(events)
assert start < datetime(2024, 1, 1)
assert end > datetime(2024, 1, 10)
def test_determine_time_unit_days(self):
"""測試天級別刻度判斷"""
start = datetime(2024, 1, 1)
end = datetime(2024, 1, 7)
unit = TimeScaleCalculator.determine_time_unit(start, end)
assert unit == TimeUnit.DAY
def test_determine_time_unit_weeks(self):
"""測試週級別刻度判斷"""
start = datetime(2024, 1, 1)
end = datetime(2024, 3, 1) # 約 2 個月
unit = TimeScaleCalculator.determine_time_unit(start, end)
assert unit == TimeUnit.WEEK
def test_determine_time_unit_months(self):
"""測試月級別刻度判斷"""
start = datetime(2024, 1, 1)
end = datetime(2024, 6, 1) # 6 個月
unit = TimeScaleCalculator.determine_time_unit(start, end)
assert unit == TimeUnit.MONTH
def test_generate_tick_values_days(self):
"""測試天級別刻度生成"""
start = datetime(2024, 1, 1)
end = datetime(2024, 1, 5)
ticks = TimeScaleCalculator.generate_tick_values(start, end, TimeUnit.DAY)
assert len(ticks) >= 5
assert all(isinstance(t, datetime) for t in ticks)
def test_generate_tick_values_months(self):
"""測試月級別刻度生成"""
start = datetime(2024, 1, 1)
end = datetime(2024, 6, 1)
ticks = TimeScaleCalculator.generate_tick_values(start, end, TimeUnit.MONTH)
assert len(ticks) >= 6
# 驗證是每月第一天
assert all(t.day == 1 for t in ticks)
class TestCollisionResolver:
"""節點避碰演算法測試"""
def test_no_overlapping_events(self):
"""測試無重疊事件"""
events = [
Event(id="1", title="E1", start=datetime(2024, 1, 1), end=datetime(2024, 1, 2)),
Event(id="2", title="E2", start=datetime(2024, 1, 3), end=datetime(2024, 1, 4))
]
resolver = CollisionResolver()
layers = resolver.resolve_collisions(events)
# 無重疊,都在第 0 層
assert layers["1"] == 0
assert layers["2"] == 0
def test_overlapping_events(self):
"""測試重疊事件分層"""
events = [
Event(id="1", title="E1", start=datetime(2024, 1, 1), end=datetime(2024, 1, 5)),
Event(id="2", title="E2", start=datetime(2024, 1, 3), end=datetime(2024, 1, 7))
]
resolver = CollisionResolver()
layers = resolver.resolve_collisions(events)
# 重疊,應該在不同層
assert layers["1"] != layers["2"]
def test_group_based_layout(self):
"""測試基於群組的排版"""
events = [
Event(id="1", title="E1", start=datetime(2024, 1, 1), group="A"),
Event(id="2", title="E2", start=datetime(2024, 1, 1), group="B")
]
resolver = CollisionResolver()
layers = resolver.group_based_layout(events)
# 不同群組,應該在不同層
assert layers["1"] != layers["2"]
def test_empty_events(self):
"""測試空事件列表"""
resolver = CollisionResolver()
layers = resolver.resolve_collisions([])
assert layers == {}
class TestThemeManager:
"""主題管理器測試"""
def test_get_modern_theme(self):
"""測試現代主題"""
from backend.schemas import ThemeStyle
theme = ThemeManager.get_theme(ThemeStyle.MODERN)
assert 'background' in theme
assert 'text' in theme
assert 'primary' in theme
def test_get_all_themes(self):
"""測試所有主題可用性"""
from backend.schemas import ThemeStyle
for style in ThemeStyle:
theme = ThemeManager.get_theme(style)
assert theme is not None
assert 'background' in theme
class TestTimelineRenderer:
"""時間軸渲染器測試"""
def test_render_basic_timeline(self):
"""測試基本時間軸渲染"""
events = [
Event(id="1", title="Event 1", start=datetime(2024, 1, 1)),
Event(id="2", title="Event 2", start=datetime(2024, 1, 5))
]
config = TimelineConfig()
renderer = TimelineRenderer()
result = renderer.render(events, config)
assert result.success is True
assert 'data' in result.data
assert result.layout is not None
def test_render_empty_timeline(self):
"""測試空白時間軸渲染"""
renderer = TimelineRenderer()
result = renderer.render([], TimelineConfig())
assert result.success is True
assert 'data' in result.data
def test_render_with_horizontal_direction(self):
"""測試水平方向渲染"""
events = [Event(id="1", title="E1", start=datetime(2024, 1, 1))]
config = TimelineConfig(direction='horizontal')
renderer = TimelineRenderer()
result = renderer.render(events, config)
assert result.success is True
def test_render_with_vertical_direction(self):
"""測試垂直方向渲染"""
events = [Event(id="1", title="E1", start=datetime(2024, 1, 1))]
config = TimelineConfig(direction='vertical')
renderer = TimelineRenderer()
result = renderer.render(events, config)
assert result.success is True
def test_render_with_different_themes(self):
"""測試不同主題渲染"""
from backend.schemas import ThemeStyle
events = [Event(id="1", title="E1", start=datetime(2024, 1, 1))]
renderer = TimelineRenderer()
for theme in [ThemeStyle.MODERN, ThemeStyle.CLASSIC]:
config = TimelineConfig(theme=theme)
result = renderer.render(events, config)
assert result.success is True
def test_render_with_grid(self):
"""測試顯示網格"""
events = [Event(id="1", title="E1", start=datetime(2024, 1, 1))]
config = TimelineConfig(show_grid=True)
renderer = TimelineRenderer()
result = renderer.render(events, config)
assert result.success is True
def test_render_single_event(self):
"""測試單一事件渲染"""
events = [Event(id="1", title="Single", start=datetime(2024, 1, 1))]
config = TimelineConfig()
renderer = TimelineRenderer()
result = renderer.render(events, config)
assert result.success is True
assert len(result.data['data']) == 1
def test_hover_text_generation(self):
"""測試提示訊息生成"""
event = Event(
id="1",
title="Test Event",
start=datetime(2024, 1, 1),
end=datetime(2024, 1, 2),
description="Test description"
)
renderer = TimelineRenderer()
hover_text = renderer._generate_hover_text(event)
assert "Test Event" in hover_text
assert "Test description" in hover_text
if __name__ == "__main__":
pytest.main([__file__, "-v"])

146
tests/unit/test_schemas.py Normal file
View File

@@ -0,0 +1,146 @@
"""
資料模型測試
測試 Pydantic schemas 的基本驗證功能
Version: 1.0.0
DocID: TDD-UT-SCHEMA-001
"""
import pytest
from datetime import datetime
from backend.schemas import Event, EventType, TimelineConfig, ExportOptions, ExportFormat
class TestEventModel:
"""Event 模型測試"""
def test_create_valid_event(self):
"""測試建立有效事件"""
event = Event(
id="test-001",
title="測試事件",
start=datetime(2024, 1, 1, 9, 0, 0),
end=datetime(2024, 1, 1, 17, 0, 0),
group="Phase 1",
description="這是一個測試事件",
color="#3B82F6",
event_type=EventType.RANGE
)
assert event.id == "test-001"
assert event.title == "測試事件"
assert event.group == "Phase 1"
assert event.color == "#3B82F6"
def test_event_end_before_start_validation(self):
"""測試結束時間早於開始時間的驗證"""
with pytest.raises(ValueError, match="結束時間必須晚於開始時間"):
Event(
id="test-002",
title="無效事件",
start=datetime(2024, 1, 2, 9, 0, 0),
end=datetime(2024, 1, 1, 9, 0, 0), # 結束早於開始
)
def test_event_with_invalid_color(self):
"""測試無效的顏色格式"""
with pytest.raises(ValueError):
Event(
id="test-003",
title="測試事件",
start=datetime(2024, 1, 1, 9, 0, 0),
color="invalid-color" # 無效的顏色格式
)
def test_event_optional_fields(self):
"""測試可選欄位"""
event = Event(
id="test-004",
title="最小事件",
start=datetime(2024, 1, 1, 9, 0, 0)
)
assert event.end is None
assert event.group is None
assert event.description is None
assert event.color is None
class TestTimelineConfig:
"""TimelineConfig 模型測試"""
def test_default_config(self):
"""測試預設配置"""
config = TimelineConfig()
assert config.direction == 'horizontal'
assert config.theme.value == 'modern'
assert config.show_grid is True
assert config.show_tooltip is True
def test_custom_config(self):
"""測試自訂配置"""
config = TimelineConfig(
direction='vertical',
theme='classic',
show_grid=False
)
assert config.direction == 'vertical'
assert config.theme.value == 'classic'
assert config.show_grid is False
class TestExportOptions:
"""ExportOptions 模型測試"""
def test_valid_export_options(self):
"""測試有效的匯出選項"""
options = ExportOptions(
fmt=ExportFormat.PDF,
dpi=300,
width=1920,
height=1080
)
assert options.fmt == ExportFormat.PDF
assert options.dpi == 300
assert options.width == 1920
assert options.height == 1080
def test_dpi_range_validation(self):
"""測試 DPI 範圍驗證"""
# DPI 太低
with pytest.raises(ValueError):
ExportOptions(
fmt=ExportFormat.PNG,
dpi=50 # < 72
)
# DPI 太高
with pytest.raises(ValueError):
ExportOptions(
fmt=ExportFormat.PNG,
dpi=700 # > 600
)
def test_dimension_validation(self):
"""測試尺寸範圍驗證"""
# 寬度太小
with pytest.raises(ValueError):
ExportOptions(
fmt=ExportFormat.PNG,
width=500 # < 800
)
# 高度太大
with pytest.raises(ValueError):
ExportOptions(
fmt=ExportFormat.PNG,
height=5000 # > 4096
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])