forked from masonhuang/cluster4npu
Phase 1 — Performance Benchmarking: - PerformanceBenchmarker: sequential vs parallel benchmark with injectable runner - PerformanceHistory: JSON-backed benchmark history with regression support - PerformanceDashboard: real-time FPS/latency display widget - BenchmarkDialog: one-click benchmark with 3-phase progress bar Phase 2 — Device Management: - DeviceManager: NPU dongle scan, assign/unassign, load balance recommendation - DeviceManagementPanel: live device status cards with auto-refresh - BottleneckAlert: dataclass for pipeline bottleneck detection Phase 3 — Advanced Features: - OptimizationEngine: 3 optimization rules (rebalance/adjust_queue/add_devices) - TemplateManager: 3 built-in pipeline templates (YOLOv5, fire detection, dual-model) Phase 4 — Report Export: - ReportExporter: PDF (reportlab, optional) and CSV export - ExportReportDialog: format selection + path picker UI 192 unit tests, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
9.7 KiB
Python
251 lines
9.7 KiB
Python
"""
|
||
tests/unit/test_report_exporter.py — ReportExporter 單元測試。
|
||
|
||
按照 TDD 3.4.9 的測試清單實作。
|
||
"""
|
||
import csv
|
||
import io
|
||
import time
|
||
from pathlib import Path
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
import pytest
|
||
|
||
from core.performance.benchmarker import BenchmarkResult
|
||
from core.performance.report_exporter import DeviceSummary, ReportData, ReportExporter
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fixtures
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_benchmark_result(mode: str = "sequential", fps: float = 14.2) -> BenchmarkResult:
|
||
return BenchmarkResult(
|
||
mode=mode,
|
||
fps=fps,
|
||
avg_latency_ms=70.4,
|
||
p95_latency_ms=95.0,
|
||
total_frames=426,
|
||
timestamp=1743856222.0,
|
||
device_config={"KL720": 1},
|
||
id=f"benchmark_20260405_143022_{mode}",
|
||
)
|
||
|
||
|
||
def _make_report_data_with_benchmark() -> ReportData:
|
||
seq = _make_benchmark_result("sequential", fps=14.2)
|
||
par = _make_benchmark_result("parallel", fps=45.6)
|
||
return ReportData(
|
||
report_title="Test Report",
|
||
pipeline_name="test_pipeline",
|
||
sequential_result=seq,
|
||
parallel_result=par,
|
||
speedup=45.6 / 14.2,
|
||
history_records=[seq, par],
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _get_timestamp_str
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestGetTimestampStr:
|
||
def test_format_is_yyyy_mm_dd_hh_mm_ss(self):
|
||
"""_get_timestamp_str 應回傳 'YYYY-MM-DD HH:MM:SS' 格式的字串"""
|
||
ts = 1743856222.0
|
||
result = ReportExporter._get_timestamp_str(ts)
|
||
# 驗證格式:長度固定為 19,包含 '-' 和 ':'
|
||
assert len(result) == 19
|
||
assert result[4] == "-"
|
||
assert result[7] == "-"
|
||
assert result[10] == " "
|
||
assert result[13] == ":"
|
||
assert result[16] == ":"
|
||
|
||
def test_all_parts_are_digits(self):
|
||
"""timestamp 各欄位均應為數字"""
|
||
ts = 1743856222.0
|
||
result = ReportExporter._get_timestamp_str(ts)
|
||
parts = result.replace("-", "").replace(":", "").replace(" ", "")
|
||
assert parts.isdigit()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ReportData 預設值
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestReportDataDefaults:
|
||
def test_report_title_is_non_empty(self):
|
||
"""ReportData 預設 report_title 應非空"""
|
||
data = ReportData()
|
||
assert data.report_title
|
||
assert len(data.report_title) > 0
|
||
|
||
def test_generated_at_is_close_to_now(self):
|
||
"""ReportData 預設 generated_at 應接近當下時間(誤差 < 5 秒)"""
|
||
before = time.time()
|
||
data = ReportData()
|
||
after = time.time()
|
||
assert before <= data.generated_at <= after + 5
|
||
|
||
def test_history_records_defaults_to_empty_list(self):
|
||
"""ReportData 預設 history_records 應為空列表"""
|
||
data = ReportData()
|
||
assert data.history_records == []
|
||
|
||
def test_devices_defaults_to_empty_list(self):
|
||
"""ReportData 預設 devices 應為空列表"""
|
||
data = ReportData()
|
||
assert data.devices == []
|
||
|
||
def test_sequential_result_defaults_to_none(self):
|
||
data = ReportData()
|
||
assert data.sequential_result is None
|
||
|
||
def test_parallel_result_defaults_to_none(self):
|
||
data = ReportData()
|
||
assert data.parallel_result is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# export_csv
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestExportCsv:
|
||
def test_creates_file_at_given_path(self, tmp_path):
|
||
"""export_csv() 應在指定路徑建立 CSV 檔案"""
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "report.csv"
|
||
exporter = ReportExporter()
|
||
result = exporter.export_csv(data, output_path)
|
||
assert output_path.exists()
|
||
assert result == output_path
|
||
|
||
def test_contains_benchmark_summary_section(self, tmp_path):
|
||
"""CSV 應包含完整的 benchmark_summary header 行"""
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "report.csv"
|
||
exporter = ReportExporter()
|
||
exporter.export_csv(data, output_path)
|
||
|
||
content = output_path.read_text(encoding="utf-8")
|
||
assert "section,metric,sequential,parallel,diff_pct" in content
|
||
|
||
def test_contains_history_section(self, tmp_path):
|
||
"""CSV 應包含完整的歷史記錄 header 行"""
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "report.csv"
|
||
exporter = ReportExporter()
|
||
exporter.export_csv(data, output_path)
|
||
|
||
content = output_path.read_text(encoding="utf-8")
|
||
assert "id,timestamp,mode,fps,avg_latency_ms,p95_latency_ms,total_frames" in content
|
||
|
||
# 歷史記錄有 2 筆,驗證資料行數
|
||
lines = [l for l in content.splitlines() if l.strip()]
|
||
history_data_lines = [l for l in lines if l.startswith("benchmark_2")]
|
||
assert len(history_data_lines) == len(data.history_records)
|
||
|
||
def test_two_sections_separated_by_blank_line(self, tmp_path):
|
||
"""CSV 的兩個 header 行之間恰有一行空行"""
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "report.csv"
|
||
exporter = ReportExporter()
|
||
exporter.export_csv(data, output_path)
|
||
|
||
content = output_path.read_text(encoding="utf-8")
|
||
lines = content.splitlines()
|
||
|
||
summary_header = "section,metric,sequential,parallel,diff_pct"
|
||
history_header = "id,timestamp,mode,fps,avg_latency_ms,p95_latency_ms,total_frames"
|
||
|
||
idx_summary = next(i for i, l in enumerate(lines) if l == summary_header)
|
||
idx_history = next(i for i, l in enumerate(lines) if l == history_header)
|
||
|
||
# 兩個 header 行之間,緊鄰 history header 的前一行必須是空行
|
||
assert idx_history > idx_summary + 1
|
||
assert lines[idx_history - 1] == ""
|
||
|
||
def test_no_benchmark_result_raises_value_error(self, tmp_path):
|
||
"""sequential_result 或 parallel_result 為 None 時,應拋出 ValueError"""
|
||
data = ReportData() # sequential_result=None, parallel_result=None
|
||
output_path = tmp_path / "report.csv"
|
||
exporter = ReportExporter()
|
||
with pytest.raises(ValueError):
|
||
exporter.export_csv(data, output_path)
|
||
|
||
def test_empty_history_produces_only_summary(self, tmp_path):
|
||
"""history_records 為空時,CSV 只輸出 Benchmark 摘要區塊,歷史記錄表為空"""
|
||
seq = _make_benchmark_result("sequential", fps=14.2)
|
||
par = _make_benchmark_result("parallel", fps=45.6)
|
||
data = ReportData(
|
||
sequential_result=seq,
|
||
parallel_result=par,
|
||
speedup=45.6 / 14.2,
|
||
history_records=[],
|
||
)
|
||
output_path = tmp_path / "report.csv"
|
||
exporter = ReportExporter()
|
||
exporter.export_csv(data, output_path)
|
||
|
||
content = output_path.read_text(encoding="utf-8")
|
||
assert "benchmark_summary" in content
|
||
# 沒有歷史資料行(id 開頭的行)
|
||
data_lines = [l for l in content.splitlines() if l.startswith("benchmark_2")]
|
||
assert len(data_lines) == 0
|
||
|
||
def test_auto_creates_parent_directory(self, tmp_path):
|
||
"""若輸出路徑的父目錄不存在,export_csv() 應自動建立"""
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "subdir" / "report.csv"
|
||
exporter = ReportExporter()
|
||
exporter.export_csv(data, output_path)
|
||
assert output_path.exists()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# export_pdf
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestExportPdf:
|
||
def test_creates_file_at_given_path(self, tmp_path):
|
||
"""export_pdf() 應在指定路徑建立 PDF 檔案(不驗證內容,只驗證存在)"""
|
||
reportlab = pytest.importorskip("reportlab")
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "report.pdf"
|
||
exporter = ReportExporter()
|
||
result = exporter.export_pdf(data, output_path)
|
||
assert output_path.exists()
|
||
assert result == output_path
|
||
|
||
def test_auto_creates_parent_directory(self, tmp_path):
|
||
"""若輸出路徑的父目錄不存在,export_pdf() 應自動建立"""
|
||
pytest.importorskip("reportlab")
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "subdir" / "report.pdf"
|
||
exporter = ReportExporter()
|
||
exporter.export_pdf(data, output_path)
|
||
assert output_path.exists()
|
||
|
||
def test_without_chart_image_does_not_raise(self, tmp_path):
|
||
"""chart_image_bytes 為 None 時,PDF 匯出不應拋出例外"""
|
||
pytest.importorskip("reportlab")
|
||
data = _make_report_data_with_benchmark()
|
||
data.chart_image_bytes = None
|
||
output_path = tmp_path / "report.pdf"
|
||
exporter = ReportExporter()
|
||
# 不應拋出例外
|
||
exporter.export_pdf(data, output_path)
|
||
|
||
def test_raises_import_error_when_reportlab_missing(self, tmp_path):
|
||
"""reportlab 未安裝時,export_pdf() 應拋出 ImportError"""
|
||
import core.performance.report_exporter as re_mod
|
||
|
||
data = _make_report_data_with_benchmark()
|
||
output_path = tmp_path / "report.pdf"
|
||
exporter = ReportExporter()
|
||
|
||
with patch.object(re_mod, "_REPORTLAB_AVAILABLE", False):
|
||
with pytest.raises(ImportError, match="reportlab"):
|
||
exporter.export_pdf(data, output_path)
|