v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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"])
|
||||
Reference in New Issue
Block a user