Newer
Older
SmTIAS-LightSim / tests / scripts / test_sweep_blend_bsdf.py
"""scripts/sweep_blend_bsdf.py の _generate_tmp_yaml 仕様ベーステスト.

仕様出典: 実装サマリー §E
- E1: weight/alpha パラメータが一時 YAML に正しく書き込まれること
- E2: geometry.meshes の STL パスが絶対パスに変換されること
- E3: 一時 YAML が BOM なし UTF-8 で書かれること
- E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式に更新されること

テスト戦略:
- subprocess(sm-render 実行)は含まない._generate_tmp_yaml の純変換ロジックのみをテストする
- 実際の TEMPLATE_YAML(configs/scene_baseline_blend.yaml)を使用する
- 出力先は tmp_path フィクスチャ(実ファイル)を使用する(GUIDE_08 ファイル I/O はモックしない方針)
"""

from __future__ import annotations

import importlib
import sys
from pathlib import Path
from types import ModuleType
from unittest import mock

import pytest
import yaml

# ---------------------------------------------------------------------------
# sweep_blend_bsdf モジュールをインポートするヘルパ
# ---------------------------------------------------------------------------

_ROOT = Path(__file__).resolve().parents[2]
_SCRIPT_PATH = _ROOT / "scripts" / "sweep_blend_bsdf.py"

_TEMPLATE_YAML = _ROOT / "configs" / "scene_baseline_blend.yaml"


def _load_sweep_module() -> ModuleType:
    """sweep_blend_bsdf.py を直接インポートする.

    scripts/ は通常のパッケージではないため importlib.util で直接ロードする.
    モジュールキャッシュに登録して再 import を防ぐ.
    """
    import importlib.util

    spec = importlib.util.spec_from_file_location("sweep_blend_bsdf", _SCRIPT_PATH)
    assert spec is not None, f"スクリプトが見つかりません: {_SCRIPT_PATH}"
    module = importlib.util.module_from_spec(spec)
    # モジュールキャッシュに登録(2回目以降の import を安全にする)
    sys.modules["sweep_blend_bsdf"] = module
    assert spec.loader is not None
    spec.loader.exec_module(module)  # type: ignore[union-attr]
    return module


@pytest.fixture(scope="module")
def sweep_module() -> ModuleType:
    """sweep_blend_bsdf モジュールを返すフィクスチャ(モジュールスコープ)."""
    return _load_sweep_module()


# ---------------------------------------------------------------------------
# テンプレート YAML の存在確認(前提条件チェック)
# ---------------------------------------------------------------------------


def test_template_yaml_exists():
    """configs/scene_baseline_blend.yaml が存在すること(テストの前提条件).

    このファイルがなければ E 系テスト全体が無効になる.
    """
    assert _TEMPLATE_YAML.exists(), (
        f"テンプレート YAML が見つかりません: {_TEMPLATE_YAML}\n"
        "configs/scene_baseline_blend.yaml を生成してからテストを実行してください."
    )


# ---------------------------------------------------------------------------
# E1: weight/alpha が一時 YAML に正しく書き込まれる
# ---------------------------------------------------------------------------


