Newer
Older
TIASjudge / tests / gui / test_main_window_logic.py
"""メイン画面の QWidget 非依存ロジックの検証(SPEC_01「メイン画面」・PLAN_02 ステップ 6).

仕様上の要件は以下(SPEC_01).

- 変換行列は `.csv`,画像は `.png` のみ受け付け,他拡張子はエラー表示
- 画像は読込時に中央基準で 1024×1280 にクロップする
- 画像上の左クリックで 51×51 px の ROI を追加(最大 4 点.5 点目はエラー),赤枠を描画
- 右クリックで全クリックを取り消す
- [舌色診断] は 1 点以上クリックで有効化し,診断(ROI 平均色 → Lab → SVM)を実行する
- 入力ガードの文言(変換行列未読込・画像未読込・クリック 0 点)

※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない.
本ファイルは画面部品に依存しない関数・状態クラスのみを対象とする.
"""

from __future__ import annotations

from pathlib import Path

import cv2
import numpy as np
import pytest

from tiasjudge.core import roi
from tiasjudge.core.color import rgb_to_lab
from tiasjudge.core.csv_io import TrainingData, load_training_data
from tiasjudge.core.svm import COLOR_KEYS, diagnose
from tiasjudge.gui.main_window import (
    CROP_HEIGHT,
    CROP_WIDTH,
    DISPLAY_DIVISOR,
    IMAGE_EXTENSION,
    MATRIX_EXTENSION,
    MESSAGE_CLICK_REQUIRED,
    MESSAGE_IMAGE_REQUIRED,
    MESSAGE_TRANSFORM_REQUIRED,
    ROI_RECT_COLOR,
    ROI_RECT_THICKNESS,
    TRAINING_DATA_ENV_VAR,
    ImageLoadError,
    InputGuardError,
    MainWindowState,
    check_extension,
    crop_center,
    default_training_data_path,
    draw_roi_markers,
    is_roi_inside_image,
    load_image_file,
    run_diagnosis,
)

# --- ヘルパー ---


def make_image(width: int, height: int, bgr: tuple[int, int, int] = (10, 20, 30)) -> np.ndarray:
    """指定サイズの単色 BGR 画像を作る."""
    image = np.empty((height, width, 3), dtype=np.uint8)
    image[:, :] = bgr
    return image


def make_ramp_image(width: int, height: int) -> np.ndarray:
    """B = x 座標,G = y 座標(256 で巡回)の画像を作る.クロップ位置を解析的に検証できる."""
    image = np.zeros((height, width, 3), dtype=np.uint8)
    image[:, :, 0] = (np.arange(width) % 256).astype(np.uint8)[np.newaxis, :]
    image[:, :, 1] = (np.arange(height) % 256).astype(np.uint8)[:, np.newaxis]
    return image


def write_png(path: Path, image: np.ndarray) -> Path:
    """PNG を書き出す(非 ASCII パス対応のため imencode + write_bytes)."""
    success, buffer = cv2.imencode(".png", image)
    assert success
    path.write_bytes(buffer.tobytes())
    return path


def linear_coefficients() -> np.ndarray:
    """1 次項のみの合成変換行列(sRGB→XYZ 相当)を作る(SPEC_02 (1) の形式に合わせた 17×3)."""
    coefficients = np.zeros((17, 3), dtype=np.float64)
    coefficients[0] = (0.4124, 0.2126, 0.0193)
    coefficients[1] = (0.3576, 0.7152, 0.1192)
    coefficients[2] = (0.1805, 0.0722, 0.9505)
    return coefficients * (100.0 / 255.0)


@pytest.fixture(scope="module")
def training_data(data_csv_path: Path) -> TrainingData:
    """同梱学習データ(`data/data.csv`)を読み込む."""
    return load_training_data(data_csv_path)


# --- 定数(SPEC_01) ---


def test_crop_size_is_1024_by_1280() -> None:
    """クロップ後サイズは 1024×1280(SPEC_01「画像選択」)."""
    assert (CROP_WIDTH, CROP_HEIGHT) == (1024, 1280)


def test_display_is_half_scale() -> None:
    """画像表示は 1/2 縮小(SPEC_01「画像表示」)."""
    assert DISPLAY_DIVISOR == 2


