Newer
Older
SmTIAS-LightSim / tests / cli / test_evaluate.py
"""cli/evaluate.py の仕様ベーステスト(統合テスト).

仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluate パイプライン・CLI エントリポイント)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation セクション)
- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 6 完了条件)
- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針)

Step 6 完了条件:
  シミュ PNG と実測 PNG の両方で同じ指標 CSV が出ること
  metrics.csv + figures/{luminance_map, histogram, radial_profile, zone_map}.png が出力される

テスト方針:
  - subprocess ではなく main() を直接呼ぶ(速度・カバレッジのため)
  - ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (GUIDE_08)
  - 乱数を使う場合は seed を必ず固定する (GUIDE_08)

注意:
  matplotlib の Agg バックエンドは evaluate.py 内部で設定される.
  テスト側でも安全のため import 前に設定する.
"""

from __future__ import annotations

import matplotlib  # noqa: I001

# headless 環境でも動くよう Agg バックエンドを pyplot より前に設定する
matplotlib.use("Agg")

from pathlib import Path

import numpy
import pytest

from sm_tias_lightsim.cli.evaluate import main

# ---------------------------------------------------------------------------
# テスト用定数
# ---------------------------------------------------------------------------

#: tests/fixtures/eval_smoke.yaml の絶対パス
EVAL_SMOKE_YAML = Path(__file__).parent.parent / "fixtures" / "eval_smoke.yaml"

# ---------------------------------------------------------------------------
# テスト用ヘルパー
# ---------------------------------------------------------------------------


def _make_luminance_array(seed: int = 0, size: int = 256) -> numpy.ndarray:
    """ゼロを含まない合成輝度配列を返す(seed 固定).

    max/min 計算が走るようゼロを含まない値にする (GUIDE_08 補助ガイダンス)
    """
    rng = numpy.random.RandomState(seed)
    return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size))


def _make_eval_yaml(
    tmp_path: Path,
    x: int = 32,
    y: int = 32,
    width: int = 192,
    height: int = 192,
    zones_enabled: bool = True,
    radial_profile: bool = True,
) -> Path:
    """評価専用 YAML(トップレベルが EvaluationConfig 互換)を tmp_path に書き出す.

    仕様: evaluation キーなし → トップレベルを EvaluationConfig として検証する
         (cli/evaluate.py _load_eval_config)
    """
    content = f"""\
roi:
  type: image_pixel
  x: {x}
  y: {y}
  width: {width}
  height: {height}
zones:
  enabled: {'true' if zones_enabled else 'false'}
  boundaries: [0.33, 0.66]
metrics:
  cov: true
  std: true
  max_min: true
  radial_profile: {'true' if radial_profile else 'false'}
"""
    yaml_path = tmp_path / "eval_config.yaml"
    yaml_path.write_text(content, encoding="utf-8")
    return yaml_path


def _run_main(npy_path: Path, config_path: Path, output_dir: Path) -> int:
    """main() を呼ぶ共通ラッパー(行長削減のため)."""
    return main(
        ["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)]
    )


# ---------------------------------------------------------------------------
# 1. 正常系 — .npy 2D 入力 + 評価専用 YAML
# ---------------------------------------------------------------------------


def test_npy_2d_returns_0(tmp_path: Path):
    """2D .npy 輝度配列を入力として main() が 0 を返すこと.

    仕様: .npy は 2D ならそのまま使用(cli/evaluate.py _load_luminance)
    """
    luminance = _make_luminance_array(seed=0)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    result = _run_main(npy_path, config_path, output_dir)
    assert result == 0


def test_npy_2d_creates_metrics_csv(tmp_path: Path):
    """2D .npy 入力後に metrics.csv が生成されること.

    仕様: 出力: output_dir/metrics.csv(cli/evaluate.py 処理フロー ステップ 8)
    """
    luminance = _make_luminance_array(seed=0)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    _run_main(npy_path, config_path, output_dir)
    assert (output_dir / "metrics.csv").exists()


def test_npy_2d_metrics_csv_contains_expected_columns(tmp_path: Path):
    """metrics.csv に必須列が含まれること.

    仕様:
      - 基本指標: cov, std, max_min_ratio, mean, max, min (uniformity.py calc_uniformity)
      - ゾーン指標: center_mean, center_std, middle_mean, middle_std,
                   periphery_mean, periphery_std, center_periphery_ratio, gradient_magnitude
                   (zones.py calc_zone_stats)
    """
    import pandas

    luminance = _make_luminance_array(seed=1)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path, zones_enabled=True)
    output_dir = tmp_path / "output"
    _run_main(npy_path, config_path, output_dir)

    df = pandas.read_csv(output_dir / "metrics.csv")
    expected_cols = {
        "cov", "std", "max_min_ratio", "mean", "max", "min",
        "center_mean", "center_std", "middle_mean", "middle_std",
        "periphery_mean", "periphery_std", "center_periphery_ratio", "gradient_magnitude",
    }
    assert expected_cols.issubset(set(df.columns))


