Newer
Older
SmTIAS-LightSim / tests / visualize / test_viewer_new_args.py
"""viewer.py / cli/visualize.py の新引数仕様ベーステスト (Phase 2).

仕様出典:
- src/sm_tias_lightsim/visualize/viewer.py (show_scene docstring)
    cull_outward: bool = True (既定 ON が新仕様)
    hide_sensor_marker: bool = False
    hide_led_markers: bool = False
- src/sm_tias_lightsim/cli/visualize.py (_parse_args)
    --bake-max-depth N (既定 16)
    --cull-outward / --no-cull-outward
    --no-sensor-marker / --no-led-markers / --no-markers (OR 論理)

テスト方針(GUIDE_08):
    - show_scene の引数シグネチャ・デフォルト値は Mitsuba 不要で純関数的に確認
    - CLI 引数パーズは _parse_args() で直接確認
    - 実描画テスト(smoke)は @pytest.mark.slow
    - 仕様変更(cull_outward 既定 True)で更新した既存テストは所見欄に明記

## 仕様変更注記 (cull_outward 既定 True)
既存 test_viewer.py の show_scene 呼び出しは cull_outward のデフォルト変更により
内部のカリング処理が走るようになった(既定 ON)。これは仕様変更による期待値更新であり
実装バグではない。既存の smoke テスト(ファイル生成確認)は動作に影響しない(PNG は生成される)。
"""

from __future__ import annotations

import inspect
from pathlib import Path

import numpy as np
import pytest

from sm_tias_lightsim.visualize.viewer import show_scene


# ===========================================================================
# 1. show_scene シグネチャの後方互換
# ===========================================================================


class TestShowSceneSignatureCompatibility:
    """show_scene の新引数シグネチャ・デフォルト値の仕様テスト (Mitsuba 不要).

    仕様(viewer.py show_scene):
        cull_outward: bool = True
        hide_sensor_marker: bool = False
        hide_led_markers: bool = False
    """

    def test_cull_outward_param_exists_with_default_true(self):
        """cull_outward 引数が存在し、デフォルト True であること.

        仕様: cull_outward: bool = True(既定 ON が新仕様)
        """
        sig = inspect.signature(show_scene)
        param = sig.parameters.get("cull_outward")
        assert param is not None, "show_scene に cull_outward 引数が存在すること"
        assert param.default is True, (
            f"cull_outward のデフォルトが True であること(新仕様): {param.default}"
        )

    def test_hide_sensor_marker_param_exists_with_default_false(self):
        """hide_sensor_marker 引数が存在し、デフォルト False であること.

        仕様: hide_sensor_marker: bool = False(後方互換)
        """
        sig = inspect.signature(show_scene)
        param = sig.parameters.get("hide_sensor_marker")
        assert param is not None, "show_scene に hide_sensor_marker 引数が存在すること"
        assert param.default is False, (
            f"hide_sensor_marker のデフォルトが False であること: {param.default}"
        )

    def test_hide_led_markers_param_exists_with_default_false(self):
        """hide_led_markers 引数が存在し、デフォルト False であること.

        仕様: hide_led_markers: bool = False(後方互換)
        """
        sig = inspect.signature(show_scene)
        param = sig.parameters.get("hide_led_markers")
        assert param is not None, "show_scene に hide_led_markers 引数が存在すること"
        assert param.default is False, (
            f"hide_led_markers のデフォルトが False であること: {param.default}"
        )

    def test_existing_params_are_preserved(self):
        """既存引数(irradiance, luminance_map, off_screen 等)が維持されていること(後方互換).

        仕様: 新 keyword 引数デフォルトで既存 show_scene 呼び出しが動く.
        """
        sig = inspect.signature(show_scene)
        existing_params = [
            "scene_config", "project_root", "irradiance", "luminance_map",
            "off_screen", "screenshot", "clip_low", "clip_high",
            "bake_opacity", "hide_below", "bake_smooth", "bake_scale",
        ]
        for name in existing_params:
            assert name in sig.parameters, (
                f"既存引数 '{name}' が show_scene に存在すること(後方互換)"
            )


# ===========================================================================
# 2. CLI 新引数のパース仕様テスト
# ===========================================================================


