"""配布用ビルド設定の検証(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]:[\\/]|(?<![\w.])/(?:home|Users)/")
@pytest.fixture(scope="session")
def spec_text(repo_root: Path) -> 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