cluster4npu/tests/unit/test_report_exporter.py
abin 55040733fe feat: implement Phase 1-4 performance visualization and device management
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>
2026-04-06 19:32:05 +08:00

251 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)