Newer
Older
SmTIAS-LightSim / tests / scene / test_schema.py
"""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]


# ---------------------------------------------------------------------------
# AreaLight の shape 別バリデーション(rectangle / disk)
# ---------------------------------------------------------------------------


def test_area_light_disk_valid():
    """shape=disk は radius 指定で正常に構築できること.

    仕様: AreaLight shape=disk には radius が必須 (SPEC_02 lights セクション)
    """
    light = AreaLight(
        id="led_disk",
        type="area",
        shape="disk",
        center=(0.0, 0.0, -142.0),
        radius=1.766,
        direction=(0.0, 0.0, -1.0),
        radiance=50.0,
    )
    assert light.radius == pytest.approx(1.766)
    assert light.size is None


def test_area_light_disk_requires_radius():
    """shape=disk で radius が無いとき ValidationError が発生すること."""
    with pytest.raises(ValidationError):
        AreaLight(
            id="led_disk",
            type="area",
            shape="disk",
            center=(0.0, 0.0, -142.0),
            direction=(0.0, 0.0, -1.0),
            radiance=50.0,
        )


def test_area_light_disk_rejects_nonpositive_radius():
    """shape=disk で radius が 0 以下のとき ValidationError が発生すること."""
    with pytest.raises(ValidationError):
        AreaLight(
            id="led_disk",
            type="area",
            shape="disk",
            center=(0.0, 0.0, -142.0),
            radius=0.0,
            direction=(0.0, 0.0, -1.0),
            radiance=50.0,
        )


def test_area_light_rectangle_requires_size():
    """shape=rectangle で size が無いとき ValidationError が発生すること."""
    with pytest.raises(ValidationError):
        AreaLight(
            id="led_area",
            type="area",
            shape="rectangle",
            center=(0.0, 0.0, 20.0),
            direction=(0.0, 0.0, -1.0),
            radiance=50.0,
        )