"""メイン画面の 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