class TestCliNewArgParsing:
    """cli/visualize.py の新引数パース仕様テスト.

    仕様(cli/visualize.py _parse_args):
        --bake-max-depth N: 既定 16
        --cull-outward (既定 True) / --no-cull-outward → dest="cull_outward"
        --no-sensor-marker: 既定 False
        --no-led-markers: 既定 False
        --no-markers: 既定 False(ショートカット = 両方 True)
    """

    def test_bake_max_depth_default_is_16(self):
        """--bake-max-depth の既定値が 16 であること.

        仕様(cli/visualize.py): --bake-max-depth 既定 16
        SPEC_03: depth≥12 推奨(default=16 は推奨値を超えている).
        """
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml"])
        assert args.bake_max_depth == 16

    def test_bake_max_depth_custom_value_12(self):
        """--bake-max-depth 12 が正しくパースされること(SPEC_03 推奨値).

        SPEC_03: depth≥12 推奨.
        """
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--bake-max-depth", "12"])
        assert args.bake_max_depth == 12

    def test_bake_max_depth_type_is_int(self):
        """--bake-max-depth がパースされ int 型であること."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--bake-max-depth", "8"])
        assert isinstance(args.bake_max_depth, int)

    def test_cull_outward_default_is_true(self):
        """--cull-outward/--no-cull-outward を指定しない場合、cull_outward=True であること.

        仕様(cli/visualize.py): --cull-outward 既定有効.
        """
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml"])
        assert args.cull_outward is True

    def test_no_cull_outward_sets_false(self):
        """--no-cull-outward を指定すると cull_outward=False になること.

        仕様: --no-cull-outward → 外向き面を含む全面を描画する(後方互換).
        """
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--no-cull-outward"])
        assert args.cull_outward is False

    def test_cull_outward_explicit_flag_stays_true(self):
        """--cull-outward を明示的に指定しても cull_outward=True であること."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--cull-outward"])
        assert args.cull_outward is True

    def test_cull_outward_and_no_cull_outward_are_mutually_exclusive(self):
        """--cull-outward と --no-cull-outward が相互排他であること(argparse で強制).

        仕様: mutually_exclusive_group で実装.
        """
        from sm_tias_lightsim.cli.visualize import _parse_args
        with pytest.raises(SystemExit) as exc_info:
            _parse_args(["--scene", "scene.yaml", "--cull-outward", "--no-cull-outward"])
        assert exc_info.value.code != 0

    def test_no_sensor_marker_default_is_false(self):
        """--no-sensor-marker を指定しない場合、no_sensor_marker=False であること.

        仕様: hide_sensor_marker: bool = False(既定:センサマーカーを表示).
        """
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml"])
        assert args.no_sensor_marker is False

    def test_no_sensor_marker_flag_sets_true(self):
        """--no-sensor-marker を指定すると no_sensor_marker=True になること."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--no-sensor-marker"])
        assert args.no_sensor_marker is True

    def test_no_led_markers_default_is_false(self):
        """--no-led-markers を指定しない場合、no_led_markers=False であること."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml"])
        assert args.no_led_markers is False

    def test_no_led_markers_flag_sets_true(self):
        """--no-led-markers を指定すると no_led_markers=True になること."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--no-led-markers"])
        assert args.no_led_markers is True

    def test_no_markers_default_is_false(self):
        """--no-markers を指定しない場合、no_markers=False であること."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml"])
        assert args.no_markers is False

    def test_no_markers_flag_sets_true(self):
        """--no-markers を指定すると no_markers=True になること."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--no-markers"])
        assert args.no_markers is True


# ===========================================================================
# 3. --no-markers が hide_sensor_marker と hide_led_markers 両方に OR 適用されること
# ===========================================================================


class TestNoMarkersOrLogic:
    """--no-markers が両マーカーに OR 論理で適用される仕様テスト.

    仕様(cli/visualize.py main):
        hide_sensor_marker = bool(args.no_sensor_marker) or bool(args.no_markers)
        hide_led_markers = bool(args.no_led_markers) or bool(args.no_markers)
    """

    def _compute_hide_flags(
        self,
        no_sensor_marker: bool,
        no_led_markers: bool,
        no_markers: bool,
    ) -> tuple[bool, bool]:
        """main() の hide_sensor_marker / hide_led_markers 計算を再現する."""
        hide_sensor_marker = no_sensor_marker or no_markers
        hide_led_markers = no_led_markers or no_markers
        return hide_sensor_marker, hide_led_markers

    def test_no_markers_hides_both_sensor_and_led(self):
        """--no-markers のみ指定で両方の hide が True になること.

        仕様: --no-markers は --no-sensor-marker --no-led-markers の両方と等価.
        """
        hide_sensor, hide_led = self._compute_hide_flags(False, False, True)
        assert hide_sensor is True, "--no-markers → hide_sensor_marker=True"
        assert hide_led is True, "--no-markers → hide_led_markers=True"

    def test_no_sensor_marker_only_hides_sensor_not_led(self):
        """--no-sensor-marker のみ指定でセンサマーカーのみ非表示になること."""
        hide_sensor, hide_led = self._compute_hide_flags(True, False, False)
        assert hide_sensor is True
        assert hide_led is False

    def test_no_led_markers_only_hides_led_not_sensor(self):
        """--no-led-markers のみ指定で LED マーカーのみ非表示になること."""
        hide_sensor, hide_led = self._compute_hide_flags(False, True, False)
        assert hide_sensor is False
        assert hide_led is True

    def test_no_flags_means_both_visible(self):
        """フラグなしで両マーカーが表示(False)になること(後方互換)."""
        hide_sensor, hide_led = self._compute_hide_flags(False, False, False)
        assert hide_sensor is False
        assert hide_led is False

    def test_all_flags_hides_both(self):
        """全フラグを True にすると両方が True になること(OR 論理)."""
        hide_sensor, hide_led = self._compute_hide_flags(True, True, True)
        assert hide_sensor is True
        assert hide_led is True

    def test_cli_no_markers_parses_to_true_for_both_compute(self):
        """CLI --no-markers が main() 内で両 hide フラグを True にすること.

        _parse_args でパースした結果を main() ロジックで評価する.
        """
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--no-markers"])
        hide_sensor = bool(args.no_sensor_marker) or bool(args.no_markers)
        hide_led = bool(args.no_led_markers) or bool(args.no_markers)
        assert hide_sensor is True
        assert hide_led is True

    def test_cli_no_sensor_marker_only_parses_correctly(self):
        """CLI --no-sensor-marker のみ: センサのみ True, LED は False."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--no-sensor-marker"])
        hide_sensor = bool(args.no_sensor_marker) or bool(args.no_markers)
        hide_led = bool(args.no_led_markers) or bool(args.no_markers)
        assert hide_sensor is True
        assert hide_led is False

    def test_cli_no_led_markers_only_parses_correctly(self):
        """CLI --no-led-markers のみ: LED のみ True, センサは False."""
        from sm_tias_lightsim.cli.visualize import _parse_args
        args = _parse_args(["--scene", "scene.yaml", "--no-led-markers"])
        hide_sensor = bool(args.no_sensor_marker) or bool(args.no_markers)
        hide_led = bool(args.no_led_markers) or bool(args.no_markers)
        assert hide_sensor is False
        assert hide_led is True


