diff --git a/tests/cli/test_evaluate_no_figures.py b/tests/cli/test_evaluate_no_figures.py
new file mode 100644
index 0000000..df6c701
--- /dev/null
+++ b/tests/cli/test_evaluate_no_figures.py
@@ -0,0 +1,145 @@
+"""sm-evaluate の --no-figures オプションの仕様ベーステスト(統合テスト).
+
+仕様出典:
+- docs/04_SPEC/SPEC_04_評価仕様.md「出力ファイル仕様」(既定は metrics.csv + figures/ 4 種)
+- docs/02_ENV/ENV_04_開発コマンド.md(sm-evaluate は PNG / luminance.npy を評価し
+ metrics.csv + figures/ を出力する)
+- 実装サマリー: --no-figures を付けると figures/ を作らず描画もせず,
+ サマリーの figures/ 行も出さない(指標だけ欲しいスイープ用)
+
+検証する仕様:
+- --no-figures なしは従来どおり(後方互換): metrics.csv + figures/ 4 種
+- --no-figures ありは metrics.csv のみ.終了コードは 0
+- どちらでも metrics.csv の内容は同一(描画は指標に影響しない)
+
+テスト方針(testing.md): subprocess ではなく main() を直接呼ぶ.tmp_path で実ファイルを使う.
+"""
+
+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
+
+_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png")
+
+
+def _make_inputs(tmp_path: Path, seed: int = 0) -> tuple[Path, Path]:
+ """合成輝度 .npy と評価 YAML を書き出してパスを返す(seed 固定)."""
+ rng = numpy.random.RandomState(seed)
+ luminance = numpy.full((128, 128), 100.0) + rng.normal(0, 5, (128, 128))
+ npy_path = tmp_path / "luminance.npy"
+ numpy.save(npy_path, luminance)
+
+ config_path = tmp_path / "eval_config.yaml"
+ config_path.write_text(
+ """\
+roi:
+ type: image_pixel
+ x: 16
+ y: 16
+ width: 96
+ height: 96
+zones:
+ enabled: true
+ boundaries: [0.33, 0.66]
+metrics:
+ cov: true
+ std: true
+ max_min: true
+ radial_profile: true
+""",
+ encoding="utf-8",
+ )
+ return npy_path, config_path
+
+
+def _run(npy: Path, config: Path, out_dir: Path, *extra: str) -> int:
+ return main(
+ ["--image", str(npy), "--config", str(config), "--output", str(out_dir), *extra]
+ )
+
+
+def test_no_figures_returns_0(tmp_path: Path):
+ """--no-figures 付きでも正常終了(0)すること."""
+ npy, config = _make_inputs(tmp_path, seed=0)
+ assert _run(npy, config, tmp_path / "out", "--no-figures") == 0
+
+
+def test_no_figures_creates_metrics_csv(tmp_path: Path):
+ """--no-figures でも metrics.csv は出力されること."""
+ npy, config = _make_inputs(tmp_path, seed=1)
+ out_dir = tmp_path / "out"
+ _run(npy, config, out_dir, "--no-figures")
+ assert (out_dir / "metrics.csv").is_file()
+
+
+def test_no_figures_does_not_create_figures_dir(tmp_path: Path):
+ """--no-figures のとき figures/ ディレクトリが作られないこと."""
+ npy, config = _make_inputs(tmp_path, seed=2)
+ out_dir = tmp_path / "out"
+ _run(npy, config, out_dir, "--no-figures")
+ assert not (out_dir / "figures").exists()
+
+
+def test_no_figures_writes_no_png(tmp_path: Path):
+ """--no-figures のとき PNG が 1 つも出力されないこと."""
+ npy, config = _make_inputs(tmp_path, seed=3)
+ out_dir = tmp_path / "out"
+ _run(npy, config, out_dir, "--no-figures")
+ assert list(out_dir.rglob("*.png")) == []
+
+
+def test_no_figures_summary_has_no_figures_line(tmp_path: Path, capsys):
+ """--no-figures のとき標準出力のサマリーに figures/ 行が出ないこと."""
+ npy, config = _make_inputs(tmp_path, seed=4)
+ _run(npy, config, tmp_path / "out", "--no-figures")
+ out = capsys.readouterr().out
+ assert "=== 均一性評価サマリー ===" in out
+ assert "metrics.csv" in out
+ assert "figures/" not in out
+
+
+def test_default_still_creates_figures(tmp_path: Path):
+ """--no-figures を付けなければ従来どおり figures/ 4 種が出ること(後方互換)."""
+ npy, config = _make_inputs(tmp_path, seed=5)
+ out_dir = tmp_path / "out"
+ assert _run(npy, config, out_dir) == 0
+ for name in _FIGURE_FILES:
+ assert (out_dir / "figures" / name).is_file(), name
+
+
+def test_metrics_csv_identical_with_and_without_figures(tmp_path: Path):
+ """--no-figures の有無で metrics.csv の内容が一致すること."""
+ npy, config = _make_inputs(tmp_path, seed=6)
+ with_dir = tmp_path / "with"
+ without_dir = tmp_path / "without"
+ _run(npy, config, with_dir)
+ _run(npy, config, without_dir, "--no-figures")
+ assert (with_dir / "metrics.csv").read_text(encoding="utf-8") == (
+ without_dir / "metrics.csv"
+ ).read_text(encoding="utf-8")
+
+
+def test_help_mentions_no_figures(capsys):
+ """--help に --no-figures が説明されていること."""
+ with pytest.raises(SystemExit) as exc:
+ main(["--help"])
+ assert exc.value.code == 0
+ assert "--no-figures" in capsys.readouterr().out
+
+
+def test_no_figures_error_path_still_returns_1(tmp_path: Path, capsys):
+ """--no-figures 付きでも異常時は「エラー: 」を stderr に出して 1 を返すこと."""
+ _, config = _make_inputs(tmp_path, seed=7)
+ result = _run(tmp_path / "missing.npy", config, tmp_path / "out", "--no-figures")
+ assert result == 1
+ assert "エラー: " in capsys.readouterr().err
diff --git a/tests/cli/test_evaluate_radial_min_max.py b/tests/cli/test_evaluate_radial_min_max.py
index 155b511..fc01ef0 100644
--- a/tests/cli/test_evaluate_radial_min_max.py
+++ b/tests/cli/test_evaluate_radial_min_max.py
@@ -3,7 +3,8 @@
仕様出典:
- docs/04_SPEC/SPEC_04_評価仕様.md(動径指標・評価パイプライン)
- docs/04_SPEC/SPEC_02_シーン定義仕様.md(evaluation/metrics セクション)
-- src/sm_tias_lightsim/cli/evaluate.py(_run_radial_min_max 実装)
+- src/sm_tias_lightsim/evaluation/pipeline.py(_run_radial_min_max 実装.cli/evaluate.py は
+ 引数パースと例外→終了コード変換だけを担い,同名を再エクスポートしている)
- src/sm_tias_lightsim/evaluation/radial.py(calc_radial_min_max_ratio 実装)
テスト方針:
@@ -11,6 +12,8 @@
- ファイル I/O はモックせず tmp_path フィクスチャで実ファイルを使う (testing.md)
- 乱数を使う場合は seed を必ず固定する (testing.md)
- monkeypatch で calc_radial_profile の呼び出し有無を検証する
+ (差し替え先は実体のある evaluation.pipeline.cli.evaluate の再エクスポートを
+ 差し替えてもパイプライン内の参照には伝播しない)
注意:
matplotlib の Agg バックエンドは evaluate.py 内部で設定される.
@@ -219,7 +222,7 @@
metrics_cfg = MetricsConfig(radial_min_max=True)
profile = calc_radial_profile(roi_luminance)
- with patch("sm_tias_lightsim.cli.evaluate.calc_radial_profile") as mock_calc:
+ with patch("sm_tias_lightsim.evaluation.pipeline.calc_radial_profile") as mock_calc:
_run_radial_min_max(roi_luminance, metrics_cfg, profile)
mock_calc.assert_not_called()
@@ -258,7 +261,7 @@
real_profile = calc_radial_profile(roi_luminance)
with patch(
- "sm_tias_lightsim.cli.evaluate.calc_radial_profile",
+ "sm_tias_lightsim.evaluation.pipeline.calc_radial_profile",
return_value=real_profile,
) as mock_calc:
result = _run_radial_min_max(roi_luminance, metrics_cfg, None)
@@ -592,7 +595,7 @@
output_dir = tmp_path / "output"
with patch(
- "sm_tias_lightsim.cli.evaluate._run_radial_min_max",
+ "sm_tias_lightsim.evaluation.pipeline._run_radial_min_max",
side_effect=RuntimeError("テスト用エラー"),
):
result = _run_main(npy_path, config_path, output_dir)
@@ -614,7 +617,7 @@
output_dir = tmp_path / "output"
with patch(
- "sm_tias_lightsim.cli.evaluate._run_radial_min_max",
+ "sm_tias_lightsim.evaluation.pipeline._run_radial_min_max",
side_effect=RuntimeError("テスト用エラー"),
):
main(["--image", str(npy_path), "--config", str(config_path), "--output", str(output_dir)])
@@ -637,7 +640,7 @@
output_dir = tmp_path / "output"
with patch(
- "sm_tias_lightsim.cli.evaluate.calc_radial_min_max_ratio",
+ "sm_tias_lightsim.evaluation.pipeline.calc_radial_min_max_ratio",
side_effect=ValueError("テスト用内部エラー"),
):
result = _run_main(npy_path, config_path, output_dir)
diff --git a/tests/cli/test_render_jit_init.py b/tests/cli/test_render_jit_init.py
index 01e6ce7..57c323e 100644
--- a/tests/cli/test_render_jit_init.py
+++ b/tests/cli/test_render_jit_init.py
@@ -7,7 +7,8 @@
テスト対象:
- E1: cli/render.py がモジュール import の段階で mitsuba を import しないこと
- E1 (動作): llvm_* variant 設定時に ensure_drjit_libllvm_path を呼んでから
- render_scene を import すること
+ render_scene を import すること(処理本体が render/pipeline.py の
+ render_yaml へ移ったため,順序の検証対象もそちら)
テスト方針:
- AST レベルで import 文の存在をチェック(モジュールレベルの mitsuba import 不在を検証)
@@ -24,9 +25,9 @@
# E1: cli/render.py がモジュール import 時点で mitsuba を import しないこと
# ---------------------------------------------------------------------------
-_CLI_RENDER_PATH = (
- Path(__file__).parent.parent.parent / "src" / "sm_tias_lightsim" / "cli" / "render.py"
-)
+_SRC = Path(__file__).parent.parent.parent / "src" / "sm_tias_lightsim"
+_CLI_RENDER_PATH = _SRC / "cli" / "render.py"
+_PIPELINE_PATH = _SRC / "render" / "pipeline.py"
class TestCliRenderImportOrder:
@@ -131,55 +132,107 @@
"仕様: モジュールレベルは標準ライブラリのみ(Mitsuba / 内部モジュールは遅延 import)"
)
- def test_ensure_drjit_libllvm_path_is_called_in_main_before_render_scene(self) -> None:
- """E1 (動作): main() の llvm_* 分岐で ensure_drjit_libllvm_path を
- render_scene より前に呼んでいること(AST による検証).
+ def test_main_delegates_rendering_to_render_yaml(self) -> None:
+ """E1: main() は処理本体を render_yaml に委譲していること.
仕様:
- runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ.
- ensure_drjit_libllvm_path は llvm_* variant のとき main() 内で先行呼び出しされる.
+ cli/render.py は引数のパースと例外 → 終了コード変換だけを担い,
+ 「YAML 読込 → variant 解決 → レンダ → 保存」は render/pipeline.py が行う.
+ JIT 初期化(ensure_drjit_libllvm_path)の順序保証もそちらへ移っている.
"""
src = open(_CLI_RENDER_PATH, encoding="utf-8").read()
tree = ast.parse(src)
- # main 関数の AST を取得
main_func = next(
(n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == "main"),
None,
)
assert main_func is not None, "main() 関数が cli/render.py に存在すること"
- # main 関数内で ensure_drjit_libllvm_path と render_scene の呼び出し順を確認
- # (AST の lineno ベースで順序を検証)
- ensure_calls: list[int] = []
- render_calls: list[int] = []
-
- for node in ast.walk(main_func):
- if isinstance(node, ast.Call):
- # ensure_drjit_libllvm_path の呼び出し
- if isinstance(node.func, ast.Name) and node.func.id == "ensure_drjit_libllvm_path":
- ensure_calls.append(node.lineno)
- elif (
- isinstance(node.func, ast.Attribute)
- and node.func.attr == "ensure_drjit_libllvm_path"
- ):
- ensure_calls.append(node.lineno)
- # render_scene の呼び出し
- if isinstance(node.func, ast.Name) and node.func.id == "render_scene":
- render_calls.append(node.lineno)
- elif isinstance(node.func, ast.Attribute) and node.func.attr == "render_scene":
- render_calls.append(node.lineno)
-
- # ensure_drjit_libllvm_path は main() 内に存在すること
- assert ensure_calls, (
- "main() 内に ensure_drjit_libllvm_path の呼び出しが存在すること\n"
- "仕様: llvm_* variant のとき render_scene より前に呼ぶ"
+ called = {
+ node.func.id
+ for node in ast.walk(main_func)
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
+ }
+ assert "render_yaml" in called, (
+ "main() は render_yaml を呼ぶこと(レンダ処理は render/pipeline.py に集約する)"
)
- # render_scene は main() 内に存在すること
- assert render_calls, "main() 内に render_scene の呼び出しが存在すること"
- # ensure が render より前の行にあること
+class TestRenderPipelineImportOrder:
+ """E1: render/pipeline.py の import 順が仕様通りであることを検証する.
+
+ 仕様:
+ render_yaml() は llvm_* variant のとき ensure_drjit_libllvm_path を
+ render_scene(= mitsuba import chain)より前に呼ぶ.
+ そのためにモジュールレベルでは mitsuba 依存モジュールを import しない.
+ """
+
+ def test_no_top_level_mitsuba_or_runner_import_in_pipeline(self) -> None:
+ """E1: render/pipeline.py のモジュールレベルに mitsuba / runner の import が無いこと.
+
+ 仕様:
+ モジュールレベルで import すると DRJIT_LIBLLVM_PATH 設定より前に
+ LLVM 初期化が走ってしまう(cli/render.py と同じ遅延 import 方針).
+ """
+ src = open(_PIPELINE_PATH, encoding="utf-8").read()
+ tree = ast.parse(src)
+
+ offenders: list[str] = []
+ for node in ast.iter_child_nodes(tree):
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ if alias.name.startswith("mitsuba"):
+ offenders.append(alias.name)
+ elif isinstance(node, ast.ImportFrom) and node.module:
+ if node.module.startswith("mitsuba") or "runner" in node.module:
+ offenders.append(node.module)
+
+ assert not offenders, (
+ f"render/pipeline.py のモジュールレベルに遅延すべき import がある: {offenders}."
+ "仕様: mitsuba 依存 import はすべて render_yaml() 内で行う"
+ )
+
+ def test_ensure_drjit_libllvm_path_is_called_before_render_scene(self) -> None:
+ """E1 (動作): render_yaml() の llvm_* 分岐で ensure_drjit_libllvm_path を
+ render_scene より前に呼んでいること(AST による検証).
+
+ 仕様:
+ runner.py の import(build_scene → sensors.py / lights.py → mitsuba)より先に呼ぶ.
+ """
+ src = open(_PIPELINE_PATH, encoding="utf-8").read()
+ tree = ast.parse(src)
+
+ func = next(
+ (
+ n
+ for n in ast.walk(tree)
+ if isinstance(n, ast.FunctionDef) and n.name == "render_yaml"
+ ),
+ None,
+ )
+ assert func is not None, "render_yaml() 関数が render/pipeline.py に存在すること"
+
+ ensure_calls: list[int] = []
+ render_calls: list[int] = []
+ for node in ast.walk(func):
+ if not isinstance(node, ast.Call):
+ continue
+ name = None
+ if isinstance(node.func, ast.Name):
+ name = node.func.id
+ elif isinstance(node.func, ast.Attribute):
+ name = node.func.attr
+ if name == "ensure_drjit_libllvm_path":
+ ensure_calls.append(node.lineno)
+ elif name == "render_scene":
+ render_calls.append(node.lineno)
+
+ assert ensure_calls, (
+ "render_yaml() 内に ensure_drjit_libllvm_path の呼び出しが存在すること."
+ "仕様: llvm_* variant のとき render_scene より前に呼ぶ"
+ )
+ assert render_calls, "render_yaml() 内に render_scene の呼び出しが存在すること"
assert min(ensure_calls) < min(render_calls), (
f"ensure_drjit_libllvm_path (line {min(ensure_calls)}) は "
f"render_scene (line {min(render_calls)}) より前に呼ばれること"
diff --git a/tests/evaluation/test_pipeline.py b/tests/evaluation/test_pipeline.py
new file mode 100644
index 0000000..89cdee2
--- /dev/null
+++ b/tests/evaluation/test_pipeline.py
@@ -0,0 +1,296 @@
+"""evaluation/pipeline.py(evaluate_image)の仕様ベーステスト.
+
+仕様出典:
+- docs/04_SPEC/SPEC_04_評価仕様.md
+ 「出力ファイル仕様」:
/metrics.csv と /figures/{luminance_map, histogram,
+ radial_profile, zone_map}.png
+ 「metrics.csv カラム定義」: cov, std, max_min_ratio, mean, max, min,
+ center_*, middle_*, periphery_*, center_periphery_ratio, gradient_magnitude
+- docs/04_SPEC/SPEC_02_シーン定義仕様.md「evaluation」セクション(ROI / zones / metrics)
+- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(evaluate パイプラインの責務分離)
+
+検証する仕様:
+- evaluate_image() が CLI と同じ評価を行い,指標の辞書を返す
+- save_figures=False のとき figures/ を作らず描画もしない(指標は変わらない)
+- 段階ごとの失敗は EvaluationStepError として,どの段階かが分かる文言で送出される
+ (CLI はこれを「エラー: …」+ 終了コード 1 に変換するだけ)
+
+テスト方針(testing.md):
+- ファイル I/O はモックせず tmp_path で実ファイルを使う
+- 合成輝度マップは seed を固定する
+"""
+
+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.evaluation.pipeline import EvaluationStepError, evaluate_image
+
+# SPEC_04「metrics.csv カラム定義(ヘッダ順)」の必須列
+_BASE_COLUMNS = ("cov", "std", "max_min_ratio", "mean", "max", "min")
+_ZONE_COLUMNS = (
+ "center_mean",
+ "center_std",
+ "middle_mean",
+ "middle_std",
+ "periphery_mean",
+ "periphery_std",
+ "center_periphery_ratio",
+ "gradient_magnitude",
+)
+# SPEC_04「可視化規約」の 4 種
+_FIGURE_FILES = ("luminance_map.png", "histogram.png", "radial_profile.png", "zone_map.png")
+
+
+def _make_luminance_array(seed: int = 0, size: int = 128) -> numpy.ndarray:
+ """ゼロを含まない合成輝度配列を返す(seed 固定)."""
+ rng = numpy.random.RandomState(seed)
+ return numpy.full((size, size), 100.0) + rng.normal(0, 5, (size, size))
+
+
+def _write_npy(tmp_path: Path, seed: int = 0, size: int = 128) -> Path:
+ path = tmp_path / "luminance.npy"
+ numpy.save(path, _make_luminance_array(seed=seed, size=size))
+ return path
+
+
+def _write_eval_yaml(
+ tmp_path: Path,
+ x: int = 16,
+ y: int = 16,
+ width: int = 96,
+ height: int = 96,
+ zones_enabled: bool = True,
+ radial_profile: bool = True,
+) -> Path:
+ """評価専用 YAML(トップレベルが EvaluationConfig 互換)を書き出す."""
+ 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"}
+"""
+ path = tmp_path / "eval_config.yaml"
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+# ---------------------------------------------------------------------------
+# 正常系: 既定(save_figures=True)
+# ---------------------------------------------------------------------------
+
+
+def test_returns_metrics_dict(tmp_path: Path):
+ """evaluate_image が指標の辞書を返すこと.
+
+ 仕様: SPEC_04「metrics.csv カラム定義」の基本 6 指標を含む.
+ """
+ metrics = evaluate_image(
+ _write_npy(tmp_path, seed=0), _write_eval_yaml(tmp_path), tmp_path / "out"
+ )
+ assert isinstance(metrics, dict)
+ for key in _BASE_COLUMNS:
+ assert key in metrics, key
+ assert isinstance(metrics[key], float)
+
+
+def test_returned_metrics_are_physically_consistent(tmp_path: Path):
+ """返された指標が互いに整合すること(min <= mean <= max・CoV = std/mean)."""
+ metrics = evaluate_image(
+ _write_npy(tmp_path, seed=1), _write_eval_yaml(tmp_path), tmp_path / "out"
+ )
+ assert metrics["min"] <= metrics["mean"] <= metrics["max"]
+ assert metrics["cov"] == pytest.approx(metrics["std"] / metrics["mean"], rel=1e-9)
+ assert metrics["max_min_ratio"] == pytest.approx(metrics["max"] / metrics["min"], rel=1e-9)
+
+
+def test_zone_metrics_included_when_zones_enabled(tmp_path: Path):
+ """zones.enabled のときゾーン指標が辞書に含まれること(SPEC_04 3 ゾーン分析)."""
+ metrics = evaluate_image(
+ _write_npy(tmp_path, seed=2), _write_eval_yaml(tmp_path), tmp_path / "out"
+ )
+ for key in _ZONE_COLUMNS:
+ assert key in metrics, key
+
+
+def test_creates_metrics_csv(tmp_path: Path):
+ """metrics.csv が出力ディレクトリ直下に作られること(SPEC_04 出力ファイル仕様)."""
+ out_dir = tmp_path / "out"
+ evaluate_image(_write_npy(tmp_path, seed=3), _write_eval_yaml(tmp_path), out_dir)
+ assert (out_dir / "metrics.csv").is_file()
+
+
+def test_metrics_csv_matches_returned_dict(tmp_path: Path):
+ """metrics.csv の内容が戻り値の辞書と一致すること."""
+ import csv
+
+ out_dir = tmp_path / "out"
+ metrics = evaluate_image(_write_npy(tmp_path, seed=4), _write_eval_yaml(tmp_path), out_dir)
+ with (out_dir / "metrics.csv").open(encoding="utf-8") as fh:
+ row = next(csv.DictReader(fh))
+ assert set(row) == set(metrics)
+ for key, value in row.items():
+ assert float(value) == pytest.approx(metrics[key], rel=1e-6)
+
+
+def test_creates_four_figures_by_default(tmp_path: Path):
+ """既定(save_figures 省略)で figures/ に 4 種の PNG が出ること(SPEC_04 可視化規約)."""
+ out_dir = tmp_path / "out"
+ evaluate_image(_write_npy(tmp_path, seed=5), _write_eval_yaml(tmp_path), out_dir)
+ for name in _FIGURE_FILES:
+ assert (out_dir / "figures" / name).is_file(), name
+
+
+def test_creates_deep_nested_output_dir(tmp_path: Path):
+ """出力ディレクトリが親ごと作られること."""
+ out_dir = tmp_path / "a" / "b" / "c"
+ evaluate_image(_write_npy(tmp_path, seed=6), _write_eval_yaml(tmp_path), out_dir)
+ assert (out_dir / "metrics.csv").is_file()
+
+
+# ---------------------------------------------------------------------------
+# save_figures=False(スイープ用の図なしモード)
+# ---------------------------------------------------------------------------
+
+
+def test_no_figures_dir_when_save_figures_false(tmp_path: Path):
+ """save_figures=False のとき figures/ が作られないこと."""
+ out_dir = tmp_path / "out"
+ evaluate_image(
+ _write_npy(tmp_path, seed=7), _write_eval_yaml(tmp_path), out_dir, save_figures=False
+ )
+ assert not (out_dir / "figures").exists()
+
+
+def test_metrics_csv_still_created_when_save_figures_false(tmp_path: Path):
+ """save_figures=False でも metrics.csv は出ること(指標だけ欲しい用途)."""
+ out_dir = tmp_path / "out"
+ evaluate_image(
+ _write_npy(tmp_path, seed=8), _write_eval_yaml(tmp_path), out_dir, save_figures=False
+ )
+ assert (out_dir / "metrics.csv").is_file()
+
+
+def test_metrics_identical_with_and_without_figures(tmp_path: Path):
+ """図の有無で指標が変わらないこと(描画は評価結果に影響しない)."""
+ npy = _write_npy(tmp_path, seed=9)
+ config = _write_eval_yaml(tmp_path)
+ with_figs = evaluate_image(npy, config, tmp_path / "with", save_figures=True)
+ without_figs = evaluate_image(npy, config, tmp_path / "without", save_figures=False)
+ assert set(with_figs) == set(without_figs)
+ for key in with_figs:
+ assert with_figs[key] == pytest.approx(without_figs[key], rel=1e-12)
+
+
+def test_summary_omits_figures_line_when_save_figures_false(tmp_path: Path, capsys):
+ """save_figures=False のときサマリーに figures/ 行を出さないこと."""
+ evaluate_image(
+ _write_npy(tmp_path, seed=10),
+ _write_eval_yaml(tmp_path),
+ tmp_path / "out",
+ save_figures=False,
+ )
+ out = capsys.readouterr().out
+ assert "metrics.csv" in out
+ assert "figures/" not in out
+
+
+def test_summary_includes_figures_line_by_default(tmp_path: Path, capsys):
+ """既定ではサマリーに figures/ 行が出ること(従来どおり)."""
+ evaluate_image(_write_npy(tmp_path, seed=11), _write_eval_yaml(tmp_path), tmp_path / "out")
+ assert "figures/" in capsys.readouterr().out
+
+
+def test_no_figure_files_written_anywhere_when_disabled(tmp_path: Path):
+ """save_figures=False のとき出力ディレクトリ配下に PNG が 1 つも無いこと."""
+ out_dir = tmp_path / "out"
+ evaluate_image(
+ _write_npy(tmp_path, seed=12), _write_eval_yaml(tmp_path), out_dir, save_figures=False
+ )
+ assert list(out_dir.rglob("*.png")) == []
+
+
+# ---------------------------------------------------------------------------
+# 異常系: 段階別の EvaluationStepError
+# ---------------------------------------------------------------------------
+
+
+def test_missing_image_raises_step_error(tmp_path: Path):
+ """画像が無いとき EvaluationStepError になること(見つからない旨を含む)."""
+ with pytest.raises(EvaluationStepError) as exc:
+ evaluate_image(tmp_path / "nope.npy", _write_eval_yaml(tmp_path), tmp_path / "out")
+ assert "画像ファイルが見つかりません" in str(exc.value)
+
+
+def test_broken_image_raises_load_step_error(tmp_path: Path):
+ """壊れた .npy は「画像の読み込みに失敗しました」段階の例外になること."""
+ bad = tmp_path / "broken.npy"
+ bad.write_bytes(b"not a npy file")
+ with pytest.raises(EvaluationStepError) as exc:
+ evaluate_image(bad, _write_eval_yaml(tmp_path), tmp_path / "out")
+ assert "画像の読み込みに失敗しました" in str(exc.value)
+
+
+def test_missing_config_raises_step_error(tmp_path: Path):
+ """設定 YAML が無いとき EvaluationStepError になること."""
+ with pytest.raises(EvaluationStepError) as exc:
+ evaluate_image(_write_npy(tmp_path, seed=13), tmp_path / "nope.yaml", tmp_path / "out")
+ assert "設定 YAML ファイルが見つかりません" in str(exc.value)
+
+
+def test_invalid_config_raises_config_step_error(tmp_path: Path):
+ """ROI 欠落の YAML は「設定 YAML の読み込みに失敗しました」段階の例外になること."""
+ config = tmp_path / "bad.yaml"
+ config.write_text("zones:\n enabled: true\n", encoding="utf-8")
+ with pytest.raises(EvaluationStepError) as exc:
+ evaluate_image(_write_npy(tmp_path, seed=14), config, tmp_path / "out")
+ assert "設定 YAML の読み込みに失敗しました" in str(exc.value)
+
+
+def test_roi_out_of_bounds_raises_roi_step_error(tmp_path: Path):
+ """ROI が画像外のとき「ROI 抽出に失敗しました」段階の例外になること."""
+ config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999)
+ with pytest.raises(EvaluationStepError) as exc:
+ evaluate_image(_write_npy(tmp_path, seed=15), config, tmp_path / "out")
+ assert "ROI 抽出に失敗しました" in str(exc.value)
+
+
+def test_zero_luminance_raises_metrics_step_error(tmp_path: Path):
+ """全ゼロ画像は「均一性指標の算出に失敗しました」段階の例外になること."""
+ npy = tmp_path / "zeros.npy"
+ numpy.save(npy, numpy.zeros((128, 128), dtype=numpy.float64))
+ with pytest.raises(EvaluationStepError) as exc:
+ evaluate_image(npy, _write_eval_yaml(tmp_path), tmp_path / "out")
+ assert "均一性指標の算出に失敗しました" in str(exc.value)
+
+
+def test_step_error_is_runtime_error_subclass():
+ """EvaluationStepError は RuntimeError 派生であること(呼び出し側の捕捉粒度)."""
+ assert issubclass(EvaluationStepError, RuntimeError)
+
+
+def test_no_output_written_when_roi_fails(tmp_path: Path):
+ """ROI 抽出で失敗したとき metrics.csv は作られないこと(中途半端な出力を残さない)."""
+ out_dir = tmp_path / "out"
+ config = _write_eval_yaml(tmp_path, x=0, y=0, width=9999, height=9999)
+ with pytest.raises(EvaluationStepError):
+ evaluate_image(_write_npy(tmp_path, seed=16), config, out_dir)
+ assert not (out_dir / "metrics.csv").exists()
diff --git a/tests/render/test_pipeline.py b/tests/render/test_pipeline.py
new file mode 100644
index 0000000..75727b9
--- /dev/null
+++ b/tests/render/test_pipeline.py
@@ -0,0 +1,245 @@
+"""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
diff --git a/tests/render/test_runner_ensure_variant.py b/tests/render/test_runner_ensure_variant.py
new file mode 100644
index 0000000..d5d5cf4
--- /dev/null
+++ b/tests/render/test_runner_ensure_variant.py
@@ -0,0 +1,260 @@
+"""render/runner.py の ensure_variant / seed 受け渡しの仕様ベーステスト.
+
+仕様出典:
+- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(render/runner 層の責務: variant 設定と mi.render)
+- docs/04_SPEC/SPEC_02_シーン定義仕様.md「render」セクション(seed を含むレンダ設定)
+- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
+ (Mitsuba の variant はプロセス内で 1 度しか設定できない.変えるにはプロセスを作り直す)
+
+検証する仕様:
+- ensure_variant(variant) は同一 variant で何度呼んでも例外にならない(2 回目以降は無操作)
+- 既に別の variant が設定済みなら警告ログを出して続行する(例外にしない)
+- スイープがレンダ前にレイキャストを目的の variant で走らせられるよう,
+ render_scene から独立して呼べる
+- render_scene は config.render.seed を mi.render に渡す
+
+テスト方針(testing.md):
+- variant はプロセス内で 1 度しか設定できないため,グローバル状態はフィクスチャで戻す
+- 実 Mitsuba を使う経路は scalar_rgb(tests/conftest.py が設定済みの variant)で確認し,
+ 「別 variant 要求時の警告」「seed の受け渡し」はモックで確認する
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import numpy as np
+import pytest
+
+import sm_tias_lightsim.render.runner as runner_module
+from sm_tias_lightsim.scene.schema import SceneConfig
+
+
+@pytest.fixture(autouse=True)
+def reset_runner_variant():
+ """各テスト前後に runner._current_variant をリセットする(テスト間の干渉防止)."""
+ original = runner_module._current_variant
+ runner_module._current_variant = None
+ yield
+ runner_module._current_variant = original
+
+
+def _make_scene_config(variant: str = "scalar_rgb", seed: int | None = None) -> SceneConfig:
+ """最小限の SceneConfig(実 STL 不要 — build_scene をモックするため)."""
+ render: dict = {"variant": variant, "integrator": "path", "max_depth": 2, "spp": 1}
+ if seed is not None:
+ render["seed"] = seed
+ data = {
+ "name": "test_ensure_variant",
+ "geometry": {
+ "meshes": [{"id": "dummy", "path": "dummy.stl", "unit": "mm", "material": "white"}]
+ },
+ "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
+ "lights": [
+ {
+ "id": "led",
+ "type": "point",
+ "position": [0.0, 0.0, 100.0],
+ "intensity": 1.0,
+ "profile": "lambertian",
+ }
+ ],
+ "sensor": {
+ "type": "perspective",
+ "position": [0.0, 0.0, 200.0],
+ "target": [0.0, 0.0, 0.0],
+ "up": [0.0, 1.0, 0.0],
+ "fov_deg": 60.0,
+ "film": {"width": 4, "height": 4},
+ },
+ "render": render,
+ }
+ return SceneConfig.model_validate(data)
+
+
+# ---------------------------------------------------------------------------
+# ensure_variant: 実 Mitsuba(scalar_rgb)での振る舞い
+# ---------------------------------------------------------------------------
+
+
+def test_ensure_variant_is_idempotent_for_same_variant():
+ """同じ variant で 2 回呼んでも例外にならないこと(2 回目以降は無操作).
+
+ 仕様: Mitsuba の set_variant はプロセス内 1 度だけ.スイープは条件ごとに
+ ensure_variant を呼ぶので,2 回目以降が失敗してはならない.
+ """
+ runner_module.ensure_variant("scalar_rgb")
+ runner_module.ensure_variant("scalar_rgb")
+ import mitsuba as mi
+
+ assert mi.variant() == "scalar_rgb"
+
+
+def test_ensure_variant_records_current_variant():
+ """1 度目の呼び出しで内部状態(_current_variant)が記録されること."""
+ assert runner_module._current_variant is None
+ runner_module.ensure_variant("scalar_rgb")
+ assert runner_module._current_variant == "scalar_rgb"
+
+
+def test_ensure_variant_does_not_call_set_variant_twice():
+ """2 回目の呼び出しでは mi.set_variant を呼ばないこと."""
+ dummy_mi = MagicMock()
+ dummy_mi.variant.return_value = "scalar_rgb"
+ with (
+ patch("sm_tias_lightsim.render.runner.log_variant_status"),
+ patch.dict("sys.modules", {"mitsuba": dummy_mi}),
+ ):
+ runner_module.ensure_variant("scalar_rgb")
+ runner_module.ensure_variant("scalar_rgb")
+ assert dummy_mi.set_variant.call_count == 1
+
+
+# ---------------------------------------------------------------------------
+# ensure_variant: 異なる variant 要求時
+# ---------------------------------------------------------------------------
+
+
+def test_ensure_variant_warns_when_different_variant_requested(caplog):
+ """別の variant を要求すると警告ログを出して続行すること(例外にしない).
+
+ 仕様: 同一プロセスで variant は切り替えられない.要求は無視し,
+ プロセスを作り直すよう促す警告を出す.
+ """
+ dummy_mi = MagicMock()
+ dummy_mi.variant.return_value = "scalar_rgb"
+ with (
+ patch("sm_tias_lightsim.render.runner.log_variant_status"),
+ patch.dict("sys.modules", {"mitsuba": dummy_mi}),
+ ):
+ runner_module.ensure_variant("scalar_rgb")
+ with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.render.runner"):
+ runner_module.ensure_variant("llvm_ad_rgb")
+
+ warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
+ assert warnings, "別 variant 要求時に WARNING ログが出ること"
+ assert dummy_mi.set_variant.call_count == 1, "2 度目の set_variant は呼ばないこと"
+
+
+def test_ensure_variant_keeps_first_variant_after_conflicting_request():
+ """別 variant を要求しても最初に設定した variant が維持されること."""
+ dummy_mi = MagicMock()
+ dummy_mi.variant.return_value = "scalar_rgb"
+ with (
+ patch("sm_tias_lightsim.render.runner.log_variant_status"),
+ patch.dict("sys.modules", {"mitsuba": dummy_mi}),
+ ):
+ runner_module.ensure_variant("scalar_rgb")
+ runner_module.ensure_variant("cuda_ad_rgb")
+ assert runner_module._current_variant == "scalar_rgb"
+
+
+def test_ensure_variant_calls_ensure_drjit_libllvm_path_for_llvm_variant():
+ """llvm_* 要求時は set_variant 前に DRJIT_LIBLLVM_PATH を探索すること."""
+ dummy_mi = MagicMock()
+ dummy_mi.variant.return_value = "llvm_ad_rgb"
+ with (
+ patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
+ patch("sm_tias_lightsim.render.runner.log_variant_status"),
+ patch.dict("sys.modules", {"mitsuba": dummy_mi}),
+ ):
+ runner_module.ensure_variant("llvm_ad_rgb")
+ mock_ensure.assert_called_once()
+
+
+def test_ensure_variant_skips_llvm_path_lookup_for_scalar_variant():
+ """scalar_* 要求時は DRJIT_LIBLLVM_PATH の探索を行わないこと."""
+ dummy_mi = MagicMock()
+ dummy_mi.variant.return_value = "scalar_rgb"
+ with (
+ patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path") as mock_ensure,
+ patch("sm_tias_lightsim.render.runner.log_variant_status"),
+ patch.dict("sys.modules", {"mitsuba": dummy_mi}),
+ ):
+ runner_module.ensure_variant("scalar_rgb")
+ mock_ensure.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# render_scene から ensure_variant を通じた variant 設定
+# ---------------------------------------------------------------------------
+
+
+def _render_with_mock(config: SceneConfig) -> MagicMock:
+ """build_scene / mitsuba をモックして render_scene を呼び,mitsuba モックを返す."""
+ dummy_image = np.zeros((4, 4, 3), dtype=np.float32)
+ dummy_mi = MagicMock()
+ dummy_mi.variant.return_value = config.render.variant
+ # numpy.array() でそのまま配列になるよう実配列を返す(mi.render の戻り値の代役)
+ dummy_mi.render.return_value = dummy_image
+ with (
+ patch("sm_tias_lightsim.render.runner.ensure_drjit_libllvm_path"),
+ patch(
+ "sm_tias_lightsim.render.runner.build_scene",
+ return_value=({"type": "scene"}, "/tmp/fake"),
+ ),
+ patch("sm_tias_lightsim.render.runner.log_variant_status"),
+ patch.dict("sys.modules", {"mitsuba": dummy_mi}),
+ patch("shutil.rmtree"),
+ ):
+ runner_module.render_scene(config, project_root=Path("/tmp"))
+ return dummy_mi
+
+
+def test_render_scene_sets_variant_from_config():
+ """render_scene が config.render.variant で variant を設定すること."""
+ _render_with_mock(_make_scene_config(variant="scalar_rgb"))
+ assert runner_module._current_variant == "scalar_rgb"
+
+
+def test_render_scene_passes_seed_to_mi_render():
+ """render_scene が config.render.seed を mi.render に渡すこと.
+
+ 仕様: 同一条件の再レンダで別のノイズ実現を得るため,seed はレンダに伝わる必要がある.
+ """
+ dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=3))
+ _, kwargs = dummy_mi.render.call_args
+ assert kwargs["seed"] == 3
+
+
+def test_render_scene_passes_default_seed_zero():
+ """seed 未指定(既定 0)でも mi.render に seed=0 が渡ること."""
+ dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb"))
+ _, kwargs = dummy_mi.render.call_args
+ assert kwargs["seed"] == 0
+
+
+def test_render_scene_passes_spp_and_seed_together():
+ """spp と seed の両方が mi.render に渡ること(片方の追加で他方が落ちない)."""
+ dummy_mi = _render_with_mock(_make_scene_config(variant="scalar_rgb", seed=5))
+ _, kwargs = dummy_mi.render.call_args
+ assert kwargs["spp"] == 1
+ assert kwargs["seed"] == 5
+
+
+@pytest.mark.slow
+def test_different_seeds_give_different_images(sphere_stl_in_cwd: Path, minimal_scene_yaml: str):
+ """seed を変えると実レンダの画素が変わること(ノイズの実現が変わる).
+
+ 仕様: 再現性チェック(同一条件の再レンダ)で seed を振ってノイズフロアを測るため,
+ seed が結果に効いていなければならない.
+ """
+ import yaml
+
+ data = yaml.safe_load(minimal_scene_yaml)
+ data["render"]["variant"] = "scalar_rgb"
+ data["render"]["spp"] = 4
+
+ data["render"]["seed"] = 0
+ img_a = runner_module.render_scene(
+ SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent
+ )
+ data["render"]["seed"] = 1
+ img_b = runner_module.render_scene(
+ SceneConfig.model_validate(data), project_root=sphere_stl_in_cwd.parent
+ )
+
+ assert img_a.shape == img_b.shape
+ assert not np.array_equal(img_a, img_b), "seed が違えば画素は一致しないこと"
diff --git a/tests/scene/test_schema_render_seed.py b/tests/scene/test_schema_render_seed.py
new file mode 100644
index 0000000..e8b03b8
--- /dev/null
+++ b/tests/scene/test_schema_render_seed.py
@@ -0,0 +1,97 @@
+"""render.seed(レンダの乱数シード)の仕様ベーステスト.
+
+仕様出典:
+- docs/04_SPEC/SPEC_02_シーン定義仕様.md「render」セクション(レンダ設定の YAML スキーマ.
+ seed は本実装で追加された新フィールドで,仕様書側は Phase 4 の更新対象)
+- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
+ (同じ YAML・同じ seed でも読み込み直すと画素は変わるが,指標はほぼ一致する.
+ ノイズの実現を意図的に変えたいときに seed を振る)
+
+検証する仕様:
+- seed は省略可能(既定 0)=既存 YAML が書き換え無しで読める(後方互換)
+- seed は 0 以上の整数のみ(負値は ValidationError)
+- YAML に書いた値がそのまま SceneConfig に載る
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+import yaml
+from pydantic import ValidationError
+
+from sm_tias_lightsim.scene.schema import RenderConfig, SceneConfig, load_scene_yaml
+
+MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml"
+
+
+# ---------------------------------------------------------------------------
+# 既定値・後方互換
+# ---------------------------------------------------------------------------
+
+
+def test_seed_defaults_to_zero():
+ """seed を省略すると 0 になること(既定値)."""
+ assert RenderConfig().seed == 0
+
+
+def test_existing_yaml_without_seed_is_still_valid():
+ """seed を書いていない既存 YAML がそのまま読め,seed=0 になること(後方互換)."""
+ config = load_scene_yaml(MINIMAL_YAML)
+ assert config.render.seed == 0
+
+
+def test_seed_omission_does_not_change_other_render_fields():
+ """seed の追加で他の render 設定の既定値が変わっていないこと."""
+ render = RenderConfig()
+ assert render.variant == "llvm_ad_rgb"
+ assert render.integrator == "path"
+ assert render.max_depth == 8
+ assert render.spp == 256
+
+
+# ---------------------------------------------------------------------------
+# 正常系: 値の指定
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("seed", [0, 1, 2, 42, 12345])
+def test_seed_accepts_non_negative_integers(seed):
+ """0 以上の整数はそのまま保持されること."""
+ assert RenderConfig(seed=seed).seed == seed
+
+
+def test_seed_from_yaml_is_loaded(tmp_path: Path):
+ """YAML の render.seed が SceneConfig に反映されること."""
+ data = yaml.safe_load(MINIMAL_YAML.read_text(encoding="utf-8"))
+ data["render"]["seed"] = 7
+ # メッシュの相対パスを保つため,YAML はフィクスチャと同じディレクトリ基準で解決する
+ yaml_path = tmp_path / "scene_with_seed.yaml"
+ yaml_path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
+ config = SceneConfig.model_validate(yaml.safe_load(yaml_path.read_text(encoding="utf-8")))
+ assert config.render.seed == 7
+
+
+# ---------------------------------------------------------------------------
+# 異常系
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("seed", [-1, -42])
+def test_negative_seed_raises_validation_error(seed):
+ """負の seed は ValidationError になること(ge=0)."""
+ with pytest.raises(ValidationError):
+ RenderConfig(seed=seed)
+
+
+def test_non_integer_seed_raises_validation_error():
+ """整数に解釈できない seed は ValidationError になること."""
+ with pytest.raises(ValidationError):
+ RenderConfig(seed="abc")
+
+
+def test_float_seed_with_fraction_raises_validation_error():
+ """小数の seed は ValidationError になること(整数フィールド)."""
+ with pytest.raises(ValidationError):
+ RenderConfig(seed=1.5)
diff --git a/tests/scripts/test_sweep_led_placement.py b/tests/scripts/test_sweep_led_placement.py
new file mode 100644
index 0000000..c952595
--- /dev/null
+++ b/tests/scripts/test_sweep_led_placement.py
@@ -0,0 +1,414 @@
+"""scripts/sweep_led_placement.py(照明配置スイープ)の仕様ベーステスト.
+
+仕様出典:
+- docs/06_TEST/TEST_17_オフ軸LED配置の影響とサイズ依存.md(オフ軸配置の定量.本スイープの前段)
+- docs/04_SPEC/SPEC_02_シーン定義仕様.md(シーン YAML スキーマ・render.seed)
+- docs/02_ENV/ENV_04_開発コマンド.md「研究室サーバでの実行」
+ (_tmp YAML のメッシュパスは YAML からの相対パスでなければサーバで読めない.
+ 同じ seed で読み直すと指標がほぼ一致するので,再現性チェックには seed を振る)
+- スクリプト冒頭の条件設計(位置 θ グリッド / 向き / 個数 / 再現性の 4 グループ)
+
+検証する仕様:
+- conditions() が設計どおりの条件集合(198 条件・名前が一意・重要な順)を返す
+- --list で条件名の一覧だけを表示して終了する(レンダしない)
+- prepare_condition() が条件ごとのシーン YAML を作り,再レンダ条件(rep>1)は
+ 幾何を共有したまま render.seed = rep-1 の別 YAML になる
+- YAML に書くメッシュパスは YAML からの相対パス(POSIX 区切り・ドライブ無し)
+
+テスト方針(testing.md):
+- sm-render / sm-evaluate は呼ばない(レンダはしない).幾何生成と YAML だけを見る
+- TMP_DIR / MESH_DIR を tmp_path に差し替える
+ (tests/scripts/test_sweep_relative_mesh_paths.py と同じ流儀)
+- 筐体メッシュ生成は球で十数秒かかるので,生成を伴うテストは立方体条件のみを使い,
+ モジュールスコープのフィクスチャで 1 度だけ生成する
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import sys
+from pathlib import Path, PurePosixPath, PureWindowsPath
+from types import ModuleType
+from unittest import mock
+
+import pytest
+import yaml
+
+_ROOT = Path(__file__).resolve().parents[2]
+_SCRIPTS = _ROOT / "scripts"
+
+# 再現性グループで使う立方体条件(スクリプトの SIZE_PAIRS[REPEAT_PAIR_INDEX] と対応)
+_CUBE_SIZE = 234.0
+
+
+def _load(name: str) -> ModuleType:
+ """scripts/.py を import する(既に読み込み済みならそれを使う)."""
+ path = (_SCRIPTS / f"{name}.py").resolve()
+ cached = sys.modules.get(name)
+ if cached is not None and Path(getattr(cached, "__file__", "")).resolve() == path:
+ return cached
+ spec = importlib.util.spec_from_file_location(name, path)
+ assert spec is not None and spec.loader is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture(scope="module")
+def sweep() -> ModuleType:
+ return _load("sweep_led_placement")
+
+
+@pytest.fixture(autouse=True)
+def _same_drive(tmp_path):
+ if tmp_path.resolve().drive.upper() != _ROOT.drive.upper():
+ pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境")
+
+
+# ---------------------------------------------------------------------------
+# 条件集合
+# ---------------------------------------------------------------------------
+
+
+def test_conditions_total_count(sweep):
+ """全条件数が 198 であること.
+
+ 仕様: 位置 2 形状 × 4 サイズ × 13θ = 104,個数 2 × 4 × (3 + 2) = 40,
+ 向き 2 × 4 × 3θ × 3 向き = 72 のうち θ=60/90/120 の板向き 1 灯 24 条件は
+ 位置グループと重複するので 48,再現性 2 形状 × 3 回 = 6.合計 198.
+ """
+ assert len(sweep.conditions()) == 198
+
+
+def test_condition_names_are_unique(sweep):
+ """条件名が一意であること(出力ディレクトリ名として使われるため)."""
+ names = [c.name for c in sweep.conditions()]
+ assert len(names) == len(set(names))
+
+
+def test_conditions_cover_all_groups(sweep):
+ """4 つのグループがすべて含まれること."""
+ groups = {c.group for c in sweep.conditions()}
+ assert groups == set(sweep.GROUPS)
+
+
+def test_group_counts_match_design(sweep):
+ """グループごとの条件数が設計どおりであること."""
+ from collections import Counter
+
+ counts = Counter(c.group for c in sweep.conditions())
+ assert counts["position"] == 104
+ assert counts["count"] == 40
+ assert counts["direction"] == 48
+ assert counts["repeat"] == 6
+
+
+def test_shapes_are_balanced(sweep):
+ """球と立方体が同数であること(同内面積ペアでの比較が目的)."""
+ conds = sweep.conditions()
+ assert sum(c.shape == "sphere" for c in conds) == sum(c.shape == "cube" for c in conds)
+
+
+def test_position_group_comes_first(sweep):
+ """主実験(位置)が先頭に並ぶこと(途中で止めても主実験が揃うようにする)."""
+ groups = [c.group for c in sweep.conditions()]
+ assert groups[0] == "position"
+ assert set(groups[:104]) == {"position"}
+
+
+def test_repeat_conditions_share_base_name_and_differ_by_rep(sweep):
+ """再現性条件は同じ base_name で rep だけが違うこと(幾何は同一)."""
+ repeats = [c for c in sweep.conditions() if c.group == "repeat"]
+ assert repeats, "再現性グループの条件が存在すること"
+ for cond in repeats:
+ assert cond.rep > 1
+ assert cond.name == f"{cond.base_name}_rep{cond.rep}"
+
+
+def test_position_conditions_use_rep_one(sweep):
+ """再現性以外の条件は rep=1(名前に rep が付かない)であること."""
+ for cond in sweep.conditions():
+ if cond.group != "repeat":
+ assert cond.rep == 1
+ assert cond.name == cond.base_name
+
+
+def test_azimuths_are_evenly_spaced(sweep):
+ """多灯条件の方位角が等間隔で,1 灯目が +X 側であること."""
+ cond = sweep.Condition("count", "cube", _CUBE_SIZE, 90.0, "board", 4)
+ assert cond.azimuths_deg == [0.0, 90.0, 180.0, 270.0]
+
+
+# ---------------------------------------------------------------------------
+# --list(レンダせず一覧のみ)
+# ---------------------------------------------------------------------------
+
+
+def test_list_prints_all_condition_names(sweep, monkeypatch, capsys):
+ """--list が全条件名と件数を表示して 0 を返すこと(レンダしない)."""
+ monkeypatch.setattr(sys, "argv", ["sweep_led_placement.py", "--list"])
+ assert sweep.main() == 0
+ out = capsys.readouterr().out
+ assert "198 条件" in out
+ assert out.count("\n") == 199 # 条件名 198 行 + 件数 1 行
+
+
+def test_list_respects_shape_and_group_filters(sweep, monkeypatch, capsys):
+ """--shapes / --groups で条件を絞れること."""
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["sweep_led_placement.py", "--list", "--shapes", "cube", "--groups", "position"],
+ )
+ assert sweep.main() == 0
+ lines = [ln for ln in capsys.readouterr().out.splitlines() if ln]
+ names = lines[:-1]
+ assert lines[-1] == "52 条件" # 4 サイズ × 13θ
+ assert all(name.startswith("cube_") for name in names)
+
+
+# ---------------------------------------------------------------------------
+# prepare_condition(幾何生成 + シーン YAML)
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def prepared(sweep, tmp_path_factory) -> dict[str, tuple[Path, dict]]:
+ """立方体条件の rep1 / rep2 / rep3 を 1 度だけ生成する.
+
+ 筐体メッシュは同じファイルを共有するので,2 件目以降の生成は速い.
+ """
+ base = tmp_path_factory.mktemp("led_placement")
+ if base.resolve().drive.upper() != _ROOT.drive.upper():
+ pytest.skip("tmp_path とリポジトリが別ドライブのため相対パスで表せない環境")
+ conds = [
+ sweep.Condition("repeat", "cube", _CUBE_SIZE, 90.0, "board", 1, rep)
+ for rep in (1, 2, 3)
+ ]
+ with (
+ mock.patch.object(sweep, "TMP_DIR", base),
+ mock.patch.object(sweep, "MESH_DIR", base / "meshes"),
+ ):
+ return {cond.name: sweep.prepare_condition(cond) for cond in conds}
+
+
+def _load_yaml(path: Path) -> dict:
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
+
+
+def test_prepare_writes_scene_yaml_named_after_condition(sweep, prepared):
+ """条件ごとに scene_.yaml が作られること."""
+ for name, (scene_yaml, _) in prepared.items():
+ assert scene_yaml.name == f"scene_{name}.yaml"
+ assert scene_yaml.is_file()
+
+
+def test_repeat_conditions_get_separate_yaml_files(sweep, prepared):
+ """再レンダ条件(rep>1)は元の条件とは別の YAML になること."""
+ paths = {str(scene_yaml) for scene_yaml, _ in prepared.values()}
+ assert len(paths) == len(prepared)
+
+
+def test_seed_equals_rep_minus_one(sweep, prepared):
+ """render.seed が rep-1 になること.
+
+ 仕様: seed が同じだとシーンを読み直しても指標が 4 桁まで一致してノイズフロアにならない
+ (ENV_04「研究室サーバでの実行」の実測).再レンダは seed を振って別実現にする.
+ """
+ for name, (scene_yaml, _) in prepared.items():
+ rep = int(name.split("_rep")[1]) if "_rep" in name else 1
+ assert _load_yaml(scene_yaml)["render"]["seed"] == rep - 1, name
+
+
+def test_first_rep_uses_default_seed_zero(sweep, prepared):
+ """rep=1(通常条件)の seed が 0 であること(既定値と同じ)."""
+ scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
+ assert _load_yaml(scene_yaml)["render"]["seed"] == 0
+
+
+def test_repeat_yaml_differs_only_in_seed_and_name(sweep, prepared):
+ """再レンダ条件の YAML は seed・name・description 以外が元の条件と同一であること.
+
+ 仕様: 幾何(筐体・LED・バッフル)は共有し,変えるのは乱数シードだけ.
+ """
+ base = _load_yaml(prepared["cube_L234_th090_board_n1"][0])
+ rep2 = _load_yaml(prepared["cube_L234_th090_board_n1_rep2"][0])
+ for key in ("name", "description"):
+ base.pop(key, None)
+ rep2.pop(key, None)
+ assert base["render"].pop("seed") == 0
+ assert rep2["render"].pop("seed") == 1
+ assert base == rep2
+
+
+def test_scene_yaml_is_valid_scene_config(sweep, prepared):
+ """生成した YAML が SceneConfig として検証を通ること(SPEC_02 スキーマ)."""
+ from sm_tias_lightsim.scene.schema import SceneConfig
+
+ scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
+ config = SceneConfig.model_validate(_load_yaml(scene_yaml))
+ assert config.render.seed == 0
+
+
+def test_mesh_paths_are_relative_to_yaml(sweep, prepared):
+ """メッシュパスが YAML からの相対パス(POSIX 区切り・ドライブ無し)であること.
+
+ 仕様: Windows 絶対パスの YAML は研究室サーバで読めない(ENV_04 注意).
+ """
+ from sm_tias_lightsim.remote.files import PushEntry, find_yaml_with_windows_paths
+
+ scene_yaml, _ = prepared["cube_L234_th090_board_n1"]
+ meshes = _load_yaml(scene_yaml)["geometry"]["meshes"]
+ assert meshes, "メッシュが 1 つ以上書かれていること"
+ for mesh in meshes:
+ path = mesh["path"]
+ assert "\\" not in path, f"{mesh['id']}: POSIX 区切りでない: {path}"
+ assert not PurePosixPath(path).is_absolute(), f"{mesh['id']}: 絶対パス: {path}"
+ assert not PureWindowsPath(path).drive, f"{mesh['id']}: ドライブ付き: {path}"
+ assert (scene_yaml.parent / path).resolve().is_file(), f"{mesh['id']}: 実体が無い: {path}"
+
+ entry = PushEntry(
+ path=scene_yaml, arcname=f"configs/_tmp_led_placement/{scene_yaml.name}", size=0
+ )
+ assert find_yaml_with_windows_paths([entry]) == []
+
+
+def test_housing_mesh_is_shared_between_repeats(sweep, prepared):
+ """再レンダ条件が同じ筐体メッシュを参照すること(幾何を作り直さない)."""
+ base_yaml, _ = prepared["cube_L234_th090_board_n1"]
+ rep_yaml, _ = prepared["cube_L234_th090_board_n1_rep2"]
+
+ def housing(path: Path) -> Path:
+ meshes = _load_yaml(path)["geometry"]["meshes"]
+ entry = next(m for m in meshes if m["id"] == "cube_housing")
+ return (path.parent / entry["path"]).resolve()
+
+ assert housing(base_yaml) == housing(rep_yaml)
+
+
+def test_baffled_condition_records_baffle(sweep, prepared):
+ """板の前方に発光面がある条件はバッフルを生成し,記録に残すこと."""
+ _, record = prepared["cube_L234_th090_board_n1"]
+ assert record["baffled"] == 1
+ assert record["baffle_radius_mm"] > 0.0
+
+
+# ---------------------------------------------------------------------------
+# variant の解決
+# ---------------------------------------------------------------------------
+
+
+def test_render_variant_prefers_env(sweep, monkeypatch):
+ """SM_RENDER_VARIANT がテンプレート YAML の variant より優先されること."""
+ monkeypatch.setattr(sweep, "_render_variant", None)
+ monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb")
+ assert sweep.render_variant() == "cuda_ad_rgb"
+
+
+def test_render_variant_falls_back_to_template(sweep, monkeypatch):
+ """環境変数が無ければテンプレート YAML の render.variant を使うこと."""
+ monkeypatch.setattr(sweep, "_render_variant", None)
+ monkeypatch.delenv("SM_RENDER_VARIANT", raising=False)
+ template = yaml.safe_load(sweep.TEMPLATE_YAML.read_text(encoding="utf-8"))
+ assert sweep.render_variant() == template["render"]["variant"]
+
+
+def test_render_variant_is_cached(sweep, monkeypatch):
+ """variant は 1 度だけ解決して使い回すこと(条件ごとの YAML 読み直しを避ける)."""
+ monkeypatch.setattr(sweep, "_render_variant", None)
+ monkeypatch.setenv("SM_RENDER_VARIANT", "cuda_ad_rgb")
+ first = sweep.render_variant()
+ monkeypatch.setenv("SM_RENDER_VARIANT", "scalar_rgb")
+ assert sweep.render_variant() == first
+
+
+# ---------------------------------------------------------------------------
+# run_job(レンダ + 評価をプロセス内で実行する)
+# ---------------------------------------------------------------------------
+
+
+def _fake_metrics_csv(path: Path) -> None:
+ """read_metrics_csv が読む列を持つ metrics.csv を書く."""
+ columns = [
+ "cov", "center_periphery_ratio", "gradient_magnitude", "radial_min_max_ratio",
+ "angular_ratio", "pca_eccentricity", "intensity_centroid_offset_norm",
+ "max_min_ratio", "intensity_centroid_x_norm", "intensity_centroid_y_norm",
+ ]
+ header = ",".join(columns)
+ values = ",".join("0.5" for _ in columns)
+ path.write_text(f"{header}\n{values}\n", encoding="utf-8")
+
+
+def test_run_job_reuses_existing_row_json(sweep, tmp_path, monkeypatch):
+ """既に row.json がある条件は再レンダせず,その内容をそのまま返すこと.
+
+ 仕様: スイープは途中で止めて再開できる(既存結果の再利用).
+ """
+ def _must_not_render(*args, **kwargs):
+ raise AssertionError("既存結果があるときはレンダしないこと")
+
+ monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
+ monkeypatch.setattr(sweep, "render_yaml", _must_not_render)
+ monkeypatch.setattr(sweep, "ensure_render_variant", _must_not_render)
+
+ cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
+ out_dir = tmp_path / "output" / cond.name
+ out_dir.mkdir(parents=True)
+ (out_dir / "row.json").write_text(
+ json.dumps({"name": cond.name, "cov": 0.01}), encoding="utf-8"
+ )
+
+ row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
+ assert row == {"name": cond.name, "cov": 0.01}
+
+
+def test_run_job_renders_and_evaluates_without_figures(sweep, tmp_path, monkeypatch):
+ """レンダ → 評価をプロセス内で呼び,評価は figures 無しで行うこと.
+
+ 仕様: スイープでは図が不要なので save_figures=False で描画時間を省く.
+ """
+ import numpy as np
+
+ calls: dict = {}
+
+ def fake_render_yaml(scene_yaml, out_dir, variant):
+ calls["render"] = {"scene": scene_yaml, "out_dir": out_dir, "variant": variant}
+ np.save(Path(out_dir) / "luminance.npy", np.full((512, 512), 100.0))
+ return Path(out_dir) / "render.png", Path(out_dir) / "luminance.npy"
+
+ def fake_evaluate_image(image, config, out_dir, save_figures=True):
+ calls["evaluate"] = {"image": image, "config": config, "save_figures": save_figures}
+ _fake_metrics_csv(Path(out_dir) / "metrics.csv")
+ return {}
+
+ monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
+ monkeypatch.setattr(sweep, "render_yaml", fake_render_yaml)
+ monkeypatch.setattr(sweep, "evaluate_image", fake_evaluate_image)
+ monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb")
+
+ cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
+ row, _ = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
+
+ assert calls["evaluate"]["save_figures"] is False
+ assert calls["render"]["variant"] == "scalar_rgb"
+ assert row["render_variant"] == "scalar_rgb"
+ assert row["name"] == cond.name
+ assert (tmp_path / "output" / cond.name / "row.json").is_file()
+
+
+def test_run_job_returns_none_on_failure(sweep, tmp_path, monkeypatch):
+ """1 条件が失敗しても例外を投げず None を返すこと(スイープ全体を止めない)."""
+ def boom(*args, **kwargs):
+ raise RuntimeError("テスト用レンダ失敗")
+
+ monkeypatch.setattr(sweep, "OUTPUT_BASE", tmp_path / "output")
+ monkeypatch.setattr(sweep, "render_yaml", boom)
+ monkeypatch.setattr(sweep, "ensure_render_variant", lambda: "scalar_rgb")
+
+ cond = sweep.Condition("position", "cube", _CUBE_SIZE, 90.0, "board", 1)
+ row, messages = sweep.run_job(cond, tmp_path / "scene.yaml", {"baffled": 0})
+ assert row is None
+ assert any("失敗" in m for m in messages)
+ assert not (tmp_path / "output" / cond.name / "row.json").exists()