def test_accepted_extensions() -> None:
    """変換行列は .csv,画像は .png(SPEC_01)."""
    assert MATRIX_EXTENSION == ".csv"
    assert IMAGE_EXTENSION == ".png"


def test_guard_messages_follow_spec() -> None:
    """入力ガードの文言は SPEC_01「入力ガード(エラー表示)」どおり."""
    assert MESSAGE_TRANSFORM_REQUIRED == "変換行列を読み込んでください"
    assert MESSAGE_IMAGE_REQUIRED == "画像を読み込んでください"
    assert "1 点以上 4 点以下クリックしてください" in MESSAGE_CLICK_REQUIRED


# --- check_extension(SPEC_01「変換行列選択」「画像選択」) ---


class TestCheckExtension:
    """拡張子の検証."""

    @pytest.mark.parametrize("name", ["matrix.csv", "MATRIX.CSV", "a.b.Csv"])
    def test_accepts_expected_extension_case_insensitively(self, name: str) -> None:
        check_extension(name, MATRIX_EXTENSION, "message")

    @pytest.mark.parametrize("name", ["matrix.txt", "matrix.png", "matrix", "csv"])
    def test_rejects_other_extensions(self, name: str) -> None:
        with pytest.raises(InputGuardError, match="message"):
            check_extension(name, MATRIX_EXTENSION, "message")

    def test_accepts_path_object(self, tmp_path: Path) -> None:
        check_extension(tmp_path / "画像.PNG", IMAGE_EXTENSION, "message")


# --- crop_center(SPEC_01「画像選択」) ---


class TestCropCenter:
    """中央基準 1024×1280 クロップ."""

    def test_large_image_is_cropped_to_target_size(self) -> None:
        cropped = crop_center(make_image(2000, 3000))
        assert cropped.shape == (CROP_HEIGHT, CROP_WIDTH, 3)

    def test_crop_is_taken_from_the_center(self) -> None:
        """切り出し位置が中央(左上オフセット = (幅-1024)//2, (高さ-1280)//2)であること."""
        width, height = 2000, 3000
        cropped = crop_center(make_ramp_image(width, height))
        x_offset = (width - CROP_WIDTH) // 2
        y_offset = (height - CROP_HEIGHT) // 2
        assert int(cropped[0, 0, 0]) == x_offset % 256
        assert int(cropped[0, 0, 1]) == y_offset % 256
        assert int(cropped[-1, -1, 0]) == (x_offset + CROP_WIDTH - 1) % 256
        assert int(cropped[-1, -1, 1]) == (y_offset + CROP_HEIGHT - 1) % 256

    def test_exact_size_image_is_unchanged(self) -> None:
        image = make_ramp_image(CROP_WIDTH, CROP_HEIGHT)
        cropped = crop_center(image)
        assert cropped.shape == (CROP_HEIGHT, CROP_WIDTH, 3)
        assert np.array_equal(cropped, image)

    @pytest.mark.parametrize(
        ("width", "height"),
        [(500, 600), (2000, 600), (500, 3000), (1, 1)],
    )
    def test_smaller_image_is_kept_within_bounds(self, width: int, height: int) -> None:
        """目標より小さい辺はそのまま(はみ出さない)."""
        cropped = crop_center(make_image(width, height))
        assert cropped.shape == (min(height, CROP_HEIGHT), min(width, CROP_WIDTH), 3)

    def test_returns_independent_copy(self) -> None:
        """クロップ結果を書き換えても元画像は変わらない(描画で元画像を壊さないため)."""
        image = make_image(2000, 3000, (1, 2, 3))
        cropped = crop_center(image)
        cropped[:, :] = (255, 255, 255)
        assert np.array_equal(image[0, 0], np.array([1, 2, 3], dtype=np.uint8))


# --- load_image_file ---


