Newer
Older
SmTIAS-LightSim / tests / render / test_runner_jit_init.py
@rintoHasegawa rintoHasegawa on 28 May 21 KB [add] Speed-1 LLVM JIT 自動検出と診断ログ強化 — runtime/llvm_init.py を新規実装.ensure_drjit_libllvm_path() で DRJIT_LIBLLVM_PATH 未設定時に Windows 標準 4 候補 (C:\Program Files\LLVM 等) + POSIX glob + LLVM_DIR + extra_candidates を順次探索して os.environ 設定(既設定なら上書きしない).log_variant_status() で llvm_/cuda_/scalar_ 経路を INFO 明示,要求 llvm_* で実 scalar_* なら WARNING 追加.render/runner.py で variant=llvm_* 時に自動検出を呼び ImportError 時は原因明示ログ→ re-raise,mitsuba を関数内遅延 import 化.visualize/baker.py の _set_variant_once に LLVM 自動探索分岐を追加し direct/path 両方で log_variant_status を呼ぶ.cli/render.py で import 順を制御し sensors.py/lights.py のモジュールレベル mitsuba import 前に検出を確実に通す.docs/02_ENV/ENV_02 に LLVM インストール手順・DRJIT_LIBLLVM_PATH 説明・動作確認コマンドを追記.新規テスト 51 件 (runtime/test_llvm_init.py 25, render/test_runner_jit_init.py 14, visualize/test_baker_jit_init.py 8, cli/test_render_jit_init.py 4) で仕様 A1-E1 完全カバー,DRJIT_LIBLLVM_PATH 設定下で 606 件全通過.速度は不変(目的は再発防止+診断容易化).数値同等性は MC 内部シードで SHA256 異なるが平均相対誤差 7.23e-05 で統計同等性パス.これで環境変数消失によるサイレントクラッシュが再発しない土台が整備された.TEST_04 Speed-1 セクションを実施結果で更新.CLAUDE.md 開発進捗に Speed-1 完了追記+実装済みモジュール一覧に runtime/llvm_init.py 追加
"""render/runner.py の JIT 初期化統合テスト (Speed-1 仕様).

仕様出典:
- docs/06_TEST/TEST_04_シミュレーション速度最適化履歴.md — Speed-1 セクション
- Phase 1 実装サマリー (runner.py の自動検出フック仕様)

テスト対象:
    render_scene() 内の JIT 初期化フロー:
    - C1: llvm_* variant のとき ensure_drjit_libllvm_path が呼ばれる
    - C2: scalar_* / cuda_* のとき ensure_drjit_libllvm_path が呼ばれない
    - C3: ImportError 時に診断ログが出て re-raise される
    - C4: set_variant 成功後に log_variant_status が呼ばれる
    - C5: 既に variant が設定済みなら警告を出して再設定しない

テスト方針:
    - mi.set_variant / mi.render / build_scene をモックして Mitsuba 実行を回避(軽量テスト)
    - caplog で診断ログを検証
    - monkeypatch で runner モジュールの _current_variant グローバルをリセットする
"""

from __future__ import annotations

import logging
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pytest

import sm_tias_lightsim.render.runner as runner_module
from sm_tias_lightsim.scene.schema import SceneConfig

# ---------------------------------------------------------------------------
# フィクスチャ
# ---------------------------------------------------------------------------


