Newer
Older
SmTIAS-LightSim / tests / scene / test_normalization_schema.py
# pyright: reportArgumentType=none, reportCallIssue=none
# Pydantic v2 の ValidationError は pydantic_core 由来で,Pylance/Pyright が
# BaseException のサブクラスと推論しないため pytest.raises(ValidationError) を
# 誤検知する.また「必須フィールドを意図的に欠落させる」テスト(line 78 等)でも
# reportCallIssue が誤発火するため,ファイル単位で抑止する(builder.py と同じ方針).
"""scene/schema.py の NormalizationConfig / EvaluationConfig.normalization 仕様ベーステスト.

仕様出典:
- 実装サマリー(NormalizationConfig クラス,EvaluationConfig.normalization フィールド,
  OETF + normalization 同時有効化禁止 model_validator)
- docs/04_SPEC/SPEC_04_評価仕様.md(評価パイプライン)
- .claude/rules/testing.md(テスト方針)
- src/sm_tias_lightsim/scene/schema.py(NormalizationConfig, EvaluationConfig 定義)

テスト方針:
  - Pydantic モデルを直接構築して振る舞いを検証する(YAML パースは不要)
  - ファイル I/O はモックしない (testing.md)
  - 後方互換性(normalization キーなし = 正規化無効)を重点的に検証する
"""

from __future__ import annotations

import pytest
from pydantic import ValidationError

from sm_tias_lightsim.scene.schema import EvaluationConfig, NormalizationConfig

# ---------------------------------------------------------------------------
# 共通ヘルパー
# ---------------------------------------------------------------------------


def _make_minimal_eval_data(**overrides: object) -> dict:
    """最小構成の EvaluationConfig データを返す."""
    data: dict = {
        "roi": {"type": "image_pixel", "x": 0, "y": 0, "width": 100, "height": 100},
    }
    data.update(overrides)
    return data


# ---------------------------------------------------------------------------
# 1. NormalizationConfig — フィールド検証
# ---------------------------------------------------------------------------


def test_normalization_config_valid_mean_anchor():
    """type=mean_anchor + target_mean で正常にパースされること.

    仕様: NormalizationConfig: type=mean_anchor(Literal),target_mean が必須(schema.py)
    """
    cfg = NormalizationConfig(enabled=True, type="mean_anchor", target_mean=146.7146)
    assert cfg.enabled is True
    assert cfg.type == "mean_anchor"
    assert cfg.target_mean == pytest.approx(146.7146)


def test_normalization_config_defaults():
    """NormalizationConfig のデフォルト値を確認する.

    仕様:
    - enabled: bool = False(省略時は正規化無効)
    - type: Literal["mean_anchor"] = "mean_anchor"(デフォルト)
    (schema.py NormalizationConfig)
    """
    cfg = NormalizationConfig(target_mean=100.0)
    assert cfg.enabled is False
    assert cfg.type == "mean_anchor"


def test_normalization_config_missing_target_mean_raises():
    """target_mean を省略したとき ValidationError が発生すること.

    仕様: target_mean: float = Field(..., ...) — 必須フィールド(schema.py)
    """
    with pytest.raises(ValidationError):
        NormalizationConfig(enabled=True)


def test_normalization_config_invalid_type_raises():
    """type に未対応の値(例: max_anchor)で ValidationError が発生すること.

    仕様: type: Literal["mean_anchor"] — mean_anchor のみ(schema.py)
    """
    with pytest.raises(ValidationError):
        NormalizationConfig(type="max_anchor", target_mean=100.0)  # type: ignore[call-arg]


def test_normalization_config_is_frozen():
    """NormalizationConfig が frozen(イミュータブル)であること.

    仕様: _FrozenBase → frozen=True(schema.py ConfigDict)
    """
    cfg = NormalizationConfig(target_mean=100.0)
    with pytest.raises(ValidationError):
        cfg.enabled = True  # type: ignore[misc]


def test_normalization_config_target_mean_various_values():
    """target_mean に任意の float 値が受け付けられること.

    仕様: target_mean: float — 任意 float 可(schema.py NormalizationConfig)
    """
    # uint8 スケールの実測 mean 相当
    cfg1 = NormalizationConfig(target_mean=146.7146)
    assert cfg1.target_mean == pytest.approx(146.7146)

    # 0 以外の任意 float
    cfg2 = NormalizationConfig(target_mean=255.0)
    assert cfg2.target_mean == pytest.approx(255.0)

    cfg3 = NormalizationConfig(target_mean=1.0)
    assert cfg3.target_mean == pytest.approx(1.0)


