diff --git "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" index e8c7800..e8a5a17 100644 --- "a/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" +++ "b/docs/02_ENV/ENV_04_\351\226\213\347\231\272\343\202\263\343\203\236\343\203\263\343\203\211.md" @@ -40,6 +40,7 @@ | プレビュー画面の構築のみ確認(即終了) | `uv run tiasjudge --preview-result --check` | ※ 日本語で開く.英語表示は画面内の言語切替ボタンで確認する. +※ プライマリ以外のモニタがあればそこに最大化して開く(2 画面運用の確認.SPEC_01「表示先モニタ」参照).モニタ 1 台なら通常サイズで開く. ※ 結果画面を閉じるとアプリが終了する. ※ ダミー値は成功パターンが淡紅 55% / 紅 25% / 暗赤 10% / 紫 5% / 淡白 5%(上位 2 色は淡紅・紅),失敗パターンが旧実装の失敗時スコア組(紫 42 / 暗赤 0 / 紅 5 / 淡紅 11 / 淡白 3). ※ オプションを付けなければ通常起動・`--check` の挙動は変わらない. diff --git "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" index f2b607f..d333c0e 100644 --- "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\344\273\225\346\247\230.md" @@ -44,6 +44,17 @@ | 診断失敗表示 | 診断失敗時(SPEC_02「診断失敗の判定」参照)に「Diagnosis failed / Please try again.」を表示 | | 注記 | 「本結果は疾患の診断を行うものではない」「表示画像は明るさ補正されている」 | +### 表示先モニタ(2 画面運用)(Target Monitor) + +2 画面運用(施術者側=メイン画面,患者側=結果画面)を前提とし,結果画面は**メイン画面が表示されているモニタとは別のモニタに最大化**して表示する. + +- 基準はメイン画面が実際に載っているモニタ(プライマリ固定ではない.メイン画面をサブ側に移せば結果はプライマリ側に出る) +- モニタが 1 台の場合は同一モニタに通常サイズで表示する(フォールバック) +- 結果パネルは 644×644 の固定描画のため,最大化時はスケーリングせず余白が増える.内容はウィンドウ中央に配置する +- デバッグ用 `--preview-result` はメイン画面が無いため,プライマリ以外のモニタを表示先とする + +※ 2 画面表示は旧実装に無い追加仕様(ユーザー要望による). + ### 5 色の表示内容 (Color Rows) | 色名(日) | 色名(英) | 説明(英語版) | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7639ac0..0bcce1a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -53,3 +53,6 @@ - 文字色を旧背景画像 img_student2.bmp のピクセル実測値に完全一致させ,5 色の色名のみ黒縁取り(旧画像実測に基づく幅 2 px).フォントサイズも旧画像のインク高実測に合わせ全体拡大(説明文は Medium 500・40 px で確定.ユーザーが試行の上決定) - 英語の長文が行に収まらない場合は省略でなく段階縮小で全文維持する方式(_fit_font)に変更 - `uv run tiasjudge --preview-result`(/ `--failed`)を追加: 結果ウィンドウ単体をダミー結果で即起動するデバッグコマンド(毎回の診断操作を省くため.ENV_04 記載) +- [x] **結果画面のサブモニタ最大化表示(2 画面運用)**: ユーザー要望による追加仕様(旧実装に無い).結果画面をメイン画面と別のモニタに最大化表示する(SPEC_01「表示先モニタ」節を新設) + - 設計判断: 基準はプライマリ固定でなく「メイン画面が載っているモニタ」.モニタ 1 台時は従来表示にフォールバック.同一モニタ判定は identity → 同名の 2 段(Qt が別ラッパを返すケース対策).移動 → 最大化の順序を厳守(逆だと元モニタで最大化される) + - 2 画面実機での動作確認済み(ユーザー確認) diff --git a/src/tiasjudge/__main__.py b/src/tiasjudge/__main__.py index 99fb62f..4ddc742 100644 --- a/src/tiasjudge/__main__.py +++ b/src/tiasjudge/__main__.py @@ -22,9 +22,12 @@ if TYPE_CHECKING: # pragma: no cover - 型チェック時のみ from numpy.typing import NDArray + from PySide6.QtWidgets import QApplication from tiasjudge.core.svm import DiagnosisResult +logger = logging.getLogger(__name__) + #: `--check`(イベントループを回さずに終了する)オプション CHECK_OPTION = "--check" @@ -109,6 +112,22 @@ ) +def _run_preview(app: QApplication, *, failed: bool, check: bool) -> int: + """結果画面だけをダミー結果で開く(`--preview-result`).""" + # 関数内 import は起動時のみ GUI を読み込むため(`core` は GUI 非依存) + from tiasjudge.gui.result_window import Language, ResultWindow, show_on_secondary_screen + + logger.info("結果画面のプレビューを開きます(failed=%s)", failed) + window = ResultWindow(_preview_result(failed=failed), _preview_image(), Language.JAPANESE) + if check: + window.close() + return 0 + + # プレビューにはメイン画面が無いため,プライマリモニタを基準に別モニタを選ぶ + show_on_secondary_screen(window, app.screens(), app.primaryScreen()) + return int(app.exec()) + + def main(argv: Sequence[str] | None = None) -> int: """アプリを起動する.戻り値はプロセスの終了コード. @@ -127,21 +146,11 @@ app = QApplication.instance() or QApplication(sys.argv[:1]) if arguments.preview_result: - from tiasjudge.gui.result_window import Language, ResultWindow + return _run_preview(app, failed=arguments.failed, check=arguments.check) - logging.getLogger(__name__).info( - "結果画面のプレビューを開きます(failed=%s)", arguments.failed - ) - window = ResultWindow( - _preview_result(failed=arguments.failed), - _preview_image(), - Language.JAPANESE, - ) - else: - from tiasjudge.gui.main_window import MainWindow + from tiasjudge.gui.main_window import MainWindow - window = MainWindow() - + window = MainWindow() if arguments.check: # 画面を構築できることだけを確認して終了する(ヘッドレス環境での確認用) window.close() diff --git a/src/tiasjudge/gui/main_window.py b/src/tiasjudge/gui/main_window.py index aa6b64c..468c9eb 100644 --- a/src/tiasjudge/gui/main_window.py +++ b/src/tiasjudge/gui/main_window.py @@ -54,7 +54,12 @@ load_transform_matrix, ) from tiasjudge.core.svm import COLOR_KEYS, DiagnosisResult, diagnose -from tiasjudge.gui.result_window import IMAGE_DISPLAY_DIVISOR, ResultWindow, to_display_pixmap +from tiasjudge.gui.result_window import ( + IMAGE_DISPLAY_DIVISOR, + ResultWindow, + show_on_secondary_screen, + to_display_pixmap, +) logger = logging.getLogger(__name__) @@ -755,7 +760,9 @@ window = ResultWindow(result, image, parent=self) window.closed.connect(self._on_result_window_closed) self._result_windows.append(window) - window.show() + # 2 画面運用(施術者側にメイン画面・患者側に結果画面)を想定し, + # メイン画面と別のモニタがあればそこへ最大化する(1 台なら従来どおり通常表示) + show_on_secondary_screen(window, QApplication.screens(), self.screen()) return window def _on_result_window_closed(self) -> None: diff --git a/src/tiasjudge/gui/result_window.py b/src/tiasjudge/gui/result_window.py index 1c6943b..630d719 100644 --- a/src/tiasjudge/gui/result_window.py +++ b/src/tiasjudge/gui/result_window.py @@ -9,6 +9,10 @@ - 診断失敗時(`DiagnosisResult.failed`)は結果パネルに「Diagnosis failed / Please try again.」 を重ねて描画する +2 画面運用(施術者側にメイン画面・患者側に結果画面)を想定し,メイン画面とは別のモニタが +あればそこへ最大化して表示する(`show_on_secondary_screen`).モニタが 1 台の場合は +従来どおり同じモニタに通常サイズで表示する. + 旧実装の日本語版は文字入りビットマップ(`img/img_student2.bmp`)を背景に使っていたが, 新実装では日英とも描画で生成し画像アセットへの依存を無くす(/setup 時の設計判断. 表示内容・レイアウトは現行踏襲).座標は旧実装の `ShowResultImg()` / @@ -23,6 +27,7 @@ from __future__ import annotations import logging +from collections.abc import Sequence from dataclasses import dataclass from enum import Enum @@ -39,6 +44,7 @@ QPainterPath, QPen, QPixmap, + QScreen, ) from PySide6.QtWidgets import ( QFrame, @@ -649,6 +655,74 @@ ) +# --- 表示先モニタの選択(QWidget 非依存.`QScreen` の一覧と基準だけで決まる)--- + + +def is_same_screen(screen: QScreen, other: QScreen | None) -> bool: + """2 つのモニタが同一かどうかを返す. + + `QApplication.screens()` と `QWidget.screen()` が別の Python ラッパを返しても + 同一と判定できるよう,同一性が取れない場合はモニタ名で比較する. + """ + if other is None: + return False + if screen is other: + return True + return bool(screen.name() == other.name()) + + +def select_secondary_screen( + screens: Sequence[QScreen], reference: QScreen | None +) -> QScreen | None: + """`reference` と異なる最初のモニタを返す(無ければ None). + + 2 画面運用(施術者側にメイン画面・患者側に結果画面)で,結果画面を映す + 「もう一方のモニタ」を決めるために使う.モニタが 1 台しか無い場合や, + `reference` 以外のモニタが見つからない場合は None を返し,呼び出し元は + 従来どおり(同じモニタに通常サイズで)表示する. + + Args: + screens: 対象となるモニタの一覧(`QApplication.screens()`). + reference: 基準となるモニタ(メイン画面のあるモニタ).None なら先頭を返す. + + Returns: + 表示先にすべきモニタ.該当が無ければ None. + """ + for screen in screens: + if not is_same_screen(screen, reference): + return screen + return None + + +def show_on_secondary_screen( + window: QWidget, screens: Sequence[QScreen], reference: QScreen | None +) -> bool: + """`reference` 以外のモニタがあれば,そこへ移してから最大化表示する. + + 移動より先に最大化すると元のモニタで最大化されてしまうため, + 「対象モニタへ移す → 最大化」の順で呼ぶ. + + Args: + window: 表示するウィンドウ. + screens: 対象となるモニタの一覧. + reference: 基準となるモニタ(このモニタは選ばない). + + Returns: + 別モニタへ最大化表示したなら True.該当モニタが無く通常表示したなら False. + """ + target = select_secondary_screen(screens, reference) + if target is None: + # モニタが 1 台しか無い場合は従来どおり通常サイズで表示する + window.show() + return False + logger.info("結果画面をサブモニタに最大化表示します: %s", target.name()) + window.setScreen(target) + # 最大化の前に対象モニタの領域へ移しておく(この順序でないと元のモニタで最大化される) + window.setGeometry(target.availableGeometry()) + window.showMaximized() + return True + + # --- 画面部品 --- @@ -691,19 +765,26 @@ # --- 画面構築 --- def _build_ui(self) -> None: - """ウィジェットを組み立てる(左: 診断画像/右: 結果パネル + ボタン).""" + """ウィジェットを組み立てる(左: 診断画像/右: 結果パネル + ボタン). + + 結果パネルは 644x644 固定描画のため,サブモニタへ最大化したときは周囲が + 余白になる.余白が偏らないよう,左右と上下に伸縮スペースを入れて内容を + ウィンドウ中央へ寄せる(通常サイズでは余白が生じないため見た目は変わらない). + """ layout = QHBoxLayout(self) + layout.addStretch(1) self._image_label = QLabel(self) self._image_label.setFrameShape(QFrame.Shape.Box) self._image_label.setPixmap(self._image_pixmap) self._image_label.setFixedSize(self._image_pixmap.size()) - layout.addWidget(self._image_label, 0, Qt.AlignmentFlag.AlignTop) + layout.addWidget(self._image_label, 0, Qt.AlignmentFlag.AlignVCenter) right = QVBoxLayout() + right.addStretch(1) self._panel_label = QLabel(self) self._panel_label.setFixedSize(PANEL_SIZE, PANEL_SIZE) - right.addWidget(self._panel_label, 0, Qt.AlignmentFlag.AlignTop) + right.addWidget(self._panel_label, 0, Qt.AlignmentFlag.AlignHCenter) self._language_button = QPushButton(self) self._language_button.clicked.connect(self.toggle_language) @@ -716,7 +797,8 @@ right.addLayout(buttons) right.addStretch(1) - layout.addLayout(right, 1) + layout.addLayout(right, 0) + layout.addStretch(1) # --- 表示の更新 --- diff --git a/tests/gui/test_result_window_logic.py b/tests/gui/test_result_window_logic.py index 270bc41..d189112 100644 --- a/tests/gui/test_result_window_logic.py +++ b/tests/gui/test_result_window_logic.py @@ -7,6 +7,9 @@ - 言語切替ボタンは日本語 ⇔ 英語を切り替え,ラベルは切替**先**の言語名 - 診断失敗時は「Diagnosis failed / Please try again.」を表示する - 注記は「本結果は疾患の診断を行うものではない」「表示画像は明るさ補正されている」の 2 件 +- 2 画面運用(施術者側にメイン画面・患者側に結果画面)では,メイン画面とは別のモニタが + あればそこへ最大化して表示する.モニタが 1 台なら従来どおり通常サイズで表示する + (`is_same_screen` / `select_secondary_screen` / `show_on_secondary_screen`) ※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない. 本ファイルは `ResultWindow` を構築せず,画面部品に依存しない定数・関数のみを対象とする. @@ -21,8 +24,9 @@ import numpy as np import pytest -from PySide6.QtGui import QFont, QPainter, QPixmap -from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QRect +from PySide6.QtGui import QFont, QPainter, QPixmap, QScreen +from PySide6.QtWidgets import QApplication, QWidget from tiasjudge.core.color import rgb_to_lab from tiasjudge.core.csv_io import TrainingData, load_training_data @@ -75,9 +79,12 @@ build_result_rows, format_score, is_highlighted, + is_same_screen, next_language, render_result_panel, row_baseline, + select_secondary_screen, + show_on_secondary_screen, switch_button_label, texts_for, to_display_pixmap, @@ -837,3 +844,213 @@ pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), result.failed) assert not pixmap.isNull() + + +# --- 表示先モニタの選択(2 画面運用) --- + + +class FakeScreen: + """`QScreen` の代わりに使う最小のスタブ. + + 実環境で 2 台以上のモニタを用意できない(テストは offscreen で動く)ため, + `is_same_screen` / `select_secondary_screen` が使う `name()` と, + `show_on_secondary_screen` が使う `availableGeometry()` だけを持つ. + """ + + def __init__(self, name: str, geometry: QRect | None = None) -> None: + self._name = name + self._geometry = geometry if geometry is not None else QRect(0, 0, 1920, 1080) + + def name(self) -> str: + return self._name + + def availableGeometry(self) -> QRect: # noqa: N802 - Qt の API 名に合わせる + return self._geometry + + +class FakeWindow: + """`QWidget` の代わりに使う最小のスタブ(呼び出し順を記録する).""" + + def __init__(self) -> None: + self.calls: list[tuple[str, object]] = [] + + def show(self) -> None: + self.calls.append(("show", None)) + + def showMaximized(self) -> None: # noqa: N802 - Qt の API 名に合わせる + self.calls.append(("showMaximized", None)) + + def setScreen(self, screen: object) -> None: # noqa: N802 - Qt の API 名に合わせる + self.calls.append(("setScreen", screen)) + + def setGeometry(self, geometry: QRect) -> None: # noqa: N802 - Qt の API 名に合わせる + self.calls.append(("setGeometry", geometry)) + + @property + def call_names(self) -> list[str]: + return [name for name, _ in self.calls] + + +class TestIsSameScreen: + """モニタの同一判定(同一性 → 同名フォールバックの 2 段).""" + + def test_same_object_is_same(self) -> None: + screen = FakeScreen("DISPLAY1") + assert is_same_screen(screen, screen) is True # type: ignore[arg-type] + + def test_none_is_never_the_same(self) -> None: + """基準が未確定(None)なら「同じモニタ」とは見なさない.""" + assert is_same_screen(FakeScreen("DISPLAY1"), None) is False # type: ignore[arg-type] + + def test_different_wrappers_with_the_same_name_are_same(self) -> None: + """`QApplication.screens()` と `QWidget.screen()` は別ラッパを返しうる.""" + assert is_same_screen(FakeScreen("DISPLAY1"), FakeScreen("DISPLAY1")) is True # type: ignore[arg-type] + + def test_different_names_are_not_same(self) -> None: + assert is_same_screen(FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")) is False # type: ignore[arg-type] + + def test_returns_a_bool(self) -> None: + """戻り値は bool(Qt の比較結果をそのまま返さない).""" + assert isinstance(is_same_screen(FakeScreen("A"), FakeScreen("B")), bool) # type: ignore[arg-type] + + def test_real_screen_is_same_as_itself(self, gui_app: QApplication) -> None: + """実 `QScreen`(offscreen)でも同一判定が成立する.""" + screen = gui_app.primaryScreen() + assert screen is not None + assert is_same_screen(screen, screen) is True + + +class TestSelectSecondaryScreen: + """結果画面を映す「もう一方のモニタ」の選択.""" + + def test_no_screens_returns_none(self) -> None: + assert select_secondary_screen([], None) is None + + def test_single_screen_matching_the_reference_returns_none(self) -> None: + """モニタ 1 台(=メイン画面と同じ)なら該当なし.""" + screen = FakeScreen("DISPLAY1") + assert select_secondary_screen([screen], screen) is None # type: ignore[arg-type] + + def test_single_screen_with_a_same_named_reference_returns_none(self) -> None: + """同じモニタを指す別ラッパが基準でも該当なしと判定する.""" + screens = [FakeScreen("DISPLAY1")] + assert select_secondary_screen(screens, FakeScreen("DISPLAY1")) is None # type: ignore[arg-type] + + def test_reference_none_returns_the_first_screen(self) -> None: + """基準が取れない場合は先頭のモニタを返す.""" + screens = [FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")] + assert select_secondary_screen(screens, None) is screens[0] # type: ignore[arg-type] + + def test_picks_the_other_of_two_screens(self) -> None: + """2 台構成では基準と異なる方を選ぶ(相互に対称).""" + first, second = FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2") + screens = [first, second] + assert select_secondary_screen(screens, first) is second # type: ignore[arg-type] + assert select_secondary_screen(screens, second) is first # type: ignore[arg-type] + + def test_picks_the_other_when_the_reference_is_another_wrapper(self) -> None: + """基準が同名の別ラッパでも,同じモニタを選ばない(実機での本命ケース).""" + first, second = FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2") + selected = select_secondary_screen([first, second], FakeScreen("DISPLAY1")) # type: ignore[arg-type] + assert selected is second + + def test_returns_the_first_non_reference_screen(self) -> None: + """3 台以上でも「基準以外の最初の 1 台」を返す(一覧の順序を保つ).""" + screens = [FakeScreen("A"), FakeScreen("B"), FakeScreen("C")] + assert select_secondary_screen(screens, screens[0]) is screens[1] # type: ignore[arg-type] + assert select_secondary_screen(screens, screens[1]) is screens[0] # type: ignore[arg-type] + + def test_unknown_reference_returns_the_first_screen(self) -> None: + """基準が一覧に無い場合も先頭を返す(表示先が失われない).""" + screens = [FakeScreen("A"), FakeScreen("B")] + assert select_secondary_screen(screens, FakeScreen("Z")) is screens[0] # type: ignore[arg-type] + + def test_selected_screen_is_never_the_reference(self) -> None: + screens = [FakeScreen("A"), FakeScreen("B"), FakeScreen("C")] + for reference in screens: + selected = select_secondary_screen(screens, reference) # type: ignore[arg-type] + assert selected is not None + assert selected.name() != reference.name() + + def test_real_single_screen_environment_returns_none(self, gui_app: QApplication) -> None: + """実環境の一覧(offscreen は 1 台)でメイン画面と同じモニタが基準なら該当なし.""" + screens = gui_app.screens() + if len(screens) != 1: + pytest.skip("モニタが 1 台の環境でのみ検証できる") + assert select_secondary_screen(screens, screens[0]) is None + + +class TestShowOnSecondaryScreen: + """別モニタへの最大化表示と,1 台構成でのフォールバック.""" + + def test_falls_back_to_normal_show_without_a_target(self) -> None: + """該当モニタが無ければ通常表示のみ(モニタ移動・最大化はしない).""" + window = FakeWindow() + screen = FakeScreen("DISPLAY1") + assert show_on_secondary_screen(window, [screen], screen) is False # type: ignore[arg-type] + assert window.call_names == ["show"] + + def test_falls_back_when_there_are_no_screens(self) -> None: + window = FakeWindow() + assert show_on_secondary_screen(window, [], None) is False # type: ignore[arg-type] + assert window.call_names == ["show"] + + def test_moves_then_maximizes_on_the_secondary_screen(self) -> None: + """「対象モニタへ移す → 最大化」の順で呼ぶ(逆順だと元のモニタで最大化される).""" + window = FakeWindow() + first = FakeScreen("DISPLAY1", QRect(0, 0, 1920, 1040)) + second = FakeScreen("DISPLAY2", QRect(1920, 0, 1280, 1000)) + + assert show_on_secondary_screen(window, [first, second], first) is True # type: ignore[arg-type] + assert window.call_names == ["setScreen", "setGeometry", "showMaximized"] + assert window.calls[0][1] is second + assert window.calls[1][1] == second.availableGeometry() + + def test_does_not_call_plain_show_when_maximizing(self) -> None: + """最大化する場合に通常表示(show)は呼ばない.""" + window = FakeWindow() + screens = [FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")] + assert show_on_secondary_screen(window, screens, screens[0]) is True # type: ignore[arg-type] + assert "show" not in window.call_names + + def test_uses_the_first_screen_when_the_reference_is_none(self) -> None: + window = FakeWindow() + screens = [FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2")] + assert show_on_secondary_screen(window, screens, None) is True # type: ignore[arg-type] + assert window.calls[0][1] is screens[0] + + def test_same_named_reference_is_not_chosen(self) -> None: + """基準と同名のモニタ(同一モニタの別ラッパ)へは移さない.""" + window = FakeWindow() + first, second = FakeScreen("DISPLAY1"), FakeScreen("DISPLAY2") + assert show_on_secondary_screen(window, [first, second], FakeScreen("DISPLAY1")) is True # type: ignore[arg-type] + assert window.calls[0][1] is second + + def test_real_widget_falls_back_to_normal_show(self, gui_app: QApplication) -> None: + """実 `QWidget`・実 `QScreen` で 1 台構成なら通常表示になる(Qt API の整合確認). + + ※ 画面の見た目は人間が確認する(testing.md).ここでは可視化されることのみ見る. + """ + screens = gui_app.screens() + if len(screens) != 1: + pytest.skip("モニタが 1 台の環境でのみ検証できる") + + window = QWidget() + try: + assert show_on_secondary_screen(window, screens, screens[0]) is False + assert window.isVisible() is True + assert window.isMaximized() is False + finally: + window.close() + + def test_real_widget_is_maximized_on_the_target_screen(self, gui_app: QApplication) -> None: + """実 `QWidget` を実 `QScreen` へ移して最大化できる(基準 None で先頭が対象になる).""" + screens = gui_app.screens() + window = QWidget() + try: + assert show_on_secondary_screen(window, screens, None) is True + assert window.isVisible() is True + assert isinstance(window.screen(), QScreen) + assert is_same_screen(window.screen(), screens[0]) is True + finally: + window.close() diff --git a/tests/test_main.py b/tests/test_main.py index 8724307..a4c09ab 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,6 +28,7 @@ _build_parser, _preview_image, _preview_result, + _run_preview, main, ) from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT @@ -238,3 +239,102 @@ timeout=120, ) assert result.returncode == 0, result.stderr + + +# --- 表示先モニタ(プレビュー) --- + + +class TestRunPreview: + """`_run_preview`(`--preview-result` の本体)のモニタ選択・終了コード. + + ※ `QApplication` はスタブを渡す(イベントループを回さずに検証するため). + 画面の見た目は人間が確認する(testing.md「GUI の自動テストは書かない」). + """ + + class FakeApp: + """`QApplication` の代わり(`screens` / `primaryScreen` / `exec` のみ).""" + + def __init__(self, screens: list[object], exit_code: int = 0) -> None: + self._screens = screens + self._exit_code = exit_code + self.exec_called = False + + def screens(self) -> list[object]: + return self._screens + + def primaryScreen(self) -> object | None: # noqa: N802 - Qt の API 名に合わせる + return self._screens[0] if self._screens else None + + def exec(self) -> int: + self.exec_called = True + return self._exit_code + + @pytest.fixture + def fake_result_window(self, monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: + """`ResultWindow` をスタブに差し替え,生成・close を記録する.""" + import tiasjudge.gui.result_window as result_window + + captured: dict[str, object] = {"closed": False} + + class FakeResultWindow: + def __init__(self, result: object, image: object, language: object) -> None: + captured.update(result=result, image=image, language=language, window=self) + + def close(self) -> None: + captured["closed"] = True + + monkeypatch.setattr(result_window, "ResultWindow", FakeResultWindow) + return captured + + def test_check_returns_zero_without_selecting_a_screen( + self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object] + ) -> None: + """`--check` 併用時は画面を構築して閉じるだけ(最大化もイベントループも行わない).""" + import tiasjudge.gui.result_window as result_window + + def fail(*args: object, **kwargs: object) -> bool: + raise AssertionError("--check では表示先モニタを選んではならない") + + monkeypatch.setattr(result_window, "show_on_secondary_screen", fail) + app = TestRunPreview.FakeApp([object()]) + + assert _run_preview(app, failed=False, check=True) == 0 # type: ignore[arg-type] + assert fake_result_window["closed"] is True + assert app.exec_called is False + + def test_uses_the_primary_screen_as_the_reference( + self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object] + ) -> None: + """プレビューにはメイン画面が無いため,基準はプライマリモニタ.""" + import tiasjudge.gui.result_window as result_window + + captured: dict[str, object] = {} + + def record(window: object, screens: object, reference: object) -> bool: + captured.update(window=window, screens=screens, reference=reference) + return True + + monkeypatch.setattr(result_window, "show_on_secondary_screen", record) + screens = [object(), object()] + app = TestRunPreview.FakeApp(screens, exit_code=0) + + assert _run_preview(app, failed=False, check=False) == 0 # type: ignore[arg-type] + assert captured["window"] is fake_result_window["window"] + assert list(captured["screens"]) == screens # type: ignore[call-overload] + assert captured["reference"] is screens[0] + assert app.exec_called is True + + def test_returns_the_event_loop_exit_code( + self, monkeypatch: pytest.MonkeyPatch, fake_result_window: dict[str, object] + ) -> None: + """イベントループの終了コードをそのまま返す.""" + import tiasjudge.gui.result_window as result_window + + monkeypatch.setattr( + result_window, "show_on_secondary_screen", lambda *args, **kwargs: False + ) + app = TestRunPreview.FakeApp([object()], exit_code=3) + + assert _run_preview(app, failed=True, check=False) == 3 # type: ignore[arg-type] + assert fake_result_window["language"] is Language.JAPANESE + assert fake_result_window["result"].failed is True # type: ignore[union-attr]