"""report.py の単体テスト.
仕様 (実装: src/export/report.py):
- encode_image_base64: PNG→data URI(不在時は空文字列)
- build_report_context: CSV からコンテキスト辞書を構築
- render_html_report: Jinja2 テンプレートレンダリング
テスト方針 (GUIDE_08_テスト方針.md):
- pytest を使用
- tmp_path フィクスチャで一時ディレクトリを利用
"""
import base64
import struct
import zlib
from pathlib import Path
import pytest
from src.export.report import (
build_report_context,
encode_image_base64,
render_html_report,
)
# ---------------------------------------------------------------------------
# ヘルパー: 最小 PNG ファイルを生成する
# ---------------------------------------------------------------------------
def _create_minimal_png(path: Path) -> None:
"""1x1 ピクセルの最小有効 PNG ファイルを tmp_path に作成する."""
# 1x1 白ピクセル PNG の最小バイナリ
def png_chunk(chunk_type: bytes, data: bytes) -> bytes:
length = struct.pack(">I", len(data))
crc = struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF)
return length + chunk_type + data + crc
signature = b"\x89PNG\r\n\x1a\n"
ihdr_data = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) # 1x1 RGB
ihdr = png_chunk(b"IHDR", ihdr_data)
# フィルタバイト (0) + RGB = 0xFF, 0xFF, 0xFF
raw_row = b"\x00\xff\xff\xff"
compressed = zlib.compress(raw_row)
idat = png_chunk(b"IDAT", compressed)
iend = png_chunk(b"IEND", b"")
path.write_bytes(signature + ihdr + idat + iend)
# ---------------------------------------------------------------------------
# encode_image_base64 テスト
# ---------------------------------------------------------------------------
class TestEncodeImageBase64:
"""encode_image_base64 関数のテスト群."""
def test_returns_data_uri_for_existing_png(self, tmp_path: Path) -> None:
"""正常: 存在する PNG ファイルで data URI 文字列が返ること."""
png_path = tmp_path / "test.png"
_create_minimal_png(png_path)
result = encode_image_base64(str(png_path))
assert result.startswith("data:image/png;base64,")
def test_returns_empty_string_for_missing_file(self, tmp_path: Path) -> None:
"""正常: 存在しないファイルパスを渡した場合に空文字列を返すこと."""
missing_path = str(tmp_path / "nonexistent.png")
result = encode_image_base64(missing_path)
assert result == ""
def test_base64_content_is_decodable(self, tmp_path: Path) -> None:
"""正常: data URI の base64 部分がデコード可能であること."""
png_path = tmp_path / "test.png"
_create_minimal_png(png_path)
result = encode_image_base64(str(png_path))
# "data:image/png;base64," プレフィックスを除去してデコードを試みる
prefix = "data:image/png;base64,"
encoded_part = result[len(prefix):]
decoded = base64.b64decode(encoded_part)
# PNG シグネチャで始まることを確認
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
def test_base64_content_matches_file_content(self, tmp_path: Path) -> None:
"""正常: エンコードされたデータがファイルの内容と一致すること."""
png_path = tmp_path / "test.png"
_create_minimal_png(png_path)
result = encode_image_base64(str(png_path))
prefix = "data:image/png;base64,"
encoded_part = result[len(prefix):]
decoded = base64.b64decode(encoded_part)
original = png_path.read_bytes()
assert decoded == original
def test_returns_string_type(self, tmp_path: Path) -> None:
"""正常: 戻り値が str 型であること(存在するファイル)."""
png_path = tmp_path / "test.png"
_create_minimal_png(png_path)
result = encode_image_base64(str(png_path))
assert isinstance(result, str)
def test_returns_string_type_for_missing_file(self, tmp_path: Path) -> None:
"""正常: 戻り値が str 型であること(存在しないファイル)."""
result = encode_image_base64(str(tmp_path / "missing.png"))
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# build_report_context テスト
# ---------------------------------------------------------------------------
class TestBuildReportContext:
"""build_report_context 関数のテスト群."""
@pytest.fixture
def setup_csv_files(self, tmp_path: Path) -> tuple[Path, Path, Path]:
"""モック CSV ファイルと figures ディレクトリを作成して返す fixture."""
summary_csv = tmp_path / "summary_uniformity.csv"
spatial_csv = tmp_path / "summary_spatial.csv"
figures_dir = tmp_path / "figures"
figures_dir.mkdir()
summary_csv.write_text(
"image_name,mean,std,cov,max_min_ratio,max,min\n"
"image_001,180.0,5.0,0.028,1.15,190.0,165.0\n"
"image_002,170.0,8.0,0.047,1.20,185.0,155.0\n",
encoding="utf-8",
)
spatial_csv.write_text(
"image_name,center_mean,middle_mean,periphery_mean,center_periphery_ratio,gradient_magnitude\n"
"image_001,185.0,178.0,165.0,1.12,10.8\n"
"image_002,175.0,168.0,152.0,1.15,13.1\n",
encoding="utf-8",
)
return summary_csv, spatial_csv, figures_dir
def test_return_value_has_generated_at_key(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: 戻り値に 'generated_at' キーが存在すること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert "generated_at" in context
def test_return_value_has_summary_rows_key(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: 戻り値に 'summary_rows' キーが存在すること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert "summary_rows" in context
def test_return_value_has_spatial_rows_key(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: 戻り値に 'spatial_rows' キーが存在すること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert "spatial_rows" in context
def test_return_value_has_image_details_key(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: 戻り値に 'image_details' キーが存在すること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert "image_details" in context
def test_return_value_has_exactly_4_keys(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: 戻り値のキー数が 4 であること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert len(context) == 4
def test_summary_rows_count_matches_csv_rows(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: summary_rows の要素数が CSV のデータ行数と一致すること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert len(context["summary_rows"]) == 2
def test_spatial_rows_count_matches_csv_rows(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: spatial_rows の要素数が CSV のデータ行数と一致すること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert len(context["spatial_rows"]) == 2
def test_image_details_count_matches_summary_rows(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: image_details の要素数が summary_rows と一致すること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert len(context["image_details"]) == len(context["summary_rows"])
def test_image_details_have_name_key(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: image_details の各要素が 'name' キーを持つこと."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
for detail in context["image_details"]:
assert "name" in detail
def test_image_details_have_figure_keys(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: image_details の各要素が図ファイルのキーを持つこと."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
required_fig_keys = {"luminance_map", "histogram", "radial_profile", "zone_map"}
for detail in context["image_details"]:
assert required_fig_keys.issubset(set(detail.keys()))
def test_missing_figure_files_return_empty_string(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: 対応する図ファイルが存在しない場合に空文字列が設定されること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
# 図ファイルを作成していないため全て空文字列
for detail in context["image_details"]:
assert detail["luminance_map"] == ""
assert detail["histogram"] == ""
assert detail["radial_profile"] == ""
assert detail["zone_map"] == ""
def test_raises_file_not_found_for_missing_summary_csv(
self, tmp_path: Path
) -> None:
"""異常: 均一性サマリ CSV が存在しない場合に FileNotFoundError が送出されること."""
missing_summary = str(tmp_path / "missing_summary.csv")
spatial_csv = tmp_path / "spatial.csv"
spatial_csv.write_text(
"image_name,center_mean,middle_mean,periphery_mean,center_periphery_ratio,gradient_magnitude\n",
encoding="utf-8",
)
with pytest.raises(FileNotFoundError):
build_report_context(missing_summary, str(spatial_csv), str(tmp_path))
def test_raises_file_not_found_for_missing_spatial_csv(
self, tmp_path: Path
) -> None:
"""異常: 空間解析サマリ CSV が存在しない場合に FileNotFoundError が送出されること."""
summary_csv = tmp_path / "summary.csv"
summary_csv.write_text(
"image_name,mean,std,cov,max_min_ratio,max,min\n",
encoding="utf-8",
)
missing_spatial = str(tmp_path / "missing_spatial.csv")
with pytest.raises(FileNotFoundError):
build_report_context(str(summary_csv), missing_spatial, str(tmp_path))
def test_generated_at_is_string(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: generated_at が文字列であること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
assert isinstance(context["generated_at"], str)
def test_existing_figure_files_are_base64_encoded(
self, setup_csv_files: tuple[Path, Path, Path]
) -> None:
"""正常: 図ファイルが存在する場合に data URI として設定されること."""
summary_csv, spatial_csv, figures_dir = setup_csv_files
# image_001 の luminance_map PNG を作成
png_path = figures_dir / "image_001_luminance_map.png"
_create_minimal_png(png_path)
context = build_report_context(
str(summary_csv), str(spatial_csv), str(figures_dir)
)
# image_001 の detail を探す
detail_001 = next(
d for d in context["image_details"] if d["name"] == "image_001"
)
assert detail_001["luminance_map"].startswith("data:image/png;base64,")
# ---------------------------------------------------------------------------
# render_html_report テスト
# ---------------------------------------------------------------------------
class TestRenderHtmlReport:
"""render_html_report 関数のテスト群."""
@pytest.fixture
def simple_template(self, tmp_path: Path) -> Path:
"""シンプルな Jinja2 テンプレートを作成して返す fixture."""
template_path = tmp_path / "templates" / "report.html.j2"
template_path.parent.mkdir(parents=True, exist_ok=True)
template_path.write_text(
"<html><body>"
"<p>Generated: {{ generated_at }}</p>"
"{% for row in summary_rows %}"
"<p>{{ row.image_name }}</p>"
"{% endfor %}"
"</body></html>",
encoding="utf-8",
)
return template_path
@pytest.fixture
def simple_context(self) -> dict:
"""シンプルなレンダリングコンテキストを返す fixture."""
return {
"generated_at": "2026-04-08 12:00:00",
"summary_rows": [{"image_name": "image_001"}],
"spatial_rows": [],
"image_details": [],
}
def test_output_html_file_is_created(
self,
simple_template: Path,
simple_context: dict,
tmp_path: Path,
) -> None:
"""正常: HTML ファイルが生成されること."""
output_path = str(tmp_path / "report.html")
render_html_report(simple_context, str(simple_template), output_path)
assert Path(output_path).exists()
def test_output_html_file_is_not_empty(
self,
simple_template: Path,
simple_context: dict,
tmp_path: Path,
) -> None:
"""正常: 出力ファイルが空でないこと."""
output_path = str(tmp_path / "report.html")
render_html_report(simple_context, str(simple_template), output_path)
assert Path(output_path).stat().st_size > 0
def test_output_html_contains_rendered_content(
self,
simple_template: Path,
simple_context: dict,
tmp_path: Path,
) -> None:
"""正常: テンプレートがコンテキストで正しくレンダリングされること."""
output_path = str(tmp_path / "report.html")
render_html_report(simple_context, str(simple_template), output_path)
html_content = Path(output_path).read_text(encoding="utf-8")
assert "2026-04-08 12:00:00" in html_content
assert "image_001" in html_content
def test_creates_parent_directory_automatically(
self,
simple_template: Path,
simple_context: dict,
tmp_path: Path,
) -> None:
"""正常: 出力ディレクトリが存在しない場合に自動作成されること."""
nested_output = str(tmp_path / "output" / "reports" / "report.html")
render_html_report(simple_context, str(simple_template), nested_output)
assert Path(nested_output).exists()
def test_output_is_utf8_encoded(
self,
simple_template: Path,
simple_context: dict,
tmp_path: Path,
) -> None:
"""正常: 出力ファイルが UTF-8 エンコードで書き込まれること."""
output_path = str(tmp_path / "report.html")
render_html_report(simple_context, str(simple_template), output_path)
# UTF-8 として読み込めること(例外が発生しないこと)
content = Path(output_path).read_text(encoding="utf-8")
assert isinstance(content, str)