"""viewer.py のユーティリティ関数の単体テスト.
テスト対象(仕様に基づく):
- _find_project_root: CLAUDE.md を探してプロジェクトルートを特定する
- CLAUDE.md が存在するディレクトリを返す
- CLAUDE.md が存在しない場合に FileNotFoundError を送出する
- load_roi: config/roi_config.json から ROI 設定を読み込む
- ファイルが存在しない場合 None を返す
- ファイルが存在し minitias.whiteboard キーがある場合 dict を返す
- ファイルが存在するが対応キーがない場合 None を返す
- overlay_roi: 元画像に ROI 矩形をオーバーレイした PIL Image を返す
- 戻り値が PIL Image であること
- 入力画像と同サイズであること
- ROI の x, y, width, height が正しく矩形に反映されること
- load_summary: output/results/summary_uniformity.csv を読み込む
- ファイルが存在しない場合 FileNotFoundError を送出する
- ファイルが存在する場合 DataFrame を返す
テスト方針 (GUIDE_08_テスト方針.md):
- pytest を使用
- tmp_path フィクスチャを利用
- 合成画像(PIL.Image)でテストデータを生成する
"""
import importlib
import json
import sys
import types
from pathlib import Path
import numpy
import pandas
import pytest
from PIL import Image
# スクリプトをインポートするため sys.path を調整
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
# ---------------------------------------------------------------------------
# _find_project_root テスト
# ---------------------------------------------------------------------------
class TestFindProjectRoot:
"""_find_project_root 関数のテスト群."""
def test_returns_path_containing_claude_md(self) -> None:
"""正常: 返された Path に CLAUDE.md が存在すること."""
from scripts.viewer import _find_project_root
result = _find_project_root()
assert (result / "CLAUDE.md").exists()
def test_returns_path_object(self) -> None:
"""正常: 戻り値が Path オブジェクトであること."""
from scripts.viewer import _find_project_root
result = _find_project_root()
assert isinstance(result, Path)
def test_raises_file_not_found_when_claude_md_absent(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""異常: CLAUDE.md が存在しないディレクトリ構造では FileNotFoundError を送出すること."""
# viewer モジュールを tmp_path 配下に疑似的に配置させるため、
# _find_project_root の __file__ を tmp_path 配下に差し替える
from scripts import viewer as viewer_module
# 一時ディレクトリ構造(CLAUDE.md なし)で疑似モジュールを作成
fake_script_path = tmp_path / "scripts" / "viewer.py"
def patched_find_project_root() -> Path:
"""CLAUDE.md を探す(tmp_path 内なので見つからない)."""
p = fake_script_path.resolve().parent
for parent in [p] + list(p.parents):
# tmp_path の外側には実際のプロジェクトルートがあるため、
# tmp_path 内のみに制限して検索を打ち切る
if parent == tmp_path.parent:
break
if (parent / "CLAUDE.md").exists():
return parent
raise FileNotFoundError("CLAUDE.md not found — プロジェクトルートを特定できません")
with pytest.raises(FileNotFoundError):
patched_find_project_root()
def test_error_message_mentions_claude_md(
self, tmp_path: Path
) -> None:
"""異常: FileNotFoundError のメッセージに CLAUDE.md が含まれること."""
fake_script_path = tmp_path / "scripts" / "viewer.py"
def patched_find_project_root() -> Path:
p = fake_script_path.resolve().parent
for parent in [p] + list(p.parents):
if parent == tmp_path.parent:
break
if (parent / "CLAUDE.md").exists():
return parent
raise FileNotFoundError("CLAUDE.md not found — プロジェクトルートを特定できません")
with pytest.raises(FileNotFoundError, match="CLAUDE.md"):
patched_find_project_root()
# ---------------------------------------------------------------------------
# load_roi テスト
# ---------------------------------------------------------------------------
class TestLoadRoi:
"""load_roi 関数のテスト群."""
def test_returns_none_when_config_file_absent(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: ROI 設定ファイルが存在しない場合 None を返すこと."""
import scripts.viewer as viewer_module
monkeypatch.setattr(viewer_module, "ROI_CONFIG", tmp_path / "no_such_config.json")
result = viewer_module.load_roi()
assert result is None
def test_returns_dict_when_config_has_minitias_whiteboard(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: minitias.whiteboard キーが存在する設定ファイルから ROI dict を返すこと."""
import scripts.viewer as viewer_module
config = {
"minitias": {
"whiteboard": {"x": 100, "y": 200, "width": 400, "height": 300}
}
}
config_path = tmp_path / "roi_config.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path)
result = viewer_module.load_roi()
assert result == {"x": 100, "y": 200, "width": 400, "height": 300}
def test_returns_roi_dict_with_required_keys(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: 返された ROI が x, y, width, height キーを持つこと."""
import scripts.viewer as viewer_module
config = {
"minitias": {
"whiteboard": {"x": 10, "y": 20, "width": 50, "height": 60}
}
}
config_path = tmp_path / "roi_config.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path)
result = viewer_module.load_roi()
assert result is not None
for key in ("x", "y", "width", "height"):
assert key in result, f"ROI に '{key}' キーが存在しない"
def test_returns_none_when_minitias_key_absent(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: minitias キーが存在しない設定ファイルの場合 None を返すこと."""
import scripts.viewer as viewer_module
config = {"other_device": {"whiteboard": {"x": 0, "y": 0, "width": 10, "height": 10}}}
config_path = tmp_path / "roi_config.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path)
result = viewer_module.load_roi()
assert result is None
def test_returns_none_when_whiteboard_key_absent(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: minitias キーはあるが whiteboard キーがない場合 None を返すこと."""
import scripts.viewer as viewer_module
config = {"minitias": {"other_region": {"x": 0, "y": 0, "width": 10, "height": 10}}}
config_path = tmp_path / "roi_config.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path)
result = viewer_module.load_roi()
assert result is None
def test_return_type_is_dict_when_roi_exists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: ROI が存在する場合の戻り値が dict 型であること."""
import scripts.viewer as viewer_module
config = {"minitias": {"whiteboard": {"x": 5, "y": 5, "width": 100, "height": 100}}}
config_path = tmp_path / "roi_config.json"
config_path.write_text(json.dumps(config), encoding="utf-8")
monkeypatch.setattr(viewer_module, "ROI_CONFIG", config_path)
result = viewer_module.load_roi()
assert isinstance(result, dict)
# ---------------------------------------------------------------------------
# overlay_roi テスト
# ---------------------------------------------------------------------------
class TestOverlayRoi:
"""overlay_roi 関数のテスト群."""
@pytest.fixture
def sample_image_path(self, tmp_path: Path) -> Path:
"""テスト用 PNG 画像(100x80 px の均一グレー)を生成して返す fixture."""
img = Image.fromarray(
numpy.full((80, 100, 3), 128, dtype=numpy.uint8), mode="RGB"
)
image_path = tmp_path / "sample.png"
img.save(image_path)
return image_path
@pytest.fixture
def sample_roi(self) -> dict:
"""テスト用 ROI(x=10, y=10, width=40, height=30)を返す fixture."""
return {"x": 10, "y": 10, "width": 40, "height": 30}
def test_returns_pil_image(self, sample_image_path: Path, sample_roi: dict) -> None:
"""正常: 戻り値が PIL Image オブジェクトであること."""
from scripts.viewer import overlay_roi
result = overlay_roi(sample_image_path, sample_roi)
assert isinstance(result, Image.Image)
def test_output_size_matches_input_size(
self, sample_image_path: Path, sample_roi: dict
) -> None:
"""正常: 戻り値の画像サイズが入力画像と同じであること."""
from scripts.viewer import overlay_roi
original = Image.open(sample_image_path)
result = overlay_roi(sample_image_path, sample_roi)
assert result.size == original.size
def test_output_mode_matches_input_mode(
self, sample_image_path: Path, sample_roi: dict
) -> None:
"""正常: 戻り値の画像モードが入力画像と同じであること."""
from scripts.viewer import overlay_roi
original = Image.open(sample_image_path)
result = overlay_roi(sample_image_path, sample_roi)
assert result.mode == original.mode
def test_original_image_is_not_modified(
self, sample_image_path: Path, sample_roi: dict
) -> None:
"""正常: 元画像ファイルが変更されないこと(コピーで処理されること)."""
from scripts.viewer import overlay_roi
original_data = numpy.array(Image.open(sample_image_path))
overlay_roi(sample_image_path, sample_roi)
after_data = numpy.array(Image.open(sample_image_path))
assert numpy.array_equal(original_data, after_data)
def test_roi_outline_pixels_are_lime_colored(
self, sample_image_path: Path
) -> None:
"""正常: ROI 矩形の輪郭ピクセルにライムカラーが描画されること."""
from scripts.viewer import overlay_roi
roi = {"x": 20, "y": 10, "width": 30, "height": 20}
result = overlay_roi(sample_image_path, roi)
result_array = numpy.array(result)
# ライムカラーは (0, 255, 0)
# 矩形の上辺ピクセル(y=10)を確認
# 枠線幅が 6px のため y=10〜15 にライムカラーが存在するはず
top_row = result_array[10, 20:51, :3]
lime_found = numpy.any(
(top_row[:, 0] == 0) & (top_row[:, 1] == 255) & (top_row[:, 2] == 0)
)
assert lime_found, "ROI 上辺にライムカラーが描画されていない"
def test_different_roi_positions_produce_different_outputs(
self, sample_image_path: Path
) -> None:
"""正常: 異なる ROI 位置では異なる画像が生成されること."""
from scripts.viewer import overlay_roi
roi_a = {"x": 5, "y": 5, "width": 20, "height": 20}
roi_b = {"x": 50, "y": 40, "width": 20, "height": 20}
result_a = numpy.array(overlay_roi(sample_image_path, roi_a))
result_b = numpy.array(overlay_roi(sample_image_path, roi_b))
assert not numpy.array_equal(result_a, result_b)
# ---------------------------------------------------------------------------
# load_summary テスト
# ---------------------------------------------------------------------------
class TestLoadSummary:
"""load_summary 関数のテスト群."""
def test_raises_file_not_found_when_csv_absent(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""異常: summary_uniformity.csv が存在しない場合 FileNotFoundError を送出すること."""
import scripts.viewer as viewer_module
monkeypatch.setattr(
viewer_module, "SUMMARY_CSV", tmp_path / "no_such_summary.csv"
)
with pytest.raises(FileNotFoundError):
viewer_module.load_summary()
def test_error_message_contains_summary_filename(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""異常: FileNotFoundError のメッセージにファイル名が含まれること."""
import scripts.viewer as viewer_module
monkeypatch.setattr(
viewer_module, "SUMMARY_CSV", tmp_path / "summary_uniformity.csv"
)
with pytest.raises(FileNotFoundError, match="summary_uniformity.csv"):
viewer_module.load_summary()
def test_returns_dataframe_when_csv_exists(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: CSV ファイルが存在する場合 DataFrame を返すこと."""
import scripts.viewer as viewer_module
csv_path = tmp_path / "summary_uniformity.csv"
df_expected = pandas.DataFrame(
{
"image_name": ["img_001", "img_002"],
"mean": [200.0, 195.0],
"std": [5.0, 6.0],
"cov": [0.025, 0.030],
"max_min_ratio": [1.05, 1.08],
"max": [210.0, 205.0],
"min": [190.0, 185.0],
}
)
df_expected.to_csv(csv_path, index=False)
monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path)
result = viewer_module.load_summary()
assert isinstance(result, pandas.DataFrame)
def test_loaded_dataframe_has_correct_columns(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: 読み込んだ DataFrame が必要なカラムを持つこと."""
import scripts.viewer as viewer_module
csv_path = tmp_path / "summary_uniformity.csv"
df_input = pandas.DataFrame(
{
"image_name": ["img_001"],
"mean": [200.0],
"std": [5.0],
"cov": [0.025],
"max_min_ratio": [1.05],
"max": [210.0],
"min": [190.0],
}
)
df_input.to_csv(csv_path, index=False)
monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path)
result = viewer_module.load_summary()
expected_columns = {"image_name", "mean", "std", "cov", "max_min_ratio", "max", "min"}
assert expected_columns.issubset(set(result.columns))
def test_loaded_dataframe_row_count_matches_csv(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: 読み込んだ DataFrame の行数が CSV のデータ行数と一致すること."""
import scripts.viewer as viewer_module
csv_path = tmp_path / "summary_uniformity.csv"
df_input = pandas.DataFrame(
{
"image_name": ["img_001", "img_002", "img_003"],
"mean": [200.0, 195.0, 198.0],
"std": [5.0, 6.0, 4.5],
"cov": [0.025, 0.030, 0.022],
"max_min_ratio": [1.05, 1.08, 1.04],
"max": [210.0, 205.0, 208.0],
"min": [190.0, 185.0, 192.0],
}
)
df_input.to_csv(csv_path, index=False)
monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path)
result = viewer_module.load_summary()
assert len(result) == 3
def test_loaded_values_match_csv_content(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""正常: 読み込んだ DataFrame の値が CSV の内容と一致すること."""
import scripts.viewer as viewer_module
csv_path = tmp_path / "summary_uniformity.csv"
df_input = pandas.DataFrame(
{
"image_name": ["img_001"],
"mean": [200.0],
"std": [5.0],
"cov": [0.025],
"max_min_ratio": [1.05],
"max": [210.0],
"min": [190.0],
}
)
df_input.to_csv(csv_path, index=False)
monkeypatch.setattr(viewer_module, "SUMMARY_CSV", csv_path)
result = viewer_module.load_summary()
assert result.iloc[0]["image_name"] == "img_001"
assert numpy.isclose(result.iloc[0]["mean"], 200.0)
assert numpy.isclose(result.iloc[0]["cov"], 0.025)
def test_error_message_contains_run_uniformity_hint(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""異常: FileNotFoundError のメッセージに run_uniformity.py の実行を促す案内が含まれること."""
import scripts.viewer as viewer_module
monkeypatch.setattr(
viewer_module, "SUMMARY_CSV", tmp_path / "summary_uniformity.csv"
)
with pytest.raises(FileNotFoundError, match="run_uniformity"):
viewer_module.load_summary()
# ---------------------------------------------------------------------------
# SPATIAL_CSV 定数テスト
# ---------------------------------------------------------------------------
class TestSpatialCsvConstant:
"""SPATIAL_CSV 定数のテスト群."""
def test_spatial_csv_constant_exists(self) -> None:
"""正常: SPATIAL_CSV 定数が viewer モジュールに存在すること."""
import scripts.viewer as viewer_module
assert hasattr(viewer_module, "SPATIAL_CSV")
def test_spatial_csv_is_path_object(self) -> None:
"""正常: SPATIAL_CSV が Path オブジェクトであること."""
import scripts.viewer as viewer_module
assert isinstance(viewer_module.SPATIAL_CSV, Path)
def test_spatial_csv_filename_is_summary_spatial(self) -> None:
"""正常: SPATIAL_CSV のファイル名が summary_spatial.csv であること."""
import scripts.viewer as viewer_module
assert viewer_module.SPATIAL_CSV.name == "summary_spatial.csv"
def test_spatial_csv_is_under_results_dir(self) -> None:
"""正常: SPATIAL_CSV が results/ ディレクトリ配下であること."""
import scripts.viewer as viewer_module
assert viewer_module.SPATIAL_CSV.parent.name == "results"
# ---------------------------------------------------------------------------
# render_tab_overview 引数テスト
# ---------------------------------------------------------------------------
class TestRenderTabOverview:
"""render_tab_overview 関数の引数仕様テスト群.
render_tab_overview(df, spatial_df) の形式で呼び出せること,
および spatial_df=None で正常動作することを検証する.
Streamlit の描画は単体テストでは検証せず,引数の受け付けのみ確認する.
"""
@pytest.fixture
def sample_df(self) -> "pandas.DataFrame":
"""テスト用の均一性指標 DataFrame を返す fixture."""
return pandas.DataFrame(
{
"image_name": ["img_001", "img_002"],
"mean": [200.0, 195.0],
"std": [5.0, 6.0],
"cov": [0.025, 0.030],
"max_min_ratio": [1.05, 1.08],
"max": [210.0, 205.0],
"min": [190.0, 185.0],
}
)
@pytest.fixture
def sample_spatial_df(self) -> "pandas.DataFrame":
"""テスト用の空間分析指標 DataFrame を返す fixture."""
return pandas.DataFrame(
{
"image_name": ["img_001", "img_002"],
"center_mean": [180.0, 185.0],
"middle_mean": [170.0, 175.0],
"periphery_mean": [150.0, 155.0],
"center_periphery_ratio": [1.2, 1.19],
"gradient_magnitude": [16.7, 16.2],
}
)
def test_render_tab_overview_accepts_two_arguments(
self, sample_df: "pandas.DataFrame", sample_spatial_df: "pandas.DataFrame"
) -> None:
"""正常: render_tab_overview が df と spatial_df の 2 引数を受け付けること."""
from scripts.viewer import render_tab_overview
import inspect
sig = inspect.signature(render_tab_overview)
params = list(sig.parameters.keys())
assert "df" in params
assert "spatial_df" in params
def test_render_tab_overview_spatial_df_has_default_or_nullable(self) -> None:
"""正常: render_tab_overview の spatial_df 引数が None を受け付けること(型ヒント確認)."""
from scripts.viewer import render_tab_overview
import inspect
sig = inspect.signature(render_tab_overview)
# spatial_df パラメータのアノテーションに None が含まれることを確認
spatial_df_param = sig.parameters.get("spatial_df")
assert spatial_df_param is not None, "spatial_df パラメータが存在しない"
# アノテーションが Union/Optional の場合、文字列表現で None を確認
annotation = str(spatial_df_param.annotation)
assert "None" in annotation or "Optional" in annotation, (
f"spatial_df の型ヒントに None が含まれない: {annotation}"
)
# ---------------------------------------------------------------------------
# render_tab_individual 引数テスト
# ---------------------------------------------------------------------------
class TestRenderTabIndividual:
"""render_tab_individual 関数の引数仕様テスト群.
render_tab_individual(df, spatial_df) の形式(2 引数)で呼び出せること,
および spatial_df=None で正常動作することを検証する.
Streamlit の描画は単体テストでは検証せず,引数の受け付けのみ確認する.
"""
def test_render_tab_individual_accepts_two_arguments(self) -> None:
"""正常: render_tab_individual が df と spatial_df の 2 引数を受け付けること."""
from scripts.viewer import render_tab_individual
import inspect
sig = inspect.signature(render_tab_individual)
params = list(sig.parameters.keys())
assert "df" in params, "df パラメータが存在しない"
assert "spatial_df" in params, "spatial_df パラメータが存在しない"
def test_render_tab_individual_has_exactly_two_parameters(self) -> None:
"""正常: render_tab_individual のパラメータがちょうど 2 個であること."""
from scripts.viewer import render_tab_individual
import inspect
sig = inspect.signature(render_tab_individual)
params = list(sig.parameters.keys())
assert len(params) == 2, (
f"パラメータ数が 2 でない: {params}"
)
def test_render_tab_individual_spatial_df_is_nullable(self) -> None:
"""正常: render_tab_individual の spatial_df 引数が None を受け付けること(型ヒント確認)."""
from scripts.viewer import render_tab_individual
import inspect
sig = inspect.signature(render_tab_individual)
spatial_df_param = sig.parameters.get("spatial_df")
assert spatial_df_param is not None, "spatial_df パラメータが存在しない"
# アノテーションが Union/Optional の場合、文字列表現で None を確認
annotation = str(spatial_df_param.annotation)
assert "None" in annotation or "Optional" in annotation, (
f"spatial_df の型ヒントに None が含まれない: {annotation}"
)
def test_render_tab_individual_first_param_is_df(self) -> None:
"""正常: render_tab_individual の第1引数が df であること."""
from scripts.viewer import render_tab_individual
import inspect
sig = inspect.signature(render_tab_individual)
params = list(sig.parameters.keys())
assert params[0] == "df", f"第1引数が df でない: {params[0]}"
def test_render_tab_individual_second_param_is_spatial_df(self) -> None:
"""正常: render_tab_individual の第2引数が spatial_df であること."""
from scripts.viewer import render_tab_individual
import inspect
sig = inspect.signature(render_tab_individual)
params = list(sig.parameters.keys())
assert params[1] == "spatial_df", f"第2引数が spatial_df でない: {params[1]}"
def test_render_tab_individual_df_annotation_is_dataframe(self) -> None:
"""正常: render_tab_individual の df 引数が DataFrame の型ヒントを持つこと."""
from scripts.viewer import render_tab_individual
import inspect
sig = inspect.signature(render_tab_individual)
df_param = sig.parameters.get("df")
assert df_param is not None, "df パラメータが存在しない"
annotation = str(df_param.annotation)
assert "DataFrame" in annotation, (
f"df の型ヒントに DataFrame が含まれない: {annotation}"
)