Newer
Older
TIASShot / scripts / verify / test_run_selftest.py
"""run_selftest.py のテスト(python -m unittest scripts/verify/test_run_selftest.py).

仕様(ENV_04「セルフテスト」・.claude/verify-profile.md):
  - ランナーがクリーンするのは Debug 出力(TIASshot/ と TIASshot.Tests/ の bin/x64/Debug・obj/x64/Debug)のみ.
    bin/x64/Release 等ほかの構成の出力は消さない
  - --full で packages の退避(改名)自体に失敗した場合は packages を変更せずに終了コード 2 で止まる
実ビルド・実行は行わず,一時ディレクトリ上で検証する.
"""

from __future__ import annotations

import importlib.util
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock

_HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("run_selftest", _HERE / "run_selftest.py")
rs = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(rs)


class CleanDirsTest(unittest.TestCase):
    def test_clean_dirs_are_exactly_debug_x64_outputs_of_both_projects(self):
        rel = sorted(str(p.relative_to(rs.REPO)).replace("\\", "/") for p in rs.CLEAN_DIRS)
        self.assertEqual(rel, sorted([
            "TIASshot/bin/x64/Debug", "TIASshot/obj/x64/Debug",
            "TIASshot.Tests/bin/x64/Debug", "TIASshot.Tests/obj/x64/Debug",
        ]))

    def test_clean_removes_only_debug_outputs(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            keep = [
                root / "TIASshot" / "bin" / "x64" / "Release" / "TIASshot.exe",
                root / "TIASshot" / "obj" / "x64" / "Release" / "a.cache",
                root / "TIASshot.Tests" / "bin" / "x64" / "Release" / "t.dll",
                root / "TIASshot" / "bin" / "Debug" / "anycpu.exe",
                root / "TIASshot" / "Program.cs",
                root / "packages" / "p.nupkg",
            ]
            remove = [root / p.relative_to(rs.REPO) / "x.dll" for p in rs.CLEAN_DIRS]
            for f in keep + remove:
                f.parent.mkdir(parents=True, exist_ok=True)
                f.write_text("x")
            with mock.patch.object(rs, "CLEAN_DIRS", [root / p.relative_to(rs.REPO) for p in rs.CLEAN_DIRS]):
                rs.clean_build_outputs()
            for f in keep:
                self.assertTrue(f.exists(), f"消してはいけない: {f}")
            for f in remove:
                self.assertFalse(f.parent.exists(), f"消すべき: {f.parent}")

    def test_clean_with_missing_dirs_is_noop(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            with mock.patch.object(rs, "CLEAN_DIRS", [root / p.relative_to(rs.REPO) for p in rs.CLEAN_DIRS]):
                rs.clean_build_outputs()   # 例外にならない

    def test_clean_failure_is_precondition_error(self):
        with tempfile.TemporaryDirectory() as tmp:
            d = Path(tmp) / "TIASshot" / "bin" / "x64" / "Debug"
            d.mkdir(parents=True)
            with mock.patch.object(rs, "CLEAN_DIRS", [d]), \
                    mock.patch.object(rs.shutil, "rmtree", side_effect=PermissionError("in use")):
                with self.assertRaises(rs.PreconditionError):
                    rs.clean_build_outputs()


class PackagesRenameFailureTest(unittest.TestCase):
    def setUp(self):
        self._tmp = tempfile.mkdtemp()
        self.root = Path(self._tmp)
        self.packages = self.root / "packages"
        (self.packages / "OpenCvSharp4").mkdir(parents=True)
        (self.packages / "OpenCvSharp4" / "x.nupkg").write_text("x")
        vs = self.root / "vs"
        msbuild = vs / "MSBuild" / "Current" / "Bin" / "MSBuild.exe"
        msbuild.parent.mkdir(parents=True)
        msbuild.write_text("")
        self.vs = vs

    def tearDown(self):
        shutil.rmtree(self._tmp, ignore_errors=True)

    def _run_main(self, argv):
        real_rename = Path.rename

        def failing_rename(self_path, target):
            if Path(self_path).name == "packages":
                raise PermissionError("使用中")
            return real_rename(self_path, target)

        run_logged = mock.Mock(return_value=0)
        with mock.patch.object(rs, "REPO", self.root), \
                mock.patch.object(rs, "SLN", self.root / "TIASshot.sln"), \
                mock.patch.object(rs, "find_vs", return_value=self.vs), \
                mock.patch.object(rs, "find_nuget", return_value="nuget"), \
                mock.patch.object(rs, "run_logged", run_logged), \
                mock.patch.object(rs.subprocess, "run", side_effect=AssertionError("実行してはいけない")), \
                mock.patch.object(Path, "rename", failing_rename), \
                mock.patch.object(sys, "argv", ["run_selftest.py"] + argv), \
                mock.patch("sys.stdout", new_callable=_Sink):
            code = rs.main()
        return code, run_logged

    def test_rename_failure_exits_2_and_keeps_packages(self):
        code, run_logged = self._run_main(["--full", "--no-clean"])
        self.assertEqual(code, rs.EXIT_PRECONDITION)
        self.assertEqual(code, 2)
        self.assertTrue((self.packages / "OpenCvSharp4" / "x.nupkg").exists(), "packages は変更しない")
        self.assertEqual(list(self.root.glob("packages.bak-*")), [], "退避先を作らない")
        run_logged.assert_not_called()   # restore・ビルドに進まない

    def test_rename_failure_summary_is_precondition(self):
        import json
        self._run_main(["--full", "--no-clean"])
        summaries = list((self.root / ".verify" / "selftest").glob("run-*/summary.json"))
        self.assertEqual(len(summaries), 1)
        data = json.loads(summaries[0].read_text(encoding="utf-8"))
        self.assertEqual(data["result"], "PRECONDITION")
        self.assertEqual(data["steps"][-1]["name"], "precondition")


class _Sink:
    """print の出力を捨てる(reconfigure を持たない stdout の代わり)."""

    def write(self, s):
        return len(s)

    def flush(self):
        pass


if __name__ == "__main__":
    unittest.main()