"""runtime/llvm_init.py の仕様ベーステスト.
仕様出典:
- docs/06_TEST/TEST_04_シミュレーション速度最適化履歴.md — Speed-1 セクション
- Phase 1 実装サマリー (ensure_drjit_libllvm_path / log_variant_status の公開 API 仕様)
テスト対象:
ensure_drjit_libllvm_path(extra_candidates) -> str | None
log_variant_status(logger, requested_variant, actual_variant) -> None
テスト方針:
- mitsuba は一切 import しない(llvm_init.py 自体が mitsuba 非依存)
- ファイル存在チェックは monkeypatch で Path.is_file をモックして軽量化
- ログ検証は pytest の caplog フィクスチャを使用
- os.environ の副作用はテスト後にクリーンアップする (monkeypatch が自動復元)
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from sm_tias_lightsim.runtime.llvm_init import (
_iter_default_candidates,
ensure_drjit_libllvm_path,
log_variant_status,
)
# ---------------------------------------------------------------------------
# ヘルパー
# ---------------------------------------------------------------------------
_FAKE_PATH = r"C:\fake\path\LLVM-C.dll"
_ANOTHER_PATH = r"C:\another\LLVM-C.dll"
# ---------------------------------------------------------------------------
# A. ensure_drjit_libllvm_path の仕様テスト
# ---------------------------------------------------------------------------
class TestEnsureDrjitLibllvmPath:
"""ensure_drjit_libllvm_path の仕様を検証するテスト群."""
# A1: DRJIT_LIBLLVM_PATH が既設定なら上書きしない
def test_already_set_returns_existing_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A1: DRJIT_LIBLLVM_PATH が既設定ならその値をそのまま返し os.environ を変更しないこと.
仕様:
既に os.environ['DRJIT_LIBLLVM_PATH'] が設定されている場合はそれを尊重して
何もしない(戻り値はその既存値).
"""
monkeypatch.setenv("DRJIT_LIBLLVM_PATH", _FAKE_PATH)
result = ensure_drjit_libllvm_path()
assert result == _FAKE_PATH
def test_already_set_does_not_overwrite_environ(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A1: 既設定の場合、候補が存在していても DRJIT_LIBLLVM_PATH は変更されないこと.
仕様: 既に設定されている場合は何もしない(上書きしない).
"""
monkeypatch.setenv("DRJIT_LIBLLVM_PATH", _FAKE_PATH)
# 候補パスが is_file=True を返すようモックしても既存値を尊重すること
with patch.object(Path, "is_file", return_value=True):
result = ensure_drjit_libllvm_path(extra_candidates=[_ANOTHER_PATH])
assert result == _FAKE_PATH
assert os.environ.get("DRJIT_LIBLLVM_PATH") == _FAKE_PATH
# A2: DRJIT_LIBLLVM_PATH 未設定でパスが見つかれば os.environ に設定して戻す
def test_unset_finds_first_candidate_and_sets_environ(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A2: DRJIT_LIBLLVM_PATH 未設定で最初の候補が存在すれば os.environ に設定して戻すこと.
仕様:
DRJIT_LIBLLVM_PATH 未設定で、Windows 標準パスのいずれかが存在すれば
最初に見つかったものを os.environ に設定して戻す.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
# _iter_default_candidates の代わりに固定リストを返すようモック
fake_candidate = r"C:\Program Files\LLVM\bin\LLVM-C.dll"
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[fake_candidate],
):
# fake_candidate だけ is_file=True にする
def is_file_side_effect(self_: Path) -> bool:
return str(self_) == fake_candidate
with patch.object(Path, "is_file", is_file_side_effect):
result = ensure_drjit_libllvm_path()
assert result == fake_candidate
assert os.environ.get("DRJIT_LIBLLVM_PATH") == fake_candidate
def test_unset_returns_first_found_not_second(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A2: 複数候補がある場合、最初に見つかったものが設定されること.
仕様: 最初に見つかったものを os.environ に設定して戻す(順序が保証されること).
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
first_candidate = r"C:\first\LLVM-C.dll"
second_candidate = r"C:\second\LLVM-C.dll"
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[first_candidate, second_candidate],
):
# 両方とも is_file=True だが最初が優先される
with patch.object(Path, "is_file", return_value=True):
result = ensure_drjit_libllvm_path()
assert result == first_candidate
assert os.environ.get("DRJIT_LIBLLVM_PATH") == first_candidate
# A3: LLVM_DIR 環境変数が設定されていれば $LLVM_DIR/bin/LLVM-C.dll を試す
def test_llvm_dir_env_var_is_used_as_candidate(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A3: LLVM_DIR 環境変数が設定されていれば $LLVM_DIR/bin/LLVM-C.dll が候補に含まれること.
仕様:
環境変数 LLVM_DIR が設定されていれば $LLVM_DIR/bin/LLVM-C.dll を試す
(標準パスより優先 or 順番は仕様通り).
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
monkeypatch.setenv("LLVM_DIR", r"C:\custom_llvm")
# _iter_default_candidates を実際に呼び出し、LLVM_DIR 由来の候補が含まれることを確認
candidates = _iter_default_candidates()
# Windows 環境では LLVM_DIR/bin/LLVM-C.dll が先頭に追加されること
if os.name == "nt":
assert r"C:\custom_llvm\bin\LLVM-C.dll" in candidates
else:
assert any("custom_llvm" in c for c in candidates)
def test_llvm_dir_candidate_is_searched_before_standard_paths(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A3: LLVM_DIR 由来の候補は標準パスより前に試される(先頭に挿入される)こと.
仕様: 候補パスの順序 - LLVM_DIR → Windows 標準パス → extra_candidates.
"""
if os.name != "nt":
pytest.skip("Windows 専用テスト")
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
monkeypatch.setenv("LLVM_DIR", r"C:\custom_llvm")
candidates = _iter_default_candidates()
# 先頭が LLVM_DIR 由来であること
assert candidates[0] == r"C:\custom_llvm\bin\LLVM-C.dll"
# A4: extra_candidates で追加候補を渡せ、Windows/POSIX 標準パスの後に試す
def test_extra_candidates_are_tried_after_default_candidates(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A4: extra_candidates は標準候補の後に試されること.
仕様: extra_candidates が指定されていれば標準候補で見つからない場合に最後に試す.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
monkeypatch.delenv("LLVM_DIR", raising=False)
extra_path = r"C:\extra\LLVM-C.dll"
# 標準候補は全て存在しない、extra_candidates のみ存在する
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[],
):
with patch.object(Path, "is_file", return_value=True):
result = ensure_drjit_libllvm_path(extra_candidates=[extra_path])
assert result == extra_path
assert os.environ.get("DRJIT_LIBLLVM_PATH") == extra_path
def test_extra_candidates_none_is_accepted(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A4: extra_candidates=None でもエラーにならないこと(None はデフォルト).
仕様: extra_candidates: list[str] | None = None
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[],
):
result = ensure_drjit_libllvm_path(extra_candidates=None)
assert result is None
def test_extra_candidates_searched_after_standard_paths_order(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A4: 標準候補が見つかれば extra_candidates は試されないこと(先に標準候補が発見).
仕様: 標準候補で見つからない場合に最後に試す.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
standard_path = r"C:\standard\LLVM-C.dll"
extra_path = r"C:\extra\LLVM-C.dll"
tried_paths: list[str] = []
def is_file_tracker(self_: Path) -> bool:
tried_paths.append(str(self_))
return str(self_) == standard_path
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[standard_path],
):
with patch.object(Path, "is_file", is_file_tracker):
result = ensure_drjit_libllvm_path(extra_candidates=[extra_path])
assert result == standard_path
# extra_path は試されないこと
assert extra_path not in tried_paths
# A5: すべての候補が不在なら None を返し os.environ も変更しない
def test_no_candidates_found_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A5: すべての候補が不在なら None を返すこと.
仕様: 何も見つからなければ None.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[],
):
with patch.object(Path, "is_file", return_value=False):
result = ensure_drjit_libllvm_path()
assert result is None
def test_no_candidates_found_does_not_set_environ(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A5: すべての候補が不在のとき os.environ['DRJIT_LIBLLVM_PATH'] が設定されないこと.
仕様: 何も見つからなければ os.environ も変更しない.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[r"C:\nonexistent\LLVM-C.dll"],
):
with patch.object(Path, "is_file", return_value=False):
ensure_drjit_libllvm_path()
assert "DRJIT_LIBLLVM_PATH" not in os.environ
# A6: 副作用は os.environ['DRJIT_LIBLLVM_PATH'] への代入のみ
def test_no_mitsuba_import_on_call(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A6: ensure_drjit_libllvm_path は mitsuba を import しないこと.
仕様: mitsuba import しない・他の環境変数を触らない.
llvm_init.py は 'mitsuba' を import しない設計である.
"""
import sys
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
# mitsuba が sys.modules に入っていないことを前提に、
# 呼び出し後も新たに import されていないことを確認する
before_modules = set(sys.modules.keys())
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[],
):
ensure_drjit_libllvm_path()
after_modules = set(sys.modules.keys())
new_modules = after_modules - before_modules
# mitsuba 関連のモジュールが新たに import されていないこと
mitsuba_new = [m for m in new_modules if "mitsuba" in m or "drjit" in m]
assert not mitsuba_new, (
f"ensure_drjit_libllvm_path が mitsuba/drjit を import した: {mitsuba_new}"
)
def test_only_drjit_libllvm_path_env_var_is_touched(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A6: 設定されるのは DRJIT_LIBLLVM_PATH のみであること(他の環境変数は変更しない).
仕様: 副作用は os.environ['DRJIT_LIBLLVM_PATH'] への代入のみ.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
# 現在の環境変数スナップショット(DRJIT_LIBLLVM_PATH を除く)
before = {k: v for k, v in os.environ.items() if k != "DRJIT_LIBLLVM_PATH"}
fake_candidate = r"C:\fake\LLVM-C.dll"
with patch(
"sm_tias_lightsim.runtime.llvm_init._iter_default_candidates",
return_value=[fake_candidate],
):
with patch.object(Path, "is_file", return_value=True):
ensure_drjit_libllvm_path()
# DRJIT_LIBLLVM_PATH 以外が変化していないこと
after = {k: v for k, v in os.environ.items() if k != "DRJIT_LIBLLVM_PATH"}
assert before == after, "DRJIT_LIBLLVM_PATH 以外の環境変数が変更された"
# ---------------------------------------------------------------------------
# B. log_variant_status の仕様テスト
# ---------------------------------------------------------------------------
class TestLogVariantStatus:
"""log_variant_status の仕様を検証するテスト群."""
# B1: actual_variant = 'llvm_ad_rgb' で INFO レベルに variant 名と DRJIT_LIBLLVM_PATH 値を含む
def test_llvm_variant_emits_info_with_variant_name(
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""B1: actual_variant='llvm_ad_rgb' のとき INFO ログに variant 名が含まれること.
仕様: llvm_* variant で INFO レベルに variant 名と DRJIT_LIBLLVM_PATH 値を含むログが出る.
"""
monkeypatch.setenv("DRJIT_LIBLLVM_PATH", r"C:\Program Files\LLVM\bin\LLVM-C.dll")
logger = logging.getLogger("test.log_variant_status_b1_name")
with caplog.at_level(logging.INFO, logger="test.log_variant_status_b1_name"):
log_variant_status(logger, "llvm_ad_rgb", "llvm_ad_rgb")
assert any("llvm_ad_rgb" in r.message for r in caplog.records), (
"INFO ログに variant 名 'llvm_ad_rgb' が含まれること"
)
def test_llvm_variant_emits_info_with_drjit_path(
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""B1: actual_variant='llvm_ad_rgb' のとき INFO ログに DRJIT_LIBLLVM_PATH 値が含まれること.
仕様: DRJIT_LIBLLVM_PATH 値を含むメッセージが出る.
"""
dll_path = r"C:\Program Files\LLVM\bin\LLVM-C.dll"
monkeypatch.setenv("DRJIT_LIBLLVM_PATH", dll_path)
logger = logging.getLogger("test.log_variant_status_b1_path")
with caplog.at_level(logging.INFO, logger="test.log_variant_status_b1_path"):
log_variant_status(logger, "llvm_ad_rgb", "llvm_ad_rgb")
info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
assert any(dll_path in msg for msg in info_messages), (
f"INFO ログに DRJIT_LIBLLVM_PATH 値 '{dll_path}' が含まれること"
)
def test_llvm_variant_emits_info_level(
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""B1: actual_variant='llvm_ad_rgb' のとき INFO レベルのレコードが出ること."""
monkeypatch.setenv("DRJIT_LIBLLVM_PATH", r"C:\fake\LLVM-C.dll")
logger = logging.getLogger("test.log_variant_status_b1_level")
with caplog.at_level(logging.INFO, logger="test.log_variant_status_b1_level"):
log_variant_status(logger, "llvm_ad_rgb", "llvm_ad_rgb")
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
assert info_records, "INFO レベルのレコードが少なくとも 1 件出ること"
# B2: actual_variant = 'cuda_ad_rgb' で INFO に CUDA 経路メッセージ
def test_cuda_variant_emits_info_message(self, caplog: pytest.LogCaptureFixture) -> None:
"""B2: actual_variant='cuda_ad_rgb' のとき INFO ログに CUDA 関連の情報が含まれること.
仕様: cuda_* で INFO に CUDA 経路メッセージ.
"""
logger = logging.getLogger("test.log_variant_status_b2")
with caplog.at_level(logging.INFO, logger="test.log_variant_status_b2"):
log_variant_status(logger, "cuda_ad_rgb", "cuda_ad_rgb")
info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
assert info_messages, "INFO メッセージが少なくとも 1 件出ること"
# CUDA 関連のキーワードが含まれること
assert any(
"cuda_ad_rgb" in msg or "cuda" in msg.lower() or "CUDA" in msg for msg in info_messages
), f"CUDA に関するメッセージが INFO ログに含まれること: {info_messages}"
def test_cuda_variant_emits_info_not_warning(self, caplog: pytest.LogCaptureFixture) -> None:
"""B2: actual_variant='cuda_ad_rgb' のとき WARNING は出ないこと.
仕様: cuda_* は正常経路なので WARNING なし.
"""
logger = logging.getLogger("test.log_variant_status_b2_no_warn")
with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b2_no_warn"):
log_variant_status(logger, "cuda_ad_rgb", "cuda_ad_rgb")
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"CUDA 経路では WARNING が出ないこと: {messages}"
# B3: actual_variant = 'scalar_rgb' で INFO に scalar 経路メッセージ
def test_scalar_variant_emits_info_message(self, caplog: pytest.LogCaptureFixture) -> None:
"""B3: actual_variant='scalar_rgb' のとき INFO ログに scalar 関連のメッセージが含まれること.
仕様: scalar_* で INFO に scalar 経路メッセージ.
"""
logger = logging.getLogger("test.log_variant_status_b3")
with caplog.at_level(logging.INFO, logger="test.log_variant_status_b3"):
log_variant_status(logger, "scalar_rgb", "scalar_rgb")
info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
assert info_messages, "INFO メッセージが少なくとも 1 件出ること"
# scalar 関連のキーワードが含まれること
assert any("scalar_rgb" in msg or "scalar" in msg.lower() for msg in info_messages), (
f"scalar に関するメッセージが INFO ログに含まれること: {info_messages}"
)
def test_scalar_variant_no_warning_when_requested_also_scalar(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""B3: requested_variant='scalar_rgb', actual='scalar_rgb' のとき WARNING は出ないこと.
仕様: 要求通りの variant が得られた場合は WARNING なし.
"""
logger = logging.getLogger("test.log_variant_status_b3_no_warn")
with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b3_no_warn"):
log_variant_status(logger, "scalar_rgb", "scalar_rgb")
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"scalar 要求 → scalar 実際のとき WARNING は出ないこと: {messages}"
)
# B4: requested='llvm_ad_rgb', actual='scalar_rgb' → WARNING が追加される
def test_llvm_requested_but_scalar_actual_emits_warning(
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""B4: requested='llvm_ad_rgb', actual='scalar_rgb' のとき WARNING が出ること.
仕様:
要求 llvm_* なのに actual scalar_* なら WARNING を追加出力(乖離を明示).
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
logger = logging.getLogger("test.log_variant_status_b4_warn")
with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b4_warn"):
log_variant_status(logger, "llvm_ad_rgb", "scalar_rgb")
warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert warning_records, "llvm_* 要求 → scalar_* 実際のとき WARNING が出ること"
def test_llvm_requested_but_scalar_actual_warning_contains_variant_names(
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""B4: WARNING メッセージに要求 variant と実 variant の両方の名前が含まれること.
仕様: 要求と実 variant の乖離を明示する.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
logger = logging.getLogger("test.log_variant_status_b4_content")
with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b4_content"):
log_variant_status(logger, "llvm_ad_rgb", "scalar_rgb")
warning_messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
assert any("llvm_ad_rgb" in msg for msg in warning_messages), (
f"WARNING に要求 variant 名が含まれること: {warning_messages}"
)
assert any("scalar_rgb" in msg for msg in warning_messages), (
f"WARNING に実 variant 名が含まれること: {warning_messages}"
)
def test_llvm_requested_but_scalar_actual_also_emits_info(
self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
"""B4: WARNING に加えて INFO も出ること(B3 の scalar INFO + B4 の WARNING).
仕様: actual='scalar_rgb' なので INFO に scalar 経路メッセージも出る.
"""
monkeypatch.delenv("DRJIT_LIBLLVM_PATH", raising=False)
logger = logging.getLogger("test.log_variant_status_b4_also_info")
with caplog.at_level(logging.DEBUG, logger="test.log_variant_status_b4_also_info"):
log_variant_status(logger, "llvm_ad_rgb", "scalar_rgb")
info_records = [r for r in caplog.records if r.levelno == logging.INFO]
warning_records = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert info_records, "INFO も出ること"
assert warning_records, "WARNING も出ること"
# B5: ロガーは引数で受け取る(テストで caplog キャプチャ可能であることを確認)
def test_logger_is_received_as_argument(self, caplog: pytest.LogCaptureFixture) -> None:
"""B5: ロガーは引数で受け取り、テストで caplog キャプチャ可能であること.
仕様: ロガーは引数で受け取り,テストで caplog キャプチャ可能.
"""
# 独自のロガー名を使い、そのロガー経由でログが出ることを確認
custom_logger = logging.getLogger("test.custom_logger_b5")
with caplog.at_level(logging.INFO, logger="test.custom_logger_b5"):
log_variant_status(custom_logger, "scalar_rgb", "scalar_rgb")
# このロガー名でキャプチャできていること
records_for_custom = [r for r in caplog.records if r.name == "test.custom_logger_b5"]
assert records_for_custom, "引数で渡したロガーでログが出て caplog にキャプチャされること"
def test_different_loggers_produce_independent_records(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""B5: 異なるロガーを渡すと独立したログレコードが出ること(ロガー分離).
仕様: ロガーは引数で受け取る(固定 logger ではない).
"""
logger_a = logging.getLogger("test.b5_logger_a")
logger_b = logging.getLogger("test.b5_logger_b")
with caplog.at_level(logging.INFO):
log_variant_status(logger_a, "scalar_rgb", "scalar_rgb")
log_variant_status(logger_b, "cuda_ad_rgb", "cuda_ad_rgb")
names = {r.name for r in caplog.records}
assert "test.b5_logger_a" in names
assert "test.b5_logger_b" in names