class TestLoadImageFile:
    """PNG 読込(クロップ込み・日本語パス対応・異常系)."""

    def test_loads_and_crops_png(self, tmp_path: Path) -> None:
        path = write_png(tmp_path / "tongue.png", make_ramp_image(2000, 3000))
        image = load_image_file(path)
        assert image.shape == (CROP_HEIGHT, CROP_WIDTH, 3)

    def test_loads_png_with_japanese_path(self, tmp_path: Path) -> None:
        """非 ASCII パスでも読み込める(cv2.imread の制約を回避していること)."""
        directory = tmp_path / "舌画像フォルダ"
        directory.mkdir()
        path = write_png(directory / "舌_01.png", make_image(1024, 1280, (5, 6, 7)))
        image = load_image_file(path)
        assert image.shape == (CROP_HEIGHT, CROP_WIDTH, 3)
        assert tuple(int(value) for value in image[0, 0]) == (5, 6, 7)

    def test_missing_file_raises_image_load_error(self, tmp_path: Path) -> None:
        with pytest.raises(ImageLoadError):
            load_image_file(tmp_path / "no_such_file.png")

    def test_corrupt_file_raises_image_load_error(self, tmp_path: Path) -> None:
        path = tmp_path / "broken.png"
        path.write_bytes(b"this is not a png")
        with pytest.raises(ImageLoadError):
            load_image_file(path)

    def test_empty_file_raises_image_load_error(self, tmp_path: Path) -> None:
        path = tmp_path / "empty.png"
        path.write_bytes(b"")
        with pytest.raises(ImageLoadError):
            load_image_file(path)


# --- draw_roi_markers(SPEC_01「ROI 指定」) ---


class TestDrawRoiMarkers:
    """クリック位置への赤枠描画."""

    def test_does_not_modify_the_source_image(self) -> None:
        image = make_image(200, 200, (0, 0, 0))
        original = image.copy()
        draw_roi_markers(image, [(100, 100)])
        assert np.array_equal(image, original)

    def test_no_points_leaves_image_as_is(self) -> None:
        image = make_image(200, 200, (0, 0, 0))
        assert np.array_equal(draw_roi_markers(image, []), image)

    def test_rectangle_is_centered_on_the_click_point(self) -> None:
        """枠は (x±25, y±25) に描かれる(ROI 51×51 に対応)."""
        image = make_image(200, 200, (0, 0, 0))
        annotated = draw_roi_markers(image, [(100, 100)])
        for corner in ((75, 75), (125, 75), (75, 125), (125, 125)):
            x, y = corner
            assert tuple(int(value) for value in annotated[y, x]) == ROI_RECT_COLOR
        # 枠の内側(中心)は塗られない
        assert tuple(int(value) for value in annotated[100, 100]) == (0, 0, 0)
        assert roi.ROI_OFFSET == 25

    def test_rectangle_color_and_thickness_follow_legacy(self) -> None:
        """枠色は BGR (0, 0, 200)・太さ 3(旧実装踏襲)."""
        assert ROI_RECT_COLOR == (0, 0, 200)
        assert ROI_RECT_THICKNESS == 3
        image = make_image(200, 200, (0, 0, 0))
        annotated = draw_roi_markers(image, [(100, 100)])
        # 上辺は太さぶんの帯になる(正確なラスタライズは OpenCV の実装依存なので,
        # 枠線上とその近傍が塗られ,離れた位置は塗られないことを確認する)
        for y in (74, 75, 76):
            assert tuple(int(v) for v in annotated[y, 100]) == ROI_RECT_COLOR
        assert tuple(int(v) for v in annotated[70, 100]) == (0, 0, 0)
        assert tuple(int(v) for v in annotated[80, 100]) == (0, 0, 0)

    def test_draws_all_points(self) -> None:
        image = make_image(300, 300, (0, 0, 0))
        annotated = draw_roi_markers(image, [(50, 50), (200, 200)])
        assert tuple(int(v) for v in annotated[25, 25]) == ROI_RECT_COLOR
        assert tuple(int(v) for v in annotated[175, 175]) == ROI_RECT_COLOR


# --- is_roi_inside_image ---


class TestIsRoiInsideImage:
    """ROI が画像内に収まるかの境界判定(旧実装のクラッシュ箇所のガード)."""

    @pytest.mark.parametrize("point", [(25, 25), (74, 74), (25, 74), (50, 50)])
    def test_inside_points(self, point: tuple[int, int]) -> None:
        assert is_roi_inside_image(make_image(100, 100), point) is True

    @pytest.mark.parametrize("point", [(24, 25), (25, 24), (75, 74), (74, 75), (0, 0), (99, 99)])
    def test_outside_points(self, point: tuple[int, int]) -> None:
        assert is_roi_inside_image(make_image(100, 100), point) is False

    def test_boundary_matches_core_roi_behaviour(self) -> None:
        """判定が True の点では `core.roi` が例外を出さず,False の点では出す."""
        image = make_image(100, 100)
        roi.roi_mean_bgr(image, (25, 25))
        roi.roi_mean_bgr(image, (74, 74))
        with pytest.raises(ValueError):
            roi.roi_mean_bgr(image, (24, 25))
        with pytest.raises(ValueError):
            roi.roi_mean_bgr(image, (75, 74))


