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

import sys
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,
    ICON_DIR_NAME,
    ICON_FILE_NAME,
    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,
    MainWindow,
    MainWindowState,
    _bundle_root,
    apply_app_icon,
    check_extension,
    crop_center,
    default_icon_path,
    default_training_data_path,
    draw_roi_markers,
    is_roi_inside_image,
    load_app_icon,
    load_image_file,
    run_diagnosis,
)
from tiasjudge.gui.result_window import Language, next_language, switch_button_label

# --- ヘルパー ---


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()


# --- 配布 exe(PyInstaller)でのリソース解決(PLAN_02 ステップ 8) ---


def make_bundle(root: Path, *, with_data: bool = False, with_icon: bool = False) -> Path:
    """PyInstaller の展開先(`sys._MEIPASS`)を模したディレクトリを作る."""
    if with_data:
        (root / "data").mkdir(parents=True, exist_ok=True)
        (root / "data" / "data.csv").write_text("dummy", encoding="utf-8")
    if with_icon:
        (root / ICON_DIR_NAME).mkdir(parents=True, exist_ok=True)
        (root / ICON_DIR_NAME / ICON_FILE_NAME).write_bytes(b"\x00\x00\x01\x00")
    return root


def freeze(monkeypatch: pytest.MonkeyPatch, meipass: Path | None) -> None:
    """frozen(exe 実行)状態を模す.`meipass` が None なら `_MEIPASS` を持たせない."""
    monkeypatch.setattr(sys, "frozen", True, raising=False)
    if meipass is None:
        monkeypatch.delattr(sys, "_MEIPASS", raising=False)
    else:
        monkeypatch.setattr(sys, "_MEIPASS", str(meipass), raising=False)