# ---------------------------------------------------------------------------
# 2. EvaluationConfig.normalization — 後方互換性
# ---------------------------------------------------------------------------


def test_evaluation_config_normalization_none_when_omitted():
    """normalization キーを省略したとき EvaluationConfig.normalization が None になること.

    仕様: normalization: NormalizationConfig | None = Field(default=None, ...)
         省略時は正規化無効(schema.py EvaluationConfig)
    """
    data = _make_minimal_eval_data()
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.normalization is None


def test_evaluation_config_normalization_none_means_disabled():
    """normalization=None のとき正規化が適用されないこと(enabled=False 相当を確認).

    仕様: 省略時は正規化無効扱い(schema.py / cli/evaluate.py)
    """
    data = _make_minimal_eval_data(normalization=None)
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.normalization is None
    # cli/evaluate.py では `if norm_cfg is not None and norm_cfg.enabled:` でスキップ
    norm_cfg = cfg.normalization
    is_applied = norm_cfg is not None and norm_cfg.enabled
    assert is_applied is False


def test_evaluation_config_normalization_disabled_explicit():
    """normalization.enabled=False を明示したとき正規化が無効であること.

    仕様: enabled: bool = False(省略時デフォルト)(schema.py)
    """
    data = _make_minimal_eval_data(
        normalization={"enabled": False, "target_mean": 100.0}
    )
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.normalization is not None
    assert cfg.normalization.enabled is False
    is_applied = cfg.normalization is not None and cfg.normalization.enabled
    assert is_applied is False


def test_evaluation_config_normalization_enabled():
    """normalization.enabled=True で正常にパースされること.

    仕様: eval_baseline_linear.yaml の normalization セクション相当
    """
    data = _make_minimal_eval_data(
        normalization={
            "enabled": True,
            "type": "mean_anchor",
            "target_mean": 146.7146,
        }
    )
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.normalization is not None
    assert cfg.normalization.enabled is True
    assert cfg.normalization.type == "mean_anchor"
    assert cfg.normalization.target_mean == pytest.approx(146.7146)


def test_evaluation_config_backward_compat_real_yaml():
    """既存の eval_real.yaml 相当(normalization キーなし)が後方互換で動くこと.

    仕様: normalization フィールドを省略した既存 YAML も後方互換(正規化無効扱い)で動作する
         (schema.py EvaluationConfig docstring)
    """
    data = {
        "roi": {"type": "image_pixel", "x": 24, "y": 37, "width": 464, "height": 450},
        "zones": {"enabled": True, "boundaries": [0.33, 0.66]},
        "metrics": {"cov": True, "std": True, "max_min": True, "radial_profile": True},
    }
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.normalization is None
    assert cfg.oetf is None


def test_evaluation_config_normalization_invalid_type_raises():
    """normalization.type に未対応の値を指定したとき ValidationError が発生すること.

    仕様: type: Literal["mean_anchor"](schema.py NormalizationConfig)
    """
    data = _make_minimal_eval_data(
        normalization={"enabled": True, "type": "max_anchor", "target_mean": 100.0}
    )
    with pytest.raises(ValidationError):
        EvaluationConfig.model_validate(data)


def test_evaluation_config_normalization_missing_target_mean_raises():
    """normalization.target_mean を省略したとき ValidationError が発生すること.

    仕様: target_mean: float = Field(...) — 必須(schema.py NormalizationConfig)
    """
    data = _make_minimal_eval_data(
        normalization={"enabled": True, "type": "mean_anchor"}
    )
    with pytest.raises(ValidationError):
        EvaluationConfig.model_validate(data)


# ---------------------------------------------------------------------------
# 3. OETF + normalization 同時有効化の排他制御
# ---------------------------------------------------------------------------


def test_oetf_and_normalization_both_enabled_raises():
    """oetf.enabled=True かつ normalization.enabled=True のとき ValidationError が発生すること.

    仕様(実装サマリー「スキーマ排他性」,schema.py EvaluationConfig model_validator):
        oetf.enabled=True かつ normalization.enabled=True は ValidationError
        (OETF が内部で露出アンカーを持つため,二重アンカーになるのを防ぐ)
    """
    data = _make_minimal_eval_data(
        oetf={
            "enabled": True,
            "transfer": "srgb",
            "exposure": {"mode": "anchor_mean", "target_mean": 146.7146},
        },
        normalization={
            "enabled": True,
            "type": "mean_anchor",
            "target_mean": 146.7146,
        },
    )
    with pytest.raises(ValidationError):
        EvaluationConfig.model_validate(data)


