Newer
Older
SmTIAS-LightSim / tests / scene / test_schema_blend_bsdf.py
"""RoughConductorBSDFConfig / BlendBSDFConfig スキーマの仕様ベーステスト.

仕様出典: 実装サマリー §A〜§C
- A: RoughConductorBSDFConfig (Pydantic スキーマ)
- B: BlendBSDFConfig (Pydantic スキーマ)
- C: MaterialConfig Union (既存 DiffuseMaterial との並列・後方互換)
"""

from __future__ import annotations

import copy

import pytest
from pydantic import ValidationError

from sm_tias_lightsim.scene.schema import (
    BlendBSDFConfig,
    DiffuseMaterial,
    RoughConductorBSDFConfig,
    SceneConfig,
    load_scene_yaml,
)

# ---------------------------------------------------------------------------
# A: RoughConductorBSDFConfig
# ---------------------------------------------------------------------------


class TestRoughConductorBSDFConfig:
    """A 系: RoughConductorBSDFConfig のバリデーション仕様."""

    # A1: 正常生成
    def test_valid_construction_with_type_roughconductor(self):
        """type="roughconductor" で正常にインスタンスを生成できること.

        仕様 A1: type: "roughconductor" で正常生成
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3)
        assert cfg.type == "roughconductor"
        assert cfg.alpha == pytest.approx(0.3)

    def test_invalid_type_raises_validation_error(self):
        """type に "diffuse" 等の他値を指定すると ValidationError になること.

        仕様 A1: type: "diffuse" 等の他値で ValidationError
        """
        with pytest.raises(ValidationError):
            RoughConductorBSDFConfig(type="diffuse", alpha=0.3)  # type: ignore[arg-type]

    def test_invalid_type_other_string_raises_validation_error(self):
        """type に "blendbsdf" を指定すると ValidationError になること.

        仕様 A1: type: "blendbsdf" でも ValidationError
        """
        with pytest.raises(ValidationError):
            RoughConductorBSDFConfig(type="blendbsdf", alpha=0.3)  # type: ignore[arg-type]

    # A2: alpha の範囲制約
    def test_alpha_zero_is_valid(self):
        """alpha=0.0(鏡面)が許可されること.

        仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.0)
        assert cfg.alpha == pytest.approx(0.0)

    def test_alpha_one_is_valid(self):
        """alpha=1.0(マット)が許可されること.

        仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=1.0)
        assert cfg.alpha == pytest.approx(1.0)

    def test_alpha_intermediate_is_valid(self):
        """alpha=0.5 が許可されること.

        仕様 A2: 0.0 ≤ alpha ≤ 1.0 のみ許可
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.5)
        assert cfg.alpha == pytest.approx(0.5)

    def test_alpha_negative_raises_validation_error(self):
        """alpha=-0.01(負値)は ValidationError になること.

        仕様 A2: 負値でエラー
        """
        with pytest.raises(ValidationError):
            RoughConductorBSDFConfig(type="roughconductor", alpha=-0.01)

    def test_alpha_exceeds_one_raises_validation_error(self):
        """alpha=1.01(1超)は ValidationError になること.

        仕様 A2: 1超でエラー
        """
        with pytest.raises(ValidationError):
            RoughConductorBSDFConfig(type="roughconductor", alpha=1.01)

    # A3: distribution の許可値
    def test_distribution_ggx_is_valid(self):
        """distribution="ggx" が許可されること.

        仕様 A3: distribution は "ggx" または "beckmann" のみ許可
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="ggx")
        assert cfg.distribution == "ggx"

    def test_distribution_beckmann_is_valid(self):
        """distribution="beckmann" が許可されること.

        仕様 A3: distribution は "ggx" または "beckmann" のみ許可
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="beckmann")
        assert cfg.distribution == "beckmann"

    def test_distribution_default_is_ggx(self):
        """distribution を省略するとデフォルト "ggx" になること.

        仕様 A3: distribution デフォルトは "ggx"
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3)
        assert cfg.distribution == "ggx"

    def test_distribution_invalid_raises_validation_error(self):
        """distribution に未知の値を指定すると ValidationError になること.

        仕様 A3: "ggx" / "beckmann" 以外は許可しない
        """
        with pytest.raises(ValidationError):
            RoughConductorBSDFConfig(type="roughconductor", alpha=0.3, distribution="phong")  # type: ignore[arg-type]

    # A4: frozen (immutable)
    def test_frozen_raises_on_attribute_assignment(self):
        """frozen=True であり属性への直接代入で例外が発生すること.

        仕様 A4: クラスは frozen (immutable)
        """
        cfg = RoughConductorBSDFConfig(type="roughconductor", alpha=0.3)
        with pytest.raises((ValidationError, AttributeError)):
            cfg.alpha = 0.9  # type: ignore[misc]


# ---------------------------------------------------------------------------
# B: BlendBSDFConfig
# ---------------------------------------------------------------------------


class TestBlendBSDFConfig:
    """B 系: BlendBSDFConfig のバリデーション仕様."""

    def _make_diffuse(self, reflectance: float = 0.85) -> DiffuseMaterial:
        return DiffuseMaterial(type="diffuse", reflectance=reflectance)

    def _make_roughconductor(self, alpha: float = 0.3) -> RoughConductorBSDFConfig:
        return RoughConductorBSDFConfig(type="roughconductor", alpha=alpha)

    # B1: 正常生成
    def test_valid_construction_with_type_blendbsdf(self):
        """type="blendbsdf" で正常にインスタンスを生成できること.

        仕様 B1: type: "blendbsdf" で正常生成
        """
        cfg = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.15,
            components=[self._make_diffuse(), self._make_roughconductor()],
        )
        assert cfg.type == "blendbsdf"
        assert cfg.weight == pytest.approx(0.15)

    # B2: weight の範囲制約
    def test_weight_zero_is_valid(self):
        """weight=0.0 が許可されること(全 component[0]).

        仕様 B2: 0.0 ≤ weight ≤ 1.0 のみ許可
        """
        cfg = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.0,
            components=[self._make_diffuse(), self._make_roughconductor()],
        )
        assert cfg.weight == pytest.approx(0.0)

    def test_weight_one_is_valid(self):
        """weight=1.0 が許可されること(全 component[1]).

        仕様 B2: 0.0 ≤ weight ≤ 1.0 のみ許可
        """
        cfg = BlendBSDFConfig(
            type="blendbsdf",
            weight=1.0,
            components=[self._make_diffuse(), self._make_roughconductor()],
        )
        assert cfg.weight == pytest.approx(1.0)

    def test_weight_negative_raises_validation_error(self):
        """weight=-0.01(負値)は ValidationError になること.

        仕様 B2: 負値でエラー
        """
        with pytest.raises(ValidationError):
            BlendBSDFConfig(
                type="blendbsdf",
                weight=-0.01,
                components=[self._make_diffuse(), self._make_roughconductor()],
            )

    def test_weight_exceeds_one_raises_validation_error(self):
        """weight=1.01(1超)は ValidationError になること.

        仕様 B2: 1超でエラー
        """
        with pytest.raises(ValidationError):
            BlendBSDFConfig(
                type="blendbsdf",
                weight=1.01,
                components=[self._make_diffuse(), self._make_roughconductor()],
            )

    # B3: components の長さ制約
    def test_components_exactly_two_required(self):
        """components がちょうど 2 要素の場合に正常生成できること.

        仕様 B3: components はちょうど 2 要素必須
        """
        cfg = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.5,
            components=[self._make_diffuse(), self._make_roughconductor()],
        )
        assert len(cfg.components) == 2

    def test_components_one_element_raises_validation_error(self):
        """components が 1 要素では ValidationError になること.

        仕様 B3: 1 要素は ValidationError
        """
        with pytest.raises(ValidationError):
            BlendBSDFConfig(
                type="blendbsdf",
                weight=0.5,
                components=[self._make_diffuse()],
            )

    def test_components_three_elements_raises_validation_error(self):
        """components が 3 要素以上では ValidationError になること.

        仕様 B3: 3 要素以上は ValidationError
        """
        with pytest.raises(ValidationError):
            BlendBSDFConfig(
                type="blendbsdf",
                weight=0.5,
                components=[
                    self._make_diffuse(),
                    self._make_roughconductor(),
                    self._make_diffuse(),
                ],
            )

    def test_components_empty_raises_validation_error(self):
        """components が空リストでは ValidationError になること.

        仕様 B3: 2 要素必須
        """
        with pytest.raises(ValidationError):
            BlendBSDFConfig(
                type="blendbsdf",
                weight=0.5,
                components=[],
            )

    # B4: discriminated union の分岐
    def test_components_discriminated_by_type_field_diffuse(self):
        """components[0] が type="diffuse" で DiffuseMaterial として識別されること.

        仕様 B4: type フィールドで discriminated union に分岐
        """
        cfg = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.5,
            components=[self._make_diffuse(), self._make_roughconductor()],
        )
        assert isinstance(cfg.components[0], DiffuseMaterial)

    def test_components_discriminated_by_type_field_roughconductor(self):
        """components[1] が type="roughconductor" で RoughConductorBSDFConfig として識別されること.

        仕様 B4: type フィールドで discriminated union に分岐
        """
        cfg = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.5,
            components=[self._make_diffuse(), self._make_roughconductor()],
        )
        assert isinstance(cfg.components[1], RoughConductorBSDFConfig)

    # B5: 再帰的な入れ子
    def test_nested_blend_bsdf_in_components(self):
        """components の 1 つに BlendBSDFConfig をネストして正常生成できること.

        仕様 B5: 再帰: components の 1 つに BlendBSDFConfig をネストして正常生成可能
        """
        inner = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.3,
            components=[self._make_diffuse(0.8), self._make_roughconductor(0.1)],
        )
        outer = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.1,
            components=[inner, self._make_diffuse(0.9)],
        )
        assert isinstance(outer.components[0], BlendBSDFConfig)
        assert outer.components[0].weight == pytest.approx(0.3)

    def test_nested_blend_bsdf_components_accessible(self):
        """ネストした BlendBSDFConfig の components に正常にアクセスできること.

        仕様 B5: 再帰的な入れ子が正常動作
        """
        inner = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.3,
            components=[self._make_diffuse(), self._make_roughconductor(0.5)],
        )
        outer = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.5,
            components=[self._make_diffuse(), inner],
        )
        nested = outer.components[1]
        assert isinstance(nested, BlendBSDFConfig)
        assert isinstance(nested.components[1], RoughConductorBSDFConfig)
        assert nested.components[1].alpha == pytest.approx(0.5)

    # B6: frozen (immutable)
    def test_frozen_raises_on_weight_assignment(self):
        """frozen=True であり weight への直接代入で例外が発生すること.

        仕様 B6: クラスは frozen
        """
        cfg = BlendBSDFConfig(
            type="blendbsdf",
            weight=0.15,
            components=[self._make_diffuse(), self._make_roughconductor()],
        )
        with pytest.raises((ValidationError, AttributeError)):
            cfg.weight = 0.9  # type: ignore[misc]


# ---------------------------------------------------------------------------
# C: MaterialConfig Union(既存との並列・後方互換)
# ---------------------------------------------------------------------------

# scene_baseline.yaml の最小相当 dict(diffuse のみ)
_MINIMAL_DIFFUSE_DICT = {
    "name": "test_scene",
    "geometry": {
        "meshes": [
            {
                "id": "housing",
                "path": "configs/meshes/housing.stl",
                "unit": "mm",
                "material": "mat_a",
            }
        ]
    },
    "materials": {
        "mat_a": {"type": "diffuse", "reflectance": 0.5},
    },
    "lights": [
        {
            "id": "led",
            "type": "point",
            "position": [0.0, 0.0, 50.0],
            "intensity": 1.0,
            "profile": "isotropic",
        }
    ],
    "sensor": {
        "type": "perspective",
        "position": [0.0, 0.0, 100.0],
        "target": [0.0, 0.0, 0.0],
        "up": [0.0, 1.0, 0.0],
        "fov_deg": 60.0,
        "film": {"width": 512, "height": 512},
    },
    "render": {"variant": "llvm_ad_rgb", "integrator": "path", "max_depth": 8, "spp": 256},
}

# scene_baseline_blend.yaml 相当の最小 dict(blendbsdf を含む)
_BLEND_DICT = {
    "name": "test_blend",
    "geometry": {
        "meshes": [
            {
                "id": "housing",
                "path": "configs/meshes/housing.stl",
                "unit": "mm",
                "material": "printer_white",
            }
        ]
    },
    "materials": {
        "printer_white": {
            "type": "blendbsdf",
            "weight": 0.15,
            "components": [
                {"type": "diffuse", "reflectance": [0.85, 0.85, 0.85]},
                {"type": "roughconductor", "alpha": 0.3, "distribution": "ggx"},
            ],
        }
    },
    "lights": [
        {
            "id": "led",
            "type": "area",
            "shape": "rectangle",
            "center": [0.0, 0.0, 10.0],
            "size": [2.8, 3.5],
            "direction": [0.0, 0.0, -1.0],
            "radiance": 100.0,
        }
    ],
    "sensor": {
        "type": "orthographic",
        "position": [0.0, 0.0, -50.0],
        "target": [0.0, 0.0, 0.0],
        "up": [0.0, 1.0, 0.0],
        "ortho_scale": [50.0, 52.0],
        "film": {"width": 512, "height": 512},
    },
    "render": {"variant": "llvm_ad_rgb", "integrator": "path", "max_depth": 8, "spp": 256},
}


class TestMaterialConfigUnion:
    """C 系: MaterialConfig Union の仕様(後方互換・新型式)."""

    def test_backward_compatible_diffuse_only_yaml_parses(self):
        """既存の type: diffuse のみの YAML が新スキーマで読めること(後方互換).

        仕様 C1: 既存の type: diffuse のみの YAML が新スキーマで読める (後方互換)
        """
        d = copy.deepcopy(_MINIMAL_DIFFUSE_DICT)
        config = SceneConfig.model_validate(d)
        assert config.name == "test_scene"
        mat = config.materials["mat_a"]
        assert isinstance(mat, DiffuseMaterial)

    def test_diffuse_material_type_preserved_in_backward_compat(self):
        """後方互換で読み込んだ DiffuseMaterial の type が "diffuse" であること.

        仕様 C1: 既存マテリアルが正しい型で取得できること
        """
        d = copy.deepcopy(_MINIMAL_DIFFUSE_DICT)
        config = SceneConfig.model_validate(d)
        mat = config.materials["mat_a"]
        assert mat.type == "diffuse"

    def test_blend_bsdf_yaml_parses_successfully(self):
        """blendbsdf を含む YAML が SceneConfig.model_validate で読み込み成功すること.

        仕様 C3: scene_baseline_blend.yaml 相当を読み込んで BlendBSDFConfig インスタンスが得られる
        """
        d = copy.deepcopy(_BLEND_DICT)
        config = SceneConfig.model_validate(d)
        assert config.name == "test_blend"

    def test_printer_white_is_blend_bsdf_instance(self):
        """printer_white が BlendBSDFConfig インスタンスとして取得できること.

        仕様 C3: printer_white が BlendBSDFConfig インスタンスとして得られる
        """
        d = copy.deepcopy(_BLEND_DICT)
        config = SceneConfig.model_validate(d)
        mat = config.materials["printer_white"]
        assert isinstance(mat, BlendBSDFConfig)

    def test_printer_white_weight_preserved(self):
        """printer_white.weight が正しく読み込まれること.

        仕様 C3: scene_baseline_blend.yaml の weight=0.15 が保持される
        """
        d = copy.deepcopy(_BLEND_DICT)
        config = SceneConfig.model_validate(d)
        mat = config.materials["printer_white"]
        assert isinstance(mat, BlendBSDFConfig)
        assert mat.weight == pytest.approx(0.15)

    def test_printer_white_components_component0_is_diffuse(self):
        """printer_white.components[0] が DiffuseMaterial であること.

        仕様 C3: components[0] = diffuse (reflectance=0.85)
        """
        d = copy.deepcopy(_BLEND_DICT)
        config = SceneConfig.model_validate(d)
        mat = config.materials["printer_white"]
        assert isinstance(mat, BlendBSDFConfig)
        assert isinstance(mat.components[0], DiffuseMaterial)

    def test_printer_white_components_component1_is_roughconductor(self):
        """printer_white.components[1] が RoughConductorBSDFConfig であること.

        仕様 C3: components[1] = roughconductor (alpha=0.3)
        """
        d = copy.deepcopy(_BLEND_DICT)
        config = SceneConfig.model_validate(d)
        mat = config.materials["printer_white"]
        assert isinstance(mat, BlendBSDFConfig)
        assert isinstance(mat.components[1], RoughConductorBSDFConfig)
        assert mat.components[1].alpha == pytest.approx(0.3)

    def test_scene_baseline_yaml_loads_without_blend(self, tmp_path):
        """scene_baseline.yaml 相当(diffuse のみ)が SceneConfig で正常読み込みできること.

        仕様 C2: scene_baseline.yaml を SceneConfig.model_validate で読み込み成功
        """
        yaml_text = """\
name: test_baseline_compat
geometry:
  meshes:
    - id: housing
      path: some/path.stl
      unit: mm
      material: printer_white
materials:
  printer_white:
    type: diffuse
    reflectance: 0.85
lights:
  - id: led_0
    type: area
    shape: rectangle
    center: [0.0, 0.0, 10.0]
    size: [2.8, 3.5]
    direction: [0.0, 0.0, -1.0]
    radiance: 100.0
sensor:
  type: orthographic
  position: [0.0, 0.0, -50.0]
  target: [0.0, 0.0, 0.0]
  up: [0.0, 1.0, 0.0]
  ortho_scale: [50.0, 52.0]
  film:
    width: 512
    height: 512
render:
  variant: llvm_ad_rgb
  integrator: path
  max_depth: 8
  spp: 256
"""
        yaml_file = tmp_path / "scene_compat.yaml"
        yaml_file.write_text(yaml_text, encoding="utf-8")
        config = load_scene_yaml(yaml_file)
        assert config.name == "test_baseline_compat"
        mat = config.materials["printer_white"]
        assert isinstance(mat, DiffuseMaterial)