"""cli/visualize.py の仕様ベーステスト.
仕様出典:
- Phase 1 実装サマリー(sm-visualize CLI 仕様)
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(CLI 層の責務)
- .claude/rules/testing.md(テスト方針)
テスト方針:
- main() を直接呼び出す(subprocess 不使用:速度・カバレッジのため)
- ファイル I/O は tmp_path で実ファイルを使う(testing.md)
- PyVista は off_screen=True 相当(screenshot 指定 or --off-screen)で実行
CLI 仕様(sm-visualize):
--scene / -s <yaml> 必須
--no-bake ベイクをスキップ
--screenshot <path> PNG を保存(off_screen 強制)
--output / -o <dir> ベイク結果を npz として保存
--off-screen ウィンドウを開かない
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from sm_tias_lightsim.cli.visualize import main
# ---------------------------------------------------------------------------
# テスト用定数
# ---------------------------------------------------------------------------
MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml"
# ---------------------------------------------------------------------------
# 1. 必須引数の欠落(軽量 — argparse 動作)
# ---------------------------------------------------------------------------
def test_missing_scene_arg_exits_with_error():
"""--scene を渡さないと SystemExit が発生すること.
仕様: --scene は必須引数(required=True)
"""
with pytest.raises(SystemExit) as exc_info:
main([])
assert exc_info.value.code != 0
def test_missing_scene_arg_with_other_args_exits_with_error():
"""--scene なしで他の引数を渡すと SystemExit が発生すること."""
with pytest.raises(SystemExit) as exc_info:
main(["--no-bake"])
assert exc_info.value.code != 0
# ---------------------------------------------------------------------------
# 2. 存在しない YAML パス(軽量 — return code 1)
# ---------------------------------------------------------------------------
def test_nonexistent_scene_returns_1(tmp_path: Path, capsys):
"""存在しない YAML パスを渡すと return 1 かつ stderr にエラーが出ること.
仕様: FileNotFoundError → stderr「シーン YAML ファイルが見つかりません」→ return 1
"""
nonexistent = tmp_path / "does_not_exist.yaml"
result = main(["--scene", str(nonexistent), "--no-bake", "--off-screen"])
assert result == 1
captured = capsys.readouterr()
assert "シーン YAML ファイルが見つかりません" in captured.err
def test_nonexistent_scene_error_message_is_on_stderr(tmp_path: Path, capsys):
"""エラーメッセージが stderr に出ること."""
nonexistent = tmp_path / "missing.yaml"
main(["--scene", str(nonexistent), "--no-bake", "--off-screen"])
captured = capsys.readouterr()
assert "エラー:" in captured.err
# ---------------------------------------------------------------------------
# 3. 不正 YAML(軽量 — return code 1)
# ---------------------------------------------------------------------------
def test_invalid_yaml_returns_1(invalid_yaml_file: Path, capsys):
"""必須フィールド欠落の YAML を渡すと return 1 が返ること.
仕様: Pydantic ValidationError → stderr「シーン YAML の読み込みに失敗しました」→ return 1
"""
result = main(["--scene", str(invalid_yaml_file), "--no-bake", "--off-screen"])
assert result == 1
def test_invalid_yaml_stderr_message(invalid_yaml_file: Path, capsys):
"""不正 YAML のエラーメッセージに「シーン YAML の読み込みに失敗しました」が含まれること."""
main(["--scene", str(invalid_yaml_file), "--no-bake", "--off-screen"])
captured = capsys.readouterr()
assert "シーン YAML の読み込みに失敗しました" in captured.err
# ---------------------------------------------------------------------------
# 4. --no-bake モード(slow — PyVista 実行)
# ---------------------------------------------------------------------------
@pytest.mark.slow
def test_no_bake_with_screenshot_creates_png(tmp_path: Path):
"""--no-bake + --screenshot で PNG が生成されること(ベイクなし).
仕様: --no-bake のとき bake_irradiance をスキップする
"""
screenshot_path = tmp_path / "no_bake_out.png"
result = main([
"--scene", str(MINIMAL_YAML),
"--no-bake",
"--screenshot", str(screenshot_path),
])
assert result == 0, "正常終了 (return 0)"
assert screenshot_path.exists(), "--no-bake + --screenshot で PNG が生成されること"
assert screenshot_path.stat().st_size > 0, "PNG ファイルが空でないこと"
@pytest.mark.slow
def test_no_bake_does_not_print_bake_start(tmp_path: Path, capsys):
"""--no-bake のとき「照度ベイクを開始します」が stdout に出ないこと.
仕様: --no-bake が指定されたとき「--no-bake: 照度ベイクをスキップします」を出力する
"""
screenshot_path = tmp_path / "out.png"
main([
"--scene", str(MINIMAL_YAML),
"--no-bake",
"--screenshot", str(screenshot_path),
])
captured = capsys.readouterr()
assert "照度ベイクを開始します" not in captured.out
assert "スキップ" in captured.out
# ---------------------------------------------------------------------------
# 5. --screenshot で PNG が生成されること(slow — ベイク + PyVista 実行)
# ---------------------------------------------------------------------------
@pytest.mark.slow
def test_screenshot_creates_png(tmp_path: Path):
"""--screenshot で PNG が生成されること(ベイクあり).
仕様: --screenshot <path> PNG を保存(off_screen 強制)
"""
screenshot_path = tmp_path / "out.png"
result = main([
"--scene", str(MINIMAL_YAML),
"--screenshot", str(screenshot_path),
])
assert result == 0, "正常終了 (return 0)"
assert screenshot_path.exists(), "--screenshot で PNG が生成されること"
assert screenshot_path.stat().st_size > 0
@pytest.mark.slow
def test_screenshot_stdout_contains_save_message(tmp_path: Path, capsys):
"""--screenshot 実行後に stdout に「スクリーンショット保存」が含まれること.
仕様: screenshot 指定時は完了後に「スクリーンショット保存: <path>」を出力する
"""
screenshot_path = tmp_path / "out.png"
main([
"--scene", str(MINIMAL_YAML),
"--screenshot", str(screenshot_path),
])
captured = capsys.readouterr()
assert "スクリーンショット保存" in captured.out
# ---------------------------------------------------------------------------
# 6. --output でベイク結果 npz が保存されること(slow)
# ---------------------------------------------------------------------------
@pytest.mark.slow
def test_output_dir_creates_npz(tmp_path: Path):
"""--output 指定でベイク結果 npz が保存されること.
仕様: --output / -o <dir> ベイク結果を {config.name}_irradiance.npz として保存
"""
output_dir = tmp_path / "bake_out"
screenshot_path = tmp_path / "out.png"
result = main([
"--scene", str(MINIMAL_YAML),
"--output", str(output_dir),
"--screenshot", str(screenshot_path),
])
assert result == 0
# {config.name}_irradiance.npz が存在すること
# minimal.yaml の name: smoke_test
npz_path = output_dir / "smoke_test_irradiance.npz"
assert npz_path.exists(), f"npz ファイルが生成されること: {npz_path}"
# npz が正しくロードできること
data = np.load(str(npz_path))
assert len(data.files) > 0, "npz に少なくとも 1 つのキーが含まれること"
@pytest.mark.slow
def test_output_dir_is_created_automatically(tmp_path: Path):
"""--output で指定したディレクトリが自動生成されること.
仕様: output_dir.mkdir(parents=True, exist_ok=True) で親ディレクトリも含めて作成する
"""
output_dir = tmp_path / "deep" / "nested" / "bake"
screenshot_path = tmp_path / "out.png"
assert not output_dir.exists(), "テスト前にはディレクトリが存在しないこと"
result = main([
"--scene", str(MINIMAL_YAML),
"--output", str(output_dir),
"--screenshot", str(screenshot_path),
])
assert result == 0
assert output_dir.exists(), "深いネストのディレクトリが自動作成されること"
# ---------------------------------------------------------------------------
# 7. 短縮引数 -s / -o(slow)
# ---------------------------------------------------------------------------
@pytest.mark.slow
def test_short_args_s_and_o_work(tmp_path: Path):
"""-s と -o の短縮形でも --scene / --output と同等に動くこと.
仕様: --scene/-s, --output/-o の短縮形を提供する
"""
output_dir = tmp_path / "short_out"
screenshot_path = tmp_path / "out.png"
result = main([
"-s", str(MINIMAL_YAML),
"-o", str(output_dir),
"--screenshot", str(screenshot_path),
])
assert result == 0
assert (output_dir / "smoke_test_irradiance.npz").exists()
# ---------------------------------------------------------------------------
# 8. --off-screen + ベイクのみ(screenshot なし,no-op)(slow)
# ---------------------------------------------------------------------------
@pytest.mark.slow
def test_off_screen_without_screenshot_returns_0(tmp_path: Path):
"""--off-screen + screenshot なしで no-op になり return 0 が返ること.
仕様: screenshot=None かつ off_screen=True のときは no-op
"""
result = main([
"--scene", str(MINIMAL_YAML),
"--no-bake",
"--off-screen",
])
assert result == 0, "no-op でも正常終了すること"
# ---------------------------------------------------------------------------
# 9. 新引数のパース検証(argparse 層、Mitsuba 不要)
# ---------------------------------------------------------------------------
class TestNewArgParsing:
"""新規追加引数のパース仕様ベーステスト(Phase 2 で追加された引数).
仕様(cli/visualize.py _parse_args):
--bake-mode choices=["direct", "path"] default="direct"
--bake-samples type=int default=64
--bake-cache type=Path default=None
--opacity type=float default=0.5
--hide-below type=float default=0.0
--bake-smooth type=int default=4
--bake-scale choices=["linear", "log"] default="log"
"""
def test_bake_mode_default_is_direct(self):
"""--bake-mode の既定値が "direct" であること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.bake_mode == "direct"
def test_bake_mode_path_is_accepted(self):
"""--bake-mode path が受け付けられること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml", "--bake-mode", "path"])
assert args.bake_mode == "path"
def test_bake_mode_invalid_raises_system_exit(self):
"""--bake-mode に不正値を渡すと SystemExit が発生すること(choices バリデーション)."""
from sm_tias_lightsim.cli.visualize import _parse_args
with pytest.raises(SystemExit) as exc_info:
_parse_args(["--scene", "scene.yaml", "--bake-mode", "invalid"])
assert exc_info.value.code != 0
def test_bake_samples_default_is_64(self):
"""--bake-samples の既定値が 64 であること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.bake_samples == 64
def test_bake_samples_custom_value(self):
"""--bake-samples 256 が正しくパースされること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml", "--bake-samples", "256"])
assert args.bake_samples == 256
def test_bake_cache_default_is_none(self):
"""--bake-cache の既定値が None であること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.bake_cache is None
def test_bake_cache_path_is_accepted(self, tmp_path):
"""--bake-cache PATH が Path 型でパースされること."""
from sm_tias_lightsim.cli.visualize import _parse_args
cache_path = tmp_path / "bake_cache.npz"
args = _parse_args(["--scene", "scene.yaml", "--bake-cache", str(cache_path)])
assert args.bake_cache == cache_path
def test_opacity_default_is_05(self):
"""--opacity の既定値が 0.5 であること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.opacity == pytest.approx(0.5)
def test_opacity_custom_value(self):
"""--opacity 0.8 が float でパースされること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml", "--opacity", "0.8"])
assert args.opacity == pytest.approx(0.8)
def test_hide_below_default_is_0(self):
"""--hide-below の既定値が 0.0 であること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.hide_below == pytest.approx(0.0)
def test_hide_below_negative_value(self):
"""--hide-below -1.0 が負の float でパースされること(全面表示モード)."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml", "--hide-below", "-1.0"])
assert args.hide_below == pytest.approx(-1.0)
def test_bake_smooth_default_is_4(self):
"""--bake-smooth の既定値が 4 であること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.bake_smooth == 4
def test_bake_smooth_zero(self):
"""--bake-smooth 0 がパースされること(平滑化なし)."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml", "--bake-smooth", "0"])
assert args.bake_smooth == 0
def test_bake_scale_default_is_log(self):
"""--bake-scale の既定値が "log" であること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.bake_scale == "log"
def test_bake_scale_linear_is_accepted(self):
"""--bake-scale linear が受け付けられること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml", "--bake-scale", "linear"])
assert args.bake_scale == "linear"
def test_bake_scale_invalid_raises_system_exit(self):
"""--bake-scale に不正値を渡すと SystemExit が発生すること."""
from sm_tias_lightsim.cli.visualize import _parse_args
with pytest.raises(SystemExit) as exc_info:
_parse_args(["--scene", "scene.yaml", "--bake-scale", "gamma"])
assert exc_info.value.code != 0
# ---------------------------------------------------------------------------
# 10. is_hybrid / no_bake ロジック(引数パース層の純論理テスト)
# ---------------------------------------------------------------------------
class TestIsHybridAndNoBakeLogic:
"""is_hybrid と no_bake の計算ロジックを検証する.
仕様(cli/visualize.py main):
is_hybrid = (render_path is not None) and (bake_mode == "path")
no_bake = args.no_bake or (render_path is not None and not is_hybrid)
つまり:
--render なし + bake_mode=direct → is_hybrid=False, no_bake=False(全面直接ベイク)
--render なし + bake_mode=path → is_hybrid=False, no_bake=False(全面 path ベイク)
--render あり + bake_mode=direct → is_hybrid=False, no_bake=True(投影のみ)
--render あり + bake_mode=path → is_hybrid=True, no_bake=False(ハイブリッド)
--no-bake 指定 → no_bake=True(常に)
"""
def _compute_hybrid_no_bake(self, render_path, bake_mode, explicit_no_bake=False):
"""main() の is_hybrid / no_bake 計算をテスト用に再現する."""
is_hybrid = (render_path is not None) and (bake_mode == "path")
no_bake = explicit_no_bake or (render_path is not None and not is_hybrid)
return is_hybrid, no_bake
def test_no_render_direct_is_not_hybrid_not_no_bake(self):
"""--render なし + direct: is_hybrid=False, no_bake=False(全面ベイク)."""
is_hybrid, no_bake = self._compute_hybrid_no_bake(None, "direct")
assert is_hybrid is False
assert no_bake is False
def test_no_render_path_is_not_hybrid_not_no_bake(self):
"""--render なし + path: is_hybrid=False, no_bake=False(全面 path ベイク)."""
is_hybrid, no_bake = self._compute_hybrid_no_bake(None, "path")
assert is_hybrid is False
assert no_bake is False
def test_render_direct_is_not_hybrid_but_no_bake(self, tmp_path):
"""--render あり + direct: is_hybrid=False, no_bake=True(投影のみ).
仕様: --render + --bake-mode direct → ベイクしない(投影のみ)
"""
render_path = tmp_path / "lum.npy"
is_hybrid, no_bake = self._compute_hybrid_no_bake(render_path, "direct")
assert is_hybrid is False
assert no_bake is True
def test_render_path_is_hybrid_not_no_bake(self, tmp_path):
"""--render あり + path: is_hybrid=True, no_bake=False(ハイブリッドモード).
仕様: --render + --bake-mode path → ハイブリッドモード(評価板=投影・壁=ベイク)
"""
render_path = tmp_path / "lum.npy"
is_hybrid, no_bake = self._compute_hybrid_no_bake(render_path, "path")
assert is_hybrid is True
assert no_bake is False
def test_explicit_no_bake_overrides_all(self, tmp_path):
"""--no-bake 指定時は常に no_bake=True になること."""
render_path = tmp_path / "lum.npy"
# --render + path でも --no-bake があれば no_bake=True
_, no_bake = self._compute_hybrid_no_bake(render_path, "path", explicit_no_bake=True)
assert no_bake is True
# ---------------------------------------------------------------------------
# 11. --bake-cache 読み書きロジック(純ロジック・argparse 層)
# ---------------------------------------------------------------------------
class TestBakeCacheParsing:
"""--bake-cache の引数パース仕様テスト.
仕様(cli/visualize.py):
--bake-cache PATH: 存在すれば読込(Mitsuba 起動なし)・なければ計算後保存
--bake-mode path のときのみ有効(direct では無視)
"""
def test_bake_cache_none_by_default(self):
"""--bake-cache を省略した場合 None になること."""
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args(["--scene", "scene.yaml"])
assert args.bake_cache is None
def test_bake_cache_existing_file_is_loaded(self, tmp_path):
"""--bake-cache に存在する npz パス指定で読込(ベイク再計算なし)のロジックが動くこと.
実際にキャッシュ読込が行われることは main() レベルで確認する必要があるが、
ここでは読込が行われる前提条件(bake_mode=path かつ bake_cache_path が存在する)の
パース層確認に留める(Mitsuba 依存を避けるため).
"""
# 有効な npz ファイルを作成
import numpy as np
from sm_tias_lightsim.cli.visualize import _parse_args
cache_path = tmp_path / "cache.npz"
np.savez(str(cache_path), mesh_a=np.zeros(10, dtype=np.float32))
args = _parse_args([
"--scene", "scene.yaml",
"--bake-mode", "path",
"--bake-cache", str(cache_path),
])
assert args.bake_cache == cache_path
assert args.bake_mode == "path"
def test_bake_cache_invalid_npz_is_handled_gracefully(self, tmp_path, capsys):
"""--bake-cache に不正な npz を指定すると警告して再計算にフォールバックすること.
仕様(cli/visualize.py):
キャッシュの読み込みに失敗した場合は警告して再計算にフォールバックする
"""
# 不正なファイル(非 npz)を作成
invalid_cache = tmp_path / "invalid.npz"
invalid_cache.write_bytes(b"not a npz file content")
# main() を呼ぶと YAML が存在しないので return 1 になるが、
# ここでは不正 npz の警告メッセージが stderr に出ることを確認する.
# 最小 YAML が存在しない場合は return 1(YAML エラー)で止まるため
# キャッシュ読込の副作用のみチェックが困難 → 純パーズ層のみ確認
from sm_tias_lightsim.cli.visualize import _parse_args
args = _parse_args([
"--scene", "scene.yaml",
"--bake-mode", "path",
"--bake-cache", str(invalid_cache),
])
# パース自体は成功する
assert args.bake_cache == invalid_cache