"""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