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