# --- MainWindowState(SPEC_01「入力ガード」「ROI 指定」「リセット」) ---


@pytest.fixture
def loaded_state() -> MainWindowState:
    """変換行列・画像を読み込み済みの状態を作る."""
    state = MainWindowState()
    state.set_transform(Path("matrix.csv"), linear_coefficients())
    state.set_image(Path("tongue.png"), make_image(CROP_WIDTH, CROP_HEIGHT))
    return state


class TestMainWindowStateGuards:
    """入力ガードの遷移."""

    def test_initial_state_requires_transform_matrix(self) -> None:
        state = MainWindowState()
        with pytest.raises(InputGuardError, match=MESSAGE_TRANSFORM_REQUIRED):
            state.ensure_clickable()

    def test_click_before_image_requires_image(self) -> None:
        state = MainWindowState()
        state.set_transform(Path("matrix.csv"), linear_coefficients())
        with pytest.raises(InputGuardError, match=MESSAGE_IMAGE_REQUIRED):
            state.add_point((100, 100))
        assert state.point_count == 0

    def test_click_before_transform_reports_transform_first(self) -> None:
        """変換行列・画像の両方が未読込なら,先に変換行列を案内する(SPEC_01 の順序)."""
        state = MainWindowState()
        with pytest.raises(InputGuardError, match=MESSAGE_TRANSFORM_REQUIRED):
            state.add_point((100, 100))

    def test_diagnose_with_zero_clicks_is_rejected(self, loaded_state: MainWindowState) -> None:
        assert loaded_state.can_diagnose is False
        with pytest.raises(InputGuardError, match="1 点以上 4 点以下"):
            loaded_state.ensure_diagnosable()

    def test_diagnose_is_enabled_after_one_click(self, loaded_state: MainWindowState) -> None:
        loaded_state.add_point((100, 100))
        assert loaded_state.can_diagnose is True
        loaded_state.ensure_diagnosable()


class TestMainWindowStatePoints:
    """クリック点の追加・上限・リセット."""

    def test_accepts_up_to_four_points(self, loaded_state: MainWindowState) -> None:
        points = [(100, 100), (200, 200), (300, 300), (400, 400)]
        for point in points:
            loaded_state.add_point(point)
        assert loaded_state.points == points
        assert loaded_state.point_count == roi.MAX_ROI_POINTS == 4

    def test_fifth_point_is_rejected_and_not_counted(self, loaded_state: MainWindowState) -> None:
        """5 点目はエラー(SPEC_01).旧実装と異なりクリック数も増やさない."""
        for index in range(roi.MAX_ROI_POINTS):
            loaded_state.add_point((100 + index * 60, 100))
        with pytest.raises(InputGuardError):
            loaded_state.add_point((500, 500))
        assert loaded_state.point_count == roi.MAX_ROI_POINTS

    def test_point_whose_roi_leaves_the_image_is_rejected(
        self, loaded_state: MainWindowState
    ) -> None:
        with pytest.raises(InputGuardError):
            loaded_state.add_point((0, 0))
        assert loaded_state.point_count == 0

    def test_clear_points_resets_all_clicks(self, loaded_state: MainWindowState) -> None:
        """右クリックのリセット(SPEC_01「リセット」).旧実装と異なり点数も 0 に戻る."""
        loaded_state.add_point((100, 100))
        loaded_state.add_point((200, 200))
        loaded_state.clear_points()
        assert loaded_state.point_count == 0
        assert loaded_state.can_diagnose is False
        # リセット後は再び 4 点までクリックできる
        for index in range(roi.MAX_ROI_POINTS):
            loaded_state.add_point((100 + index * 60, 100))
        assert loaded_state.point_count == roi.MAX_ROI_POINTS

    def test_loading_a_new_image_clears_points(self, loaded_state: MainWindowState) -> None:
        loaded_state.add_point((100, 100))
        loaded_state.set_image(Path("other.png"), make_image(CROP_WIDTH, CROP_HEIGHT))
        assert loaded_state.point_count == 0

    def test_clear_image_disables_clicking(self, loaded_state: MainWindowState) -> None:
        """[診断続行] 相当.画像とクリックが戻り,変換行列は保持される."""
        loaded_state.add_point((100, 100))
        loaded_state.clear_image()
        assert loaded_state.has_image is False
        assert loaded_state.has_transform is True
        assert loaded_state.point_count == 0
        with pytest.raises(InputGuardError, match=MESSAGE_IMAGE_REQUIRED):
            loaded_state.ensure_clickable()

    def test_clear_transform_requires_reloading_the_matrix(
        self, loaded_state: MainWindowState
    ) -> None:
        """[診断続行] で「同じ変換行列を使わない」を選んだ場合."""
        loaded_state.clear_transform()
        assert loaded_state.has_transform is False
        with pytest.raises(InputGuardError, match=MESSAGE_TRANSFORM_REQUIRED):
            loaded_state.ensure_clickable()