def test_npy_2d_creates_figures(tmp_path: Path):
    """2D .npy 入力後に figures/ 内の 4 つの PNG が生成されること.

    仕様: output_dir/figures/{luminance_map, histogram, radial_profile, zone_map}.png
         (SPEC_01 全体パイプライン)
    """
    luminance = _make_luminance_array(seed=2)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path, zones_enabled=True, radial_profile=True)
    output_dir = tmp_path / "output"
    _run_main(npy_path, config_path, output_dir)

    figures_dir = output_dir / "figures"
    assert (figures_dir / "luminance_map.png").exists()
    assert (figures_dir / "histogram.png").exists()
    assert (figures_dir / "radial_profile.png").exists()
    assert (figures_dir / "zone_map.png").exists()


# ---------------------------------------------------------------------------
# 2. 正常系 — PNG 入力
# ---------------------------------------------------------------------------


def test_png_input_returns_0(tmp_path: Path):
    """合成 RGB PNG を入力として main() が 0 を返すこと.

    仕様: PNG は PIL で RGB 読み込み → to_grayscale 経由(cli/evaluate.py _load_luminance)
    """
    from PIL import Image

    rng = numpy.random.RandomState(3)
    rgb = (numpy.full((256, 256, 3), 100) + rng.randint(0, 30, (256, 256, 3))).astype(numpy.uint8)
    pil_img = Image.fromarray(rgb, mode="RGB")
    png_path = tmp_path / "render.png"
    pil_img.save(png_path)

    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    result = main(
        ["--image", str(png_path), "--config", str(config_path), "--output", str(output_dir)]
    )
    assert result == 0


def test_png_input_creates_metrics_csv(tmp_path: Path):
    """PNG 入力後に metrics.csv が生成されること."""
    from PIL import Image

    rng = numpy.random.RandomState(4)
    rgb = (numpy.full((256, 256, 3), 100) + rng.randint(0, 30, (256, 256, 3))).astype(numpy.uint8)
    pil_img = Image.fromarray(rgb, mode="RGB")
    png_path = tmp_path / "render.png"
    pil_img.save(png_path)

    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    main(
        ["--image", str(png_path), "--config", str(config_path), "--output", str(output_dir)]
    )
    assert (output_dir / "metrics.csv").exists()


# ---------------------------------------------------------------------------
# 3. 正常系 — SceneConfig 形式 YAML(evaluation サブツリー)
# ---------------------------------------------------------------------------


def test_scene_config_yaml_with_evaluation_key_returns_0(tmp_path: Path):
    """evaluation キーを持つ YAML(SceneConfig 形式)でも成功すること.

    仕様: "evaluation" キーがあれば SceneConfig 埋め込みとみなし,
         data["evaluation"] を EvaluationConfig として検証する
         (cli/evaluate.py _load_eval_config)
    注意: SceneConfig の他フィールドは無視されるため,evaluation キーだけあれば十分
    """
    luminance = _make_luminance_array(seed=5)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)

    # evaluation キーを持つ YAML(他フィールドはダミー)
    yaml_content = """\
# SceneConfig 形式: evaluation サブツリーを持つダミー YAML
some_other_key: dummy_value
evaluation:
  roi:
    type: image_pixel
    x: 32
    y: 32
    width: 192
    height: 192
  zones:
    enabled: true
    boundaries: [0.33, 0.66]
  metrics:
    cov: true
    std: true
    max_min: true
    radial_profile: true
"""
    config_path = tmp_path / "scene_with_eval.yaml"
    config_path.write_text(yaml_content, encoding="utf-8")

    output_dir = tmp_path / "output"
    result = _run_main(npy_path, config_path, output_dir)
    assert result == 0
    assert (output_dir / "metrics.csv").exists()


