diff --git a/CLAUDE.md b/CLAUDE.md index e9eb182..3c67cac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: PLAN_02 ステップ 5(回帰テスト)完了・**M1 達成**(旧 exe 実測 12 ケース全一致).全ステップ完了,残りはステップ 8(配布,任意)のみ +最新: PLAN_02 ステップ 8(配布)完了・**全 8 ステップ完了**(PyInstaller 単体 exe.手順は ENV_04「配布用ビルド」) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/TIASjudge.spec b/TIASjudge.spec new file mode 100644 index 0000000..9234f90 --- /dev/null +++ b/TIASjudge.spec @@ -0,0 +1,61 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller 設定(PLAN_02 ステップ 8: Windows 単体 exe). + +ビルド: `uv run pyinstaller TIASjudge.spec --noconfirm` +成果物: `dist/TIASjudge.exe`(onefile・コンソール非表示) + +同梱データ `data/data.csv` は onefile 実行時に `sys._MEIPASS/data/data.csv` へ展開され, +`gui/main_window.py` の `default_training_data_path()` がそれを解決する. +アイコン `packaging/TIASjudge.ico`(旧 MFC 実装 `IDR_MAINFRAME` と同一ファイル)は +exe のファイルアイコンに設定しつつ同梱もし,実行時のウィンドウアイコンにも使う +(`gui/main_window.py` の `default_icon_path()` が解決する). +パスはすべて spec の位置(`SPECPATH`)からの相対で組み立てる(絶対パスを埋め込まない). +""" + +from pathlib import Path + +project_root = Path(SPECPATH) # noqa: F821 - PyInstaller が注入する +icon_path = project_root / "packaging" / "TIASjudge.ico" + +a = Analysis( # noqa: F821 + [str(project_root / "packaging" / "entry.py")], + pathex=[str(project_root / "src")], + binaries=[], + datas=[ + (str(project_root / "data" / "data.csv"), "data"), + # ウィンドウ・タスクバーのアイコン用に同梱する(展開先は `sys._MEIPASS/packaging/`) + (str(icon_path), "packaging"), + ], + hiddenimports=["tiasjudge", "tiasjudge.gui.main_window", "tiasjudge.gui.result_window"], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + # 不要な GUI ツールキット・開発用ツールを除外して exe を小さくする + excludes=["tkinter", "pytest", "ruff", "PyQt5", "PyQt6", "PySide2"], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) # noqa: F821 + +exe = EXE( # noqa: F821 + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="TIASjudge", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # GUI アプリのためコンソールを出さない + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=str(icon_path), # 旧 MFC 実装と同じアイコンを exe に埋め込む +) diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index 368947a..0364f1f 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -13,7 +13,7 @@ | テスト | pytest | core ロジック(Lab 変換・SVM 分類・CSV 読込)の回帰テスト | | lint / フォーマット | Ruff | lint とフォーマットを 1 ツールで両立 | | パッケージ管理 | uv(pyproject.toml + uv.lock) | 依存の完全な再現.`uv sync` 一発で環境構築 | -| 配布 | PyInstaller(必要になった時点で導入) | Windows は exe,Mac は app として単体配布可能 | +| 配布 | PyInstaller(dev 依存として導入済み) | Windows は exe,Mac は app として単体配布可能.ビルド手順は ENV_04「配布用ビルド」参照 | ## 現行実装からの移行対応 (Migration Notes) 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 db97184..b81bf59 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" @@ -25,6 +25,7 @@ | アプリを起動(メイン画面) | `uv run python -m tiasjudge` | | コンソールスクリプトで起動 | `uv run tiasjudge` | | 起動可能性のみ確認(画面を出さずに終了) | `uv run python -m tiasjudge --check` | +| 同梱学習データと診断の動作のみ確認(画面を出さない) | `uv run python -m tiasjudge --verify-data` | ※ 診断を実行すると結果画面(モードレス)が開く.結果画面は表示専用で,言語切替と「閉じる」はメイン画面の「結果画面の操作」から行う. ※ ヘッドレス環境(画面の無い CI 等)では `QT_QPA_PLATFORM=offscreen` を付けて `--check` を実行する. @@ -77,6 +78,22 @@ uv run pytest ``` +## 配布用ビルド (Packaging) + +Windows 単体 exe(onefile・コンソール非表示)を PyInstaller で作る(PLAN_02 ステップ 8). + +| 目的 | コマンド | +| --- | --- | +| exe をビルド | `uv run pyinstaller TIASjudge.spec --noconfirm` | +| ビルドした exe の起動確認(画面を出さない) | `dist/TIASjudge.exe --check` | +| ビルドした exe の同梱データ・診断確認 | `dist/TIASjudge.exe --verify-data` | +| ビルド成果物を削除 | `rm -r build dist`(PowerShell: `Remove-Item -Recurse build, dist`) | + +※ 成果物は `dist/TIASjudge.exe`(約 93 MB).`build/`・`dist/` は Git 管理外. +※ ビルド設定は リポジトリ直下の `TIASjudge.spec`,エントリスクリプトは `packaging/entry.py`. +※ `data/data.csv` は exe に同梱され,実行時に `sys._MEIPASS/data/data.csv` へ展開される.差し替えたい場合は環境変数 `TIASJUDGE_DATA_CSV` にパスを指定する. +※ ビルドは実行環境の OS 向けの成果物のみ作られる(Windows で Mac 用 app は作れない). + ## 同梱データ (Bundled Data) | ファイル | 内容 | 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 2d47f6a..7790629 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" @@ -2,6 +2,8 @@ 現行アプリの画面構成を踏襲する.画面は 2 つ(メイン画面・結果画面). +※ アプリアイコン(ウィンドウ・タスクバー・配布 exe)は旧実装と同一の ico(`packaging/TIASjudge.ico`.旧 MFC 実装の `IDR_MAINFRAME` 相当)を使用する.ico が見つからない場合はアイコン無しで起動を継続する(起動を妨げない). + ## 画面遷移 (Screen Flow) ```text diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ad8533d..554d271 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -64,3 +64,8 @@ - 経緯: ① 旧実装のバグで紫スコアが画面から読めない(IDC_PURPLE のフォント指定ミスで 150px の巨大文字)→ WM_GETTEXT で吸い出す `scripts/read_legacy_values.py` を作成 ② それを発展させ全自動化 ③ 初回失敗の原因は「結果画面(#32770)をエラーダイアログと誤検出」というスクリプト側のバグで,旧 exe への WM_SETTEXT 入力は最初から機能していた - failed 判定はスコア組(42/0/5/11/3)から導出(旧 ResultDlg の表示条件そのもの).この変換行列では純色だけでなく一部の舌色(115,62,68 / 170,92,112 / 196,74,74)も失敗パターンになることが判明(学習データ域外) - 残: `lab_to_scores.csv`(高精度照合用の任意フィクスチャ)は未採取のまま skip(Debug ビルドが必要.必要になったら TEST_01 末尾の手順で) +- [x] **PLAN_02 ステップ 8 完了(配布)— 全ステップ完了**: PyInstaller で Windows 単体 exe(onefile・コンソール非表示・`dist/TIASjudge.exe`)を作成.同梱 data.csv での起動・診断を確認し,ユーザーの実機確認も済み(ENV_04「配布用ビルド」に手順記載) + - 設計判断: エントリはパッケージの `__main__.py` 直指定でなく薄いラッパ `packaging/entry.py` を新設(`__main__.py` 直指定だと PyInstaller がパッケージを辿れないことがある).同梱リソースのパス解決は frozen 判定+候補リスト方式(環境変数 → `sys._MEIPASS` → パッケージ同梱 → リポジトリ直下) + - アプリアイコンは旧実装と同一の ico を `packaging/TIASjudge.ico` に配置(`org/` は Git 管理外のため管理下コピーはこれが唯一).exe・ウィンドウ・タスクバーで統一(SPEC_01 に注記) + - 失敗パターン: exe 差し替え後もエクスプローラーの表示が変わらない事象はアイコンキャッシュが原因(exe 内容は正しい).別名コピーでの確認・`ie4uinit.exe -ClearIconCache`+エクスプローラー再起動で解消.ショートカット(.lnk)は別途 IconLocation の再設定が必要 + - 後段メモ: GUI を出さない配布物検証用に `--verify-data` を追加.`_verify_bundled_data()` は CsvIoError のみ捕捉(diagnose の例外は素通り)・他フラグ併用時は `--verify-data` 優先で黙って無視,の 2 点は既知の改善余地.リソースパス解決を `core/` へ移す案も申し送りあり(現状は gui 層) diff --git a/packaging/TIASjudge.ico b/packaging/TIASjudge.ico new file mode 100644 index 0000000..c7486b4 --- /dev/null +++ b/packaging/TIASjudge.ico Binary files differ diff --git a/packaging/entry.py b/packaging/entry.py new file mode 100644 index 0000000..9862935 --- /dev/null +++ b/packaging/entry.py @@ -0,0 +1,18 @@ +"""PyInstaller 用のエントリスクリプト(`TIASjudge.spec` から参照する). + +`python -m tiasjudge` と同じ入口(`tiasjudge.__main__.main`)を呼ぶだけの薄いラッパ. +PyInstaller はパッケージの `__main__.py` を直接エントリにすると +パッケージ自体を辿れないことがあるため,本スクリプトを噛ませる. +""" + +from __future__ import annotations + +import multiprocessing +import sys + +from tiasjudge.__main__ import main + +if __name__ == "__main__": + # onefile exe が子プロセスを再起動しないようにする(PyInstaller 推奨) + multiprocessing.freeze_support() + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 6e10143..68feb95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ [dependency-groups] dev = [ + "pyinstaller>=6.22.2", "pytest>=8.0", "ruff>=0.6", ] diff --git a/src/tiasjudge/__main__.py b/src/tiasjudge/__main__.py index 79b57fa..87aed7e 100644 --- a/src/tiasjudge/__main__.py +++ b/src/tiasjudge/__main__.py @@ -10,6 +10,8 @@ - `--failed`: `--preview-result` と併用し,診断失敗パターンで開く - `--english`: `--preview-result` と併用し,英語表示で開く(結果画面は表示専用のため, プレビューでは画面内で言語を切り替えられない) +- `--verify-data`: 同梱学習データ(`data/data.csv`)を読み込み,ダミー L*a*b* で診断を + 1 回実行して終了する(画面を出さない.配布 exe の動作確認用.PLAN_02 ステップ 8) """ from __future__ import annotations @@ -84,6 +86,11 @@ action="store_true", help="--preview-result と併用し,英語表示で開く", ) + parser.add_argument( + "--verify-data", + action="store_true", + help="同梱学習データを読み込みダミー値で診断して終了する(配布 exe の確認用)", + ) return parser @@ -140,6 +147,35 @@ return int(app.exec()) +def _verify_bundled_data() -> int: + """同梱学習データを読み込み,ダミー L*a*b* で診断を 1 回実行する(`--verify-data`). + + PyInstaller で固めた exe が学習データを同梱できているか,OpenCV の SVM が + 動作するかを画面なしで確認するためのオプション. + + Returns: + 正常なら 0,学習データを読めなければ 1. + """ + from tiasjudge.core.color import Lab + from tiasjudge.core.csv_io import CsvIoError, load_training_data + from tiasjudge.core.svm import diagnose + + # パス解決は GUI 側の同梱データ解決(frozen 対応)を再利用する + from tiasjudge.gui.main_window import default_training_data_path + + path = default_training_data_path() + logger.info("同梱学習データを確認します: %s", path) + try: + training = load_training_data(path) + except CsvIoError as error: + logger.error("学習データを読み込めません: %s", error) + return 1 + + result = diagnose(Lab(*PREVIEW_LAB), training) + logger.info("動作確認の診断結果: %s(failed=%s)", result.percentages, result.failed) + return 0 + + def main(argv: Sequence[str] | None = None) -> int: """アプリを起動する.戻り値はプロセスの終了コード. @@ -152,11 +188,21 @@ arguments = _build_parser().parse_args(list(sys.argv[1:] if argv is None else argv)) logging.basicConfig(level=logging.INFO, format=LOG_FORMAT) + if arguments.verify_data: + # 画面を作らずに同梱データと診断の動作だけを確認する + return _verify_bundled_data() + # GUI ツールキットの import は起動時のみに限定する(`core` は GUI 非依存) from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication(sys.argv[:1]) + # アプリ全体(結果画面・ダイアログを含む)に旧実装と同じアイコンを適用する. + # アイコンが無くても起動は続行する(`apply_app_icon` が None を返すだけ) + from tiasjudge.gui.main_window import apply_app_icon + + apply_app_icon(app) + if arguments.preview_result: return _run_preview( app, diff --git a/src/tiasjudge/gui/main_window.py b/src/tiasjudge/gui/main_window.py index bd3ce00..7dbf3c2 100644 --- a/src/tiasjudge/gui/main_window.py +++ b/src/tiasjudge/gui/main_window.py @@ -23,6 +23,7 @@ import logging import os +import sys from collections.abc import Callable, Sequence from dataclasses import dataclass, field from pathlib import Path @@ -31,7 +32,7 @@ import numpy as np from numpy.typing import NDArray from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter, QPixmap +from PySide6.QtGui import QColor, QFont, QIcon, QMouseEvent, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, QFileDialog, @@ -100,6 +101,16 @@ #: 学習データのパスを上書きする環境変数(配布形態が変わっても絶対パスを埋め込まないため) TRAINING_DATA_ENV_VAR = "TIASJUDGE_DATA_CSV" +#: 学習データのファイル名 +TRAINING_DATA_FILE_NAME = "data.csv" +#: 学習データを置くディレクトリ名(リポジトリ・PyInstaller 展開先で共通) +TRAINING_DATA_DIR_NAME = "data" + +#: アプリのアイコンファイル名(旧 MFC 実装 `IDR_MAINFRAME` と同一のアイコン) +ICON_FILE_NAME = "TIASjudge.ico" +#: アイコンを置くディレクトリ名(リポジトリ・PyInstaller 展開先で共通) +ICON_DIR_NAME = "packaging" + # --- 入力ガードのメッセージ(SPEC_01「入力ガード(エラー表示)」)--- MESSAGE_TRANSFORM_REQUIRED = "変換行列を読み込んでください" @@ -266,24 +277,100 @@ ) +def _bundle_root() -> Path | None: + """PyInstaller で固めた実行ファイルの展開先(`sys._MEIPASS`)を返す. + + 通常の Python 実行時は `None` を返す(frozen 判定は PyInstaller の推奨方法). + """ + if not getattr(sys, "frozen", False): + return None + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + return Path(meipass) + # onedir 形式で `_MEIPASS` が無い場合は exe と同じ場所を見る + return Path(sys.executable).resolve().parent + + +def _resource_candidates(dir_name: str, file_name: str) -> list[Path]: + """同梱リソース `/` の探索候補を優先順に返す. + + PyInstaller の展開先(`sys._MEIPASS`)→ パッケージ同梱 → リポジトリ直下の順. + いずれも実行位置からの相対で組み立てる(絶対パスは埋め込まない). + """ + package_root = Path(__file__).resolve().parents[1] + candidates: list[Path] = [] + bundle_root = _bundle_root() + if bundle_root is not None: + candidates.append(bundle_root / dir_name / file_name) # PyInstaller 同梱 + candidates += [ + package_root / dir_name / file_name, # 将来パッケージに同梱した場合 + package_root.parents[1] / dir_name / file_name, # リポジトリ直下 + ] + return candidates + + +def _first_existing(candidates: Sequence[Path]) -> Path | None: + """候補のうち最初に実在するファイルを返す(無ければ None).""" + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + def default_training_data_path() -> Path: """同梱学習データ `data/data.csv` のパスを返す(絶対パスは埋め込まない). 環境変数 `TIASJUDGE_DATA_CSV` があればそれを優先する(配布形態が変わった場合の逃げ道). + PyInstaller の onefile exe では展開先(`sys._MEIPASS`)配下の `data/data.csv` を使う. + 実在するものが無い場合は最後の候補(リポジトリ直下)を返す. """ override = os.environ.get(TRAINING_DATA_ENV_VAR) if override: return Path(override) - package_root = Path(__file__).resolve().parents[1] - candidates = ( - package_root / "data" / "data.csv", # 将来パッケージに同梱した場合 - package_root.parents[1] / "data" / "data.csv", # リポジトリ直下の data/ - ) - for candidate in candidates: - if candidate.is_file(): - return candidate - return candidates[-1] + candidates = _resource_candidates(TRAINING_DATA_DIR_NAME, TRAINING_DATA_FILE_NAME) + found = _first_existing(candidates) + return found if found is not None else candidates[-1] + + +def default_icon_path() -> Path | None: + """アプリのアイコン(`packaging/TIASjudge.ico`)のパスを返す(無ければ None). + + PyInstaller の onefile exe では展開先(`sys._MEIPASS`)配下を,通常実行時は + リポジトリ直下の `packaging/` を見る(絶対パスは埋め込まない). + """ + return _first_existing(_resource_candidates(ICON_DIR_NAME, ICON_FILE_NAME)) + + +def load_app_icon() -> QIcon | None: + """ウィンドウ・タスクバーに表示するアイコンを読み込む(読めなければ None). + + アイコンは装飾であり,見つからなくても起動を妨げない(警告ログのみ残す). + """ + path = default_icon_path() + if path is None: + logger.warning("アイコンファイルが見つかりません: %s", ICON_FILE_NAME) + return None + icon = QIcon(str(path)) + if icon.isNull(): + logger.warning("アイコンを読み込めません: %s", path) + return None + return icon + + +def apply_app_icon(app: QApplication) -> QIcon | None: + """アプリ全体(すべてのウィンドウ・タスクバー)にアイコンを適用する. + + Args: + app: 対象の `QApplication`. + + Returns: + 適用したアイコン(読み込めなかった場合は None). + """ + icon = load_app_icon() + if icon is not None: + app.setWindowIcon(icon) + return icon def run_diagnosis( @@ -468,6 +555,11 @@ self.result_presenter: ResultPresenter | None = None self.setWindowTitle("TIASjudge - 舌色診断") + # 旧実装(MFC の `SetIcon`)と同じアイコンをタイトルバー・タスクバーに出す. + # 結果画面は本画面を親に持つため,指定しなければこのアイコンを引き継ぐ + icon = load_app_icon() + if icon is not None: + self.setWindowIcon(icon) self._build_ui() self._refresh_image() self._update_controls() diff --git a/tests/gui/test_main_window_logic.py b/tests/gui/test_main_window_logic.py index 6922e70..2207674 100644 --- a/tests/gui/test_main_window_logic.py +++ b/tests/gui/test_main_window_logic.py @@ -17,6 +17,7 @@ from __future__ import annotations +import sys from pathlib import Path import cv2 @@ -31,6 +32,8 @@ CROP_HEIGHT, CROP_WIDTH, DISPLAY_DIVISOR, + ICON_DIR_NAME, + ICON_FILE_NAME, IMAGE_EXTENSION, MATRIX_EXTENSION, MESSAGE_CLICK_REQUIRED, @@ -43,11 +46,15 @@ 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, ) @@ -420,6 +427,222 @@ 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「処理パイプライン」の結合) --- diff --git a/tests/test_main.py b/tests/test_main.py index 97852f7..ad15eeb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -25,12 +25,14 @@ PREVIEW_FAILED_TOP_LABELS, PREVIEW_IMAGE_RGB, PREVIEW_IMAGE_SIZE, + PREVIEW_LAB, PREVIEW_PERCENTAGES, PREVIEW_TOP_LABELS, _build_parser, _preview_image, _preview_result, _run_preview, + _verify_bundled_data, main, ) from tiasjudge.core.csv_io import EVALUATION_COLUMN_COUNT @@ -387,3 +389,114 @@ app = TestRunPreview.FakeApp([object()]) assert _run_preview(app, failed=False, english=english, check=True) == 0 # type: ignore[arg-type] assert fake_result_window["language"] is expected_language + + +# --- 同梱データの動作確認(`--verify-data`.PLAN_02 ステップ 8「配布」) --- + + +class TestVerifyDataOption: + """`--verify-data`: 画面を出さずに同梱学習データの読込と診断を 1 回だけ行う. + + 配布 exe(PyInstaller onefile)が `data/data.csv` を同梱できているか, + OpenCV の SVM が動くかを画面なしで確認するためのオプション(ENV_04「配布用ビルド」). + """ + + def test_flag_defaults_to_false(self) -> None: + assert _build_parser().parse_args([]).verify_data is False + + def test_flag_is_parsed(self) -> None: + assert _build_parser().parse_args(["--verify-data"]).verify_data is True + + def test_help_lists_the_option(self) -> None: + assert "--verify-data" in _build_parser().format_help() + + def test_returns_zero_with_the_bundled_data(self) -> None: + assert _verify_bundled_data() == 0 + + def test_reports_the_diagnosis_of_the_bundled_data( + self, data_csv_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + """診断は同梱データ + 既定の L*a*b* で行い,結果をログに出す(SPEC_02 の 5 色).""" + from tiasjudge.core.color import Lab + from tiasjudge.core.csv_io import load_training_data + from tiasjudge.core.svm import diagnose + + with caplog.at_level("INFO"): + assert _verify_bundled_data() == 0 + + expected = diagnose(Lab(*PREVIEW_LAB), load_training_data(data_csv_path)) + messages = "\n".join(record.getMessage() for record in caplog.records) + assert str(expected.percentages) in messages + + def test_uses_the_default_training_data_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + """学習データのパスは GUI と同じ解決(frozen 対応)を使う.""" + import tiasjudge.gui.main_window as main_window + + called: list[bool] = [] + original = main_window.default_training_data_path + + def record() -> Path: + called.append(True) + return original() + + monkeypatch.setattr(main_window, "default_training_data_path", record) + assert _verify_bundled_data() == 0 + assert called == [True] + + def test_returns_one_when_the_data_is_missing( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """学習データを読めない場合は終了コード 1(黙って成功しない).""" + from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR + + monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(tmp_path / "missing.csv")) + assert _verify_bundled_data() == 1 + + def test_returns_one_for_a_malformed_csv( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """形式不正な CSV でも例外を投げず終了コード 1 を返す.""" + from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR + + broken = tmp_path / "broken.csv" + broken.write_text("L,a,b\n1,2\n", encoding="utf-8") + monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(broken)) + assert _verify_bundled_data() == 1 + + def test_main_returns_zero_without_building_any_window( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """`--verify-data` は画面を作らない(メイン画面・結果画面とも構築しない).""" + import tiasjudge.gui.main_window as main_window + import tiasjudge.gui.result_window as result_window + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("--verify-data では画面を作ってはならない") + + monkeypatch.setattr(main_window, "MainWindow", fail) + monkeypatch.setattr(result_window, "ResultWindow", fail) + assert main(["--verify-data"]) == 0 + + def test_main_returns_one_when_the_data_is_missing( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR + + monkeypatch.setenv(TRAINING_DATA_ENV_VAR, str(tmp_path / "missing.csv")) + assert main(["--verify-data"]) == 1 + + def test_module_invocation_exits_successfully( + self, repo_root: Path, headless_env: dict[str, str] + ) -> None: + """`python -m tiasjudge --verify-data` が正常終了する(ENV_04 の記載どおり).""" + result = subprocess.run( + [sys.executable, "-m", "tiasjudge", "--verify-data"], + cwd=repo_root, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=headless_env, + timeout=120, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..82249c9 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,201 @@ +"""配布用ビルド設定の検証(PLAN_02 ステップ 8「配布」・ENV_04「配布用ビルド」). + +ステップ 8 の完了条件は「単体 exe で起動・診断できる」であり,ENV_04 に記載された +ビルド手順(`uv run pyinstaller TIASjudge.spec --noconfirm` → `dist/TIASjudge.exe`)が +成立するための設定を検証する. + +※ exe の実ビルドは数分かかるためテストしない(人間が ENV_04 の手順で確認する). +ここでは spec・エントリスクリプト・同梱リソースの静的な整合性のみを対象とする. +※ 絶対パスのハードコード禁止(ENV_02「環境再現性のルール」・coding-style.md)も検証する. +""" + +from __future__ import annotations + +import ast +import re +import subprocess +import tomllib +from pathlib import Path + +import pytest + +SPEC_FILE_NAME = "TIASjudge.spec" +ENTRY_SCRIPT = "packaging/entry.py" +ICON_FILE = "packaging/TIASjudge.ico" + +#: Windows / POSIX の絶対パスらしき記述(環境依存の埋め込みを検出する) +ABSOLUTE_PATH_PATTERN = re.compile(r"[A-Za-z]:[\\/]|(? str: + return (repo_root / SPEC_FILE_NAME).read_text(encoding="utf-8") + + +@pytest.fixture(scope="session") +def entry_text(repo_root: Path) -> str: + return (repo_root / ENTRY_SCRIPT).read_text(encoding="utf-8") + + +@pytest.fixture(scope="session") +def pyproject(repo_root: Path) -> dict: + return tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8")) + + +@pytest.fixture(scope="session") +def env04_text(repo_root: Path) -> str: + return (repo_root / "docs/02_ENV/ENV_04_開発コマンド.md").read_text(encoding="utf-8") + + +class TestPackagingFiles: + """配布に必要なファイルが揃っていること(ENV_04「配布用ビルド」).""" + + @pytest.mark.parametrize("relative_path", [SPEC_FILE_NAME, ENTRY_SCRIPT, ICON_FILE]) + def test_file_exists(self, repo_root: Path, relative_path: str) -> None: + assert (repo_root / relative_path).is_file() + + @pytest.mark.parametrize("relative_path", [SPEC_FILE_NAME, ENTRY_SCRIPT, ICON_FILE]) + def test_file_is_not_git_ignored(self, repo_root: Path, relative_path: str) -> None: + """ビルドに必要なファイルは Git 管理対象(.gitignore で除外されていない).""" + result = subprocess.run( + ["git", "check-ignore", relative_path], + cwd=repo_root, + capture_output=True, + text=True, + ) + assert result.returncode != 0, f"{relative_path} が .gitignore で除外されている" + + @pytest.mark.parametrize("relative_path", ["build", "dist"]) + def test_build_outputs_are_git_ignored(self, repo_root: Path, relative_path: str) -> None: + """ビルド成果物は Git 管理外(ENV_04「配布用ビルド」).""" + result = subprocess.run( + ["git", "check-ignore", relative_path], + cwd=repo_root, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"{relative_path} が .gitignore で除外されていない" + + def test_icon_is_a_windows_icon_file(self, repo_root: Path) -> None: + """アイコンは実体のある ICO ファイル(先頭 4 バイトが ICO のシグネチャ).""" + header = (repo_root / ICON_FILE).read_bytes()[:4] + assert header == b"\x00\x00\x01\x00" + + +class TestPyInstallerDependency: + """PyInstaller は開発用依存として宣言する(実行時依存にしない).""" + + def test_pyinstaller_is_a_dev_dependency(self, pyproject: dict) -> None: + dev = [ + dependency.split(">")[0].split("<")[0].split("=")[0].strip().lower() + for dependency in pyproject["dependency-groups"]["dev"] + ] + assert "pyinstaller" in dev + + def test_pyinstaller_is_not_a_runtime_dependency(self, pyproject: dict) -> None: + runtime = [ + dependency.split(">")[0].split("<")[0].split("=")[0].strip().lower() + for dependency in pyproject["project"]["dependencies"] + ] + assert "pyinstaller" not in runtime + + +class TestSpecFile: + """`TIASjudge.spec` の内容(ENV_04「配布用ビルド」の記載と一致すること).""" + + def test_is_valid_python_syntax(self, spec_text: str) -> None: + """spec は Python として解釈される(PyInstaller が exec する).""" + assert ast.parse(spec_text) is not None + + def test_entry_script_is_referenced(self, spec_text: str) -> None: + assert "entry.py" in spec_text + + def test_executable_name_is_tiasjudge(self, spec_text: str) -> None: + """成果物は `dist/TIASjudge.exe`.""" + assert re.search(r'name\s*=\s*["\']TIASjudge["\']', spec_text) + + def test_console_is_disabled(self, spec_text: str) -> None: + """GUI アプリのためコンソールを出さない.""" + assert re.search(r"console\s*=\s*False", spec_text) + + def test_is_onefile_build(self, spec_text: str) -> None: + """onefile(`COLLECT` を作らず EXE にバイナリを含める).""" + assert "COLLECT(" not in spec_text + assert "a.binaries" in spec_text + + def test_bundles_training_data(self, spec_text: str) -> None: + """`data/data.csv` を `data` として同梱する(実行時に `_MEIPASS/data` に展開).""" + assert "data.csv" in spec_text + assert re.search(r'["\']data["\']\s*\)', spec_text) + + def test_bundles_icon(self, spec_text: str) -> None: + """アイコンを同梱する(実行時のウィンドウアイコン用).""" + assert "TIASjudge.ico" in spec_text + assert re.search(r'["\']packaging["\']', spec_text) + + def test_sets_the_executable_icon(self, spec_text: str) -> None: + """exe のファイルアイコンにも同じ ico を設定する.""" + assert re.search(r"icon\s*=\s*", spec_text) + + def test_uses_specpath_relative_paths(self, spec_text: str) -> None: + """パスは spec の位置(`SPECPATH`)からの相対で組み立てる.""" + assert "SPECPATH" in spec_text + + def test_has_no_absolute_path(self, spec_text: str) -> None: + """絶対パスのハードコード禁止(ENV_02).""" + assert ABSOLUTE_PATH_PATTERN.search(spec_text) is None + + +class TestEntryScript: + """`packaging/entry.py` は `tiasjudge.__main__.main` を呼ぶ薄いラッパであること.""" + + def test_is_valid_python_syntax(self, entry_text: str) -> None: + assert ast.parse(entry_text) is not None + + def test_imports_main_from_the_package_entry_point(self, entry_text: str) -> None: + """`python -m tiasjudge` と同じ入口を使う(実装が二重化しない).""" + imported = { + (node.module, alias.name) + for node in ast.walk(ast.parse(entry_text)) + if isinstance(node, ast.ImportFrom) + for alias in node.names + } + assert ("tiasjudge.__main__", "main") in imported + + def test_calls_freeze_support(self, entry_text: str) -> None: + """onefile exe が子プロセスを再起動しないようにする(PyInstaller 推奨).""" + assert "freeze_support" in entry_text + + def test_exits_with_the_return_code_of_main(self, entry_text: str) -> None: + """`main()` の戻り値をプロセスの終了コードにする.""" + assert re.search(r"sys\.exit\(\s*main\(\s*\)\s*\)", entry_text) + + def test_runs_only_under_the_main_guard(self, entry_text: str) -> None: + """import しただけでは起動しない(`if __name__ == "__main__"` で囲う).""" + tree = ast.parse(entry_text) + calls_at_module_level = [ + node + for node in tree.body + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) + ] + assert calls_at_module_level == [] + assert '__name__ == "__main__"' in entry_text or "__name__ == '__main__'" in entry_text + + def test_has_no_absolute_path(self, entry_text: str) -> None: + assert ABSOLUTE_PATH_PATTERN.search(entry_text) is None + + +class TestPackagingDocumentation: + """ENV_04 に配布手順が記載されていること(CLAUDE.md「開発コマンド」).""" + + @pytest.mark.parametrize( + "expected", + [ + "pyinstaller TIASjudge.spec", + "dist/TIASjudge.exe", + "--verify-data", + "TIASJUDGE_DATA_CSV", + ], + ) + def test_documents_the_build_commands(self, env04_text: str, expected: str) -> None: + assert expected in env04_text diff --git a/tests/test_packaging_bundle.py b/tests/test_packaging_bundle.py new file mode 100644 index 0000000..2f536be --- /dev/null +++ b/tests/test_packaging_bundle.py @@ -0,0 +1,320 @@ +"""配布 exe の «同梱レイアウト» と «実行時のパス解決» の整合(PLAN_02 ステップ 8). + +`tests/test_packaging.py` が spec・エントリスクリプトの静的な体裁を見るのに対し, +本ファイルは «ビルド時に何処へ同梱されるか(`TIASjudge.spec`)» と +«実行時に何処を見るか(`default_training_data_path` / `default_icon_path`)» が +一致することを検証する.ここがずれると,テストは通るのに exe だけが動かない. + +- ステップ 8 の完了条件は「単体 exe で起動・診断できる」(PLAN_02) +- 学習データは SPEC_02 の診断に必須(`data/data.csv`) +- 絶対パスのハードコード禁止(ENV_02「環境再現性のルール」) + +※ 実ビルド(数分)は行わず,PyInstaller が spec を exec する動作を模して設定値を取り出し, +frozen 実行は `sys.frozen` / `sys._MEIPASS` を差し替えて模す. +""" + +from __future__ import annotations + +import importlib.util +import shutil +import sys +from pathlib import Path +from typing import Any + +import pytest + +SPEC_FILE_NAME = "TIASjudge.spec" +ENTRY_SCRIPT = "packaging/entry.py" + +#: exe 実行時に学習データ・アイコンを探す場所(`_MEIPASS` 配下の相対パス) +BUNDLED_DATA_RELATIVE = Path("data") / "data.csv" +BUNDLED_ICON_RELATIVE = Path("packaging") / "TIASjudge.ico" + + +# --- spec の evaluate(PyInstaller が exec する動作を模す) --- + + +class _Recorder: + """`Analysis` / `PYZ` / `EXE` の呼び出し引数を記録するスタブ.""" + + def __init__(self, name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + self.name = name + self.args = args + self.kwargs = kwargs + # Analysis の戻り値として参照される属性(`a.scripts` 等)を用意する + self.scripts = f"{name}.scripts" + self.pure = f"{name}.pure" + self.binaries = f"{name}.binaries" + self.datas = f"{name}.datas" + + +@pytest.fixture(scope="module") +def evaluated_spec(repo_root: Path) -> dict[str, _Recorder]: + """`TIASjudge.spec` を PyInstaller と同じように exec し,各呼び出しを記録して返す.""" + calls: dict[str, _Recorder] = {} + + def make(name: str): + def factory(*args: Any, **kwargs: Any) -> _Recorder: + recorder = _Recorder(name, args, kwargs) + calls[name] = recorder + return recorder + + return factory + + namespace: dict[str, Any] = { + "SPECPATH": str(repo_root), + "DISTPATH": str(repo_root / "dist"), + "workpath": str(repo_root / "build"), + "Analysis": make("Analysis"), + "PYZ": make("PYZ"), + "EXE": make("EXE"), + "COLLECT": make("COLLECT"), + "BUNDLE": make("BUNDLE"), + "__file__": str(repo_root / SPEC_FILE_NAME), + } + source = (repo_root / SPEC_FILE_NAME).read_text(encoding="utf-8") + exec(compile(source, SPEC_FILE_NAME, "exec"), namespace) # noqa: S102 - spec は Python + return calls + + +def _analysis_kwarg(evaluated_spec: dict[str, _Recorder], key: str) -> Any: + analysis = evaluated_spec["Analysis"] + return analysis.kwargs.get(key) + + +class TestSpecEvaluates: + """spec が PyInstaller の実行手順(Analysis → PYZ → EXE)どおりに評価できること.""" + + def test_calls_analysis_pyz_and_exe(self, evaluated_spec: dict[str, _Recorder]) -> None: + assert set(evaluated_spec) == {"Analysis", "PYZ", "EXE"} + + def test_entry_script_exists(self, evaluated_spec: dict[str, _Recorder]) -> None: + """エントリスクリプトは実在するファイルを指す(ビルドが失敗しない).""" + scripts = evaluated_spec["Analysis"].args[0] + assert [Path(script).name for script in scripts] == ["entry.py"] + assert all(Path(script).is_file() for script in scripts) + + def test_search_path_includes_the_source_root( + self, evaluated_spec: dict[str, _Recorder], repo_root: Path + ) -> None: + """`src` レイアウトのため `pathex` に `src/` が必要(無いと `tiasjudge` を辿れない).""" + pathex = [Path(entry).resolve() for entry in _analysis_kwarg(evaluated_spec, "pathex")] + assert (repo_root / "src").resolve() in pathex + + def test_every_referenced_path_is_inside_the_repository( + self, evaluated_spec: dict[str, _Recorder], repo_root: Path + ) -> None: + """パスは `SPECPATH` 相対で組む(環境依存の絶対パスを埋め込まない.ENV_02).""" + referenced = [Path(script) for script in evaluated_spec["Analysis"].args[0]] + referenced += [Path(entry) for entry in _analysis_kwarg(evaluated_spec, "pathex")] + referenced += [Path(source) for source, _ in _analysis_kwarg(evaluated_spec, "datas")] + referenced.append(Path(evaluated_spec["EXE"].kwargs["icon"])) + for path in referenced: + assert path.resolve().is_relative_to(repo_root.resolve()), path + + +@pytest.fixture(scope="module") +def bundled(evaluated_spec: dict[str, _Recorder]) -> dict[Path, Path]: + """`{展開後の相対パス: 元ファイル}` を返す.""" + mapping: dict[Path, Path] = {} + for source, destination in _analysis_kwarg(evaluated_spec, "datas"): + source_path = Path(source) + mapping[Path(destination) / source_path.name] = source_path + return mapping + + +class TestBundledResources: + """同梱先(`datas` の展開先)が実行時の探索先と一致すること.""" + + def test_sources_exist(self, bundled: dict[Path, Path]) -> None: + for source in bundled.values(): + assert source.is_file(), source + + def test_training_data_is_bundled_where_the_app_looks_for_it( + self, bundled: dict[Path, Path], data_csv_path: Path + ) -> None: + """`_MEIPASS/data/data.csv`(`default_training_data_path` の探索先)に展開される.""" + assert BUNDLED_DATA_RELATIVE in bundled + assert bundled[BUNDLED_DATA_RELATIVE].resolve() == data_csv_path.resolve() + + def test_icon_is_bundled_where_the_app_looks_for_it(self, bundled: dict[Path, Path]) -> None: + """`_MEIPASS/packaging/TIASjudge.ico`(`default_icon_path` の探索先)に展開される.""" + from tiasjudge.gui.main_window import ICON_DIR_NAME, ICON_FILE_NAME + + assert Path(ICON_DIR_NAME) / ICON_FILE_NAME == BUNDLED_ICON_RELATIVE + assert BUNDLED_ICON_RELATIVE in bundled + + def test_the_executable_icon_is_the_bundled_icon( + self, evaluated_spec: dict[str, _Recorder], bundled: dict[Path, Path] + ) -> None: + """exe のファイルアイコンとウィンドウアイコンは同一ファイル(旧実装と同じ 1 つ).""" + exe_icon = Path(evaluated_spec["EXE"].kwargs["icon"]).resolve() + assert exe_icon == bundled[BUNDLED_ICON_RELATIVE].resolve() + + +class TestExecutableOptions: + """ENV_04「配布用ビルド」に記載した成果物の形(onefile・コンソール非表示).""" + + def test_is_named_tiasjudge(self, evaluated_spec: dict[str, _Recorder]) -> None: + assert evaluated_spec["EXE"].kwargs["name"] == "TIASjudge" + + def test_console_is_disabled(self, evaluated_spec: dict[str, _Recorder]) -> None: + assert evaluated_spec["EXE"].kwargs["console"] is False + + def test_is_onefile(self, evaluated_spec: dict[str, _Recorder]) -> None: + """binaries・datas を EXE に渡す(`COLLECT` を作らない)= 単体 exe.""" + exe_arguments = evaluated_spec["EXE"].args + assert "Analysis.binaries" in exe_arguments + assert "Analysis.datas" in exe_arguments + assert "COLLECT" not in evaluated_spec + + def test_does_not_exclude_the_gui_toolkit(self, evaluated_spec: dict[str, _Recorder]) -> None: + """PySide6・OpenCV・NumPy を除外していない(除外すると exe が起動しない).""" + excludes = {name.lower() for name in _analysis_kwarg(evaluated_spec, "excludes")} + assert not ({"pyside6", "cv2", "opencv-python", "numpy"} & excludes) + + +# --- エントリスクリプト(`packaging/entry.py`) --- + + +def _load_entry_module(repo_root: Path) -> Any: + """`packaging/entry.py` を通常の import と同じように読み込む.""" + spec = importlib.util.spec_from_file_location( + "tiasjudge_entry_under_test", repo_root / ENTRY_SCRIPT + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestEntryScriptBehaviour: + """エントリは `python -m tiasjudge` と同じ入口を呼ぶこと(実装の二重化を防ぐ).""" + + def test_main_is_the_package_entry_point(self, repo_root: Path) -> None: + from tiasjudge.__main__ import main + + assert _load_entry_module(repo_root).main is main + + def test_import_does_not_start_the_application( + self, monkeypatch: pytest.MonkeyPatch, repo_root: Path + ) -> None: + """import しただけでは起動しない(`__main__` ガードの中でのみ実行する).""" + import tiasjudge.__main__ as entry_point + + def fail() -> int: + raise AssertionError("import 時にアプリを起動してはならない") + + monkeypatch.setattr(entry_point, "main", fail) + _load_entry_module(repo_root) + + def test_running_as_a_script_exits_with_the_return_code_of_main( + self, monkeypatch: pytest.MonkeyPatch, repo_root: Path + ) -> None: + """スクリプトとして実行すると `main()` の戻り値がプロセスの終了コードになる.""" + import tiasjudge.__main__ as entry_point + + monkeypatch.setattr(entry_point, "main", lambda: 7) + source = (repo_root / ENTRY_SCRIPT).read_text(encoding="utf-8") + namespace: dict[str, Any] = { + "__name__": "__main__", + "__file__": str(repo_root / ENTRY_SCRIPT), + } + with pytest.raises(SystemExit) as exit_info: + exec(compile(source, ENTRY_SCRIPT, "exec"), namespace) # noqa: S102 + assert exit_info.value.code == 7 + + +# --- frozen(exe)実行時のふるまい --- + + +def _freeze(monkeypatch: pytest.MonkeyPatch, meipass: Path) -> None: + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(meipass), raising=False) + + +class TestFrozenRuntime: + """exe と同じ配置(`_MEIPASS` 配下)だけで起動・診断できること(ステップ 8 完了条件).""" + + @pytest.fixture(autouse=True) + def _clear_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + from tiasjudge.gui.main_window import TRAINING_DATA_ENV_VAR + + monkeypatch.delenv(TRAINING_DATA_ENV_VAR, raising=False) + + @pytest.fixture + def bundle(self, tmp_path: Path, repo_root: Path, data_csv_path: Path) -> Path: + """spec の `datas` どおりに展開された `_MEIPASS` を作る.""" + root = tmp_path / "meipass" + (root / BUNDLED_DATA_RELATIVE).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(data_csv_path, root / BUNDLED_DATA_RELATIVE) + (root / BUNDLED_ICON_RELATIVE).parent.mkdir(parents=True, exist_ok=True) + shutil.copy(repo_root / BUNDLED_ICON_RELATIVE, root / BUNDLED_ICON_RELATIVE) + return root + + def test_resolves_both_resources_from_the_bundle( + self, monkeypatch: pytest.MonkeyPatch, bundle: Path + ) -> None: + from tiasjudge.gui.main_window import default_icon_path, default_training_data_path + + _freeze(monkeypatch, bundle) + assert default_training_data_path() == bundle / BUNDLED_DATA_RELATIVE + assert default_icon_path() == bundle / BUNDLED_ICON_RELATIVE + + def test_diagnosis_works_with_the_bundled_data_only( + self, monkeypatch: pytest.MonkeyPatch, bundle: Path + ) -> None: + """同梱データだけで診断が成立する(「単体 exe で起動・診断できる」の中身).""" + from tiasjudge.__main__ import _verify_bundled_data + + _freeze(monkeypatch, bundle) + assert _verify_bundled_data() == 0 + + def test_reports_failure_when_the_bundle_lacks_the_data( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """同梱漏れは終了コード 1 で分かる(`--verify-data` の用途). + + ※ 非 frozen 用のフォールバック(リポジトリ直下)に拾われないよう, + パッケージの探索起点も `_MEIPASS` 配下へ差し替える. + """ + import tiasjudge.gui.main_window as main_window + from tiasjudge.__main__ import _verify_bundled_data + + empty_bundle = tmp_path / "meipass_empty" + empty_bundle.mkdir() + _freeze(monkeypatch, empty_bundle) + monkeypatch.setattr( + main_window, "default_training_data_path", lambda: empty_bundle / BUNDLED_DATA_RELATIVE + ) + assert _verify_bundled_data() == 1 + + +# --- `--verify-data`(配布 exe の動作確認オプション) --- + + +class TestVerifyDataIsGuiFree: + """`--verify-data` は画面も GUI ツールキットも要らないこと(ENV_04 の記載).""" + + def test_does_not_import_pyside6(self, monkeypatch: pytest.MonkeyPatch) -> None: + """PySide6 が import できない環境でも成功する(QApplication を作らない).""" + from tiasjudge.__main__ import main + + monkeypatch.setitem(sys.modules, "PySide6.QtWidgets", None) + assert main(["--verify-data"]) == 0 + + def test_wins_over_the_preview_option(self, monkeypatch: pytest.MonkeyPatch) -> None: + """`--preview-result` と併用しても画面は開かない(データ確認が優先).""" + import tiasjudge.__main__ as entry_point + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("--verify-data では画面を開いてはならない") + + monkeypatch.setattr(entry_point, "_run_preview", fail) + assert entry_point.main(["--verify-data", "--preview-result"]) == 0 + + def test_returns_zero_with_the_check_option(self) -> None: + """`--check` と併用しても «データ確認して 0» の挙動は変わらない.""" + from tiasjudge.__main__ import main + + assert main(["--verify-data", "--check"]) == 0 diff --git a/uv.lock b/uv.lock index a76bf88..12bfba3 100644 --- a/uv.lock +++ b/uv.lock @@ -3,6 +3,15 @@ requires-python = "==3.12.*" [[package]] +name = "altgraph" +version = "0.17.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, +] + +[[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } @@ -21,6 +30,18 @@ ] [[package]] +name = "macholib" +version = "1.16.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, +] + +[[package]] name = "numpy" version = "2.5.1" source = { registry = "https://pypi.org/simple" } @@ -68,6 +89,15 @@ ] [[package]] +name = "pefile" +version = "2024.8.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, +] + +[[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } @@ -86,6 +116,46 @@ ] [[package]] +name = "pyinstaller" +version = "6.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/2b/836d9def811c02522e0921d8b8cdf0c16b0545a216e97e71041758057859/pyinstaller-6.22.2.tar.gz", hash = "sha256:89b65a3ad07d9dd5832253e37bc45f31872d10d7f9d5c9fd0fdd6088a83829dd", size = 4092631, upload-time = "2026-08-17T20:53:22.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/39/08cd53632276de70426e7c273820277a48253fee397b4048301ec03c3566/pyinstaller-6.22.2-py3-none-macosx_10_13_universal2.whl", hash = "sha256:ebd1b1ca932d7cf25d7366ce691aaf79a5ff9425811ed7328b5116e4471b6d6d", size = 1062734, upload-time = "2026-08-17T20:52:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/74/22/2d865896782cbb41e2388c7314207c17a98acdcc1b8e5eef668873505c9f/pyinstaller-6.22.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f5ccb847451df4207bce18bf53a57b124c9bb4e7e4bad08c5ecc627bcf00b28c", size = 755697, upload-time = "2026-08-17T20:52:21.676Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/6c11e4d5a76e716ea68da1946ab706a4bdf6d8e68b0e1dea314366d046a0/pyinstaller-6.22.2-py3-none-manylinux2014_i686.whl", hash = "sha256:becb47ad78272bede87acf2ed830d7545a8f65ab11d06a68fe2b99ba1afaac6d", size = 768870, upload-time = "2026-08-17T20:52:25.511Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e2/dbfea6a58b68acf644f7193b11d570476d6ffaed57381b6d1dd9e898a971/pyinstaller-6.22.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:06d7b3827a8049db4a2d47e3ec4ae2f69a1041577d8833b8f169745cde573ab4", size = 767445, upload-time = "2026-08-17T20:52:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/f24a21af2f87ce1df4e07fdad95eec65ef9f284267a7c1e5eeea40f49aa4/pyinstaller-6.22.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:7bee432404eca5dc3ef37c36811c266561b03988f6d3e70ab0fa5352dd5de9e4", size = 762113, upload-time = "2026-08-17T20:52:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/07/b304ff3f5f8333778065e3658b604b0e108cd934b920f3fbab825a0dd5b7/pyinstaller-6.22.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9622686ecc5d5fa492fe6cde29d47df9dd41138cff8177be9f901ca3260f2096", size = 762173, upload-time = "2026-08-17T20:52:38.477Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9b/0af69d93dfad2e3d590a5de5828d5bcd903747c0224bd9560ab476904dfe/pyinstaller-6.22.2-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:0260eaad6be3f6fbc1affffe6dc7b8e5b636dbca51b463224daba971610b6fc1", size = 761674, upload-time = "2026-08-17T20:52:42.646Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4e/28b6094dcbd1e1bbb868daf4f8752999a20c1020ab64569232be42af2be8/pyinstaller-6.22.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8c2c4b14caad38c1f3df8e9bf5276fc265e27fe8b4180cab4a37864288427bc0", size = 761053, upload-time = "2026-08-17T20:52:46.594Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2c/f91b63fd01422111ac04e3b9bf61c039da5a3292acb4fe5248905f0be688/pyinstaller-6.22.2-py3-none-win32.whl", hash = "sha256:9a078877caa3920558a3242d0a7019fe5b825cff4b65ed380c7d1e1bd200ddd9", size = 1344474, upload-time = "2026-08-17T20:52:53.108Z" }, + { url = "https://files.pythonhosted.org/packages/3f/53/8ba1d0f6159b490f700eac6161a4be5f0d4672608a6dae9fd73679f183ee/pyinstaller-6.22.2-py3-none-win_amd64.whl", hash = "sha256:9b990fa6bbe143572f06644a984ad0d7aa2e2ccc6929d4916031343a5888e9a7", size = 1405725, upload-time = "2026-08-17T20:52:59.667Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/9a3062cc34c939f694d4262c4754681f6ffe853c21c124209ad2d08b8e84/pyinstaller-6.22.2-py3-none-win_arm64.whl", hash = "sha256:afb6f9a95d19b6dcd3a7decc40d9adb6ba9c4f8802ddd6c972dfb552953f384e", size = 1353806, upload-time = "2026-08-17T20:53:06.222Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2026.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, +] + +[[package]] name = "pyside6" version = "6.11.1" source = { registry = "https://pypi.org/simple" } @@ -150,6 +220,15 @@ ] [[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] name = "ruff" version = "0.16.1" source = { registry = "https://pypi.org/simple" } @@ -175,6 +254,15 @@ ] [[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] name = "shiboken6" version = "6.11.1" source = { registry = "https://pypi.org/simple" } @@ -198,6 +286,7 @@ [package.dev-dependencies] dev = [ + { name = "pyinstaller" }, { name = "pytest" }, { name = "ruff" }, ] @@ -211,6 +300,7 @@ [package.metadata.requires-dev] dev = [ + { name = "pyinstaller", specifier = ">=6.22.2" }, { name = "pytest", specifier = ">=8.0" }, { name = "ruff", specifier = ">=0.6" }, ]