Newer
Older
SmTIAS-Evaluation / tests / scripts / test_viewer.py
"""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}"
        )