Newer
Older
SmTIAS-LightSim / tests / render / test_pipeline.py
"""render/pipeline.py(render_yaml)の仕様ベーステスト.

仕様出典:
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render パイプラインの責務.CLI は薄い層)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(シーン YAML スキーマ・render セクション)
- docs/02_ENV/ENV_04_開発コマンド.md
  (sm-render は PNG / luminance.npy を出力.variant は --variant > SM_RENDER_VARIANT > YAML)

検証する仕様:
- render_yaml(scene_yaml, output_dir, variant) が render.png と luminance.npy を書き,
  そのパスを (png, npy) の順で返す
- variant の優先順位は 引数 > 環境変数 SM_RENDER_VARIANT > YAML の render.variant で,
  上書き時だけ「variant を上書き: A → B」を表示する(YAML ファイルは書き換えない)
- 失敗は段階別の文言を持つ RenderStepError になる(CLI が「エラー: …」に変換する)

テスト方針(testing.md):
- 軽量テストでは render_scene を差し替えて「どの variant / どの config で呼ばれたか」を見る
  (variant はプロセス内で 1 度しか設定できず,実レンダでは切り替えを検証できないため)
- 実レンダを伴う end-to-end は @pytest.mark.slow に 1 本置く
"""

from __future__ import annotations

from pathlib import Path

import numpy as np
import pytest
import yaml

from sm_tias_lightsim.render.pipeline import RenderStepError, render_yaml
from sm_tias_lightsim.scene.schema import load_scene_yaml

MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml"
YAML_VARIANT = "llvm_ad_rgb"  # minimal.yaml の render.variant


@pytest.fixture
def render_calls(monkeypatch):
    """render_scene を差し替え,渡された SceneConfig を記録する(実レンダはしない)."""
    import sm_tias_lightsim.render.runner as runner
    import sm_tias_lightsim.runtime.llvm_init as llvm_init

    calls: list[dict] = []

    def fake_render_scene(config, project_root=None):
        calls.append({"config": config, "project_root": project_root})
        return np.full((4, 4, 3), 0.5, dtype=np.float32)

    monkeypatch.setattr(runner, "render_scene", fake_render_scene)
    monkeypatch.setattr(llvm_init, "ensure_drjit_libllvm_path", lambda *a, **k: None)
    monkeypatch.delenv("SM_RENDER_VARIANT", raising=False)
    return calls


# ---------------------------------------------------------------------------
# 出力ファイル
# ---------------------------------------------------------------------------


def test_returns_png_and_npy_paths(tmp_path, render_calls):
    """戻り値が (render.png, luminance.npy) のパスで,実ファイルが存在すること."""
    out_dir = tmp_path / "out"
    png_path, npy_path = render_yaml(MINIMAL_YAML, out_dir)
    assert png_path == out_dir / "render.png"
    assert npy_path == out_dir / "luminance.npy"
    assert png_path.is_file()
    assert npy_path.is_file()


def test_luminance_npy_is_loadable_2d_or_3d(tmp_path, render_calls):
    """luminance.npy が numpy で読める配列であること(評価側の入力になる)."""
    _, npy_path = render_yaml(MINIMAL_YAML, tmp_path / "out")
    data = np.load(npy_path)
    assert data.ndim in (2, 3)
    assert data.shape[0] == 4
    assert data.shape[1] == 4


def test_creates_deep_nested_output_dir(tmp_path, render_calls):
    """出力ディレクトリが親ごと作られること."""
    out_dir = tmp_path / "a" / "b" / "c"
    png_path, _ = render_yaml(MINIMAL_YAML, out_dir)
    assert png_path.is_file()


def test_project_root_is_yaml_parent(tmp_path, render_calls):
    """STL 相対パスの基点が YAML のあるディレクトリになること."""
    render_yaml(MINIMAL_YAML, tmp_path / "out")
    assert render_calls[0]["project_root"] == MINIMAL_YAML.resolve().parent


def test_accepts_relative_paths(tmp_path, render_calls, monkeypatch):
    """相対パスで渡しても解決されて出力できること."""
    monkeypatch.chdir(MINIMAL_YAML.parent)
    png_path, npy_path = render_yaml(Path("minimal.yaml"), tmp_path / "out")
    assert png_path.is_file()
    assert npy_path.is_file()


# ---------------------------------------------------------------------------
# variant の解決(優先順位)
# ---------------------------------------------------------------------------


def test_yaml_variant_used_without_override(tmp_path, render_calls, capsys):
    """引数・環境変数が無ければ YAML の variant を使い,上書き表示も出さないこと."""
    render_yaml(MINIMAL_YAML, tmp_path / "out")
    assert render_calls[0]["config"].render.variant == YAML_VARIANT
    assert "variant を上書き" not in capsys.readouterr().out


def test_env_overrides_yaml(tmp_path, render_calls, capsys, monkeypatch):
    """環境変数 SM_RENDER_VARIANT が YAML より優先されること."""
    monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb")
    render_yaml(MINIMAL_YAML, tmp_path / "out")
    assert render_calls[0]["config"].render.variant == "scalar_rgb"
    assert f"variant を上書き: {YAML_VARIANT} → scalar_rgb" in capsys.readouterr().out


def test_argument_overrides_yaml(tmp_path, render_calls, capsys):
    """variant 引数が YAML より優先されること."""
    render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb")
    assert render_calls[0]["config"].render.variant == "cuda_ad_rgb"
    assert f"variant を上書き: {YAML_VARIANT} → cuda_ad_rgb" in capsys.readouterr().out


