"""配布 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