# ---------------------------------------------------------------------------
# 4. 正常系 — .npy 3D(RGB)入力
# ---------------------------------------------------------------------------


def test_npy_3d_rgb_returns_0(tmp_path: Path):
    """3D の .npy(H, W, 3)を渡すと to_grayscale で 2D 化されて成功すること.

    仕様: .npy が 3D(H, W, 3)だった場合も to_grayscale を通す
         (cli/evaluate.py _load_luminance)
    """
    rng = numpy.random.RandomState(6)
    rgb = numpy.full((256, 256, 3), 100.0) + rng.normal(0, 5, (256, 256, 3))
    npy_path = tmp_path / "luminance_rgb.npy"
    numpy.save(npy_path, rgb)

    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    result = _run_main(npy_path, config_path, output_dir)
    assert result == 0


def test_npy_3d_rgb_creates_metrics_csv(tmp_path: Path):
    """3D .npy 入力後に metrics.csv が生成されること."""
    rng = numpy.random.RandomState(7)
    rgb = numpy.full((256, 256, 3), 100.0) + rng.normal(0, 5, (256, 256, 3))
    npy_path = tmp_path / "luminance_rgb.npy"
    numpy.save(npy_path, rgb)

    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    _run_main(npy_path, config_path, output_dir)
    assert (output_dir / "metrics.csv").exists()


# ---------------------------------------------------------------------------
# 5. 正常系 — zones.enabled = false
# ---------------------------------------------------------------------------


def test_zones_disabled_csv_no_zone_columns(tmp_path: Path):
    """zones.enabled=false の場合,metrics.csv に center_mean 等のカラムが含まれないこと.

    仕様: zones.enabled のときゾーン分析を実行する(cli/evaluate.py 処理フロー ステップ 6)
    """
    import pandas

    luminance = _make_luminance_array(seed=8)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path, zones_enabled=False)
    output_dir = tmp_path / "output"
    _run_main(npy_path, config_path, output_dir)

    df = pandas.read_csv(output_dir / "metrics.csv")
    assert "center_mean" not in df.columns
    assert "gradient_magnitude" not in df.columns


def test_zones_disabled_no_zone_map_png(tmp_path: Path):
    """zones.enabled=false の場合,zone_map.png が生成されないこと.

    仕様: zone_map が None のとき save_zone_map は呼ばれない(cli/evaluate.py 処理フロー)
    """
    luminance = _make_luminance_array(seed=9)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path, zones_enabled=False)
    output_dir = tmp_path / "output"
    _run_main(npy_path, config_path, output_dir)

    assert not (output_dir / "figures" / "zone_map.png").exists()


# ---------------------------------------------------------------------------
# 6. 正常系 — metrics.radial_profile = false
# ---------------------------------------------------------------------------


def test_radial_profile_disabled_no_png(tmp_path: Path):
    """metrics.radial_profile=false の場合,radial_profile.png が生成されないこと.

    仕様: metrics.radial_profile が有効のとき放射状プロファイルを算出する
         (cli/evaluate.py 処理フロー ステップ 7)
    """
    luminance = _make_luminance_array(seed=10)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path, radial_profile=False)
    output_dir = tmp_path / "output"
    _run_main(npy_path, config_path, output_dir)

    assert not (output_dir / "figures" / "radial_profile.png").exists()


# ---------------------------------------------------------------------------
# 7. 正常系 — eval_smoke.yaml フィクスチャを使ったスモークテスト
# ---------------------------------------------------------------------------