def test_argument_overrides_env(tmp_path, render_calls, capsys, monkeypatch):
    """variant 引数が環境変数より優先されること."""
    monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb")
    render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb")
    assert render_calls[0]["config"].render.variant == "cuda_ad_rgb"


def test_same_variant_is_not_reported_as_override(tmp_path, render_calls, capsys):
    """YAML と同じ variant を指定しても「上書き」とは表示しないこと."""
    render_yaml(MINIMAL_YAML, tmp_path / "out", YAML_VARIANT)
    assert "variant を上書き" not in capsys.readouterr().out


def test_override_changes_only_variant(tmp_path, render_calls):
    """上書きするのは variant だけで,他の設定・シーン内容は YAML のままであること."""
    render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb")
    original = load_scene_yaml(MINIMAL_YAML)
    config = render_calls[0]["config"]
    assert config.render.model_dump(exclude={"variant"}) == original.render.model_dump(
        exclude={"variant"}
    )
    assert config.model_dump(exclude={"render"}) == original.model_dump(exclude={"render"})


def test_yaml_file_is_not_modified(tmp_path, render_calls, monkeypatch):
    """variant を上書きしても YAML ファイル自体は書き換えないこと."""
    before = MINIMAL_YAML.read_bytes()
    monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb")
    render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb")
    assert MINIMAL_YAML.read_bytes() == before


def test_seed_from_yaml_reaches_render_scene(tmp_path, render_calls):
    """YAML の render.seed がそのままレンダに渡ること(再レンダのノイズ実現の切り替え)."""
    data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8"))
    data["render"]["seed"] = 4
    # render_scene は差し替え済みなので STL の実在は問われない(YAML の中身だけを見る)
    scene_yaml = tmp_path / "scene_with_seed.yaml"
    scene_yaml.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
    render_yaml(scene_yaml, tmp_path / "out")
    assert render_calls[0]["config"].render.seed == 4


def test_seed_survives_variant_override(tmp_path, render_calls):
    """variant を上書きしても seed が既定 0 のまま保たれること."""
    render_yaml(MINIMAL_YAML, tmp_path / "out", "cuda_ad_rgb")
    assert render_calls[0]["config"].render.seed == 0


# ---------------------------------------------------------------------------
# 異常系: 段階別の RenderStepError
# ---------------------------------------------------------------------------


def test_missing_yaml_raises_step_error(tmp_path, render_calls):
    """シーン YAML が無いとき RenderStepError になること."""
    with pytest.raises(RenderStepError) as exc:
        render_yaml(tmp_path / "nope.yaml", tmp_path / "out")
    assert "見つかりません" in str(exc.value)


def test_invalid_yaml_raises_load_step_error(tmp_path, render_calls):
    """必須フィールド欠落の YAML は「シーン YAML の読み込みに失敗しました」になること."""
    data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8"))
    del data["name"]  # 必須フィールドを落とす(SPEC_02 トップレベル構造)
    broken = tmp_path / "broken.yaml"
    broken.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
    with pytest.raises(RenderStepError) as exc:
        render_yaml(broken, tmp_path / "out")
    assert "シーン YAML の読み込みに失敗しました" in str(exc.value)


def test_render_failure_raises_render_step_error(tmp_path, monkeypatch):
    """レンダ中の例外は「レンダリングに失敗しました」段階の例外になること."""
    import sm_tias_lightsim.render.runner as runner
    import sm_tias_lightsim.runtime.llvm_init as llvm_init

    monkeypatch.delenv("SM_RENDER_VARIANT", raising=False)
    monkeypatch.setattr(llvm_init, "ensure_drjit_libllvm_path", lambda *a, **k: None)

    def boom(config, project_root=None):
        raise RuntimeError("テスト用レンダ失敗")

    monkeypatch.setattr(runner, "render_scene", boom)
    with pytest.raises(RenderStepError) as exc:
        render_yaml(MINIMAL_YAML, tmp_path / "out")
    assert "レンダリングに失敗しました" in str(exc.value)
    assert "テスト用レンダ失敗" in str(exc.value)


def test_step_error_is_runtime_error_subclass():
    """RenderStepError は RuntimeError 派生であること."""
    assert issubclass(RenderStepError, RuntimeError)


def test_no_output_written_when_load_fails(tmp_path, render_calls):
    """YAML 読み込みで失敗したときは出力ファイルを作らないこと."""
    out_dir = tmp_path / "out"
    with pytest.raises(RenderStepError):
        render_yaml(tmp_path / "nope.yaml", out_dir)
    assert not (out_dir / "render.png").exists()


# ---------------------------------------------------------------------------
# end-to-end(実レンダ)
# ---------------------------------------------------------------------------


@pytest.mark.slow
def test_end_to_end_render_writes_files(tmp_path, monkeypatch, capsys):
    """実レンダで render.png と luminance.npy が出力されること(scalar_rgb)."""
    monkeypatch.delenv("SM_RENDER_VARIANT", raising=False)
    monkeypatch.delenv("SM_DRJIT_THREADS", raising=False)
    png_path, npy_path = render_yaml(MINIMAL_YAML, tmp_path / "out", "scalar_rgb")
    assert png_path.is_file()
    assert npy_path.is_file()
    luminance = np.load(npy_path)
    assert luminance.shape[:2] == (64, 64)
    assert float(np.nanmean(luminance)) > 0.0