"""scene/schema.py の仕様ベーステスト.
仕様出典: docs/04_SPEC/SPEC_02_シーン定義仕様.md
"""
import copy
import pytest
import yaml
from pydantic import ValidationError
from sm_tias_lightsim.scene.schema import (
AreaLight,
DielectricMaterial,
DiffuseMaterial,
DirectionalLight,
PointLight,
SceneConfig,
ThinDielectricMaterial,
)
# ---------------------------------------------------------------------------
# 正常系: 最小構成
# ---------------------------------------------------------------------------
def test_minimal_valid_yaml_parses(valid_minimal_yaml):
"""必須 6 セクションのみで SceneConfig が生成されること.
仕様: name, geometry, materials, lights, sensor, render が必須 (SPEC_02 トップレベル構造)
"""
data = yaml.safe_load(valid_minimal_yaml)
config = SceneConfig.model_validate(data)
assert config.name == "test_scene"
assert len(config.geometry.meshes) == 1
assert len(config.lights) == 1
def test_optional_evaluation_section(valid_minimal_dict):
"""evaluation を省略しても SceneConfig が生成されること.
仕様: evaluation は任意(SPEC_02 evaluation セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d.pop("evaluation", None)
config = SceneConfig.model_validate(d)
assert config.evaluation is None
def test_optional_description(valid_minimal_dict):
"""description を省略しても SceneConfig が生成されること.
仕様: description は任意(SPEC_02 トップレベル構造)
"""
d = copy.deepcopy(valid_minimal_dict)
d.pop("description", None)
config = SceneConfig.model_validate(d)
assert config.description is None
def test_render_defaults(valid_minimal_dict):
"""render セクションを省略しても既定値が設定されること.
仕様: render.variant デフォルト llvm_ad_rgb, spp デフォルト 256 (SPEC_02 render セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d.pop("render", None)
config = SceneConfig.model_validate(d)
assert config.render.variant == "llvm_ad_rgb"
assert config.render.spp == 256
assert config.render.integrator == "path"
assert config.render.max_depth == 8
# ---------------------------------------------------------------------------
# 正常系: マテリアル
# ---------------------------------------------------------------------------
def test_diffuse_material_with_scalar_reflectance(valid_minimal_dict):
"""reflectance をスカラ値で指定できること.
仕様: reflectance スカラ可 (SPEC_02 materials セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["materials"]["mat_diffuse"] = {"type": "diffuse", "reflectance": 0.95}
config = SceneConfig.model_validate(d)
mat = config.materials["mat_diffuse"]
assert isinstance(mat, DiffuseMaterial)
assert mat.reflectance == pytest.approx(0.95)
def test_diffuse_material_with_rgb_reflectance(valid_minimal_dict):
"""reflectance を RGB リストで指定できること.
仕様: reflectance: [0.05, 0.05, 0.05] の RGB 表記 (SPEC_02 materials セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["materials"]["mat_diffuse"] = {"type": "diffuse", "reflectance": [0.05, 0.05, 0.05]}
config = SceneConfig.model_validate(d)
mat = config.materials["mat_diffuse"]
assert isinstance(mat, DiffuseMaterial)
assert mat.reflectance == pytest.approx((0.05, 0.05, 0.05))
def test_dielectric_and_thindielectric_materials(valid_minimal_dict):
"""dielectric / thindielectric の type が正しく識別されること.
仕様: type: dielectric / thindielectric の discriminated union (SPEC_02 materials セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["materials"]["mat_dielectric"] = {
"type": "dielectric",
"int_ior": 1.5,
"ext_ior": 1.0,
}
d["materials"]["mat_thin"] = {
"type": "thindielectric",
"int_ior": 1.49,
"ext_ior": 1.0,
}
d["geometry"]["meshes"].append(
{"id": "mesh2", "path": "m.stl", "unit": "mm", "material": "mat_dielectric"}
)
d["geometry"]["meshes"].append(
{"id": "mesh3", "path": "m.stl", "unit": "mm", "material": "mat_thin"}
)
config = SceneConfig.model_validate(d)
assert isinstance(config.materials["mat_dielectric"], DielectricMaterial)
assert isinstance(config.materials["mat_thin"], ThinDielectricMaterial)
# ---------------------------------------------------------------------------
# 正常系: ライト
# ---------------------------------------------------------------------------
def test_point_light_parses(valid_minimal_dict):
"""point 光源の必須フィールドが正しくパースされること.
仕様: type: point, position, intensity, profile (SPEC_02 lights セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
config = SceneConfig.model_validate(d)
light = config.lights[0]
assert isinstance(light, PointLight)
assert light.id == "led_top"
assert light.type == "point"
assert light.position == pytest.approx((0.0, 0.0, 50.0))
assert light.profile == "lambertian"
def test_area_light_parses(valid_minimal_dict):
"""area 光源の必須フィールドが正しくパースされること.
仕様: type: area, shape, center, size, direction, radiance (SPEC_02 lights セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["lights"] = [
{
"id": "led_area",
"type": "area",
"shape": "rectangle",
"center": [0.0, 0.0, 20.0],
"size": [10.0, 10.0],
"direction": [0.0, 0.0, -1.0],
"radiance": [50.0, 50.0, 50.0],
}
]
config = SceneConfig.model_validate(d)
light = config.lights[0]
assert isinstance(light, AreaLight)
assert light.id == "led_area"
assert light.shape == "rectangle"
assert light.size == pytest.approx((10.0, 10.0))
assert light.radiance == pytest.approx((50.0, 50.0, 50.0))
def test_directional_light_parses(valid_minimal_dict):
"""directional 光源が正しくパースされること.
仕様: type: directional, direction, irradiance (SPEC_02 lights セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["lights"] = [
{
"id": "sun",
"type": "directional",
"direction": [0.0, 0.0, -1.0],
"irradiance": 1.0,
}
]
config = SceneConfig.model_validate(d)
light = config.lights[0]
assert isinstance(light, DirectionalLight)
assert light.id == "sun"
assert light.direction == pytest.approx((0.0, 0.0, -1.0))
# ---------------------------------------------------------------------------
# 正常系: evaluation / zones
# ---------------------------------------------------------------------------
def test_zones_default_boundaries(valid_minimal_dict):
"""boundaries を省略したとき既定値 (0.33, 0.66) が設定されること.
仕様: boundaries デフォルト [0.33, 0.66] (SPEC_02 evaluation セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["evaluation"] = {
"roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100},
"zones": {"enabled": True},
}
config = SceneConfig.model_validate(d)
assert config.evaluation is not None
assert config.evaluation.zones is not None
assert config.evaluation.zones.boundaries == pytest.approx((0.33, 0.66))
# ---------------------------------------------------------------------------
# 異常系: 必須キー欠落
# ---------------------------------------------------------------------------
def test_missing_name_raises(valid_minimal_dict):
"""name が欠落したとき ValidationError が発生すること.
仕様: name 必須 (SPEC_02 トップレベル構造)
"""
d = copy.deepcopy(valid_minimal_dict)
del d["name"]
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_missing_geometry_raises(valid_minimal_dict):
"""geometry が欠落したとき ValidationError が発生すること.
仕様: geometry 必須 (SPEC_02 トップレベル構造)
"""
d = copy.deepcopy(valid_minimal_dict)
del d["geometry"]
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_missing_materials_raises(valid_minimal_dict):
"""materials が欠落したとき ValidationError が発生すること.
仕様: materials 必須 (SPEC_02 トップレベル構造)
"""
d = copy.deepcopy(valid_minimal_dict)
del d["materials"]
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_missing_lights_raises(valid_minimal_dict):
"""lights が欠落したとき ValidationError が発生すること.
仕様: lights 必須 (SPEC_02 トップレベル構造)
"""
d = copy.deepcopy(valid_minimal_dict)
del d["lights"]
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_missing_sensor_raises(valid_minimal_dict):
"""sensor が欠落したとき ValidationError が発生すること.
仕様: sensor 必須 (SPEC_02 トップレベル構造)
"""
d = copy.deepcopy(valid_minimal_dict)
del d["sensor"]
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_empty_lights_list_raises(valid_minimal_dict):
"""lights が空リストのとき ValidationError が発生すること.
仕様: lights は 1 個以上必須 (SPEC_02 lights セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["lights"] = []
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_empty_meshes_list_raises(valid_minimal_dict):
"""geometry.meshes が空リストのとき ValidationError が発生すること.
仕様: meshes は 1 個以上必須(min_length=1)
"""
d = copy.deepcopy(valid_minimal_dict)
d["geometry"]["meshes"] = []
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
# ---------------------------------------------------------------------------
# 異常系: 参照整合性・ID 重複
# ---------------------------------------------------------------------------
def test_dangling_material_reference_raises(valid_minimal_dict):
"""存在しない material を mesh が参照したとき ValidationError が発生し,
エラーメッセージに mesh id と material name が含まれること.
仕様: 参照整合性チェック (SPEC_02 バリデーション節)
"""
d = copy.deepcopy(valid_minimal_dict)
d["geometry"]["meshes"][0]["material"] = "nonexistent_material"
with pytest.raises(ValidationError) as exc_info:
SceneConfig.model_validate(d)
error_str = str(exc_info.value)
assert "housing" in error_str
assert "nonexistent_material" in error_str
def test_duplicate_mesh_id_raises(valid_minimal_dict):
"""同じ mesh.id が 2 つあるとき ValidationError が発生すること.
仕様: ID の重複チェック (SPEC_02 バリデーション節)
"""
d = copy.deepcopy(valid_minimal_dict)
d["geometry"]["meshes"].append(
{
"id": "housing", # 重複
"path": "other.stl",
"unit": "mm",
"material": "mat_diffuse",
}
)
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_duplicate_light_id_raises(valid_minimal_dict):
"""同じ light.id が 2 つあるとき ValidationError が発生すること.
仕様: ID の重複チェック (SPEC_02 バリデーション節)
"""
d = copy.deepcopy(valid_minimal_dict)
d["lights"].append(
{
"id": "led_top", # 重複
"type": "point",
"position": [1.0, 0.0, 50.0],
"intensity": 1.0,
"profile": "isotropic",
}
)
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
# ---------------------------------------------------------------------------
# 異常系: 型・値範囲
# ---------------------------------------------------------------------------
def test_invalid_unit_cm_raises(valid_minimal_dict):
"""unit: cm を指定したとき ValidationError が発生すること.
仕様: unit は mm | m のみ (SPEC_02 geometry セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["geometry"]["meshes"][0]["unit"] = "cm"
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_invalid_material_type_raises(valid_minimal_dict):
"""未対応の material type(mask 等)で ValidationError が発生すること.
仕様: type は diffuse / dielectric / thindielectric の discriminated union (SPEC_02)
注: SPEC_02 では mask が将来拡張として記載されているが,現在はサポート外
"""
d = copy.deepcopy(valid_minimal_dict)
d["materials"]["mat_diffuse"] = {"type": "mask", "opacity": 0.5}
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_invalid_light_type_raises(valid_minimal_dict):
"""未対応の light type(laser 等)で ValidationError が発生すること.
仕様: type は point / area / directional の discriminated union (SPEC_02 lights セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["lights"] = [
{
"id": "laser_1",
"type": "laser",
"position": [0.0, 0.0, 50.0],
"intensity": 1.0,
}
]
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_invalid_sensor_type_raises(valid_minimal_dict):
"""type: fisheye 等のサポート外センサ型で ValidationError が発生すること.
仕様: sensor.type は perspective | orthographic の Literal (schema.py SensorConfig)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["type"] = "fisheye"
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_fov_zero_raises(valid_minimal_dict):
"""fov_deg: 0 のとき ValidationError が発生すること.
仕様: 0 < fov_deg < 180 (SPEC_02 バリデーション節)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["fov_deg"] = 0.0
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_fov_180_raises(valid_minimal_dict):
"""fov_deg: 180 のとき ValidationError が発生すること.
仕様: 0 < fov_deg < 180(端点は含まない)(SPEC_02 バリデーション節)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["fov_deg"] = 180.0
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_zones_invalid_boundaries_raises(valid_minimal_dict):
"""boundaries: [0.66, 0.33](逆順)のとき ValidationError が発生すること.
仕様: 0 < b1 < b2 < 1 (SPEC_02 evaluation/zones セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["evaluation"] = {
"roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100},
"zones": {"enabled": True, "boundaries": [0.66, 0.33]},
}
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_zones_boundaries_out_of_range_raises(valid_minimal_dict):
"""boundaries: [0.0, 1.0](範囲外)のとき ValidationError が発生すること.
仕様: 0 < b1 < b2 < 1(端点 0 と 1 は含まない)(SPEC_02 evaluation/zones セクション)
"""
d = copy.deepcopy(valid_minimal_dict)
d["evaluation"] = {
"roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100},
"zones": {"enabled": True, "boundaries": [0.0, 1.0]},
}
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
# ---------------------------------------------------------------------------
# 異常系: フィールドの細部
# ---------------------------------------------------------------------------
def test_film_dimensions_must_be_positive(valid_minimal_dict):
"""film.width: 0 のとき ValidationError が発生すること.
仕様: width >= 1 (ge=1)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["film"]["width"] = 0
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_max_depth_must_be_positive(valid_minimal_dict):
"""render.max_depth: 0 のとき ValidationError が発生すること.
仕様: max_depth >= 1 (ge=1)
"""
d = copy.deepcopy(valid_minimal_dict)
d["render"]["max_depth"] = 0
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
def test_spp_must_be_positive(valid_minimal_dict):
"""render.spp: 0 のとき ValidationError が発生すること.
仕様: spp >= 1 (ge=1)
"""
d = copy.deepcopy(valid_minimal_dict)
d["render"]["spp"] = 0
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
# ---------------------------------------------------------------------------
# 正常系: SensorConfig orthographic 対応(Step 8 追加)
# ---------------------------------------------------------------------------
def test_sensor_perspective_with_fov_is_valid(valid_minimal_dict):
"""type=perspective + fov_deg=60 のとき検証が通過し,ortho_scale は None で OK なこと.
仕様: type=perspective のとき fov_deg 必須,ortho_scale は任意(schema.py SensorConfig)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["type"] = "perspective"
d["sensor"]["fov_deg"] = 60.0
d["sensor"].pop("ortho_scale", None)
config = SceneConfig.model_validate(d)
assert config.sensor.type == "perspective"
assert config.sensor.fov_deg == pytest.approx(60.0)
assert config.sensor.ortho_scale is None
def test_sensor_orthographic_with_ortho_scale_is_valid(valid_minimal_dict):
"""type=orthographic + ortho_scale=(50, 52) のとき検証が通過し,fov_deg は None で OK なこと.
仕様: type=orthographic のとき ortho_scale 必須,fov_deg は任意(schema.py SensorConfig)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["type"] = "orthographic"
d["sensor"].pop("fov_deg", None)
d["sensor"]["ortho_scale"] = [50.0, 52.0]
config = SceneConfig.model_validate(d)
assert config.sensor.type == "orthographic"
assert config.sensor.fov_deg is None
assert config.sensor.ortho_scale == pytest.approx((50.0, 52.0))
def test_sensor_perspective_without_fov_deg_raises(valid_minimal_dict):
"""type=perspective かつ fov_deg=None のとき ValidationError が発生すること.
仕様: perspective sensor requires fov_deg(schema.py _validate_type_specific_fields)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["type"] = "perspective"
d["sensor"]["fov_deg"] = None
with pytest.raises(ValidationError) as exc_info:
SceneConfig.model_validate(d)
assert "perspective sensor requires fov_deg" in str(exc_info.value)
def test_sensor_orthographic_without_ortho_scale_raises(valid_minimal_dict):
"""type=orthographic かつ ortho_scale=None のとき ValidationError が発生すること.
仕様: orthographic sensor requires ortho_scale(schema.py _validate_type_specific_fields)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["type"] = "orthographic"
d["sensor"].pop("fov_deg", None)
d["sensor"]["ortho_scale"] = None
with pytest.raises(ValidationError) as exc_info:
SceneConfig.model_validate(d)
assert "orthographic sensor requires ortho_scale" in str(exc_info.value)
def test_sensor_perspective_fov_deg_200_raises(valid_minimal_dict):
"""type=perspective かつ fov_deg=200 のとき既存の FOV 範囲チェックで ValidationError が発生すること.
仕様: 0 < fov_deg < 180(後方互換)(schema.py _validate_fov)
"""
d = copy.deepcopy(valid_minimal_dict)
d["sensor"]["type"] = "perspective"
d["sensor"]["fov_deg"] = 200.0
with pytest.raises(ValidationError):
SceneConfig.model_validate(d)
# ---------------------------------------------------------------------------
# イミュータビリティ
# ---------------------------------------------------------------------------
def test_scene_config_is_frozen(valid_minimal_dict):
"""パース後の SceneConfig インスタンスへの代入で ValidationError が発生すること.
仕様: frozen=True (schema.py ConfigDict)
"""
config = SceneConfig.model_validate(valid_minimal_dict)
with pytest.raises(ValidationError):
config.name = "modified" # type: ignore[misc]