# ===========================================================================
# 4. show_scene smoke テスト(新引数を渡して正常終了すること)
# ===========================================================================


MINIMAL_YAML = Path(__file__).parent.parent / "fixtures" / "minimal.yaml"


class TestShowSceneNewArgsSmokeTests:
    """show_scene に新引数を渡した smoke テスト.

    仕様: 新 keyword 引数デフォルトで既存 show_scene 呼び出しが動くこと.
    GUIDE_08: 可視化は「ファイルが生成されたか」程度の smoke テストで十分.
    """

    @pytest.mark.slow
    def test_show_scene_with_cull_outward_true_creates_screenshot(self, tmp_path: Path):
        """cull_outward=True(既定値)で screenshot が生成されること.

        仕様変更注記: cull_outward=True が既定になったため、irradiance ありの場合
        内向き face のみ描画する処理が走る. screenshot 生成には影響しない.
        """
        from sm_tias_lightsim.scene.schema import load_scene_yaml
        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        screenshot_path = tmp_path / "cull_true.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            cull_outward=True,  # 新仕様の既定値
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_show_scene_with_cull_outward_false_creates_screenshot(self, tmp_path: Path):
        """cull_outward=False(後方互換)で screenshot が生成されること.

        仕様: cull_outward=False のとき全面描画(後方互換).
        """
        from sm_tias_lightsim.scene.schema import load_scene_yaml
        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        screenshot_path = tmp_path / "cull_false.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            cull_outward=False,  # 後方互換
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_show_scene_hide_sensor_marker_true_creates_screenshot(self, tmp_path: Path):
        """hide_sensor_marker=True でセンサマーカーを非表示にして screenshot が生成されること.

        仕様: True のとき、センサマーカー(青い球+視線矢印)の add_mesh をスキップ.
        メッシュ着色・scalar bar・座標軸はそのまま残る.
        """
        from sm_tias_lightsim.scene.schema import load_scene_yaml
        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        screenshot_path = tmp_path / "no_sensor.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            hide_sensor_marker=True,
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_show_scene_hide_led_markers_true_creates_screenshot(self, tmp_path: Path):
        """hide_led_markers=True で LED マーカーを非表示にして screenshot が生成されること.

        仕様: True のとき、LED マーカーの add_mesh をスキップ.
        """
        from sm_tias_lightsim.scene.schema import load_scene_yaml
        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        screenshot_path = tmp_path / "no_led.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            hide_led_markers=True,
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_show_scene_hide_both_markers_creates_screenshot(self, tmp_path: Path):
        """両マーカーを非表示にして screenshot が生成されること(--no-markers 相当).

        仕様: hide_sensor_marker=True AND hide_led_markers=True で両方非表示.
        """
        from sm_tias_lightsim.scene.schema import load_scene_yaml
        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        screenshot_path = tmp_path / "no_markers.png"
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            hide_sensor_marker=True,
            hide_led_markers=True,
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_show_scene_default_args_backward_compat(self, tmp_path: Path):
        """新引数のデフォルト値で既存の show_scene 呼び出しが動くこと(後方互換).

        仕様: 新 keyword 引数デフォルトで既存 show_scene 呼び出しが動く.
        cull_outward=True(新デフォルト)も含めて正常終了すること.
        """
        from sm_tias_lightsim.scene.schema import load_scene_yaml
        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        screenshot_path = tmp_path / "default_args.png"
        # 新引数を一切指定しない(全デフォルト値)
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
        )
        assert screenshot_path.exists()