def test_eval_smoke_yaml_fixture_returns_0(tmp_path: Path):
    """tests/fixtures/eval_smoke.yaml を使って main() が 0 を返すこと.

    仕様: eval_smoke.yaml(評価専用,evaluation キーなし,トップレベルが EvaluationConfig)
    """
    # eval_smoke.yaml の ROI: x=112, y=112, width=32, height=32
    # → 画像は 144x144 以上必要
    luminance = _make_luminance_array(seed=99, size=256)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    output_dir = tmp_path / "output"
    result = main(
        [
            "--image", str(npy_path),
            "--config", str(EVAL_SMOKE_YAML),
            "--output", str(output_dir),
        ]
    )
    assert result == 0


# ---------------------------------------------------------------------------
# 8. 異常系 — 画像ファイルが存在しない
# ---------------------------------------------------------------------------


def test_nonexistent_image_returns_1(tmp_path: Path):
    """存在しない画像ファイルを渡すと main() が 1 を返すこと.

    仕様: FileNotFoundError 捕捉 → return 1(cli/evaluate.py 処理フロー ステップ 1)
    """
    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    result = main(
        [
            "--image", str(tmp_path / "nonexistent.npy"),
            "--config", str(config_path),
            "--output", str(output_dir),
        ]
    )
    assert result == 1


def test_nonexistent_image_stderr_message(tmp_path: Path, capsys):
    """存在しない画像ファイルのエラーメッセージが stderr に出ること."""
    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    main(
        [
            "--image", str(tmp_path / "nonexistent.npy"),
            "--config", str(config_path),
            "--output", str(output_dir),
        ]
    )
    captured = capsys.readouterr()
    assert "エラー" in captured.err


# ---------------------------------------------------------------------------
# 9. 異常系 — YAML ファイルが存在しない
# ---------------------------------------------------------------------------


def test_nonexistent_config_returns_1(tmp_path: Path):
    """存在しない YAML ファイルを渡すと main() が 1 を返すこと.

    仕様: FileNotFoundError 捕捉 → return 1(cli/evaluate.py 処理フロー ステップ 2)
    """
    luminance = _make_luminance_array(seed=11)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    output_dir = tmp_path / "output"
    result = main(
        [
            "--image", str(npy_path),
            "--config", str(tmp_path / "nonexistent.yaml"),
            "--output", str(output_dir),
        ]
    )
    assert result == 1


# ---------------------------------------------------------------------------
# 10. 異常系 — YAML が EvaluationConfig として不正(roi が無い)
# ---------------------------------------------------------------------------


def test_invalid_config_no_roi_returns_1(tmp_path: Path):
    """roi フィールドが欠落した YAML を渡すと main() が 1 を返すこと.

    仕様: Pydantic ValidationError → return 1(cli/evaluate.py 処理フロー ステップ 2)
    """
    luminance = _make_luminance_array(seed=12)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)

    # roi フィールドが無い不正 YAML
    invalid_content = """\
zones:
  enabled: true
  boundaries: [0.33, 0.66]
"""
    config_path = tmp_path / "invalid_eval.yaml"
    config_path.write_text(invalid_content, encoding="utf-8")

    output_dir = tmp_path / "output"
    result = main(
        [
            "--image", str(npy_path),
            "--config", str(config_path),
            "--output", str(output_dir),
        ]
    )
    assert result == 1


# ---------------------------------------------------------------------------
# 11. 異常系 — ROI が画像範囲外
# ---------------------------------------------------------------------------


def test_roi_out_of_bounds_returns_1(tmp_path: Path):
    """ROI が画像範囲外のとき main() が 1 を返すこと.

    仕様: extract_roi が ValueError → return 1(cli/evaluate.py 処理フロー ステップ 4)
    """
    # 小さい画像(64x64)だが ROI は x=200, y=200 → 範囲外
    luminance = _make_luminance_array(seed=13, size=64)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)

    out_of_bounds_content = """\
roi:
  type: image_pixel
  x: 200
  y: 200
  width: 50
  height: 50
"""
    config_path = tmp_path / "out_of_bounds.yaml"
    config_path.write_text(out_of_bounds_content, encoding="utf-8")

    output_dir = tmp_path / "output"
    result = main(
        [
            "--image", str(npy_path),
            "--config", str(config_path),
            "--output", str(output_dir),
        ]
    )
    assert result == 1


