v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
13
tests/__init__.py
Normal file
13
tests/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
TimeLine Designer Test Suite
|
||||
|
||||
測試覆蓋範圍:
|
||||
- 單元測試(Unit Tests)
|
||||
- 端對端測試(E2E Tests)
|
||||
- 效能測試(Performance Tests)
|
||||
|
||||
Version: 1.0.0
|
||||
DocID: TDD-TEST-001
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
3
tests/fixtures/invalid_dates.csv
vendored
Normal file
3
tests/fixtures/invalid_dates.csv
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id,title,start,end,group,description,color
|
||||
evt-001,測試事件,2024-13-01 09:00:00,2024-01-01 17:00:00,Phase 1,無效的月份,#3B82F6
|
||||
evt-002,測試事件2,2024-01-01 09:00:00,2023-12-31 18:00:00,Phase 1,結束時間早於開始時間,#10B981
|
||||
|
7
tests/fixtures/sample_events.csv
vendored
Normal file
7
tests/fixtures/sample_events.csv
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
id,title,start,end,group,description,color
|
||||
evt-001,專案啟動,2024-01-01 09:00:00,2024-01-01 17:00:00,Phase 1,專案正式啟動會議,#3B82F6
|
||||
evt-002,需求分析,2024-01-02 09:00:00,2024-01-05 18:00:00,Phase 1,收集並分析系統需求,#10B981
|
||||
evt-003,系統設計,2024-01-08 09:00:00,2024-01-15 18:00:00,Phase 2,完成系統架構設計,#F59E0B
|
||||
evt-004,開發階段,2024-01-16 09:00:00,2024-02-28 18:00:00,Phase 3,程式碼開發與單元測試,#EF4444
|
||||
evt-005,整合測試,2024-03-01 09:00:00,2024-03-15 18:00:00,Phase 4,系統整合與測試,#8B5CF6
|
||||
evt-006,上線部署,2024-03-20 09:00:00,,Phase 5,正式上線,#EC4899
|
||||
|
3
tests/integration/__init__.py
Normal file
3
tests/integration/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
整合測試模組
|
||||
"""
|
||||
31
tests/integration/conftest.py
Normal file
31
tests/integration/conftest.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
整合測試配置
|
||||
|
||||
提供 FastAPI 測試客戶端和通用 fixtures
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from backend.main import app
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
"""
|
||||
AsyncClient fixture for testing FastAPI endpoints
|
||||
|
||||
使用 httpx.AsyncClient 來測試 async 端點
|
||||
"""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csv_content():
|
||||
"""範例 CSV 內容"""
|
||||
return b"""id,title,start,end,group,description,color
|
||||
evt-001,Event 1,2024-01-01,2024-01-02,Group A,Test event 1,#3B82F6
|
||||
evt-002,Event 2,2024-01-05,2024-01-06,Group B,Test event 2,#10B981
|
||||
evt-003,Event 3,2024-01-10,,Group A,Test event 3,#F59E0B
|
||||
"""
|
||||
613
tests/integration/test_api.py
Normal file
613
tests/integration/test_api.py
Normal file
@@ -0,0 +1,613 @@
|
||||
"""
|
||||
API 端點整合測試
|
||||
|
||||
對應 TDD.md - IT-API-01: API 端點整合測試
|
||||
驗證所有 REST API 端點功能正常運作
|
||||
|
||||
Version: 1.0.0
|
||||
DocID: TDD-IT-API-001
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
"""健康檢查 API 測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_success(self, client):
|
||||
"""
|
||||
IT-API-01-001: 測試健康檢查端點
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- success = True
|
||||
- 包含版本資訊
|
||||
"""
|
||||
response = await client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["message"] == "Service is healthy"
|
||||
assert "version" in data["data"]
|
||||
assert "timestamp" in data["data"]
|
||||
|
||||
|
||||
class TestImportAPI:
|
||||
"""匯入 API 測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_csv_success(self, client, sample_csv_content):
|
||||
"""
|
||||
IT-API-02-001: 測試成功匯入 CSV
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- success = True
|
||||
- imported_count = 3
|
||||
"""
|
||||
# 清空事件
|
||||
await client.delete("/api/events")
|
||||
|
||||
# 上傳 CSV
|
||||
files = {"file": ("test.csv", BytesIO(sample_csv_content), "text/csv")}
|
||||
response = await client.post("/api/import", files=files)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["imported_count"] == 3
|
||||
assert len(data["events"]) == 3
|
||||
assert data["events"][0]["title"] == "Event 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_invalid_file_type(self, client):
|
||||
"""
|
||||
IT-API-02-002: 測試不支援的檔案類型
|
||||
|
||||
預期結果:
|
||||
- HTTP 400
|
||||
- 錯誤訊息
|
||||
"""
|
||||
files = {"file": ("test.txt", BytesIO(b"invalid"), "text/plain")}
|
||||
response = await client.post("/api/import", files=files)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "不支援的檔案格式" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_no_filename(self, client):
|
||||
"""
|
||||
IT-API-02-003: 測試未提供檔案名稱
|
||||
|
||||
預期結果:
|
||||
- HTTP 422 (FastAPI 驗證錯誤) 或 400
|
||||
"""
|
||||
files = {"file": ("", BytesIO(b"test"), "text/csv")}
|
||||
response = await client.post("/api/import", files=files)
|
||||
|
||||
# FastAPI 會在更早的層級驗證並返回 422
|
||||
assert response.status_code in [400, 422]
|
||||
|
||||
|
||||
class TestEventsAPI:
|
||||
"""事件管理 API 測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_events_empty(self, client):
|
||||
"""
|
||||
IT-API-03-001: 測試取得空事件列表
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- 空陣列
|
||||
"""
|
||||
# 先清空
|
||||
await client.delete("/api/events")
|
||||
|
||||
response = await client.get("/api/events")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_event_success(self, client):
|
||||
"""
|
||||
IT-API-03-002: 測試新增事件
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- 回傳新增的事件
|
||||
"""
|
||||
# 清空
|
||||
await client.delete("/api/events")
|
||||
|
||||
event_data = {
|
||||
"id": "test-001",
|
||||
"title": "Integration Test Event",
|
||||
"start": "2024-01-01T09:00:00",
|
||||
"end": "2024-01-01T17:00:00",
|
||||
"group": "Test",
|
||||
"description": "Test description",
|
||||
"color": "#3B82F6",
|
||||
"event_type": "range"
|
||||
}
|
||||
|
||||
response = await client.post("/api/events", json=event_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == "test-001"
|
||||
assert data["title"] == "Integration Test Event"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_events_after_add(self, client):
|
||||
"""
|
||||
IT-API-03-003: 測試新增後取得事件列表
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- 包含新增的事件
|
||||
"""
|
||||
# 清空並新增
|
||||
await client.delete("/api/events")
|
||||
event_data = {
|
||||
"id": "test-002",
|
||||
"title": "Test Event 2",
|
||||
"start": "2024-01-01T09:00:00"
|
||||
}
|
||||
await client.post("/api/events", json=event_data)
|
||||
|
||||
response = await client.get("/api/events")
|
||||
|
||||
assert response.status_code == 200
|
||||
events = response.json()
|
||||
assert len(events) >= 1
|
||||
assert any(e["id"] == "test-002" for e in events)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_success(self, client):
|
||||
"""
|
||||
IT-API-03-004: 測試刪除事件
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- success = True
|
||||
"""
|
||||
# 先新增
|
||||
await client.delete("/api/events")
|
||||
event_data = {
|
||||
"id": "test-delete",
|
||||
"title": "To Be Deleted",
|
||||
"start": "2024-01-01T09:00:00"
|
||||
}
|
||||
await client.post("/api/events", json=event_data)
|
||||
|
||||
# 刪除
|
||||
response = await client.delete("/api/events/test-delete")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "成功刪除" in data["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_event(self, client):
|
||||
"""
|
||||
IT-API-03-005: 測試刪除不存在的事件
|
||||
|
||||
預期結果:
|
||||
- HTTP 404
|
||||
- 使用 APIResponse 格式回應
|
||||
"""
|
||||
response = await client.delete("/api/events/nonexistent-id")
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
# API 使用自訂 404 handler,回應格式為 APIResponse
|
||||
assert data["success"] is False
|
||||
assert "找不到" in data["message"] or data["error_code"] == "NOT_FOUND"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_events(self, client):
|
||||
"""
|
||||
IT-API-03-006: 測試清空所有事件
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- 事件列表清空
|
||||
"""
|
||||
# 先新增一些事件
|
||||
await client.post("/api/events", json={
|
||||
"id": "clear-1",
|
||||
"title": "Event 1",
|
||||
"start": "2024-01-01T09:00:00"
|
||||
})
|
||||
|
||||
# 清空
|
||||
response = await client.delete("/api/events")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
# 驗證已清空
|
||||
events_response = await client.get("/api/events")
|
||||
assert len(events_response.json()) == 0
|
||||
|
||||
|
||||
class TestRenderAPI:
|
||||
"""渲染 API 測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_with_events(self, client):
|
||||
"""
|
||||
IT-API-04-001: 測試渲染時間軸
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- success = True
|
||||
- 包含 Plotly data 和 layout
|
||||
"""
|
||||
# 準備事件
|
||||
events = [
|
||||
{
|
||||
"id": "render-1",
|
||||
"title": "Event 1",
|
||||
"start": "2024-01-01T09:00:00"
|
||||
},
|
||||
{
|
||||
"id": "render-2",
|
||||
"title": "Event 2",
|
||||
"start": "2024-01-05T09:00:00"
|
||||
}
|
||||
]
|
||||
|
||||
request_data = {
|
||||
"events": events,
|
||||
"config": {
|
||||
"direction": "horizontal",
|
||||
"theme": "modern",
|
||||
"show_grid": True
|
||||
}
|
||||
}
|
||||
|
||||
response = await client.post("/api/render", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "data" in data
|
||||
assert "layout" in data
|
||||
assert "data" in data["data"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_empty_events(self, client):
|
||||
"""
|
||||
IT-API-04-002: 測試渲染空事件列表
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- 可以處理空事件
|
||||
"""
|
||||
request_data = {
|
||||
"events": [],
|
||||
"config": {
|
||||
"direction": "horizontal",
|
||||
"theme": "modern"
|
||||
}
|
||||
}
|
||||
|
||||
response = await client.post("/api/render", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_with_different_themes(self, client):
|
||||
"""
|
||||
IT-API-04-003: 測試不同主題渲染
|
||||
|
||||
預期結果:
|
||||
- 所有主題都能正常渲染
|
||||
"""
|
||||
events = [{
|
||||
"id": "theme-test",
|
||||
"title": "Theme Test",
|
||||
"start": "2024-01-01T09:00:00"
|
||||
}]
|
||||
|
||||
themes = ["modern", "classic", "minimal", "corporate"]
|
||||
|
||||
for theme in themes:
|
||||
request_data = {
|
||||
"events": events,
|
||||
"config": {"theme": theme}
|
||||
}
|
||||
|
||||
response = await client.post("/api/render", json=request_data)
|
||||
|
||||
assert response.status_code == 200, f"Theme {theme} failed"
|
||||
assert response.json()["success"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_render_with_stored_events(self, client, sample_csv_content):
|
||||
"""
|
||||
IT-API-04-004: 測試使用已儲存的事件渲染
|
||||
|
||||
預期結果:
|
||||
- 可以使用全域儲存的事件
|
||||
"""
|
||||
# 先匯入事件
|
||||
await client.delete("/api/events")
|
||||
files = {"file": ("test.csv", BytesIO(sample_csv_content), "text/csv")}
|
||||
await client.post("/api/import", files=files)
|
||||
|
||||
# 渲染(不提供 events,使用全域儲存)
|
||||
request_data = {
|
||||
"config": {"direction": "horizontal"}
|
||||
}
|
||||
|
||||
response = await client.post("/api/render", json=request_data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
|
||||
class TestExportAPI:
|
||||
"""匯出 API 測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_pdf_success(self, client):
|
||||
"""
|
||||
IT-API-05-001: 測試匯出 PDF
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- Content-Type = application/pdf
|
||||
- 檔案內容正確
|
||||
"""
|
||||
# 先渲染
|
||||
events = [{"id": "exp-1", "title": "Export Test", "start": "2024-01-01T09:00:00"}]
|
||||
render_response = await client.post("/api/render", json={
|
||||
"events": events,
|
||||
"config": {}
|
||||
})
|
||||
render_data = render_response.json()
|
||||
|
||||
# 匯出
|
||||
export_request = {
|
||||
"plotly_data": render_data["data"],
|
||||
"plotly_layout": render_data["layout"],
|
||||
"options": {
|
||||
"fmt": "pdf",
|
||||
"dpi": 300,
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"filename": "test_export.pdf"
|
||||
}
|
||||
|
||||
response = await client.post("/api/export", json=export_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
assert len(response.content) > 0
|
||||
# 檢查 PDF 檔案標記
|
||||
assert response.content.startswith(b'%PDF-')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_png_success(self, client):
|
||||
"""
|
||||
IT-API-05-002: 測試匯出 PNG
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- Content-Type = image/png
|
||||
"""
|
||||
# 渲染
|
||||
events = [{"id": "png-1", "title": "PNG Test", "start": "2024-01-01T09:00:00"}]
|
||||
render_response = await client.post("/api/render", json={"events": events})
|
||||
render_data = render_response.json()
|
||||
|
||||
# 匯出 PNG
|
||||
export_request = {
|
||||
"plotly_data": render_data["data"],
|
||||
"plotly_layout": render_data["layout"],
|
||||
"options": {
|
||||
"fmt": "png",
|
||||
"dpi": 300
|
||||
}
|
||||
}
|
||||
|
||||
response = await client.post("/api/export", json=export_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
# 檢查 PNG 檔案簽名
|
||||
assert response.content.startswith(b'\x89PNG')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_svg_success(self, client):
|
||||
"""
|
||||
IT-API-05-003: 測試匯出 SVG
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- Content-Type = image/svg+xml
|
||||
"""
|
||||
# 渲染
|
||||
events = [{"id": "svg-1", "title": "SVG Test", "start": "2024-01-01T09:00:00"}]
|
||||
render_response = await client.post("/api/render", json={"events": events})
|
||||
render_data = render_response.json()
|
||||
|
||||
# 匯出 SVG
|
||||
export_request = {
|
||||
"plotly_data": render_data["data"],
|
||||
"plotly_layout": render_data["layout"],
|
||||
"options": {"fmt": "svg"}
|
||||
}
|
||||
|
||||
response = await client.post("/api/export", json=export_request)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/svg+xml"
|
||||
# SVG 是文字格式
|
||||
assert b'<svg' in response.content or b'<?xml' in response.content
|
||||
|
||||
|
||||
class TestThemesAPI:
|
||||
"""主題 API 測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_themes_success(self, client):
|
||||
"""
|
||||
IT-API-06-001: 測試取得主題列表
|
||||
|
||||
預期結果:
|
||||
- HTTP 200
|
||||
- 包含 4 個主題
|
||||
- 每個主題包含必要欄位
|
||||
"""
|
||||
response = await client.get("/api/themes")
|
||||
|
||||
assert response.status_code == 200
|
||||
themes = response.json()
|
||||
assert len(themes) == 4
|
||||
|
||||
# 驗證主題結構
|
||||
for theme in themes:
|
||||
assert "name" in theme
|
||||
assert "style" in theme
|
||||
assert "primary_color" in theme
|
||||
assert "background_color" in theme
|
||||
assert "text_color" in theme
|
||||
# 驗證顏色格式
|
||||
assert theme["primary_color"].startswith("#")
|
||||
assert len(theme["primary_color"]) == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_themes_includes_all_styles(self, client):
|
||||
"""
|
||||
IT-API-06-002: 測試主題包含所有樣式
|
||||
|
||||
預期結果:
|
||||
- 包含 modern, classic, minimal, corporate
|
||||
"""
|
||||
response = await client.get("/api/themes")
|
||||
themes = response.json()
|
||||
|
||||
styles = [theme["style"] for theme in themes]
|
||||
assert "modern" in styles
|
||||
assert "classic" in styles
|
||||
assert "minimal" in styles
|
||||
assert "corporate" in styles
|
||||
|
||||
|
||||
class TestWorkflows:
|
||||
"""完整工作流程測試"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_workflow(self, client, sample_csv_content):
|
||||
"""
|
||||
IT-API-07-001: 測試完整工作流程
|
||||
|
||||
流程:
|
||||
1. 匯入 CSV
|
||||
2. 取得事件列表
|
||||
3. 渲染時間軸
|
||||
4. 匯出 PDF
|
||||
|
||||
預期結果:
|
||||
- 所有步驟成功
|
||||
"""
|
||||
# 1. 清空並匯入
|
||||
await client.delete("/api/events")
|
||||
import_response = await client.post(
|
||||
"/api/import",
|
||||
files={"file": ("test.csv", BytesIO(sample_csv_content), "text/csv")}
|
||||
)
|
||||
assert import_response.status_code == 200
|
||||
assert import_response.json()["imported_count"] == 3
|
||||
|
||||
# 2. 取得事件
|
||||
events_response = await client.get("/api/events")
|
||||
assert events_response.status_code == 200
|
||||
events = events_response.json()
|
||||
assert len(events) == 3
|
||||
|
||||
# 3. 渲染
|
||||
render_response = await client.post("/api/render", json={
|
||||
"config": {"direction": "horizontal", "theme": "modern"}
|
||||
})
|
||||
assert render_response.status_code == 200
|
||||
render_data = render_response.json()
|
||||
assert render_data["success"] is True
|
||||
|
||||
# 4. 匯出
|
||||
export_response = await client.post("/api/export", json={
|
||||
"plotly_data": render_data["data"],
|
||||
"plotly_layout": render_data["layout"],
|
||||
"options": {"fmt": "pdf", "dpi": 300}
|
||||
})
|
||||
assert export_response.status_code == 200
|
||||
assert export_response.headers["content-type"] == "application/pdf"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_crud_workflow(self, client):
|
||||
"""
|
||||
IT-API-07-002: 測試事件 CRUD 工作流程
|
||||
|
||||
流程:
|
||||
1. 清空事件
|
||||
2. 新增多個事件
|
||||
3. 取得列表驗證
|
||||
4. 刪除一個事件
|
||||
5. 清空所有事件
|
||||
|
||||
預期結果:
|
||||
- 所有 CRUD 操作成功
|
||||
"""
|
||||
# 1. 清空
|
||||
await client.delete("/api/events")
|
||||
|
||||
# 2. 新增
|
||||
for i in range(3):
|
||||
event = {
|
||||
"id": f"crud-{i}",
|
||||
"title": f"CRUD Test {i}",
|
||||
"start": f"2024-01-0{i+1}T09:00:00"
|
||||
}
|
||||
response = await client.post("/api/events", json=event)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 3. 取得列表
|
||||
events_response = await client.get("/api/events")
|
||||
events = events_response.json()
|
||||
assert len(events) == 3
|
||||
|
||||
# 4. 刪除一個
|
||||
delete_response = await client.delete("/api/events/crud-1")
|
||||
assert delete_response.status_code == 200
|
||||
|
||||
# 驗證刪除後
|
||||
events_response = await client.get("/api/events")
|
||||
events = events_response.json()
|
||||
assert len(events) == 2
|
||||
assert not any(e["id"] == "crud-1" for e in events)
|
||||
|
||||
# 5. 清空
|
||||
clear_response = await client.delete("/api/events")
|
||||
assert clear_response.status_code == 200
|
||||
|
||||
# 驗證清空
|
||||
events_response = await client.get("/api/events")
|
||||
assert len(events_response.json()) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit Tests Package"""
|
||||
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"])
|
||||
245
tests/unit/test_importer.py
Normal file
245
tests/unit/test_importer.py
Normal 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
255
tests/unit/test_renderer.py
Normal 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
146
tests/unit/test_schemas.py
Normal 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"])
|
||||
Reference in New Issue
Block a user