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 adf0802..e8c7800 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" @@ -29,6 +29,21 @@ ※ 診断を実行すると結果画面(モードレス)が開く.結果画面は日本語 ⇔ 英語を切り替えられる. ※ ヘッドレス環境(画面の無い CI 等)では `QT_QPA_PLATFORM=offscreen` を付けて `--check` を実行する. +### デバッグ用 (Debug) + +結果画面の見た目を確認するための**デバッグ専用**オプション.舌画像の読み込みや診断(SVM 学習)は行わず,ダミーの診断結果とダミー画像で結果画面だけを開く. + +| 目的 | コマンド | +| --- | --- | +| 結果画面をダミー結果で開く(成功パターン) | `uv run tiasjudge --preview-result` | +| 結果画面をダミー結果で開く(診断失敗パターン) | `uv run tiasjudge --preview-result --failed` | +| プレビュー画面の構築のみ確認(即終了) | `uv run tiasjudge --preview-result --check` | + +※ 日本語で開く.英語表示は画面内の言語切替ボタンで確認する. +※ 結果画面を閉じるとアプリが終了する. +※ ダミー値は成功パターンが淡紅 55% / 紅 25% / 暗赤 10% / 紫 5% / 淡白 5%(上位 2 色は淡紅・紅),失敗パターンが旧実装の失敗時スコア組(紫 42 / 暗赤 0 / 紅 5 / 淡紅 11 / 淡白 3). +※ オプションを付けなければ通常起動・`--check` の挙動は変わらない. + ## テスト (Test) | 目的 | コマンド | 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 165cd0a..f2b607f 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" @@ -56,4 +56,6 @@ ※ 現行の日本語版は文字入り背景画像(`img_student2.bmp`)を使用している.新実装では日英とも描画で生成し,画像アセット依存を無くす(見た目は現行踏襲). +※ 描画生成の色は旧背景画像からのピクセル実測値を使用する(見出し RGB(233,113,50),5 色の色名は `result_window.py` の `COLOR_SWATCHES` 参照).5 色の色名のみ黒縁取り(幅 2 px)を付ける.フォントサイズ・太さも旧画像のインク高実測に基づく(説明文は Medium). + ※ 5 色目の日本語表記は画面間で異なる: メイン画面は「淡白」,結果画面は「淡白紅」.旧実装(メイン画面ダイアログと結果画面の背景画像)がこの表記だったため,現行踏襲としてどちらもそのまま維持する(ステップ 7 で背景画像を実確認して確定). diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index af52077..7639ac0 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -49,3 +49,7 @@ - 設計判断: 日本語結果画面は設計判断どおり描画生成(座標・色は旧 ResultDlg.cpp の値を移植).5 色目の日本語表記は旧実装が画面間で異なっていた(メイン「淡白」・結果「淡白紅」)ことを旧背景画像 img_student2.bmp の実確認で確定し,現行踏襲でどちらも維持(SPEC_01 修正済み) - 旧実装との軽微な意図的差(SPEC_01 規定なし・要レビュー): % 記号の色(旧黒 → 新赤)・y オフセット +7 の省略・英語時の切替ボタンラベル(旧 "Japanese" → 新「日本語」) - 残タスク: ステップ 5 の採取(ユーザー作業,TEST_01)→ 回帰テスト有効化が M1.ステップ 6〜7 の実機確認が M2.__main__ の QGuiApplication 既存時の堅牢性は既知の軽微リスクとして記録 +- [x] **結果画面の見た目調整+プレビューコマンド**: ユーザーの実機確認フィードバックを受けた調整(動作自体は問題なしと確認済み) + - 文字色を旧背景画像 img_student2.bmp のピクセル実測値に完全一致させ,5 色の色名のみ黒縁取り(旧画像実測に基づく幅 2 px).フォントサイズも旧画像のインク高実測に合わせ全体拡大(説明文は Medium 500・40 px で確定.ユーザーが試行の上決定) + - 英語の長文が行に収まらない場合は省略でなく段階縮小で全文維持する方式(_fit_font)に変更 + - `uv run tiasjudge --preview-result`(/ `--failed`)を追加: 結果ウィンドウ単体をダミー結果で即起動するデバッグコマンド(毎回の診断操作を省くため.ENV_04 記載) diff --git a/src/tiasjudge/__main__.py b/src/tiasjudge/__main__.py index 3295206..99fb62f 100644 --- a/src/tiasjudge/__main__.py +++ b/src/tiasjudge/__main__.py @@ -6,13 +6,24 @@ 起動オプション: - `--check`: 画面を生成して即終了する(起動可能性の確認用.イベントループを回さない) +- `--preview-result`: 結果画面(SPEC_01「結果画面」)だけをダミー結果で開く(デバッグ用) +- `--failed`: `--preview-result` と併用し,診断失敗パターンで開く """ from __future__ import annotations +import argparse import logging import sys from collections.abc import Sequence +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: # pragma: no cover - 型チェック時のみ + from numpy.typing import NDArray + + from tiasjudge.core.svm import DiagnosisResult #: `--check`(イベントループを回さずに終了する)オプション CHECK_OPTION = "--check" @@ -20,6 +31,83 @@ #: ログの書式(診断の入出力は `core` が INFO で記録する) LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s" +# --- `--preview-result` 用のダミーデータ(デバッグ表示専用.診断は実行しない)--- + +#: プレビューに使うダミー画像の一辺(結果画面は 1/2 に縮小して表示する) +PREVIEW_IMAGE_SIZE = 400 +#: プレビューに使うダミー画像の色(舌色に近い RGB) +PREVIEW_IMAGE_RGB = (198, 120, 120) +#: プレビューのダミー L*a*b*(学習データの範囲内にある代表値) +PREVIEW_LAB = (58.0, 24.0, 12.0) +#: 成功パターンのダミースコア(%) +PREVIEW_PERCENTAGES: dict[str, int] = { + "purple": 5, + "dark_red": 10, + "crimson": 25, + "pink": 55, + "pale": 5, +} +#: 成功パターンの上位 2 色(`PREVIEW_PERCENTAGES` を降順に並べた先頭 2 件) +PREVIEW_TOP_LABELS = ("pink", "crimson") +#: 失敗パターンの上位 2 色(`LEGACY_FAILURE_PERCENTAGES` を降順に並べた先頭 2 件) +PREVIEW_FAILED_TOP_LABELS = ("purple", "pink") + + +def _build_parser() -> argparse.ArgumentParser: + """コマンドライン引数のパーサを作る.""" + parser = argparse.ArgumentParser( + prog="tiasjudge", + description="舌診支援システム(TIAS)を起動する.", + ) + parser.add_argument( + CHECK_OPTION, + action="store_true", + help="画面を生成して即終了する(起動可能性の確認用)", + ) + parser.add_argument( + "--preview-result", + action="store_true", + help="結果画面だけをダミー結果で開く(デバッグ用.診断は実行しない)", + ) + parser.add_argument( + "--failed", + action="store_true", + help="--preview-result と併用し,診断失敗パターンで開く", + ) + return parser + + +def _preview_image() -> NDArray[np.uint8]: + """プレビュー用の単色ダミー画像(BGR)を作る.""" + red, green, blue = PREVIEW_IMAGE_RGB + image = np.empty((PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3), dtype=np.uint8) + # OpenCV と同じ BGR 並びで埋める + image[:, :] = (blue, green, red) + return image + + +def _preview_result(*, failed: bool) -> DiagnosisResult: + """プレビュー用のダミー診断結果を組み立てる(SVM 学習は行わない).""" + from tiasjudge.core.color import Lab + from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT + from tiasjudge.core.svm import ( + LEGACY_FAILURE_PERCENTAGES, + DiagnosisResult, + FailureReason, + ) + + percentages = dict(LEGACY_FAILURE_PERCENTAGES) if failed else dict(PREVIEW_PERCENTAGES) + top_labels = PREVIEW_FAILED_TOP_LABELS if failed else PREVIEW_TOP_LABELS + return DiagnosisResult( + lab=Lab(*PREVIEW_LAB), + scores={key: value / 100 for key, value in percentages.items()}, + percentages=percentages, + top_labels=top_labels, + predictions=np.zeros(EVALUATION_COLUMN_COUNT, dtype=np.float32), + failed=failed, + failure_reason=FailureReason.OUT_OF_DOMAIN if failed else None, + ) + def main(argv: Sequence[str] | None = None) -> int: """アプリを起動する.戻り値はプロセスの終了コード. @@ -30,18 +118,31 @@ Returns: プロセスの終了コード(0 が正常終了). """ - arguments = list(sys.argv[1:] if argv is None else argv) + arguments = _build_parser().parse_args(list(sys.argv[1:] if argv is None else argv)) logging.basicConfig(level=logging.INFO, format=LOG_FORMAT) # GUI ツールキットの import は起動時のみに限定する(`core` は GUI 非依存) from PySide6.QtWidgets import QApplication - from tiasjudge.gui.main_window import MainWindow - app = QApplication.instance() or QApplication(sys.argv[:1]) - window = MainWindow() - if CHECK_OPTION in arguments: + if arguments.preview_result: + from tiasjudge.gui.result_window import Language, ResultWindow + + 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 + + window = MainWindow() + + if arguments.check: # 画面を構築できることだけを確認して終了する(ヘッドレス環境での確認用) window.close() return 0 diff --git a/src/tiasjudge/gui/result_window.py b/src/tiasjudge/gui/result_window.py index 06e5ea8..1c6943b 100644 --- a/src/tiasjudge/gui/result_window.py +++ b/src/tiasjudge/gui/result_window.py @@ -11,8 +11,10 @@ 旧実装の日本語版は文字入りビットマップ(`img/img_student2.bmp`)を背景に使っていたが, 新実装では日英とも描画で生成し画像アセットへの依存を無くす(/setup 時の設計判断. -表示内容・レイアウトは現行踏襲).座標・色は旧実装の `ShowResultImg()` / -`BuildEnglishBackground()` の値をそのまま使う. +表示内容・レイアウトは現行踏襲).座標は旧実装の `ShowResultImg()` / +`BuildEnglishBackground()` の値をそのまま使う.見出しと 5 色の色名の文字色は,旧背景画像 +`img/img_student2.bmp` から実測した値を使い(旧ソースの `Scalar(...)` 指定は画像と一致 +しないため),色名には旧画像と同じ黒い縁取りを付ける(日英とも同じ扱い). 依存方向は `gui` → `core` の一方向とする(project-structure.md).表示文字列の組み立て・ 強調対象の決定・言語リソースは QWidget 非依存の定数・関数に分離してある. @@ -28,7 +30,16 @@ import numpy as np from numpy.typing import NDArray from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QCloseEvent, QColor, QFont, QImage, QPainter, QPen, QPixmap +from PySide6.QtGui import ( + QCloseEvent, + QColor, + QFont, + QImage, + QPainter, + QPainterPath, + QPen, + QPixmap, +) from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -68,19 +79,24 @@ #: 1 行目のベースライン y と行間(旧実装 `int y = 250; int margin = 87;`) ROW_FIRST_Y = 250 ROW_MARGIN = 87 -#: 色名・説明・スコア・パーセント記号の x 位置(旧実装の `cv::Point` と同じ) +#: 色名・説明・スコア・パーセント記号の x 位置.説明文の x は旧背景画像の実測値 +#: (説明文のインクが x=206 から始まる)に合わせてある.文字を旧画像相当まで拡大した際に +#: 色名の描画幅を確保するための調整でもある(旧実装のコード上の値は 185) NAME_X = 20 -DESCRIPTION_X = 185 +DESCRIPTION_X = 206 SCORE_RIGHT_X = 610 PERCENT_X = 620 -#: スコア表示のために空けておく幅(強調行の説明文はこの手前で切り詰める) -SCORE_AREA_WIDTH = 120 +#: スコア表示のために空けておく幅(強調行の説明文はこの手前で切り詰める). +#: スコア最大表示 "100" は `SCORE_FONT_SIZE` で約 73 px 幅のため,余白を含めて 80 とする +SCORE_AREA_WIDTH = 80 #: 文字列の右端に確保する余白 TEXT_MARGIN = 8 #: 色名・説明文の最大幅(フォントが変わっても隣の要素へはみ出さないよう切り詰める) NAME_MAX_WIDTH = DESCRIPTION_X - NAME_X - TEXT_MARGIN DESCRIPTION_MAX_WIDTH = PANEL_SIZE - DESCRIPTION_X - TEXT_MARGIN DESCRIPTION_MAX_WIDTH_HIGHLIGHTED = SCORE_RIGHT_X - SCORE_AREA_WIDTH - DESCRIPTION_X +#: 所属表記の最大幅(右端をはみ出さないための保険) +AFFILIATION_MAX_WIDTH = PANEL_SIZE - AFFILIATION_POSITION[0] - TEXT_MARGIN #: 下線の x 範囲・ベースラインからの下げ幅・太さ(旧実装 `cv::line(..., 8)`) UNDERLINE_X_RANGE = (15, 635) @@ -95,8 +111,10 @@ # --- 色(旧実装は BGR 指定.ここでは RGB に読み替える)--- PANEL_BACKGROUND = (255, 255, 255) -#: 見出しのオレンジ(旧 `Scalar(0, 90, 230)`) -HEADING_COLOR = (230, 90, 0) +#: 見出しのオレンジ.旧背景画像 `img/img_student2.bmp` の見出し「分析結果」から実測した +#: 最頻値(旧コードの `Scalar(0, 90, 230)` = RGB(230, 90, 0) より画像の実値を優先する). +#: 英語版見出しも旧 `BuildEnglishBackground()` が日本語と同色を使うため同じ値を用いる +HEADING_COLOR = (233, 113, 50) #: 注記・所属のグレー(旧 `Scalar(60, 60, 60)`) NOTE_COLOR = (60, 60, 60) #: 説明文の色(旧 `Scalar(30, 30, 30)`) @@ -109,15 +127,24 @@ FAILURE_BACKGROUND = (0, 0, 200) FAILURE_TEXT_COLOR = (200, 200, 200) -#: 色名の表示色(旧 `BuildEnglishBackground()` の cPink / cRed / cDarkRed / cPurple / cPale) +#: 色名の表示色.旧背景画像 `img/img_student2.bmp` の各色名文字からピクセルを +#: サンプリングして得た最頻値(旧 `BuildEnglishBackground()` の cPink / cRed / +#: cDarkRed / cPurple / cPale は画像と一致しないため,画像の実測値を正とする) COLOR_SWATCHES: dict[str, tuple[int, int, int]] = { - "pink": (240, 130, 130), - "crimson": (210, 0, 0), - "dark_red": (120, 0, 0), - "purple": (140, 0, 200), - "pale": (235, 150, 150), + "pink": (255, 187, 174), + "crimson": (224, 112, 146), + "dark_red": (214, 69, 65), + "purple": (125, 94, 125), + "pale": (255, 241, 167), } +#: 色名の縁取り色と太さ.旧背景画像の色名文字には暗色の縁取りがあり,走査線を実測すると +#: 文字の外側 1 px 程度が暗色(アンチエイリアスで平均 RGB(30〜45) 相当)になっている. +#: `strokePath` のペン幅はパスの内外へ半分ずつ広がるため,外側 1 px 相当となる 2 を使う. +#: 縁取りを付けるのは 5 色の色名のみ(見出し・説明文・スコア・注記は縁取りしない) +NAME_OUTLINE_COLOR = (0, 0, 0) +NAME_OUTLINE_WIDTH = 2 + # --- フォント(px 指定.環境差で行がずれないよう固定する)--- #: パネル描画に使うフォントの優先順(日本語の字形を確実に出すため明示する. @@ -130,15 +157,27 @@ "Noto Sans CJK JP", ) -HEADING_FONT_SIZE = 40 -AFFILIATION_FONT_SIZE = 17 -NOTE_FONT_SIZE = 21 -NAME_FONT_SIZE = 32 -DESCRIPTION_FONT_SIZE = 24 -SCORE_FONT_SIZE = 40 -PERCENT_FONT_SIZE = 18 -FAILURE_TITLE_FONT_SIZE = 44 -FAILURE_DETAIL_FONT_SIZE = 22 +#: 各サイズは旧背景画像 `img/img_student2.bmp` の文字のインク高さ(実測)に合わせてある. +#: 実測値: 見出し 64 px / 所属 18 px / 注記 26 px / 色名 50 px / 説明文 40 px +#: (説明文は旧画像のインク高 40 px に対し,ベース 40 px でほぼ同等の見た目になる). +#: ただし見出しはベースライン y=55 を動かさない制約から,パネル上端をはみ出さない +#: 範囲(インク高さ 52 px)が上限となるため 56 px で頭打ちにしている. +#: 色名は英語の "Dark red" が `NAME_MAX_WIDTH` に収まる範囲が上限となるため 44 px とする +#: (日英で挙動を変えないよう,言語別のサイズは設けない). +HEADING_FONT_SIZE = 56 +AFFILIATION_FONT_SIZE = 19 +NOTE_FONT_SIZE = 28 +NAME_FONT_SIZE = 44 +DESCRIPTION_FONT_SIZE = 40 +#: 説明文を縮めてよい下限(強調行は右側にスコア欄がある分だけ幅が狭いため) +DESCRIPTION_MIN_FONT_SIZE = 28 +#: 説明文(症状)の太さ.既定よりわずかに太くしつつ,太さより字の大きさで読みやすさを +#: 稼ぐ方針として Medium に留める(DemiBold は太すぎ・Bold は色名より目立つ.説明文のみに適用) +DESCRIPTION_FONT_WEIGHT = QFont.Weight.Medium +SCORE_FONT_SIZE = 48 +PERCENT_FONT_SIZE = 24 +FAILURE_TITLE_FONT_SIZE = 52 +FAILURE_DETAIL_FONT_SIZE = 28 #: 診断失敗の表示文言(SPEC_01「診断失敗表示」.旧実装は日英とも英語で表示する) FAILURE_TITLE = "Diagnosis failed" @@ -353,15 +392,76 @@ return QPixmap.fromImage(qimage.copy()) -def _set_font(painter: QPainter, pixel_size: int, *, bold: bool = False) -> None: - """描画に使うフォント(書体・サイズ・太さ)を設定する.""" +def _set_font( + painter: QPainter, + pixel_size: int, + *, + bold: bool = False, + weight: QFont.Weight | None = None, +) -> None: + """描画に使うフォント(書体・サイズ・太さ)を設定する. + + `weight` を指定すると `bold` より優先し,Bold 以外の太さ(DemiBold 等)も選べる. + """ font = QFont(painter.font()) font.setFamilies([*PANEL_FONT_FAMILIES, font.family()]) font.setPixelSize(pixel_size) - font.setBold(bold) + if weight is not None: + font.setWeight(weight) + else: + font.setBold(bold) painter.setFont(font) +def _fit_font( + painter: QPainter, + text: str, + pixel_size: int, + max_width: int, + min_pixel_size: int, + *, + bold: bool = False, + weight: QFont.Weight | None = None, +) -> None: + """`max_width` に収まる最大のサイズでフォントを設定する(下限は `min_pixel_size`). + + 文字を旧背景画像に合わせて大きくした結果,長い説明文(英語の + "Cold / fatigue / anemia" 等)がスコア欄に達する行が出るため,末尾を省略する前に + まず文字サイズを縮めて全文を残す.下限まで縮めても収まらない場合は + 呼び出し元の省略(elide)に委ねる. + """ + for size in range(pixel_size, min_pixel_size - 1, -1): + _set_font(painter, size, bold=bold, weight=weight) + if painter.fontMetrics().horizontalAdvance(text) <= max_width: + return + + +def _prepare_text( + painter: QPainter, + text: str, + pixel_size: int, + *, + bold: bool = False, + weight: QFont.Weight | None = None, + max_width: int | None = None, + min_pixel_size: int | None = None, +) -> str: + """フォントを設定し,`max_width` に収まるよう調整した描画用の文字列を返す. + + `max_width` を指定すると,その幅に収まらない文字列を末尾省略(...)で切り詰める + (環境ごとのフォント差で隣の要素へはみ出さないようにするため). + `min_pixel_size` も指定した場合は,省略する前にその大きさまで文字を縮めて全文を残す + (`_fit_font` 参照).`weight` を指定すると `bold` より優先する(`_set_font` 参照). + """ + if max_width is not None and min_pixel_size is not None: + _fit_font(painter, text, pixel_size, max_width, min_pixel_size, bold=bold, weight=weight) + else: + _set_font(painter, pixel_size, bold=bold, weight=weight) + if max_width is None: + return text + return painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max_width) + + def _draw_text( painter: QPainter, position: tuple[int, int], @@ -370,21 +470,62 @@ pixel_size: int, *, bold: bool = False, + weight: QFont.Weight | None = None, max_width: int | None = None, + min_pixel_size: int | None = None, ) -> None: """ベースライン基準で文字列を描く(旧実装の `cv::putText` に対応). - `max_width` を指定すると,その幅に収まらない文字列を末尾省略(...)で切り詰める - (環境ごとのフォント差で隣の要素へはみ出さないようにするため). + フォントの設定・切り詰めの扱いは `_prepare_text` を参照. """ + drawn = _prepare_text( + painter, + text, + pixel_size, + bold=bold, + weight=weight, + max_width=max_width, + min_pixel_size=min_pixel_size, + ) painter.setPen(QColor(*color)) - _set_font(painter, pixel_size, bold=bold) - drawn = text - if max_width is not None: - drawn = painter.fontMetrics().elidedText(text, Qt.TextElideMode.ElideRight, max_width) painter.drawText(position[0], position[1], drawn) +def _draw_outlined_text( + painter: QPainter, + position: tuple[int, int], + text: str, + color: tuple[int, int, int], + pixel_size: int, + *, + bold: bool = False, + max_width: int | None = None, +) -> None: + """ベースライン基準で,黒い縁取りを付けた文字列を描く(旧背景画像の色名の見え方). + + `drawText` では縁取りを描けないため,`QPainterPath` で文字をパス化し, + 黒ペンで輪郭を描いてから本来の色で塗りつぶす.フォントの設定・切り詰め(`max_width`)の + 扱いは `_draw_text` と同じ(`_prepare_text` を共用する). + """ + drawn = _prepare_text(painter, text, pixel_size, bold=bold, max_width=max_width) + + path = QPainterPath() + path.addText(float(position[0]), float(position[1]), painter.font(), drawn) + + pen = QPen(QColor(*NAME_OUTLINE_COLOR)) + pen.setWidth(NAME_OUTLINE_WIDTH) + # 角の尖りで文字が太って見えないよう,線の継ぎ目は丸める + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + + painter.save() + try: + painter.strokePath(path, pen) + painter.fillPath(path, QColor(*color)) + finally: + painter.restore() + + def render_result_panel(rows: tuple[ResultRow, ...], texts: LanguageTexts, failed: bool) -> QPixmap: """結果パネル(644x644)を描画して返す. @@ -424,7 +565,14 @@ AFFILIATION_POSITION[0], AFFILIATION_POSITION[1] + AFFILIATION_LINE_HEIGHT * index, ) - _draw_text(painter, position, line, NOTE_COLOR, AFFILIATION_FONT_SIZE) + _draw_text( + painter, + position, + line, + NOTE_COLOR, + AFFILIATION_FONT_SIZE, + max_width=AFFILIATION_MAX_WIDTH, + ) for position, note in zip(NOTE_POSITIONS, texts.notes, strict=True): _draw_text(painter, position, note, NOTE_COLOR, NOTE_FONT_SIZE) @@ -433,7 +581,8 @@ """5 色の行(色名・説明)と,上位 2 色のスコア・下線を描く.""" for index, row in enumerate(rows): baseline = row_baseline(index) - _draw_text( + # 色名だけは旧背景画像に合わせて黒縁取りを付ける(日英とも同じ扱い) + _draw_outlined_text( painter, (NAME_X, baseline), row.name, @@ -448,9 +597,12 @@ f": {row.description}", DESCRIPTION_COLOR, DESCRIPTION_FONT_SIZE, + weight=DESCRIPTION_FONT_WEIGHT, max_width=( DESCRIPTION_MAX_WIDTH_HIGHLIGHTED if row.highlighted else DESCRIPTION_MAX_WIDTH ), + # 強調行はスコア欄の分だけ幅が狭いため,収まらなければ縮めて全文を残す + min_pixel_size=DESCRIPTION_MIN_FONT_SIZE, ) if row.score_text is None: continue diff --git a/tests/gui/test_result_window_logic.py b/tests/gui/test_result_window_logic.py index 2dabb5d..270bc41 100644 --- a/tests/gui/test_result_window_logic.py +++ b/tests/gui/test_result_window_logic.py @@ -15,30 +15,63 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path import numpy as np import pytest +from PySide6.QtGui import QFont, QPainter, QPixmap from PySide6.QtWidgets import QApplication 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, DiagnosisResult, diagnose from tiasjudge.gui.result_window import ( + AFFILIATION_FONT_SIZE, + AFFILIATION_MAX_WIDTH, + AFFILIATION_POSITION, COLOR_SWATCHES, + DESCRIPTION_FONT_SIZE, + DESCRIPTION_FONT_WEIGHT, + DESCRIPTION_MAX_WIDTH, + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED, + DESCRIPTION_MIN_FONT_SIZE, + DESCRIPTION_X, DISPLAY_ORDER, FAILURE_DETAIL, + FAILURE_DETAIL_FONT_SIZE, FAILURE_RECT, FAILURE_TITLE, + FAILURE_TITLE_FONT_SIZE, + HEADING_COLOR, + HEADING_FONT_SIZE, + HEADING_POSITION, HIGHLIGHT_COUNT, IMAGE_DISPLAY_DIVISOR, LANGUAGE_TEXTS, + NAME_FONT_SIZE, + NAME_MAX_WIDTH, + NAME_OUTLINE_COLOR, + NAME_OUTLINE_WIDTH, + NAME_X, + NOTE_FONT_SIZE, + NOTE_POSITIONS, PANEL_SIZE, + PERCENT_FONT_SIZE, + PERCENT_X, ROW_FIRST_Y, ROW_MARGIN, + SCORE_AREA_WIDTH, + SCORE_FONT_SIZE, + SCORE_RIGHT_X, + TEXT_MARGIN, UNDERLINE_WIDTH, UNDERLINE_X_RANGE, Language, + ResultRow, + _fit_font, + _set_font, build_result_rows, format_score, is_highlighted, @@ -110,6 +143,17 @@ return load_training_data(data_csv_path) +@contextmanager +def painter_on_panel() -> Iterator[QPainter]: + """パネルと同じ大きさの QPixmap 上の `QPainter` を貸す(フォント設定の検証用).""" + pixmap = QPixmap(PANEL_SIZE, PANEL_SIZE) + painter = QPainter(pixmap) + try: + yield painter + finally: + painter.end() + + 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) @@ -247,6 +291,198 @@ assert all(0 <= channel <= 255 for channel in value) +# --- 文字色(旧背景画像 img_student2.bmp からの実測値.回帰固定) --- + + +class TestPanelColors: + """見出し・色名の表示色. + + 旧実装のソース上の `Scalar(...)` 指定は旧背景画像 `img/img_student2.bmp` と一致しない + ため,画像から実測した値を正とする(result_window.py の定数コメント参照). + 見た目の一致は人間が確認する項目なので,ここでは**実測値からの意図しない逸脱**を + 検出するための回帰固定のみ行う. + """ + + def test_heading_color_matches_the_measured_value(self) -> None: + """見出しのオレンジは実測値 RGB(233, 113, 50).""" + assert HEADING_COLOR == (233, 113, 50) + + def test_color_swatches_match_the_measured_values(self) -> None: + """5 色の色名の文字色は旧背景画像からの実測値.""" + assert COLOR_SWATCHES == { + "pink": (255, 187, 174), + "crimson": (224, 112, 146), + "dark_red": (214, 69, 65), + "purple": (125, 94, 125), + "pale": (255, 241, 167), + } + + def test_swatches_are_distinct(self) -> None: + """5 色は互いに異なる色で描く(同じ色だと行の区別が付かない).""" + assert len(set(COLOR_SWATCHES.values())) == len(DISPLAY_ORDER) + + def test_name_outline_is_a_dark_thin_stroke(self) -> None: + """色名の縁取りは黒・1 px 以上(旧背景画像の色名には暗色の縁取りがある).""" + assert NAME_OUTLINE_COLOR == (0, 0, 0) + assert NAME_OUTLINE_WIDTH >= 1 + + +# --- レイアウト・フォントサイズの整合(はみ出し・重なりの予防) --- + + +class TestLayoutConstraints: + """文字を拡大しても要素同士が重ならないための制約.""" + + def test_x_positions_are_in_left_to_right_order(self) -> None: + """色名 → 説明 → スコア → % の順に左から並ぶ.""" + assert NAME_X < DESCRIPTION_X < SCORE_RIGHT_X < PERCENT_X + + def test_all_max_widths_are_positive(self) -> None: + widths = ( + NAME_MAX_WIDTH, + DESCRIPTION_MAX_WIDTH, + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED, + AFFILIATION_MAX_WIDTH, + ) + assert all(width > 0 for width in widths) + + def test_name_and_description_do_not_overlap(self) -> None: + """色名の描画上限は説明文の開始位置より手前で止まる.""" + assert NAME_X + NAME_MAX_WIDTH <= DESCRIPTION_X + + def test_highlighted_description_stops_before_the_score_area(self) -> None: + """強調行の説明文はスコア欄(`SCORE_AREA_WIDTH`)に食い込まない.""" + assert DESCRIPTION_X + DESCRIPTION_MAX_WIDTH_HIGHLIGHTED == SCORE_RIGHT_X - SCORE_AREA_WIDTH + assert DESCRIPTION_MAX_WIDTH_HIGHLIGHTED < DESCRIPTION_MAX_WIDTH + + def test_all_max_widths_fit_in_the_panel(self) -> None: + assert DESCRIPTION_X + DESCRIPTION_MAX_WIDTH + TEXT_MARGIN <= PANEL_SIZE + assert AFFILIATION_POSITION[0] + AFFILIATION_MAX_WIDTH + TEXT_MARGIN <= PANEL_SIZE + + @pytest.mark.parametrize( + "pixel_size", + [ + HEADING_FONT_SIZE, + AFFILIATION_FONT_SIZE, + NOTE_FONT_SIZE, + NAME_FONT_SIZE, + DESCRIPTION_FONT_SIZE, + DESCRIPTION_MIN_FONT_SIZE, + SCORE_FONT_SIZE, + PERCENT_FONT_SIZE, + FAILURE_TITLE_FONT_SIZE, + FAILURE_DETAIL_FONT_SIZE, + ], + ) + def test_font_sizes_are_positive(self, pixel_size: int) -> None: + assert pixel_size > 0 + + def test_description_shrink_limit_is_below_the_base_size(self) -> None: + """説明文は基準サイズから下限まで縮められる(下限 < 基準).""" + assert 0 < DESCRIPTION_MIN_FONT_SIZE < DESCRIPTION_FONT_SIZE + + def test_header_elements_are_stacked_top_down(self) -> None: + """見出し → 注記 2 行 → 5 色の行 の順に上から並ぶ.""" + assert HEADING_POSITION[1] < NOTE_POSITIONS[0][1] < NOTE_POSITIONS[1][1] < ROW_FIRST_Y + + def test_first_row_is_below_the_notes_area(self) -> None: + """1 行目の色名は注記より下に来る(色名を拡大しても重ならない). + + ※ 見出し自体の上端の収まりはフォント(字形)依存のため,ここでは検証しない + (拡大後の見た目は人間が確認する.testing.md「GUI の自動テストは書かない」). + """ + assert NOTE_POSITIONS[1][1] < ROW_FIRST_Y - NAME_FONT_SIZE + + +# --- フォント設定(_set_font / _fit_font) --- + + +class TestSetFont: + """`_set_font`(サイズ・太さの指定). + + ※ 描画結果ではなく `QFont` の状態のみを見るため,インストール済みフォントに依存しない. + """ + + def test_sets_the_pixel_size(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 33) + assert painter.font().pixelSize() == 33 + + def test_default_is_not_bold(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 20) + assert painter.font().bold() is False + + def test_bold_flag_is_applied(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 20, bold=True) + assert painter.font().bold() is True + + def test_weight_selects_an_intermediate_thickness(self, gui_app: QApplication) -> None: + """`weight` では Bold 以外の太さ(Medium 等)も選べる.""" + with painter_on_panel() as painter: + _set_font(painter, 20, weight=QFont.Weight.Medium) + font = painter.font() + assert font.weight() == QFont.Weight.Medium + assert QFont.Weight.Normal < font.weight() < QFont.Weight.Bold + + def test_weight_takes_precedence_over_bold(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _set_font(painter, 20, bold=True, weight=QFont.Weight.Normal) + assert painter.font().weight() == QFont.Weight.Normal + + def test_description_weight_is_between_normal_and_bold(self) -> None: + """説明文は太字にせず,やや太い程度に留める(色名より目立たせない).""" + assert QFont.Weight.Normal < DESCRIPTION_FONT_WEIGHT < QFont.Weight.Bold + + +class TestFitFont: + """`_fit_font`(省略の前に文字を縮めて全文を残す).""" + + def test_keeps_the_base_size_when_it_already_fits(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _fit_font(painter, "abc", 40, PANEL_SIZE, 28) + assert painter.font().pixelSize() == 40 + + def test_shrinks_until_the_text_fits(self, gui_app: QApplication) -> None: + """収まらない幅では,収まる最大サイズまで 1 px ずつ縮める.""" + text = "Cold / fatigue / anemia" + with painter_on_panel() as painter: + _set_font(painter, 40) + full_width = painter.fontMetrics().horizontalAdvance(text) + if full_width == 0: # フォントが無い環境(幅が測れない)では検証できない + pytest.skip("フォントメトリクスが取得できない環境") + target_width = full_width // 2 + + _fit_font(painter, text, 40, target_width, 1) + fitted = painter.font().pixelSize() + assert fitted < 40 + assert painter.fontMetrics().horizontalAdvance(text) <= target_width + + # 1 px 大きくすると収まらない(=収まる最大サイズを選んでいる) + _set_font(painter, fitted + 1) + assert painter.fontMetrics().horizontalAdvance(text) > target_width + + def test_never_goes_below_the_minimum_size(self, gui_app: QApplication) -> None: + """下限まで縮めても収まらない場合は下限で止める(残りは呼び出し元が省略する).""" + with painter_on_panel() as painter: + _fit_font(painter, "とても長い説明文" * 20, 40, 10, 28) + assert painter.font().pixelSize() == 28 + + @pytest.mark.parametrize("max_width", [1, 50, 200, 1000]) + def test_result_is_always_within_the_allowed_range( + self, gui_app: QApplication, max_width: int + ) -> None: + with painter_on_panel() as painter: + _fit_font(painter, "Blood slightly thick", 40, max_width, 28) + assert 28 <= painter.font().pixelSize() <= 40 + + def test_weight_is_preserved_while_shrinking(self, gui_app: QApplication) -> None: + with painter_on_panel() as painter: + _fit_font(painter, "abcdefghij" * 10, 40, 50, 28, weight=QFont.Weight.Medium) + assert painter.font().weight() == QFont.Weight.Medium + + # --- 言語切替(SPEC_01「言語切替ボタン」) --- @@ -522,6 +758,44 @@ pixmap = render_result_panel(rows, texts_for(Language.ENGLISH), failed=False) assert not pixmap.isNull() + @pytest.mark.parametrize("highlighted", [True, False]) + def test_renders_overlong_texts(self, gui_app: QApplication, highlighted: bool) -> None: + """幅に収まらない色名・説明でも描画が成立する(縮小・省略の経路を通す). + + ※ 色名は縁取り付き(`QPainterPath`)で描かれるため,省略の経路も含めて + 例外なく描けることを確認する.見た目は人間が確認する. + """ + rows = tuple( + ResultRow( + key=key, + name="非常に長い色名" * 5, + description="非常に長い説明文" * 10, + swatch=COLOR_SWATCHES[key], + highlighted=highlighted, + score_text=format_score(50) if highlighted else None, + ) + for key in DISPLAY_ORDER + ) + pixmap = render_result_panel(rows, texts_for(Language.JAPANESE), failed=False) + assert not pixmap.isNull() + assert (pixmap.width(), pixmap.height()) == (PANEL_SIZE, PANEL_SIZE) + + def test_renders_with_empty_texts(self, gui_app: QApplication) -> None: + """空文字でも描画が成立する(縁取りのパス生成が空になるケース).""" + rows = tuple( + ResultRow( + key=key, + name="", + description="", + swatch=COLOR_SWATCHES[key], + highlighted=False, + score_text=None, + ) + for key in DISPLAY_ORDER + ) + pixmap = render_result_panel(rows, texts_for(Language.JAPANESE), failed=False) + assert not pixmap.isNull() + # --- 結合(core.diagnose → 表示行) --- diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..8724307 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,240 @@ +"""エントリポイントのコマンドライン引数の検証(ENV_04「起動」「デバッグ用」). + +対象は `tiasjudge.__main__` の引数解析と,`--preview-result` 用のダミー診断結果の +組み立て(デバッグ専用.診断・SVM 学習は行わない). + +- `--check`: 画面を構築して即終了する(既存の起動確認は `test_package_skeleton.py`) +- `--preview-result`: 結果画面だけをダミー結果で開く +- `--failed`: `--preview-result` と併用し,診断失敗パターン(旧実装の失敗時スコア組)で開く + +※ testing.md の方針により GUI(QWidget)そのものの自動テストは書かない.ここで画面を +生成するのは `--check` による**起動可能性の確認**に限る(Qt は conftest.py で offscreen). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from tiasjudge.__main__ import ( + PREVIEW_FAILED_TOP_LABELS, + PREVIEW_IMAGE_RGB, + PREVIEW_IMAGE_SIZE, + PREVIEW_PERCENTAGES, + PREVIEW_TOP_LABELS, + _build_parser, + _preview_image, + _preview_result, + main, +) +from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT +from tiasjudge.core.svm import COLOR_KEYS, LEGACY_FAILURE_PERCENTAGES +from tiasjudge.gui.result_window import DISPLAY_ORDER, HIGHLIGHT_COUNT, Language, build_result_rows + +# --- 引数解析 --- + + +class TestArgumentParser: + """`--check` / `--preview-result` / `--failed` の 3 オプション(ENV_04).""" + + def test_no_options_by_default(self) -> None: + arguments = _build_parser().parse_args([]) + flags = (arguments.check, arguments.preview_result, arguments.failed) + assert flags == (False, False, False) + + @pytest.mark.parametrize( + ("argv", "expected"), + [ + (["--check"], ("check",)), + (["--preview-result"], ("preview_result",)), + (["--preview-result", "--failed"], ("preview_result", "failed")), + (["--failed", "--preview-result", "--check"], ("preview_result", "failed", "check")), + ], + ) + def test_flags_are_parsed(self, argv: list[str], expected: tuple[str, ...]) -> None: + arguments = _build_parser().parse_args(argv) + for name in ("check", "preview_result", "failed"): + assert getattr(arguments, name) is (name in expected), name + + def test_unknown_option_is_rejected(self) -> None: + """未知のオプションはエラー終了する(黙って無視しない).""" + with pytest.raises(SystemExit) as error: + _build_parser().parse_args(["--unknown"]) + assert error.value.code == 2 + + @pytest.mark.parametrize("option", ["--check", "--preview-result", "--failed"]) + def test_help_lists_the_option(self, option: str) -> None: + assert option in _build_parser().format_help() + + +# --- プレビュー用のダミーデータ(デバッグ専用) --- + + +class TestPreviewImage: + """ダミー画像(舌画像の代わり).""" + + def test_shape_and_dtype(self) -> None: + image = _preview_image() + assert image.shape == (PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3) + assert image.dtype.name == "uint8" + + def test_is_stored_in_bgr_order(self) -> None: + """結果画面は BGR 前提で受け取るため,BGR 並びで埋める.""" + red, green, blue = PREVIEW_IMAGE_RGB + assert tuple(_preview_image()[0, 0]) == (blue, green, red) + + +class TestPreviewResult: + """ダミー診断結果が `DiagnosisResult` として妥当であること.""" + + @pytest.mark.parametrize("failed", [False, True]) + def test_scores_cover_all_color_keys(self, failed: bool) -> None: + result = _preview_result(failed=failed) + assert set(result.scores) == set(COLOR_KEYS) + assert set(result.percentages) == set(COLOR_KEYS) + + @pytest.mark.parametrize("failed", [False, True]) + def test_scores_are_percentages_divided_by_100(self, failed: bool) -> None: + result = _preview_result(failed=failed) + for key, percent in result.percentages.items(): + assert result.scores[key] == pytest.approx(percent / 100) + assert 0.0 <= result.scores[key] <= 1.0 + + @pytest.mark.parametrize("failed", [False, True]) + def test_top_labels_are_two_known_colors(self, failed: bool) -> None: + result = _preview_result(failed=failed) + assert len(result.top_labels) == HIGHLIGHT_COUNT + assert len(set(result.top_labels)) == HIGHLIGHT_COUNT + assert set(result.top_labels) <= set(COLOR_KEYS) + + @pytest.mark.parametrize("failed", [False, True]) + def test_top_labels_are_the_two_highest_scores(self, failed: bool) -> None: + """上位 2 色はスコア降順の先頭 2 件(表示と矛盾しないダミーであること).""" + result = _preview_result(failed=failed) + ranked = sorted(result.percentages, key=lambda key: result.percentages[key], reverse=True) + assert set(result.top_labels) == set(ranked[:HIGHLIGHT_COUNT]) + + @pytest.mark.parametrize("failed", [False, True]) + def test_predictions_have_the_full_column_count(self, failed: bool) -> None: + result = _preview_result(failed=failed) + assert result.predictions.shape == (EVALUATION_COLUMN_COUNT,) + + def test_success_pattern(self) -> None: + """成功パターンは `PREVIEW_PERCENTAGES`・失敗フラグなし(ENV_04「デバッグ用」).""" + result = _preview_result(failed=False) + assert result.percentages == PREVIEW_PERCENTAGES + assert result.top_labels == PREVIEW_TOP_LABELS + assert result.failed is False + assert result.failure_reason is None + + def test_success_pattern_totals_100_percent(self) -> None: + assert sum(PREVIEW_PERCENTAGES.values()) == 100 + + def test_failed_pattern_uses_the_legacy_failure_scores(self) -> None: + """失敗パターンは旧実装の失敗時スコア組(`LEGACY_FAILURE_PERCENTAGES`).""" + result = _preview_result(failed=True) + assert result.percentages == LEGACY_FAILURE_PERCENTAGES + assert result.top_labels == PREVIEW_FAILED_TOP_LABELS + assert result.failed is True + assert result.failure_reason is not None + + def test_failed_top_labels_follow_the_legacy_scores(self) -> None: + """失敗パターンの上位 2 色は旧スコア組を降順に並べた先頭 2 件(紫・淡紅).""" + ranked = sorted( + LEGACY_FAILURE_PERCENTAGES, + key=lambda key: LEGACY_FAILURE_PERCENTAGES[key], + reverse=True, + ) + assert set(PREVIEW_FAILED_TOP_LABELS) == set(ranked[:HIGHLIGHT_COUNT]) + + def test_does_not_share_the_legacy_score_dictionary(self) -> None: + """モジュール定数を書き換えない(コピーを持つ).""" + result = _preview_result(failed=True) + assert result.percentages is not LEGACY_FAILURE_PERCENTAGES + result = _preview_result(failed=False) + assert result.percentages is not PREVIEW_PERCENTAGES + + @pytest.mark.parametrize("failed", [False, True]) + @pytest.mark.parametrize("language", list(Language)) + def test_rows_can_be_built_from_the_dummy_result( + self, failed: bool, language: Language + ) -> None: + """ダミー結果から結果画面の 5 行を組み立てられる(表示可能な形であること).""" + rows = build_result_rows(_preview_result(failed=failed), language) + assert tuple(row.key for row in rows) == DISPLAY_ORDER + assert len({row.key for row in rows if row.highlighted}) == HIGHLIGHT_COUNT + + +# --- 起動(ENV_04「デバッグ用」) --- + + +class TestPreviewLaunch: + """`--preview-result` の起動確認(`--check` で画面を構築して即終了する).""" + + @pytest.mark.parametrize( + "argv", + [ + ["--check"], + ["--preview-result", "--check"], + ["--preview-result", "--failed", "--check"], + ], + ) + def test_check_exits_successfully(self, argv: list[str]) -> None: + assert main(argv) == 0 + + def test_preview_does_not_build_the_main_window(self, monkeypatch: pytest.MonkeyPatch) -> None: + """プレビューはメイン画面を作らない(結果画面だけを開く).""" + import tiasjudge.gui.main_window as main_window + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("プレビューではメイン画面を作ってはならない") + + monkeypatch.setattr(main_window, "MainWindow", fail) + assert main(["--preview-result", "--check"]) == 0 + + @pytest.mark.parametrize("failed", [False, True]) + def test_preview_opens_the_result_window_in_japanese( + self, monkeypatch: pytest.MonkeyPatch, failed: bool + ) -> None: + """結果画面にダミー結果・ダミー画像・日本語が渡される(ENV_04「デバッグ用」).""" + import tiasjudge.gui.result_window as result_window + + captured: dict[str, object] = {} + + class FakeResultWindow: + def __init__(self, result: object, image: object, language: object) -> None: + captured.update(result=result, image=image, language=language) + + def close(self) -> None: + captured["closed"] = True + + monkeypatch.setattr(result_window, "ResultWindow", FakeResultWindow) + argv = ["--preview-result", "--check"] + (["--failed"] if failed else []) + assert main(argv) == 0 + + assert captured["language"] is Language.JAPANESE + assert captured["closed"] is True + result = captured["result"] + assert result.failed is failed # type: ignore[union-attr] + assert result.percentages == _preview_result(failed=failed).percentages # type: ignore[union-attr] + image = captured["image"] + assert image.shape == (PREVIEW_IMAGE_SIZE, PREVIEW_IMAGE_SIZE, 3) # type: ignore[union-attr] + + def test_module_invocation_with_preview_exits_successfully( + self, repo_root: Path, headless_env: dict[str, str] + ) -> None: + """`python -m tiasjudge --preview-result --check` が正常終了する(ENV_04 の記載どおり).""" + result = subprocess.run( + [sys.executable, "-m", "tiasjudge", "--preview-result", "--check"], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=headless_env, + timeout=120, + ) + assert result.returncode == 0, result.stderr