Newer
Older
SmTIAS-LightSim / tests / cli / test_visualize_cli.py
"""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 でも正常終了すること"


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