def test_oetf_enabled_normalization_disabled_is_valid():
    """oetf.enabled=True かつ normalization.enabled=False は有効であること.

    仕様: 片方だけ有効化は OK(スキーマ排他性は両方 True のみ禁止)
    """
    data = _make_minimal_eval_data(
        oetf={
            "enabled": True,
            "transfer": "srgb",
            "exposure": {"mode": "anchor_mean", "target_mean": 146.7146},
        },
        normalization={
            "enabled": False,
            "type": "mean_anchor",
            "target_mean": 146.7146,
        },
    )
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.oetf is not None
    assert cfg.oetf.enabled is True
    assert cfg.normalization is not None
    assert cfg.normalization.enabled is False


def test_oetf_disabled_normalization_enabled_is_valid():
    """oetf.enabled=False かつ normalization.enabled=True は有効であること.

    仕様: 片方だけ有効化は OK
    """
    data = _make_minimal_eval_data(
        oetf={"enabled": False},
        normalization={
            "enabled": True,
            "type": "mean_anchor",
            "target_mean": 146.7146,
        },
    )
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.oetf is not None
    assert cfg.oetf.enabled is False
    assert cfg.normalization is not None
    assert cfg.normalization.enabled is True


def test_both_disabled_is_valid():
    """oetf.enabled=False かつ normalization.enabled=False は有効であること(無変換).

    仕様: 両方 None または両方 disabled は OK(無変換)
    """
    data = _make_minimal_eval_data(
        oetf={"enabled": False},
        normalization={"enabled": False, "target_mean": 100.0},
    )
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.oetf is not None
    assert cfg.oetf.enabled is False
    assert cfg.normalization is not None
    assert cfg.normalization.enabled is False


def test_both_none_is_valid():
    """oetf=None かつ normalization=None は有効であること(既存 YAML 後方互換).

    仕様: 両方 None は OK
    """
    data = _make_minimal_eval_data()
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.oetf is None
    assert cfg.normalization is None


def test_oetf_none_normalization_enabled_is_valid():
    """oetf=None(省略)かつ normalization.enabled=True は有効であること.

    仕様: 片方だけ有効化は OK(None は disabled 扱い)
    """
    data = _make_minimal_eval_data(
        normalization={
            "enabled": True,
            "type": "mean_anchor",
            "target_mean": 100.0,
        }
    )
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.oetf is None
    assert cfg.normalization is not None
    assert cfg.normalization.enabled is True


def test_oetf_enabled_normalization_none_is_valid():
    """oetf.enabled=True かつ normalization=None(省略)は有効であること.

    仕様: 片方だけ有効化は OK
    """
    data = _make_minimal_eval_data(
        oetf={
            "enabled": True,
            "transfer": "srgb",
            "exposure": {"mode": "anchor_mean", "target_mean": 146.7146},
        }
    )
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.oetf is not None
    assert cfg.oetf.enabled is True
    assert cfg.normalization is None


# ---------------------------------------------------------------------------
# 4. eval_baseline_linear.yaml 相当の YAML ロード検証
# ---------------------------------------------------------------------------


def test_eval_baseline_linear_yaml_equivalent_parses_correctly():
    """eval_baseline_linear.yaml 相当のデータが正しくパースされること.

    仕様: configs/eval_baseline_linear.yaml の normalization セクション相当
    """
    data = {
        "roi": {"type": "image_pixel", "x": 24, "y": 37, "width": 464, "height": 450},
        "zones": {"enabled": True, "boundaries": [0.33, 0.66]},
        "metrics": {"cov": True, "std": True, "max_min": True, "radial_profile": True},
        "normalization": {
            "enabled": True,
            "type": "mean_anchor",
            "target_mean": 146.7146,
        },
    }
    cfg = EvaluationConfig.model_validate(data)
    assert cfg.normalization is not None
    assert cfg.normalization.enabled is True
    assert cfg.normalization.type == "mean_anchor"
    assert cfg.normalization.target_mean == pytest.approx(146.7146)
    assert cfg.oetf is None
    assert cfg.roi.x == 24
    assert cfg.roi.y == 37
    assert cfg.roi.width == 464
    assert cfg.roi.height == 450