# --- default_training_data_path ---


class TestDefaultTrainingDataPath:
    """学習データの既定パス解決(絶対パスのハードコード禁止)."""

    def test_environment_variable_takes_precedence(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        override = tmp_path / "custom_data.csv"
        monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(override))
        assert default_training_data_path() == override

    def test_falls_back_to_bundled_data_csv(
        self, monkeypatch: pytest.MonkeyPatch, data_csv_path: Path
    ) -> None:
        monkeypatch.delenv(TRAINING_DATA_ENV_VAR, raising=False)
        resolved = default_training_data_path()
        assert resolved.is_file()
        assert resolved.resolve() == data_csv_path.resolve()


# --- run_diagnosis(SPEC_02「処理パイプライン」の結合) ---


class TestRunDiagnosis:
    """ROI 平均色 → Lab → SVM のパイプライン."""

    def test_returns_diagnosis_result_for_a_single_point(self, training_data: TrainingData) -> None:
        image = make_image(CROP_WIDTH, CROP_HEIGHT, (110, 110, 180))  # BGR
        result = run_diagnosis(image, [(100, 100)], linear_coefficients(), training_data)

        assert set(result.percentages) == set(COLOR_KEYS)
        assert all(0 <= value <= 100 for value in result.percentages.values())
        assert len(result.top_labels) == 2
        assert result.lab == rgb_to_lab((180.0, 110.0, 110.0), linear_coefficients())

    def test_matches_composing_core_functions(self, training_data: TrainingData) -> None:
        """`core` を直接組み合わせた場合と同じ結果になる(gui は計算を持たない)."""
        image = make_ramp_image(CROP_WIDTH, CROP_HEIGHT)
        points = [(100, 100), (300, 400)]
        coefficients = linear_coefficients()

        result = run_diagnosis(image, points, coefficients, training_data)
        expected = diagnose(rgb_to_lab(roi.mean_rgb(image, points), coefficients), training_data)

        assert result.lab == expected.lab
        assert result.percentages == expected.percentages
        assert result.top_labels == expected.top_labels
        assert result.failed == expected.failed

    def test_multiple_points_average_the_rois(self, training_data: TrainingData) -> None:
        """複数点は各 ROI 平均の平均を使う(SPEC_02 (0))."""
        image = make_image(CROP_WIDTH, CROP_HEIGHT, (100, 100, 100))
        image[0:200, 0:200] = (200, 200, 200)
        points = [(100, 100), (600, 600)]
        result = run_diagnosis(image, points, linear_coefficients(), training_data)
        assert result.lab == rgb_to_lab((150.0, 150.0, 150.0), linear_coefficients())

    def test_point_outside_the_image_raises_value_error(self, training_data: TrainingData) -> None:
        """GUI 側でガードされる想定だが,`core` が明示的な例外を出すこと."""
        image = make_image(CROP_WIDTH, CROP_HEIGHT)
        with pytest.raises(ValueError):
            run_diagnosis(image, [(0, 0)], linear_coefficients(), training_data)

    def test_no_points_raises_value_error(self, training_data: TrainingData) -> None:
        image = make_image(CROP_WIDTH, CROP_HEIGHT)
        with pytest.raises(ValueError):
            run_diagnosis(image, [], linear_coefficients(), training_data)