class TestGenerateTmpYamlParameters:
    """E1 系: weight と alpha が一時 YAML の正しいパスに書き込まれること."""

    @pytest.fixture(autouse=True)
    def _patch_tmp_dir(self, tmp_path, sweep_module):
        """TMP_DIR を tmp_path に差し替えて一時 YAML の出力先をテスト用にする."""
        with mock.patch.object(sweep_module, "TMP_DIR", tmp_path):
            yield

    def test_weight_written_to_printer_white(self, sweep_module):
        """生成された YAML の materials.printer_white.weight が入力値 0.15 と一致すること.

        仕様 E1: weight=0.15 が一時 YAML の printer_white.weight に書き込まれる
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.15)

    def test_alpha_written_to_roughconductor_component(self, sweep_module):
        """生成された YAML の components[1].alpha が入力値 0.3 と一致すること.

        仕様 E1: alpha=0.3 が components[1].alpha に書き込まれる
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["materials"]["printer_white"]["components"][1]["alpha"] == pytest.approx(0.3)

    def test_weight_0_05_written_correctly(self, sweep_module):
        """weight=0.05 が正しく書き込まれること(スイープ最小値).

        仕様 E1: スイープグリッドの全値で正しく書き込まれること
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.05, 0.1)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.05)

    def test_alpha_0_6_written_correctly(self, sweep_module):
        """alpha=0.6 が正しく書き込まれること(スイープ最大値).

        仕様 E1: スイープグリッドの全値で正しく書き込まれること
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.6)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["materials"]["printer_white"]["components"][1]["alpha"] == pytest.approx(0.6)

    def test_weight_0_30_written_correctly(self, sweep_module):
        """weight=0.30 が正しく書き込まれること(スイープ最大値).

        仕様 E1: スイープグリッドの全値で正しく書き込まれること
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["materials"]["printer_white"]["weight"] == pytest.approx(0.30)

    def test_component0_type_still_diffuse_after_write(self, sweep_module):
        """components[0].type が "diffuse" のまま保持されること(破壊的書き換えがないこと).

        仕様 E1: alpha 書き換えは components[1] のみに影響する
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["materials"]["printer_white"]["components"][0]["type"] == "diffuse"

    def test_component1_type_still_roughconductor_after_write(self, sweep_module):
        """components[1].type が "roughconductor" のまま保持されること.

        仕様 E1: alpha 書き換えは値のみで type フィールドは変更しない
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["materials"]["printer_white"]["components"][1]["type"] == "roughconductor"


# ---------------------------------------------------------------------------
# E2: geometry.meshes の path が絶対パスに変換されること
# ---------------------------------------------------------------------------


class TestGenerateTmpYamlAbsolutePaths:
    """E2 系: geometry.meshes の STL パスが絶対パスに変換されること."""

    @pytest.fixture(autouse=True)
    def _patch_tmp_dir(self, tmp_path, sweep_module):
        """TMP_DIR を tmp_path に差し替える."""
        with mock.patch.object(sweep_module, "TMP_DIR", tmp_path):
            yield

    def test_all_mesh_paths_are_absolute(self, sweep_module):
        """全メッシュの path が絶対パスになっていること.

        仕様 E2: 一時 YAML の geometry.meshes の各 path が絶対パスに変換されている
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        meshes = data["geometry"]["meshes"]
        assert len(meshes) > 0, "meshes が空のため検証不可"
        for mesh in meshes:
            p = Path(mesh["path"])
            assert p.is_absolute(), f"mesh '{mesh.get('id')}' の path が絶対パスでない: {mesh['path']}"

    def test_absolute_path_resolves_relative_to_template_dir(self, sweep_module):
        """絶対化されたパスがテンプレート YAML の親ディレクトリ(configs/)基準で解決されること.

        仕様 E2: template_base = TEMPLATE_YAML.parent(configs/)を基準に resolve
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        meshes = data["geometry"]["meshes"]
        template_base = _TEMPLATE_YAML.parent  # configs/
        for mesh in meshes:
            p = Path(mesh["path"])
            # 絶対化されたパスは template_base 配下またはそのサブディレクトリにあること
            # (relative path が "meshes/xxx.stl" → configs/meshes/xxx.stl の absolute)
            try:
                p.relative_to(template_base)
            except ValueError:
                pytest.fail(
                    f"mesh '{mesh.get('id')}' の絶対パス '{p}' が template_base '{template_base}' の"
                    "下にありません(パス解決ロジックの不整合)"
                )

    def test_no_mesh_path_is_relative(self, sweep_module):
        """一時 YAML のメッシュパスに相対パスが含まれないこと.

        仕様 E2: 相対パスが残っていないことを確認
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        meshes = data["geometry"]["meshes"]
        for mesh in meshes:
            assert Path(mesh["path"]).is_absolute(), (
                f"mesh '{mesh.get('id')}' の path が相対パス: {mesh['path']}"
            )


# ---------------------------------------------------------------------------
# E3: 一時 YAML が BOM なし UTF-8 で書かれること
# ---------------------------------------------------------------------------