def _make_scene_config(variant: str = "llvm_ad_rgb") -> SceneConfig:
    """最小限の SceneConfig を作成する(実 STL 不要 — runner 自体をモックするため)."""
    data = {
        "name": "test_jit",
        "geometry": {
            "meshes": [
                {
                    "id": "dummy_mesh",
                    "path": "dummy.stl",
                    "unit": "mm",
                    "material": "white",
                }
            ]
        },
        "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
        "lights": [
            {
                "id": "led",
                "type": "point",
                "position": [0.0, 0.0, 100.0],
                "intensity": 1.0,
                "profile": "lambertian",
            }
        ],
        "sensor": {
            "type": "perspective",
            "position": [0.0, 0.0, 200.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "fov_deg": 60.0,
            "film": {"width": 4, "height": 4},
        },
        "render": {
            "variant": variant,
            "integrator": "path",
            "max_depth": 2,
            "spp": 1,
        },
    }
    return SceneConfig.model_validate(data)


@pytest.fixture(autouse=True)
def reset_runner_variant():
    """各テスト前後に runner._current_variant をリセットする.

    runner.py の _current_variant はモジュールレベルのグローバル変数で、
    プロセス内で 1 度しか設定できない仕様のため、テスト間の干渉を防ぐためリセットする.
    """
    original = runner_module._current_variant
    runner_module._current_variant = None
    yield
    runner_module._current_variant = original


# ---------------------------------------------------------------------------
# C1: llvm_* variant のとき ensure_drjit_libllvm_path が呼ばれる
# ---------------------------------------------------------------------------


class TestLlvmVariantCallsEnsurePath:
    """C1: llvm_* variant のとき ensure_drjit_libllvm_path が呼ばれること."""

    def test_llvm_variant_calls_ensure_drjit_libllvm_path(self) -> None:
        """C1: variant='llvm_ad_rgb' のとき ensure_drjit_libllvm_path が呼ばれること.

        仕様:
            desired_variant.startswith('llvm_') のとき、mi.set_variant の前に
            ensure_drjit_libllvm_path が呼ばれる.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_scene = MagicMock()
        dummy_image = np.zeros((4, 4, 3), dtype=np.float32)

        # 全モックを一括設定
        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, str(Path("/tmp/fake"))),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict(
                "sys.modules",
                {
                    "mitsuba": MagicMock(
                        set_variant=MagicMock(),
                        variant=MagicMock(return_value="llvm_ad_rgb"),
                        load_dict=MagicMock(return_value=dummy_scene),
                        render=MagicMock(
                            return_value=MagicMock(__array__=lambda s, d=None: dummy_image)
                        ),
                    )
                },
            ),
        ):
            # shutil.rmtree も no-op に
            with patch("shutil.rmtree"):
                try:
                    runner_module.render_scene(config, project_root=Path("/tmp"))
                except Exception:
                    pass  # レンダリング後の後処理エラーは無視

        mock_ensure.assert_called_once()

    def test_llvm_variant_ensure_called_before_set_variant(self) -> None:
        """C1: ensure_drjit_libllvm_path は mi.set_variant より先に呼ばれること.

        仕様: mitsuba の import 前に os.environ['DRJIT_LIBLLVM_PATH'] を設定することが必須.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")
        call_order: list[str] = []

        def ensure_side_effect() -> None:
            call_order.append("ensure")

        dummy_mi = MagicMock()

        def set_variant_side_effect(variant: str) -> None:
            call_order.append(f"set_variant:{variant}")

        dummy_mi.set_variant.side_effect = set_variant_side_effect
        dummy_mi.variant.return_value = "llvm_ad_rgb"

        with (
            patch(
                "sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path",
                side_effect=ensure_side_effect,
            ),
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            try:
                runner_module.render_scene(config, project_root=Path("/tmp"))
            except Exception:
                pass

        # ensure が set_variant より前に来ること
        if "ensure" in call_order and any("set_variant" in c for c in call_order):
            ensure_idx = call_order.index("ensure")
            set_variant_idx = next(i for i, c in enumerate(call_order) if "set_variant" in c)
            assert ensure_idx < set_variant_idx, (
                f"ensure_drjit_libllvm_path は mi.set_variant より先に呼ばれること: {call_order}"
            )


# ---------------------------------------------------------------------------
# C2: scalar_* / cuda_* のとき ensure_drjit_libllvm_path が呼ばれない
# ---------------------------------------------------------------------------


class TestNonLlvmVariantSkipsEnsurePath:
    """C2: scalar_* / cuda_* のとき ensure_drjit_libllvm_path が呼ばれないこと."""

    def test_scalar_variant_does_not_call_ensure_drjit_libllvm_path(self) -> None:
        """C2: variant='scalar_rgb' のとき ensure_drjit_libllvm_path が呼ばれないこと.

        仕様:
            scalar_* / cuda_* は LLVM-C.dll 不要なので探索しない.
        """
        config = _make_scene_config(variant="scalar_rgb")

        dummy_mi = MagicMock()
        dummy_mi.variant.return_value = "scalar_rgb"

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            try:
                runner_module.render_scene(config, project_root=Path("/tmp"))
            except Exception:
                pass

        mock_ensure.assert_not_called()

    def test_cuda_variant_does_not_call_ensure_drjit_libllvm_path(self) -> None:
        """C2: variant='cuda_ad_rgb' のとき ensure_drjit_libllvm_path が呼ばれないこと.

        仕様:
            cuda_* は LLVM-C.dll 不要なので探索しない.
        """
        config = _make_scene_config(variant="cuda_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.variant.return_value = "cuda_ad_rgb"

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            try:
                runner_module.render_scene(config, project_root=Path("/tmp"))
            except Exception:
                pass

        mock_ensure.assert_not_called()


# ---------------------------------------------------------------------------
# C3: ImportError 時に診断ログが出て re-raise される
# ---------------------------------------------------------------------------


class TestImportErrorDiagnostics:
    """C3: ImportError 時に診断ログが出て re-raise されること."""

    def test_import_error_with_llvm_message_emits_error_log(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """C3: mi.set_variant が 'LLVM' 含む ImportError を出したとき ERROR ログが出ること.

        仕様:
            mi.set_variant が ImportError を出した場合、原因明示ログを出してから re-raise する.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.set_variant.side_effect = ImportError(
            "jit_init_thread_state(): LLVM backend is inactive"
        )

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
        ):
            with caplog.at_level(logging.ERROR, logger="sm_tias_lightsim.render.runner"):
                with pytest.raises(ImportError):
                    runner_module.render_scene(config, project_root=Path("/tmp"))

        error_records = [r for r in caplog.records if r.levelno >= logging.ERROR]
        assert error_records, "ImportError 時に ERROR レベルのログが出ること"

    def test_import_error_is_reraised(self) -> None:
        """C3: ImportError は診断ログ後に re-raise されること(握りつぶされない).

        仕様:
            ImportError → 原因明示ログ → re-raise.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.set_variant.side_effect = ImportError("LLVM backend inactive")

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
        ):
            with pytest.raises(ImportError):
                runner_module.render_scene(config, project_root=Path("/tmp"))

    def test_import_error_without_llvm_in_message_is_still_reraised(self) -> None:
        """C3: ImportError のメッセージに 'LLVM' を含まない場合も re-raise されること.

        仕様: ImportError は必ず re-raise する(メッセージ内容に依らず).
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.set_variant.side_effect = ImportError("some other import error")

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
        ):
            with pytest.raises(ImportError):
                runner_module.render_scene(config, project_root=Path("/tmp"))

    def test_import_error_log_contains_variant_name(self, caplog: pytest.LogCaptureFixture) -> None:
        """C3: ImportError ログに variant 名が含まれること(診断容易化).

        仕様: 原因明示ログを出す.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.set_variant.side_effect = ImportError("LLVM backend is inactive")

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
        ):
            with caplog.at_level(logging.ERROR, logger="sm_tias_lightsim.render.runner"):
                with pytest.raises(ImportError):
                    runner_module.render_scene(config, project_root=Path("/tmp"))

        error_messages = [r.message for r in caplog.records if r.levelno >= logging.ERROR]
        assert any("llvm_ad_rgb" in msg for msg in error_messages), (
            f"ERROR ログに variant 名が含まれること: {error_messages}"
        )


# ---------------------------------------------------------------------------
# C4: set_variant 成功後に log_variant_status が呼ばれる
# ---------------------------------------------------------------------------


class TestLogVariantStatusCalled:
    """C4: set_variant 成功後に log_variant_status が呼ばれること."""

    def test_log_variant_status_called_after_successful_set_variant(self) -> None:
        """C4: mi.set_variant 成功後に log_variant_status が呼ばれること.

        仕様:
            set_variant 成功後に variant 状態を INFO ログで出力する.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.variant.return_value = "llvm_ad_rgb"

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status") as mock_log,
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            try:
                runner_module.render_scene(config, project_root=Path("/tmp"))
            except Exception:
                pass

        mock_log.assert_called_once()

    def test_log_variant_status_receives_correct_variants(self) -> None:
        """C4: log_variant_status に requested_variant と actual_variant が正しく渡されること.

        仕様: log_variant_status(_logger, desired_variant, mi.variant()) が呼ばれる.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.variant.return_value = "llvm_ad_rgb"

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status") as mock_log,
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            try:
                runner_module.render_scene(config, project_root=Path("/tmp"))
            except Exception:
                pass

        if mock_log.call_args is not None:
            _logger_arg, requested, actual = mock_log.call_args[0]
            assert requested == "llvm_ad_rgb", (
                f"requested_variant が 'llvm_ad_rgb' であること: got {requested}"
            )
            assert actual == "llvm_ad_rgb", (
                f"actual_variant が 'llvm_ad_rgb' であること: got {actual}"
            )

    def test_log_variant_status_not_called_on_import_error(self) -> None:
        """C4: ImportError 時には log_variant_status が呼ばれないこと.

        仕様: set_variant が失敗した場合は log_variant_status に到達しない.
        """
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.set_variant.side_effect = ImportError("LLVM inactive")

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch("sm_tias_lightsim.render.runner.log_variant_status") as mock_log,
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
        ):
            with pytest.raises(ImportError):
                runner_module.render_scene(config, project_root=Path("/tmp"))

        mock_log.assert_not_called()


# ---------------------------------------------------------------------------
# C5: 既に variant が設定済みなら警告を出して再設定しない
# ---------------------------------------------------------------------------


class TestVariantAlreadySetWarning:
    """C5: 既に variant が設定済みなら警告を出して再設定しない(既存挙動の維持)."""

    def test_already_set_different_variant_emits_warning(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """C5: 既に異なる variant が設定済みのとき WARNING が出ること.

        仕様: 既に異なる variant が設定されている場合は警告ログを出して続行する.
        """
        # 事前に異なる variant を設定済みにする
        runner_module._current_variant = "scalar_rgb"
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.variant.return_value = "scalar_rgb"

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"):
                try:
                    runner_module.render_scene(config, project_root=Path("/tmp"))
                except Exception:
                    pass

        warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING]
        assert warning_records, "異なる variant が既設定のとき WARNING が出ること"

    def test_already_set_different_variant_does_not_call_set_variant_again(self) -> None:
        """C5: 既に設定済みのとき mi.set_variant が再呼び出しされないこと.

        仕様: 既に variant が設定済みなら再設定しない.
        """
        runner_module._current_variant = "scalar_rgb"
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.variant.return_value = "scalar_rgb"

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            try:
                runner_module.render_scene(config, project_root=Path("/tmp"))
            except Exception:
                pass

        dummy_mi.set_variant.assert_not_called()

    def test_same_variant_already_set_does_not_emit_warning(
        self, caplog: pytest.LogCaptureFixture
    ) -> None:
        """C5: 同じ variant が既設定のとき WARNING が出ないこと.

        仕様: 同じ variant が既に設定されている場合は何もしない(警告なし).
        """
        runner_module._current_variant = "llvm_ad_rgb"
        config = _make_scene_config(variant="llvm_ad_rgb")

        dummy_mi = MagicMock()
        dummy_mi.variant.return_value = "llvm_ad_rgb"

        with (
            patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
            patch(
                "sm_tias_lightsim.render.runner.build_scene",
                return_value=({"type": "scene"}, "/tmp/fake"),
            ),
            patch("sm_tias_lightsim.render.runner.log_variant_status"),
            patch.dict("sys.modules", {"mitsuba": dummy_mi}),
            patch("shutil.rmtree"),
        ):
            with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"):
                try:
                    runner_module.render_scene(config, project_root=Path("/tmp"))
                except Exception:
                    pass

        warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING]
        messages = [r.message for r in warning_records]
        assert not warning_records, f"同じ variant 設定済みのとき WARNING は出ないこと: {messages}"