Newer
Older
SmTIAS-LightSim / tests / runtime / test_llvm_init.py
@rintoHasegawa rintoHasegawa on 28 May 24 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 追加
"""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