"""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, "minitias.whiteboard", roi)
assert config == {"minitias": {"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 = {"minitias": {"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 = {"minitias": {"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["minitias"]["whiteboard"]["x"] == 10