Newer
Older
SmTIAS-Evaluation / tests / scripts / test_select_roi.py
"""select_roi.py の単体テスト(GUI 以外の関数).

仕様(実装サマリー):
  - calc_display_scale: 表示用スケール係数を計算
  - load_or_create_config: 既存 JSON 読み込みまたは空辞書
  - set_nested: ドット区切りキーでネスト辞書に値をセット
  - save_config: JSON 保存

テスト方針 (GUIDE_08_テスト方針.md):
  - pytest を使用
  - GUI(selectROI)を伴うテストは除外
"""

import json
from pathlib import Path

import pytest

# スクリプトを直接インポートするため sys.path を調整
import sys

sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))

from scripts.select_roi import (
    DISPLAY_MAX_HEIGHT,
    DISPLAY_MAX_WIDTH,
    calc_display_scale,
    load_or_create_config,
    save_config,
    set_nested,
)


# ---------------------------------------------------------------------------
# calc_display_scale テスト
# ---------------------------------------------------------------------------


class TestCalcDisplayScale:
    """calc_display_scale 関数のテスト群."""

    def test_small_image_returns_1_0(self) -> None:
        """正常: 表示最大サイズより小さい画像はスケール 1.0 を返すこと."""
        result = calc_display_scale(640, 360)
        assert result == 1.0

    def test_image_equal_to_max_returns_1_0(self) -> None:
        """境界値: 表示最大サイズと同じ画像はスケール 1.0 を返すこと."""
        result = calc_display_scale(DISPLAY_MAX_WIDTH, DISPLAY_MAX_HEIGHT)
        assert result == 1.0

    def test_wide_image_returns_scale_less_than_1(self) -> None:
        """正常: 幅が DISPLAY_MAX_WIDTH を超える画像は 1.0 未満のスケールを返すこと."""
        result = calc_display_scale(DISPLAY_MAX_WIDTH * 2, 360)
        assert result < 1.0

    def test_tall_image_returns_scale_less_than_1(self) -> None:
        """正常: 高さが DISPLAY_MAX_HEIGHT を超える画像は 1.0 未満のスケールを返すこと."""
        result = calc_display_scale(640, DISPLAY_MAX_HEIGHT * 2)
        assert result < 1.0

    def test_large_image_scale_is_limited_by_smaller_dimension_ratio(self) -> None:
        """正常: 縦横の両方が最大サイズを超える場合、制限のきつい方に合わせること."""
        # 幅 2560 (2x), 高さ 1440 (2x) → 両方 0.5 になるはず
        result = calc_display_scale(DISPLAY_MAX_WIDTH * 2, DISPLAY_MAX_HEIGHT * 2)
        assert result == pytest.approx(0.5)

    def test_asymmetric_large_image_limited_by_width(self) -> None:
        """正常: 幅のみが大きい場合、幅の比率でスケーリングされること."""
        # 幅 2560 (2x max), 高さ 360 (0.5x max)
        result = calc_display_scale(DISPLAY_MAX_WIDTH * 2, 360)
        expected = DISPLAY_MAX_WIDTH / (DISPLAY_MAX_WIDTH * 2)
        assert result == pytest.approx(expected)

    def test_asymmetric_large_image_limited_by_height(self) -> None:
        """正常: 高さのみが大きく制限がきつい場合、高さの比率でスケーリングされること."""
        # 幅 1280 (1x max), 高さ 4320 (6x max)
        result = calc_display_scale(DISPLAY_MAX_WIDTH, DISPLAY_MAX_HEIGHT * 6)
        expected = DISPLAY_MAX_HEIGHT / (DISPLAY_MAX_HEIGHT * 6)
        assert result == pytest.approx(expected)


# ---------------------------------------------------------------------------
# set_nested テスト
# ---------------------------------------------------------------------------