class TestBundleRoot:
    """`_bundle_root`: PyInstaller の展開先を返す(通常実行では None)."""

    def test_returns_none_when_not_frozen(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delattr(sys, "frozen", raising=False)
        assert _bundle_root() is None

    def test_returns_none_when_frozen_is_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.setattr(sys, "frozen", False, raising=False)
        monkeypatch.setattr(sys, "_MEIPASS", "C:/never/used", raising=False)
        assert _bundle_root() is None

    def test_returns_meipass_when_frozen(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        freeze(monkeypatch, tmp_path)
        assert _bundle_root() == tmp_path

    def test_falls_back_to_the_executable_directory(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """onedir 形式(`_MEIPASS` 無し)では exe と同じ場所を見る."""
        freeze(monkeypatch, None)
        executable = tmp_path / "TIASjudge.exe"
        executable.write_bytes(b"")
        monkeypatch.setattr(sys, "executable", str(executable))
        assert _bundle_root() == tmp_path.resolve()


class TestTrainingDataPathWhenFrozen:
    """同梱学習データの候補順(環境変数 → `_MEIPASS/data` → パッケージ → リポジトリ).

    ※ 候補順は配布 exe の動作を左右するため仕様として固定する.
    """

    @pytest.fixture(autouse=True)
    def _clear_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
        monkeypatch.delenv(TRAINING_DATA_ENV_VAR, raising=False)

    def test_uses_the_bundled_data_when_frozen(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        bundle = make_bundle(tmp_path / "meipass", with_data=True)
        freeze(monkeypatch, bundle)
        assert default_training_data_path() == bundle / "data" / "data.csv"

    def test_environment_variable_beats_the_bundled_data(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """環境変数は同梱データより優先する(配布形態が変わった場合の逃げ道)."""
        bundle = make_bundle(tmp_path / "meipass", with_data=True)
        freeze(monkeypatch, bundle)
        override = tmp_path / "custom.csv"
        monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(override))
        assert default_training_data_path() == override

    def test_falls_back_to_the_repository_data_when_the_bundle_lacks_it(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, data_csv_path: Path
    ) -> None:
        freeze(monkeypatch, make_bundle(tmp_path / "meipass"))
        resolved = default_training_data_path()
        assert resolved.is_file()
        assert resolved.resolve() == data_csv_path.resolve()

    def test_bundle_takes_precedence_over_the_repository_data(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, data_csv_path: Path
    ) -> None:
        """リポジトリの data.csv が存在しても,frozen 時は同梱データを優先する."""
        bundle = make_bundle(tmp_path / "meipass", with_data=True)
        freeze(monkeypatch, bundle)
        assert data_csv_path.is_file()
        assert default_training_data_path() != data_csv_path


class TestDefaultIconPath:
    """`default_icon_path`: アイコンのパス解決(見つからなければ None)."""

    def test_resolves_the_repository_icon_when_not_frozen(
        self, monkeypatch: pytest.MonkeyPatch, repo_root: Path
    ) -> None:
        monkeypatch.delattr(sys, "frozen", raising=False)
        resolved = default_icon_path()
        assert resolved is not None
        assert resolved.is_file()
        assert resolved.resolve() == (repo_root / ICON_DIR_NAME / ICON_FILE_NAME).resolve()

    def test_uses_the_bundled_icon_when_frozen(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        bundle = make_bundle(tmp_path / "meipass", with_icon=True)
        freeze(monkeypatch, bundle)
        assert default_icon_path() == bundle / ICON_DIR_NAME / ICON_FILE_NAME

    def test_falls_back_to_the_repository_icon(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, repo_root: Path
    ) -> None:
        freeze(monkeypatch, make_bundle(tmp_path / "meipass"))
        resolved = default_icon_path()
        assert resolved is not None
        assert resolved.resolve() == (repo_root / ICON_DIR_NAME / ICON_FILE_NAME).resolve()

    def test_returns_none_when_the_icon_is_missing_everywhere(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """アイコンは装飾のため,見つからない場合は例外を投げず None を返す."""
        import tiasjudge.gui.main_window as main_window

        monkeypatch.delattr(sys, "frozen", raising=False)
        monkeypatch.setattr(main_window, "ICON_FILE_NAME", "does_not_exist.ico")
        assert default_icon_path() is None

    def test_does_not_embed_an_absolute_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """パスは実行時に解決する(絶対パスのハードコード禁止.ENV_02)."""
        monkeypatch.delattr(sys, "frozen", raising=False)
        resolved = default_icon_path()
        assert resolved is not None
        assert resolved.name == ICON_FILE_NAME
        assert resolved.parent.name == ICON_DIR_NAME


class TestAppIcon:
    """`load_app_icon` / `apply_app_icon`: アイコンが無くても起動を妨げないこと.

    ※ 画面の見た目は人間が確認する(testing.md「GUI の自動テストは書かない」).
    ここでは読込失敗時のフォールバックと適用先だけを検証する.
    """

    def test_load_returns_none_when_the_icon_is_missing(
        self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
    ) -> None:
        import tiasjudge.gui.main_window as main_window

        monkeypatch.setattr(main_window, "default_icon_path", lambda: None)
        with caplog.at_level("WARNING"):
            assert load_app_icon() is None
        assert caplog.records, "アイコンが無いことは警告ログに残す"

    def test_apply_does_not_set_an_icon_when_it_is_missing(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        import tiasjudge.gui.main_window as main_window

        class FakeApp:
            def setWindowIcon(self, icon: object) -> None:  # noqa: N802 - Qt の API 名
                raise AssertionError("アイコンが無いときは適用してはならない")

        monkeypatch.setattr(main_window, "load_app_icon", lambda: None)
        assert apply_app_icon(FakeApp()) is None  # type: ignore[arg-type]

    def test_apply_sets_the_loaded_icon_on_the_application(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """アプリ全体(結果画面・ダイアログを含む)に適用する."""
        import tiasjudge.gui.main_window as main_window

        sentinel = object()
        applied: list[object] = []

        class FakeApp:
            def setWindowIcon(self, icon: object) -> None:  # noqa: N802 - Qt の API 名
                applied.append(icon)

        monkeypatch.setattr(main_window, "load_app_icon", lambda: sentinel)
        assert apply_app_icon(FakeApp()) is sentinel  # type: ignore[arg-type]
        assert applied == [sentinel]

    def test_load_reads_the_bundled_icon_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """同梱アイコンは Qt が読める形式である(QIcon が null にならない)."""
        from PySide6.QtWidgets import QApplication

        QApplication.instance() or QApplication([])
        monkeypatch.delattr(sys, "frozen", raising=False)
        icon = load_app_icon()
        assert icon is not None
        assert icon.isNull() is False

    def test_load_returns_none_for_an_unreadable_icon(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        """壊れたアイコンでも例外にせず None を返す(起動継続)."""
        from PySide6.QtWidgets import QApplication

        import tiasjudge.gui.main_window as main_window

        QApplication.instance() or QApplication([])
        broken = tmp_path / ICON_FILE_NAME
        broken.write_bytes(b"not an icon")
        monkeypatch.setattr(main_window, "default_icon_path", lambda: broken)
        with caplog.at_level("WARNING"):
            assert load_app_icon() is None
        assert caplog.records


# --- 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)


# --- 結果画面の操作(言語切替・閉じる)---


class FakeResultWindow:
    """開いている結果画面の代役(`set_language` / `close` のみ).

    実際の結果画面は `close` すると `closed` シグナル経由でメイン画面の一覧から
    取り除かれるため,スタブでも同じ振る舞い(自分を一覧から外す)を再現する.
    """

    def __init__(self, owner: ResultControlHost) -> None:
        self._owner = owner
        self.languages: list[Language] = []
        self.closed = False

    def set_language(self, language: Language) -> None:
        self.languages.append(language)

    def close(self) -> None:
        self.closed = True
        if self in self._owner._result_windows:
            self._owner._result_windows.remove(self)


class ResultControlHost:
    """`MainWindow` の結果画面操作ロジックだけを取り出したスタブ.

    `MainWindow` を構築すると QWidget を作ることになる(testing.md「GUI の自動テストは
    書かない」)ため,実メソッドを未束縛のまま借りて状態遷移のみを検証する.
    """

    result_language = MainWindow.result_language
    toggle_result_language = MainWindow.toggle_result_language
    close_result_windows = MainWindow.close_result_windows

    def __init__(self, window_count: int = 0, language: Language = Language.JAPANESE) -> None:
        self._result_language = language
        self._result_windows: list[FakeResultWindow] = [
            FakeResultWindow(self) for _ in range(window_count)
        ]
        self.control_updates = 0

    def _update_controls(self) -> None:
        """ボタンの有効・無効とラベルの更新(実装では画面部品を触る)の代役."""
        self.control_updates += 1


class TestResultLanguage:
    """結果画面の表示言語をメイン画面が保持する."""

    @pytest.mark.parametrize("language", list(Language))
    def test_property_exposes_the_current_language(self, language: Language) -> None:
        assert ResultControlHost(language=language).result_language is language

    @pytest.mark.parametrize("language", list(Language))
    def test_toggle_switches_the_language(self, language: Language) -> None:
        host = ResultControlHost(language=language)
        host.toggle_result_language()
        assert host.result_language is next_language(language)

    def test_toggle_without_open_windows_is_allowed(self) -> None:
        """結果画面が 1 枚も開いていなくても切り替えられる(次に開く画面の言語を決める)."""
        host = ResultControlHost(window_count=0)
        host.toggle_result_language()
        assert host.result_language is Language.ENGLISH

    def test_toggle_applies_to_every_open_window(self) -> None:
        host = ResultControlHost(window_count=3)
        host.toggle_result_language()
        for window in host._result_windows:
            assert window.languages == [Language.ENGLISH]

    def test_toggling_twice_restores_the_language(self) -> None:
        host = ResultControlHost(window_count=1)
        host.toggle_result_language()
        host.toggle_result_language()
        assert host.result_language is Language.JAPANESE
        assert host._result_windows[0].languages == [Language.ENGLISH, Language.JAPANESE]

    def test_toggle_refreshes_the_controls(self) -> None:
        """ボタンのラベル(切替先の言語名)を更新するため,毎回 `_update_controls` を呼ぶ."""
        host = ResultControlHost(window_count=1)
        host.toggle_result_language()
        assert host.control_updates == 1

    def test_button_label_shows_the_language_to_switch_to(self) -> None:
        """ラベルは切替**先**の言語名(SPEC_01「言語切替ボタン」.メイン画面に移設)."""
        host = ResultControlHost()
        assert switch_button_label(host.result_language) == "English"
        host.toggle_result_language()
        assert switch_button_label(host.result_language) == "日本語"

    def test_language_is_not_part_of_the_diagnosis_state(self) -> None:
        """言語は表示の関心事なので `MainWindowState`(診断入力の状態)には持たせない."""
        state = MainWindowState()
        assert not hasattr(state, "language")
        assert not hasattr(state, "result_language")


class TestCloseResultWindows:
    """「結果画面を閉じる」(患者側モニタの画面をメイン画面から閉じる)."""

    def test_closes_every_open_window(self) -> None:
        host = ResultControlHost(window_count=3)
        windows = list(host._result_windows)
        host.close_result_windows()
        assert all(window.closed for window in windows)

    def test_leaves_no_window_open(self) -> None:
        """閉じる途中で一覧が縮んでも取りこぼさない(複製を回している)."""
        host = ResultControlHost(window_count=3)
        host.close_result_windows()
        assert host._result_windows == []

    def test_without_open_windows_does_nothing(self) -> None:
        host = ResultControlHost(window_count=0)
        host.close_result_windows()
        assert host._result_windows == []

    def test_does_not_change_the_language(self) -> None:
        """閉じても言語は保持する(次に開く画面へ引き継ぐ)."""
        host = ResultControlHost(window_count=2, language=Language.ENGLISH)
        host.close_result_windows()
        assert host.result_language is Language.ENGLISH