"""cli/visualize.py の仕様ベーステスト.
仕様出典:
- Phase 1 実装サマリー(sm-visualize CLI 仕様)
- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(CLI 層の責務)
- docs/01_GUIDE/GUIDE_08_テスト方針.md(テスト方針)
テスト方針:
- main() を直接呼び出す(subprocess 不使用:速度・カバレッジのため)
- ファイル I/O は tmp_path で実ファイルを使う(GUIDE_08)
- 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 でも正常終了すること"