"""RoughConductorBSDFConfig / BlendBSDFConfig スキーマの仕様ベーステスト.
仕様出典: 実装サマリー §A〜§C
- A: RoughConductorBSDFConfig (Pydantic スキーマ)
- B: BlendBSDFConfig (Pydantic スキーマ)
- C: MaterialConfig Union (既存 DiffuseMaterial との並列・後方互換)
"""
from __future__ import annotations
import copy
import pytest
import yaml
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)