class TestSetNested:
    """set_nested 関数のテスト群."""

    def test_single_key(self) -> None:
        """正常: 単一キーで値をセットできること."""
        config: dict = {}
        set_nested(config, "key", "value")
        assert config == {"key": "value"}

    def test_two_level_nested_key(self) -> None:
        """正常: "a.b" のキーで 2 階層のネストが作成されること."""
        config: dict = {}
        set_nested(config, "a.b", 42)
        assert config == {"a": {"b": 42}}

    def test_three_level_nested_key(self) -> None:
        """正常: "a.b.c" のキーで 3 階層のネストが作成されること."""
        config: dict = {}
        set_nested(config, "a.b.c", "deep")
        assert config == {"a": {"b": {"c": "deep"}}}

    def test_existing_keys_are_preserved(self) -> None:
        """正常: 既存のキーが上書きされず保持されること."""
        config = {"existing": "value"}
        set_nested(config, "new_key", 100)
        assert config["existing"] == "value"
        assert config["new_key"] == 100

    def test_merge_with_existing_nested_dict(self) -> None:
        """正常: 既存のネスト辞書にマージされること."""
        config = {"a": {"existing": "old"}}
        set_nested(config, "a.new", "added")
        assert config == {"a": {"existing": "old", "new": "added"}}

    def test_overwrite_existing_leaf_value(self) -> None:
        """正常: 既存のリーフ値が上書きされること."""
        config = {"a": {"b": "old_value"}}
        set_nested(config, "a.b", "new_value")
        assert config["a"]["b"] == "new_value"

    def test_non_dict_intermediate_node_is_replaced(self) -> None:
        """正常: 中間ノードが dict でない場合、dict で上書きされること."""
        config = {"a": "not_a_dict"}
        set_nested(config, "a.b", "value")
        assert config == {"a": {"b": "value"}}

    def test_set_dict_value(self) -> None:
        """正常: 値として辞書をセットできること."""
        config: dict = {}
        roi = {"x": 10, "y": 20, "width": 100, "height": 80}
        set_nested(config, "smtias.whiteboard", roi)
        assert config == {"smtias": {"whiteboard": roi}}


# ---------------------------------------------------------------------------
# load_or_create_config テスト
# ---------------------------------------------------------------------------


class TestLoadOrCreateConfig:
    """load_or_create_config 関数のテスト群."""

    def test_returns_empty_dict_for_nonexistent_file(self, tmp_path: Path) -> None:
        """正常: 存在しないファイルで空の辞書が返ること."""
        nonexistent = tmp_path / "no_such_file.json"
        result = load_or_create_config(nonexistent)
        assert result == {}

    def test_loads_existing_json_file(self, tmp_path: Path) -> None:
        """正常: 既存の JSON ファイルが正しく読み込まれること."""
        config_data = {"smtias": {"whiteboard": {"x": 10, "y": 20, "width": 100, "height": 80}}}
        config_path = tmp_path / "roi_config.json"
        config_path.write_text(json.dumps(config_data), encoding="utf-8")

        result = load_or_create_config(config_path)
        assert result == config_data

    def test_loaded_config_is_dict(self, tmp_path: Path) -> None:
        """正常: 戻り値が dict 型であること."""
        config_path = tmp_path / "config.json"
        config_path.write_text("{}", encoding="utf-8")

        result = load_or_create_config(config_path)
        assert isinstance(result, dict)

    def test_empty_dict_returned_for_missing_file_is_dict_type(
        self, tmp_path: Path
    ) -> None:
        """正常: ファイル不在時の戻り値が dict 型であること."""
        result = load_or_create_config(tmp_path / "missing.json")
        assert isinstance(result, dict)


# ---------------------------------------------------------------------------
# save_config テスト
# ---------------------------------------------------------------------------


class TestSaveConfig:
    """save_config 関数のテスト群."""

    def test_saves_config_as_json(self, tmp_path: Path) -> None:
        """正常: 設定辞書が JSON ファイルとして保存されること."""
        config = {"key": "value"}
        config_path = tmp_path / "config.json"
        save_config(config, config_path)

        assert config_path.exists()
        loaded = json.loads(config_path.read_text(encoding="utf-8"))
        assert loaded == config

    def test_creates_parent_directory_if_not_exists(self, tmp_path: Path) -> None:
        """正常: 出力ディレクトリが存在しない場合に自動作成されること."""
        nested_path = tmp_path / "config" / "roi_config.json"
        save_config({"a": 1}, nested_path)
        assert nested_path.exists()

    def test_saved_json_is_valid(self, tmp_path: Path) -> None:
        """正常: 保存された JSON が有効な形式であること."""
        config = {"smtias": {"whiteboard": {"x": 10, "y": 20}}}
        config_path = tmp_path / "config.json"
        save_config(config, config_path)

        # JSON として正常にパースできること
        loaded = json.loads(config_path.read_text(encoding="utf-8"))
        assert loaded["smtias"]["whiteboard"]["x"] == 10