# ---------------------------------------------------------------------------
# 12. 異常系 — 輝度に 0 が含まれて max/min が計算不能
# ---------------------------------------------------------------------------


def test_zero_luminance_returns_1(tmp_path: Path):
    """輝度に 0 が含まれる場合,calc_uniformity が ValueError を投げて main() が 1 を返すこと.

    仕様: 輝度の最小値が 0 の場合は ValueError(uniformity.py calc_uniformity)
         → return 1(cli/evaluate.py 処理フロー ステップ 5)
    """
    # 全画素を 0 にする(min == 0 → ValueError)
    luminance = numpy.zeros((256, 256), dtype=numpy.float64)
    npy_path = tmp_path / "zero_luminance.npy"
    numpy.save(npy_path, luminance)

    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    result = _run_main(npy_path, config_path, output_dir)
    assert result == 1


# ---------------------------------------------------------------------------
# 13. 異常系 — 必須引数欠落(argparse が SystemExit を投げる)
# ---------------------------------------------------------------------------


def test_missing_image_arg_exits():
    """--image を渡さないと SystemExit が発生すること.

    仕様: --image は必須引数(cli/evaluate.py _parse_args, required=True)
    argparse の required=True は SystemExit を発生させる.
    """
    with pytest.raises(SystemExit) as exc_info:
        main(["--config", "dummy.yaml", "--output", "/tmp/out"])
    assert exc_info.value.code != 0


def test_missing_config_arg_exits():
    """--config を渡さないと SystemExit が発生すること."""
    with pytest.raises(SystemExit) as exc_info:
        main(["--image", "dummy.npy", "--output", "/tmp/out"])
    assert exc_info.value.code != 0


def test_missing_output_arg_exits():
    """--output を渡さないと SystemExit が発生すること."""
    with pytest.raises(SystemExit) as exc_info:
        main(["--image", "dummy.npy", "--config", "dummy.yaml"])
    assert exc_info.value.code != 0


def test_no_args_exits():
    """引数なしで呼ぶと SystemExit が発生すること."""
    with pytest.raises(SystemExit) as exc_info:
        main([])
    assert exc_info.value.code != 0


# ---------------------------------------------------------------------------
# 14. 短縮引数 -i / -c / -o
# ---------------------------------------------------------------------------


def test_short_args_work(tmp_path: Path):
    """-i / -c / -o の短縮形でも --image / --config / --output と同等に動くこと.

    仕様: --image/-i, --config/-c, --output/-o の短縮形を提供する
         (cli/evaluate.py _parse_args)
    """
    luminance = _make_luminance_array(seed=14)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path)
    output_dir = tmp_path / "output"
    result = main(["-i", str(npy_path), "-c", str(config_path), "-o", str(output_dir)])
    assert result == 0


# ---------------------------------------------------------------------------
# 15. 出力ディレクトリの自動作成
# ---------------------------------------------------------------------------


def test_deep_nested_output_dir_created(tmp_path: Path):
    """存在しない深いネストのディレクトリも自動作成されること.

    仕様: output_dir.mkdir(parents=True, exist_ok=True) で親ごと作成する
         (cli/evaluate.py 処理フロー ステップ 3)
    """
    luminance = _make_luminance_array(seed=15)
    npy_path = tmp_path / "luminance.npy"
    numpy.save(npy_path, luminance)
    config_path = _make_eval_yaml(tmp_path)

    deep_output = tmp_path / "deep" / "nested" / "out"
    assert not deep_output.exists()
    result = main(
        [
            "--image", str(npy_path),
            "--config", str(config_path),
            "--output", str(deep_output),
        ]
    )
    assert result == 0
    assert deep_output.exists()
    assert (deep_output / "metrics.csv").exists()