# ===========================================================================
# 5. CLI 新引数の main() 統合テスト(smoke)
# ===========================================================================


class TestCliNewArgIntegration:
    """CLI 新引数の main() 統合 smoke テスト.

    --no-bake モード(Mitsuba 不要)で新引数が main() に渡ること確認.
    """

    @pytest.mark.slow
    def test_no_sensor_marker_in_cli_creates_screenshot(self, tmp_path: Path):
        """--no-sensor-marker + --screenshot で PNG が生成されること."""
        from sm_tias_lightsim.cli.visualize import main

        screenshot_path = tmp_path / "cli_no_sensor.png"
        result = main([
            "--scene", str(MINIMAL_YAML),
            "--no-bake",
            "--no-sensor-marker",
            "--screenshot", str(screenshot_path),
        ])
        assert result == 0
        assert screenshot_path.exists()

    @pytest.mark.slow
    def test_no_led_markers_in_cli_creates_screenshot(self, tmp_path: Path):
        """--no-led-markers + --screenshot で PNG が生成されること."""
        from sm_tias_lightsim.cli.visualize import main

        screenshot_path = tmp_path / "cli_no_led.png"
        result = main([
            "--scene", str(MINIMAL_YAML),
            "--no-bake",
            "--no-led-markers",
            "--screenshot", str(screenshot_path),
        ])
        assert result == 0
        assert screenshot_path.exists()

    @pytest.mark.slow
    def test_no_markers_in_cli_creates_screenshot(self, tmp_path: Path):
        """--no-markers + --screenshot で PNG が生成されること(ショートカット)."""
        from sm_tias_lightsim.cli.visualize import main

        screenshot_path = tmp_path / "cli_no_markers.png"
        result = main([
            "--scene", str(MINIMAL_YAML),
            "--no-bake",
            "--no-markers",
            "--screenshot", str(screenshot_path),
        ])
        assert result == 0
        assert screenshot_path.exists()

    @pytest.mark.slow
    def test_no_cull_outward_in_cli_creates_screenshot(self, tmp_path: Path):
        """--no-cull-outward + --screenshot で PNG が生成されること(後方互換)."""
        from sm_tias_lightsim.cli.visualize import main

        screenshot_path = tmp_path / "cli_no_cull.png"
        result = main([
            "--scene", str(MINIMAL_YAML),
            "--no-bake",
            "--no-cull-outward",
            "--screenshot", str(screenshot_path),
        ])
        assert result == 0
        assert screenshot_path.exists()

    @pytest.mark.slow
    def test_bake_max_depth_16_in_cli_no_bake_parses_without_error(self, tmp_path: Path):
        """--bake-max-depth 16 が CLI で受け付けられ、--no-bake モードで正常終了すること.

        --no-bake モードでは path ベイクを実行しないため、Mitsuba 依存なし.
        """
        from sm_tias_lightsim.cli.visualize import main

        screenshot_path = tmp_path / "cli_max_depth.png"
        result = main([
            "--scene", str(MINIMAL_YAML),
            "--no-bake",
            "--bake-max-depth", "16",
            "--screenshot", str(screenshot_path),
        ])
        assert result == 0
        assert screenshot_path.exists()