"""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}"