class TestGenerateTmpYamlEncoding:
    """E3 系: 一時 YAML のエンコーディング仕様."""

    @pytest.fixture(autouse=True)
    def _patch_tmp_dir(self, tmp_path, sweep_module):
        """TMP_DIR を tmp_path に差し替える."""
        with mock.patch.object(sweep_module, "TMP_DIR", tmp_path):
            yield

    def test_output_file_has_no_bom(self, sweep_module):
        """一時 YAML ファイルが UTF-8 BOM(EF BB BF)で始まっていないこと.

        仕様 E3: BOM なし UTF-8(Mitsuba YAML パーサは BOM 付きで失敗するため)
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        raw = tmp_yaml.read_bytes()
        # UTF-8 BOM は 0xEF 0xBB 0xBF
        bom = b"\xef\xbb\xbf"
        assert not raw.startswith(bom), (
            "一時 YAML が UTF-8 BOM で始まっています.BOM なし UTF-8 で書き込んでください."
        )

    def test_output_file_is_valid_utf8(self, sweep_module):
        """一時 YAML ファイルが UTF-8 として正常にデコードできること.

        仕様 E3: UTF-8 エンコーディングで書かれていること
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        raw = tmp_yaml.read_bytes()
        # UnicodeDecodeError が発生しなければ OK
        decoded = raw.decode("utf-8")
        assert len(decoded) > 0

    def test_output_file_parseable_as_yaml(self, sweep_module):
        """一時 YAML ファイルが YAML として正常にパースできること.

        仕様 E3: 書き込み後に yaml.safe_load で再読み込みできること(エンコーディング崩れなし)
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert isinstance(data, dict)
        assert "name" in data


# ---------------------------------------------------------------------------
# E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式になること
# ---------------------------------------------------------------------------


class TestGenerateTmpYamlName:
    """E4 系: 一時 YAML の name フィールド仕様."""

    @pytest.fixture(autouse=True)
    def _patch_tmp_dir(self, tmp_path, sweep_module):
        """TMP_DIR を tmp_path に差し替える."""
        with mock.patch.object(sweep_module, "TMP_DIR", tmp_path):
            yield

    def test_name_starts_with_baseline_minitias_blend(self, sweep_module):
        """name が "baseline_minitias_blend_" で始まること.

        仕様 E4: name フィールドが "baseline_minitias_blend_{job_name}" 形式
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        assert data["name"].startswith("baseline_minitias_blend_")

    def test_name_contains_weight_and_alpha(self, sweep_module):
        """name に weight と alpha の値が含まれること.

        仕様 E4: job_name = f"w{weight}_a{alpha}" 形式
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        # job_name は "w0.15_a0.3" 相当
        name = data["name"]
        assert "0.15" in name, f"weight 0.15 が name に含まれない: {name}"
        assert "0.3" in name, f"alpha 0.3 が name に含まれない: {name}"

    def test_name_format_for_weight_0_05_alpha_0_1(self, sweep_module):
        """weight=0.05, alpha=0.1 のとき name が適切な形式であること.

        仕様 E4: スイープグリッドの全組合せで name が正しい形式になる
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.05, 0.1)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        name = data["name"]
        assert name.startswith("baseline_minitias_blend_")
        assert "0.05" in name
        assert "0.1" in name

    def test_name_format_for_weight_0_30_alpha_0_6(self, sweep_module):
        """weight=0.30, alpha=0.6 のとき name が適切な形式であること.

        仕様 E4: スイープグリッドの最大値でも name が正しい形式になる
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.30, 0.6)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        name = data["name"]
        assert name.startswith("baseline_minitias_blend_")
        assert "0.3" in name  # 0.30 → "0.3" が含まれれば OK
        assert "0.6" in name

    def test_name_differs_across_different_params(self, sweep_module):
        """異なるパラメータ組み合わせで生成された name が異なること.

        仕様 E4: name がジョブ識別子を含むため各ジョブで一意になる
        """
        tmp1 = sweep_module._generate_tmp_yaml(0.05, 0.1)
        tmp2 = sweep_module._generate_tmp_yaml(0.30, 0.6)
        with tmp1.open(encoding="utf-8") as f:
            d1 = yaml.safe_load(f)
        with tmp2.open(encoding="utf-8") as f:
            d2 = yaml.safe_load(f)
        assert d1["name"] != d2["name"], "異なるパラメータで同じ name が生成されています"

    def test_original_template_name_is_not_used(self, sweep_module):
        """テンプレートの元の name ("baseline_minitias_blend") がそのまま使われないこと.

        仕様 E4: name フィールドがジョブごとに更新される
        """
        tmp_yaml = sweep_module._generate_tmp_yaml(0.15, 0.3)
        with tmp_yaml.open(encoding="utf-8") as f:
            data = yaml.safe_load(f)
        # テンプレートの元 name は "baseline_minitias_blend"(サフィックスなし)
        assert data["name"] != "baseline_minitias_blend", (
            "name がテンプレートのまま更新されていません"
        )