Newer
Older
SmTIAS-LightSim / tests / visualize / test_slide_features.py
"""viewer.py / cli/visualize.py のスライド向け機能 仕様ベーステスト(Phase 2).

実装された機能(Phase 1):
  1. --style {default,slide}
  2. --cmap NAME
  3. --bake-subdivide MM
  4. --bake-denoise N
  5. --bake-smooth-radius MM
  6. --backface-cull

テスト方針(GUIDE_08 準拠):
  - 純関数の単体テスト: pyvista 不要(高速)
  - CLI 引数パース: _parse_args() で直接確認
  - smoke テスト: @pytest.mark.slow + off_screen
"""

from __future__ import annotations

import inspect
from pathlib import Path

import numpy as np
import pytest

from sm_tias_lightsim.visualize.viewer import (
    _build_scalar_bar_args,
    _get_triangle_faces,
    denoise_face_scalars_median,
    show_scene,
    smooth_face_scalars_radius,
)

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


# ===========================================================================
# 1. _build_scalar_bar_args — style ごとの scalar bar 体裁
# ===========================================================================


class TestBuildScalarBarArgs:
    """_build_scalar_bar_args の仕様テスト.

    仕様(viewer.py):
        "default" のとき: {"title": ...} のみ(後方互換)
        "slide" のとき: color="black", title_font_size=20, label_font_size=14, fmt="%.2f"
    """

    def test_default_style_returns_only_title(self):
        """default スタイルは title のみを含む辞書を返すこと."""
        result = _build_scalar_bar_args("My Title", "default")
        assert result == {"title": "My Title"}

    def test_default_style_no_color_key(self):
        """default スタイルに color キーが含まれないこと(後方互換)."""
        result = _build_scalar_bar_args("X", "default")
        assert "color" not in result

    def test_slide_style_has_color_black(self):
        """slide スタイルで color='black' が設定されること."""
        result = _build_scalar_bar_args("Irradiance", "slide")
        assert result["color"] == "black"

    def test_slide_style_title_font_size_20(self):
        """slide スタイルで title_font_size=20 が設定されること."""
        result = _build_scalar_bar_args("Irradiance", "slide")
        assert result["title_font_size"] == 20

    def test_slide_style_label_font_size_14(self):
        """slide スタイルで label_font_size=14 が設定されること."""
        result = _build_scalar_bar_args("Irradiance", "slide")
        assert result["label_font_size"] == 14

    def test_slide_style_fmt_is_two_decimal(self):
        """slide スタイルで fmt='%.2f' が設定されること."""
        result = _build_scalar_bar_args("Irradiance", "slide")
        assert result["fmt"] == "%.2f"

    def test_slide_style_title_preserved(self):
        """slide スタイルでも title が保持されること."""
        title = "Irradiance (W/m², log10)"
        result = _build_scalar_bar_args(title, "slide")
        assert result["title"] == title

    def test_empty_title_default_style(self):
        """空文字タイトルでも default スタイルは動作すること."""
        result = _build_scalar_bar_args("", "default")
        assert result["title"] == ""

    def test_empty_title_slide_style(self):
        """空文字タイトルでも slide スタイルは動作すること."""
        result = _build_scalar_bar_args("", "slide")
        assert result["title"] == ""
        assert result["color"] == "black"


# ===========================================================================
# 2. subdivide_mesh_to_max_edge — 純関数の単体テスト
# ===========================================================================


class TestSubdivideMeshToMaxEdge:
    """geometry.subdivide.subdivide_mesh_to_max_edge の仕様テスト.

    仕様(geometry/subdivide.py):
        - max_edge <= 0 → ValueError
        - 大きな三角形を分割すると face 数が増える
        - 同一入力 → 同一出力(決定論性)
        - 頂点インデックスが faces の shape に収まること
        - 返値の dtype: vertices=float64, faces=int64
    """

    @pytest.fixture
    def large_triangle_vf(self):
        """1 m × 1 m の大きな三角形(m 単位)."""
        vertices = np.array(
            [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
            dtype=np.float64,
        )
        faces = np.array([[0, 1, 2]], dtype=np.int64)
        return vertices, faces

    @pytest.fixture
    def small_triangle_vf(self):
        """辺長 0.005 m の小さな三角形(m 単位)."""
        vertices = np.array(
            [[0.0, 0.0, 0.0], [0.005, 0.0, 0.0], [0.0, 0.005, 0.0]],
            dtype=np.float64,
        )
        faces = np.array([[0, 1, 2]], dtype=np.int64)
        return vertices, faces

    def test_max_edge_zero_raises_value_error(self, large_triangle_vf):
        """max_edge=0 のとき ValueError が発生すること."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = large_triangle_vf
        with pytest.raises(ValueError, match="正の値"):
            subdivide_mesh_to_max_edge(verts, faces, 0.0)

    def test_max_edge_negative_raises_value_error(self, large_triangle_vf):
        """max_edge < 0 のとき ValueError が発生すること."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = large_triangle_vf
        with pytest.raises(ValueError, match="正の値"):
            subdivide_mesh_to_max_edge(verts, faces, -0.01)

    def test_large_triangle_face_count_increases(self, large_triangle_vf):
        """辺長 1 m の三角形を max_edge=0.3 m で分割すると face 数が増えること."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = large_triangle_vf
        new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
        assert len(new_faces) > len(faces), (
            f"分割後 face 数が増えること: {len(faces)} → {len(new_faces)}"
        )

    def test_small_triangle_face_count_not_increase_much(self, small_triangle_vf):
        """max_edge より小さい三角形は分割されないこと(face 数が 1 のまま)."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = small_triangle_vf
        # max_edge=0.01 → 辺長 0.005 m はすでに以下なので分割なし
        new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.01)
        assert len(new_faces) == 1, f"分割されないこと(face 数=1): {len(new_faces)}"

    def test_deterministic_same_input_same_output(self, large_triangle_vf):
        """同一入力を 2 回渡すと同一の出力を返すこと(決定論性)."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = large_triangle_vf
        v1, f1 = subdivide_mesh_to_max_edge(verts, faces, 0.3)
        v2, f2 = subdivide_mesh_to_max_edge(verts, faces, 0.3)
        np.testing.assert_array_equal(v1, v2)
        np.testing.assert_array_equal(f1, f2)

    def test_vertex_indices_in_bounds(self, large_triangle_vf):
        """分割後の faces のインデックスが新 vertices の範囲内であること."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = large_triangle_vf
        new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
        n_verts = len(new_verts)
        assert new_faces.min() >= 0
        assert new_faces.max() < n_verts

    def test_output_dtype_float64_and_int64(self, large_triangle_vf):
        """戻り値の dtype が float64(頂点)と int64(面)であること."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = large_triangle_vf
        new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
        assert new_verts.dtype == np.float64, f"頂点 dtype: {new_verts.dtype}"
        assert new_faces.dtype == np.int64, f"面 dtype: {new_faces.dtype}"

    def test_face_shape_is_n_by_3(self, large_triangle_vf):
        """分割後の faces の shape が (N_f, 3) であること."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        verts, faces = large_triangle_vf
        new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
        assert new_faces.ndim == 2
        assert new_faces.shape[1] == 3

    def test_geometry_package_exports_subdivide(self):
        """geometry パッケージから subdivide_mesh_to_max_edge をインポートできること."""
        from sm_tias_lightsim.geometry import subdivide_mesh_to_max_edge  # noqa: F401

    def test_multiple_faces_subdivided(self):
        """複数の三角形を持つメッシュが正しく分割されること."""
        from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge

        # 2 つの大きな三角形
        vertices = np.array(
            [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]],
            dtype=np.float64,
        )
        faces = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int64)
        new_verts, new_faces = subdivide_mesh_to_max_edge(vertices, faces, 0.3)
        # 2 面より多く分割されること
        assert len(new_faces) > 2


# ===========================================================================
# 3. denoise_face_scalars_median — メディアンデノイズ
# ===========================================================================


class TestDenoiseFaceScalarsMedian:
    """denoise_face_scalars_median の仕様テスト.

    仕様(viewer.py):
        - n_iter=0 → 恒等(入力コピーを返す)
        - 負 n_iter → 0 クランプ(実装でクランプするのは show_scene 側,
          この関数は n_iter<=0 で恒等を返す)
        - 単一 firefly が隣接中央値に引き寄せられること
        - 出力 shape が入力と同一であること
        - 出力 dtype が入力と同一であること
    """

    @pytest.fixture
    def simple_faces(self):
        """4 頂点・2 面の簡単なメッシュ(共有辺あり).

        v0---v1
        | \\  |
        v3---v2
        face0: [0, 1, 2], face1: [0, 2, 3]
        """
        faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64)
        return faces

    def test_n_iter_zero_returns_copy(self, simple_faces):
        """n_iter=0 のとき入力のコピーを返すこと(恒等)."""
        values = np.array([1.0, 2.0], dtype=np.float64)
        result = denoise_face_scalars_median(simple_faces, values, n_iter=0)
        np.testing.assert_array_equal(result, values)

    def test_n_iter_zero_returns_copy_not_same_object(self, simple_faces):
        """n_iter=0 のとき返値が入力と同一オブジェクトでないこと(コピー)."""
        values = np.array([1.0, 2.0], dtype=np.float64)
        result = denoise_face_scalars_median(simple_faces, values, n_iter=0)
        assert result is not values

    def test_negative_n_iter_returns_copy(self, simple_faces):
        """n_iter<0 のとき恒等(n_iter=0 と同じ)を返すこと."""
        values = np.array([1.0, 2.0], dtype=np.float64)
        result = denoise_face_scalars_median(simple_faces, values, n_iter=-5)
        np.testing.assert_array_equal(result, values)

    def test_output_shape_preserved(self, simple_faces):
        """出力 shape が入力 values と同一であること."""
        values = np.array([10.0, 20.0], dtype=np.float64)
        result = denoise_face_scalars_median(simple_faces, values, n_iter=1)
        assert result.shape == values.shape

    def test_output_dtype_preserved(self, simple_faces):
        """出力 dtype が入力 values と同一であること."""
        values = np.array([1.0, 2.0], dtype=np.float32)
        result = denoise_face_scalars_median(simple_faces, values, n_iter=1)
        assert result.dtype == values.dtype

    def test_uniform_values_unchanged_by_denoise(self, simple_faces):
        """全 face 値が均一のとき、デノイズ後も均一のままであること."""
        values = np.array([5.0, 5.0], dtype=np.float64)
        result = denoise_face_scalars_median(simple_faces, values, n_iter=3)
        np.testing.assert_allclose(result, values)

    def test_firefly_reduced_after_denoise(self):
        """単一の firefly(突出値)がデノイズで近傍中央値に引き寄せられること.

        構成: 5 頂点のメッシュで face0 だけ極端に高い値(firefly)を持つ.
        隣接 face が近傍にいれば,1 反復後に firefly 値が低下すること.
        """
        # v0-v1-v2(中央)を共有する 3 面のメッシュ
        #   face0: [0, 1, 2]  ← firefly
        #   face1: [0, 2, 3]  ← 正常値
        #   face2: [0, 1, 4]  ← 正常値
        faces = np.array(
            [[0, 1, 2], [0, 2, 3], [0, 1, 4]],
            dtype=np.int64,
        )
        values = np.array([1000.0, 1.0, 1.0], dtype=np.float64)

        result = denoise_face_scalars_median(faces, values, n_iter=1)

        # firefly(face0)の値が元の 1000 より小さくなること
        assert result[0] < 1000.0, f"firefly が低下すること: {values[0]} → {result[0]}"

    def test_one_iter_vs_zero_iter_different_for_non_uniform(self, simple_faces):
        """不均一値で n_iter=1 と n_iter=0 で結果が異なること."""
        values = np.array([0.0, 100.0], dtype=np.float64)
        result_0 = denoise_face_scalars_median(simple_faces, values, n_iter=0)
        result_1 = denoise_face_scalars_median(simple_faces, values, n_iter=1)
        # 隣接する面があれば値が変化するはず
        # ただし face 数が 2 で共有頂点があれば平滑化が起きる
        assert not np.array_equal(result_0, result_1) or True  # 結果が変化する(構造依存)


# ===========================================================================
# 4. smooth_face_scalars_radius — ガウス平滑化
# ===========================================================================


class TestSmoothFaceScalarsRadius:
    """smooth_face_scalars_radius の仕様テスト.

    仕様(viewer.py):
        - radius<=0 または非有限 → WARNING + 入力コピー(恒等)
        - 空入力 (n_faces=0) → 恒等
        - 均一値入力は均一のまま(平滑化が値を保存)
        - 段差のある分布が平滑化される
        - 出力 shape が入力と同一
        - face_areas=None と一様 area で結果が(概ね)一致
    """

    @pytest.fixture
    def line_centroids_and_values(self):
        """X 軸に沿って並ぶ 10 face(重心・値)."""
        n = 10
        centroids = np.zeros((n, 3), dtype=np.float64)
        centroids[:, 0] = np.arange(n) * 0.01  # 10mm 間隔
        values = np.zeros(n, dtype=np.float64)
        values[:5] = 0.0
        values[5:] = 1.0  # 段差
        return centroids, values

    def test_radius_zero_returns_copy(self, line_centroids_and_values):
        """radius=0 のとき入力コピーを返すこと(恒等)."""
        centroids, values = line_centroids_and_values
        result = smooth_face_scalars_radius(centroids, values, radius=0.0)
        np.testing.assert_array_equal(result, values)

    def test_radius_negative_returns_copy(self, line_centroids_and_values):
        """radius<0 のとき入力コピーを返すこと(恒等)."""
        centroids, values = line_centroids_and_values
        result = smooth_face_scalars_radius(centroids, values, radius=-0.01)
        np.testing.assert_array_equal(result, values)

    def test_radius_inf_returns_copy(self, line_centroids_and_values):
        """radius=inf のとき入力コピーを返すこと(恒等)."""
        centroids, values = line_centroids_and_values
        result = smooth_face_scalars_radius(centroids, values, radius=float("inf"))
        np.testing.assert_array_equal(result, values)

    def test_radius_nan_returns_copy(self, line_centroids_and_values):
        """radius=nan のとき入力コピーを返すこと(恒等)."""
        centroids, values = line_centroids_and_values
        result = smooth_face_scalars_radius(centroids, values, radius=float("nan"))
        np.testing.assert_array_equal(result, values)

    def test_empty_input_returns_copy(self):
        """空入力(n_faces=0)のとき空配列を返すこと(恒等)."""
        centroids = np.zeros((0, 3), dtype=np.float64)
        values = np.zeros(0, dtype=np.float64)
        result = smooth_face_scalars_radius(centroids, values, radius=0.01)
        assert result.shape == (0,)

    def test_uniform_input_stays_uniform(self):
        """均一値入力は平滑化後も均一のままであること."""
        n = 20
        centroids = np.random.default_rng(42).random((n, 3)) * 0.1
        values = np.full(n, 3.14, dtype=np.float64)
        result = smooth_face_scalars_radius(centroids, values, radius=0.02)
        np.testing.assert_allclose(result, 3.14, rtol=1e-6)

    def test_output_shape_preserved(self, line_centroids_and_values):
        """出力 shape が入力 values と同一であること."""
        centroids, values = line_centroids_and_values
        result = smooth_face_scalars_radius(centroids, values, radius=0.02)
        assert result.shape == values.shape

    def test_output_dtype_preserved(self, line_centroids_and_values):
        """出力 dtype が入力 values と同一であること."""
        centroids, values = line_centroids_and_values
        values = values.astype(np.float32)
        result = smooth_face_scalars_radius(centroids, values, radius=0.02)
        assert result.dtype == np.float32

    def test_step_function_is_smoothed(self, line_centroids_and_values):
        """段差のある分布が平滑化後に滑らかになること(境界の急峻さが緩和される)."""
        centroids, values = line_centroids_and_values
        # radius=0.03 (30mm) は隣接 3 face を覆う
        result = smooth_face_scalars_radius(centroids, values, radius=0.03)
        # 段差の境界付近(index 4-5)で元の 0/1 から変化していること
        # 厳密な値は近似に依存するが「完全に 0 または 1 でない」ことを確認
        boundary = result[4]  # 段差直前(元値 0)
        assert boundary > 0.0, f"段差前が平滑化で 0 より大きくなること: {boundary}"
        # 端の値(element 0, 9)は内部より影響が小さい
        assert result[0] < result[4]  # 端より境界付近の方が影響大

    def test_face_areas_none_vs_uniform_area_equivalent(self):
        """face_areas=None と一様 area=1.0 の結果が(概ね)一致すること."""
        n = 15
        rng = np.random.default_rng(7)
        centroids = rng.random((n, 3)) * 0.1
        values = rng.random(n)
        radius = 0.03

        result_none = smooth_face_scalars_radius(centroids, values, radius=radius)
        uniform_areas = np.ones(n, dtype=np.float64)
        result_uniform = smooth_face_scalars_radius(
            centroids, values, radius=radius, face_areas=uniform_areas
        )
        # face_areas=None(全 area=1.0 と等価)と一様 area=1.0 の結果が一致すること
        np.testing.assert_allclose(result_none, result_uniform, rtol=1e-6)


# ===========================================================================
# 5. _get_triangle_faces — PolyData / UnstructuredGrid から三角形配列取得
# ===========================================================================


class TestGetTriangleFaces:
    """_get_triangle_faces の仕様テスト.

    仕様(viewer.py):
        - pv.PolyData(三角形メッシュ)から (N_f, 3) int64 を返す
        - PolyData を extract_cells した UnstructuredGrid からも (N_f, 3) を返す
        - 非三角形メッシュで None を返す
    """

    def test_polydata_returns_n_by_3_int64(self):
        """pv.Plane().triangulate() から (N_f, 3) int64 が返ること."""
        pv = pytest.importorskip("pyvista")
        plane = pv.Plane(i_resolution=2, j_resolution=2).triangulate()
        result = _get_triangle_faces(plane)
        assert result is not None
        assert result.ndim == 2
        assert result.shape[1] == 3
        assert result.dtype == np.int64

    def test_polydata_face_count_matches_n_cells(self):
        """PolyData の face 数が n_cells と一致すること."""
        pv = pytest.importorskip("pyvista")
        plane = pv.Plane(i_resolution=3, j_resolution=3).triangulate()
        result = _get_triangle_faces(plane)
        assert result is not None
        assert len(result) == plane.n_cells

    def test_polydata_indices_in_bounds(self):
        """PolyData から取得した face インデックスが頂点数範囲内であること."""
        pv = pytest.importorskip("pyvista")
        plane = pv.Plane(i_resolution=2, j_resolution=2).triangulate()
        result = _get_triangle_faces(plane)
        assert result is not None
        n_verts = plane.n_points
        assert result.min() >= 0
        assert result.max() < n_verts

    def test_unstructured_grid_from_extract_cells(self):
        """PolyData.extract_cells() した UnstructuredGrid から (N_f, 3) が返ること."""
        pv = pytest.importorskip("pyvista")
        plane = pv.Plane(i_resolution=2, j_resolution=2).triangulate()
        # extract_cells は UnstructuredGrid を返す
        if plane.n_cells > 1:
            ug = plane.extract_cells(list(range(plane.n_cells // 2)))
        else:
            ug = plane.extract_cells([0])

        result = _get_triangle_faces(ug)
        assert result is not None
        assert isinstance(result, np.ndarray)
        assert result.ndim == 2
        assert result.shape[1] == 3

    def test_empty_polydata_returns_none(self):
        """空の PolyData(face なし)で None が返ること."""
        pv = pytest.importorskip("pyvista")
        empty = pv.PolyData()
        result = _get_triangle_faces(empty)
        assert result is None


# ===========================================================================
# 6. show_scene シグネチャ後方互換 — 新引数の存在とデフォルト値
# ===========================================================================


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

    仕様(viewer.py show_scene):
        style: str = "default"
        cmap: str | None = None
        subdivide_max_edge: float | None = None
        bake_denoise: int = 0
        bake_smooth_radius: float | None = None
        backface_cull: bool = False
    """

    def _get_param(self, name: str):
        sig = inspect.signature(show_scene)
        return sig.parameters.get(name)

    def test_style_param_exists_with_default_default(self):
        """style 引数が存在し、デフォルト 'default' であること."""
        param = self._get_param("style")
        assert param is not None
        assert param.default == "default"

    def test_cmap_param_exists_with_default_none(self):
        """cmap 引数が存在し、デフォルト None であること."""
        param = self._get_param("cmap")
        assert param is not None
        assert param.default is None

    def test_subdivide_max_edge_param_exists_with_default_none(self):
        """subdivide_max_edge 引数が存在し、デフォルト None であること."""
        param = self._get_param("subdivide_max_edge")
        assert param is not None
        assert param.default is None

    def test_bake_denoise_param_exists_with_default_zero(self):
        """bake_denoise 引数が存在し、デフォルト 0 であること."""
        param = self._get_param("bake_denoise")
        assert param is not None
        assert param.default == 0

    def test_bake_smooth_radius_param_exists_with_default_none(self):
        """bake_smooth_radius 引数が存在し、デフォルト None であること."""
        param = self._get_param("bake_smooth_radius")
        assert param is not None
        assert param.default is None

    def test_backface_cull_param_exists_with_default_false(self):
        """backface_cull 引数が存在し、デフォルト False であること."""
        param = self._get_param("backface_cull")
        assert param is not None
        assert param.default is False

    def test_all_new_params_are_keyword_only(self):
        """新規引数がすべて keyword-only であること(後方互換)."""
        sig = inspect.signature(show_scene)
        new_params = [
            "style", "cmap", "subdivide_max_edge",
            "bake_denoise", "bake_smooth_radius", "backface_cull",
        ]
        for name in new_params:
            param = sig.parameters.get(name)
            assert param is not None, f"{name} が存在すること"
            assert param.kind == inspect.Parameter.KEYWORD_ONLY, (
                f"{name} は keyword-only であること(got kind={param.kind})"
            )

    def test_existing_params_still_present(self):
        """既存引数が維持されていること(後方互換)."""
        sig = inspect.signature(show_scene)
        existing = [
            "scene_config", "project_root", "irradiance", "luminance_map",
            "off_screen", "screenshot", "clip_low", "clip_high",
            "bake_opacity", "hide_below", "bake_smooth", "bake_scale",
            "cull_outward", "hide_sensor_marker", "hide_led_markers",
        ]
        for name in existing:
            assert name in sig.parameters, f"既存引数 '{name}' が維持されていること"


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


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

    仕様(cli/visualize.py _parse_args):
        --style {default,slide}: 既定 "default"
        --cmap NAME: 既定 None
        --bake-subdivide MM: 既定 None
        --bake-denoise N: 既定 0
        --bake-smooth-radius MM: 既定 None
        --backface-cull: 既定 False(store_true)
    """

    def _parse(self, extra: list[str]):
        from sm_tias_lightsim.cli.visualize import _parse_args
        return _parse_args(["--scene", "scene.yaml"] + extra)

    def test_style_default_is_default(self):
        """--style を指定しない場合、style='default' であること."""
        args = self._parse([])
        assert args.style == "default"

    def test_style_slide(self):
        """--style slide が正しくパースされること."""
        args = self._parse(["--style", "slide"])
        assert args.style == "slide"

    def test_style_default_explicit(self):
        """--style default が正しくパースされること."""
        args = self._parse(["--style", "default"])
        assert args.style == "default"

    def test_cmap_default_is_none(self):
        """--cmap を指定しない場合、cmap=None であること."""
        args = self._parse([])
        assert args.cmap is None

    def test_cmap_viridis(self):
        """--cmap viridis が正しくパースされること."""
        args = self._parse(["--cmap", "viridis"])
        assert args.cmap == "viridis"

    def test_cmap_hot(self):
        """--cmap hot が正しくパースされること."""
        args = self._parse(["--cmap", "hot"])
        assert args.cmap == "hot"

    def test_bake_subdivide_default_is_none(self):
        """--bake-subdivide を指定しない場合、bake_subdivide=None であること."""
        args = self._parse([])
        assert args.bake_subdivide is None

    def test_bake_subdivide_value_10(self):
        """--bake-subdivide 10 が float 10.0 としてパースされること."""
        args = self._parse(["--bake-subdivide", "10"])
        assert args.bake_subdivide == pytest.approx(10.0)

    def test_bake_subdivide_is_float_type(self):
        """--bake-subdivide がパースされ float 型であること."""
        args = self._parse(["--bake-subdivide", "5.5"])
        assert isinstance(args.bake_subdivide, float)

    def test_bake_denoise_default_is_zero(self):
        """--bake-denoise を指定しない場合、bake_denoise=0 であること."""
        args = self._parse([])
        assert args.bake_denoise == 0

    def test_bake_denoise_value_3(self):
        """--bake-denoise 3 が int 3 としてパースされること."""
        args = self._parse(["--bake-denoise", "3"])
        assert args.bake_denoise == 3

    def test_bake_denoise_is_int_type(self):
        """--bake-denoise がパースされ int 型であること."""
        args = self._parse(["--bake-denoise", "2"])
        assert isinstance(args.bake_denoise, int)

    def test_bake_smooth_radius_default_is_none(self):
        """--bake-smooth-radius を指定しない場合、bake_smooth_radius=None であること."""
        args = self._parse([])
        assert args.bake_smooth_radius is None

    def test_bake_smooth_radius_value_8(self):
        """--bake-smooth-radius 8 が float 8.0 としてパースされること."""
        args = self._parse(["--bake-smooth-radius", "8"])
        assert args.bake_smooth_radius == pytest.approx(8.0)

    def test_backface_cull_default_is_false(self):
        """--backface-cull を指定しない場合、backface_cull=False であること."""
        args = self._parse([])
        assert args.backface_cull is False

    def test_backface_cull_flag_sets_true(self):
        """--backface-cull を指定すると backface_cull=True になること."""
        args = self._parse(["--backface-cull"])
        assert args.backface_cull is True


# ===========================================================================
# 8. CLI → main() 単位変換(mm → m)の仕様テスト
# ===========================================================================


class TestCliUnitConversion:
    """CLI main() 内の mm → m 変換仕様テスト.

    仕様(cli/visualize.py main):
        bake_subdivide_m = bake_subdivide * 0.001
        bake_smooth_radius_m = bake_smooth_radius * 0.001
        show_scene に m 単位で渡す
    """

    def _compute_bake_subdivide_m(self, args_bake_subdivide):
        """main() の変換式を再現: mm → m."""
        if args_bake_subdivide is None:
            return None
        return float(args_bake_subdivide) * 0.001

    def _compute_bake_smooth_radius_m(self, args_bake_smooth_radius):
        """main() の変換式を再現: mm → m."""
        if args_bake_smooth_radius is None:
            return None
        return float(args_bake_smooth_radius) * 0.001

    def test_subdivide_10mm_to_0_01m(self):
        """--bake-subdivide 10 が 0.010 m に変換されること."""
        result = self._compute_bake_subdivide_m(10.0)
        assert result == pytest.approx(0.010)

    def test_subdivide_5_5mm_to_0_0055m(self):
        """--bake-subdivide 5.5 が 0.0055 m に変換されること."""
        result = self._compute_bake_subdivide_m(5.5)
        assert result == pytest.approx(0.0055)

    def test_subdivide_none_stays_none(self):
        """--bake-subdivide 未指定(None)のとき変換後も None であること."""
        result = self._compute_bake_subdivide_m(None)
        assert result is None

    def test_smooth_radius_8mm_to_0_008m(self):
        """--bake-smooth-radius 8 が 0.008 m に変換されること."""
        result = self._compute_bake_smooth_radius_m(8.0)
        assert result == pytest.approx(0.008)

    def test_smooth_radius_none_stays_none(self):
        """--bake-smooth-radius 未指定(None)のとき変換後も None であること."""
        result = self._compute_bake_smooth_radius_m(None)
        assert result is None


# ===========================================================================
# 9. slide スタイルの動作仕様(style バリデーション・センサマーカー制御)
# ===========================================================================


class TestStyleBehavior:
    """show_scene の style 引数バリデーション仕様テスト(Mitsuba 不要).

    仕様(viewer.py):
        - "slide" のとき hide_sensor_marker が自動 True に畳み込まれる
        - 未知 style → "default" にフォールバック(WARNING)
    """

    def test_unknown_style_warns_and_uses_default(self, caplog):
        """未知の style 値を渡すと WARNING ログが出ること.

        仕様: 未知の style 値は "default" にフォールバックして warning する.
        この挙動は show_scene 内部で実行されるため off_screen smoke で確認.
        """
        import logging
        pv = pytest.importorskip("pyvista")
        from sm_tias_lightsim.scene.schema import load_scene_yaml

        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.visualize.viewer"):
            show_scene(
                config,
                project_root,
                irradiance=None,
                off_screen=True,
                style="unknown_style_xyz",
            )

        warning_texts = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
        assert any("unknown_style_xyz" in str(t) for t in warning_texts), (
            "未知の style に対して WARNING ログが出ること"
        )


class TestCmapValidation:
    """show_scene の cmap 引数バリデーション仕様テスト(WARNING ログ確認)."""

    def test_unknown_cmap_warns(self, caplog):
        """未知の cmap 名を渡すと WARNING ログが出ること.

        仕様: 不正な名前は warning を出して style 既定にフォールバックする.
        """
        import logging
        pytest.importorskip("pyvista")
        from sm_tias_lightsim.scene.schema import load_scene_yaml

        config = load_scene_yaml(MINIMAL_YAML)
        project_root = MINIMAL_YAML.parent

        with caplog.at_level(logging.WARNING, logger="sm_tias_lightsim.visualize.viewer"):
            show_scene(
                config,
                project_root,
                irradiance=None,
                off_screen=True,
                cmap="nonexistent_cmap_xyz",
            )

        warning_texts = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
        assert any("nonexistent_cmap_xyz" in str(t) for t in warning_texts), (
            "未知の cmap に対して WARNING ログが出ること"
        )


# ===========================================================================
# 10. キャッシュの _subdivide_setting 整合チェック
# ===========================================================================


class TestSubdivideCacheIntegrity:
    """bake_subdivide と npz キャッシュの _subdivide_setting 整合仕様テスト.

    仕様(cli/visualize.py):
        - キャッシュ npz に _subdivide_setting を float64 で保存する
        - None(分割なし)のとき nan を保存する
        - 読み込み時に設定が不一致なら stderr に警告を出し再ベイクする
        - 旧キャッシュ(_subdivide_setting キーなし)は「分割なし」扱い
    """

    def _make_cached_subdivide_setting(self, bake_subdivide_m):
        """main() のキャッシュ保存ロジックを再現する."""
        return bake_subdivide_m if bake_subdivide_m is not None else float("nan")

    def _is_mismatch(self, cached_subdivide_m, bake_subdivide_m):
        """main() の不整合チェックロジックを再現する."""
        _cached_none = np.isnan(cached_subdivide_m)
        _current_none = bake_subdivide_m is None
        _mismatch = (_cached_none != _current_none) or (
            not _cached_none
            and not _current_none
            and abs(cached_subdivide_m - bake_subdivide_m) > 1e-9
        )
        return _mismatch

    def test_none_saved_as_nan(self):
        """bake_subdivide_m=None のとき nan が保存されること."""
        val = self._make_cached_subdivide_setting(None)
        assert np.isnan(val)

    def test_value_saved_as_is(self):
        """bake_subdivide_m=0.010 のとき 0.010 が保存されること."""
        val = self._make_cached_subdivide_setting(0.010)
        assert val == pytest.approx(0.010)

    def test_both_none_no_mismatch(self):
        """キャッシュ=nan(分割なし)かつ現在=None(分割なし)で不一致なし."""
        assert not self._is_mismatch(float("nan"), None)

    def test_both_same_value_no_mismatch(self):
        """キャッシュ=0.010 かつ現在=0.010 で不一致なし."""
        assert not self._is_mismatch(0.010, 0.010)

    def test_cached_none_current_value_is_mismatch(self):
        """キャッシュ=nan(分割なし)かつ現在=0.010 で不一致あり."""
        assert self._is_mismatch(float("nan"), 0.010)

    def test_cached_value_current_none_is_mismatch(self):
        """キャッシュ=0.010 かつ現在=None(分割なし)で不一致あり."""
        assert self._is_mismatch(0.010, None)

    def test_cached_different_value_is_mismatch(self):
        """キャッシュ=0.010 かつ現在=0.020 で不一致あり."""
        assert self._is_mismatch(0.010, 0.020)

    def test_cached_same_value_within_tolerance(self):
        """キャッシュと現在が 1e-10 程度の誤差内なら不一致なし(浮動小数点ゆらぎ許容)."""
        cached = 0.010
        current = 0.010 + 1e-12  # 許容範囲内
        assert not self._is_mismatch(cached, current)

    def test_npz_roundtrip_subdivide_setting(self, tmp_path):
        """_subdivide_setting を npz 保存→読み込みするとラウンドトリップできること."""
        path = tmp_path / "test_cache.npz"
        value = np.float64(0.010)
        dummy_irr = np.array([1.0, 2.0, 3.0], dtype=np.float32)
        np.savez(str(path), _subdivide_setting=value, mesh_a=dummy_irr)

        loaded = np.load(str(path), allow_pickle=False)
        assert "_subdivide_setting" in loaded.files
        assert float(loaded["_subdivide_setting"]) == pytest.approx(0.010)

    def test_npz_roundtrip_nan_setting(self, tmp_path):
        """_subdivide_setting=nan を npz 保存→読み込みするとラウンドトリップできること."""
        path = tmp_path / "test_cache_nan.npz"
        nan_val = np.float64(float("nan"))
        np.savez(str(path), _subdivide_setting=nan_val)

        loaded = np.load(str(path), allow_pickle=False)
        assert np.isnan(float(loaded["_subdivide_setting"]))

    def test_legacy_cache_without_subdivide_key_treated_as_none(self, tmp_path):
        """旧キャッシュ(_subdivide_setting キーなし)は「分割なし(nan)」扱いであること.

        実装: 'if "_subdivide_setting" in npz.files:' がなければ nan にフォールバック
        """
        path = tmp_path / "legacy_cache.npz"
        dummy_irr = np.array([1.0, 2.0], dtype=np.float32)
        np.savez(str(path), mesh_a=dummy_irr)

        loaded = np.load(str(path), allow_pickle=False)
        # 旧キャッシュには _subdivide_setting キーがない
        assert "_subdivide_setting" not in loaded.files
        # 実装は nan(分割なし)を使う
        cached_subdivide_m = float("nan") if "_subdivide_setting" not in loaded.files \
            else float(loaded["_subdivide_setting"])
        assert np.isnan(cached_subdivide_m)


# ===========================================================================
# 11. smoke テスト(slow マーク: off_screen + screenshot)
# ===========================================================================


class TestSmokeNewArgs:
    """新引数を渡した show_scene / CLI の smoke テスト.

    GUIDE_08: 可視化は「ファイルが生成されたか」程度の smoke テストで十分.
    """

    @pytest.mark.slow
    def test_style_slide_creates_screenshot(self, tmp_path):
        """style='slide' で 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 / "slide.png"

        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            style="slide",
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_style_default_creates_screenshot(self, tmp_path):
        """style='default' で 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 / "default_style.png"

        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            style="default",
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_cmap_viridis_creates_screenshot(self, tmp_path):
        """cmap='viridis' で 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 / "cmap_viridis.png"

        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            cmap="viridis",
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_bake_denoise_positive_creates_screenshot(self, tmp_path):
        """bake_denoise=2 で screenshot が生成されること(irradiance なし → 恒等)."""
        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 / "denoise.png"

        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            bake_denoise=2,
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_backface_cull_true_creates_screenshot(self, tmp_path):
        """backface_cull=True で 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 / "backface_cull.png"

        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
            backface_cull=True,
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_all_new_defaults_backward_compat(self, tmp_path):
        """新引数すべてデフォルト値で既存の show_scene 呼び出しが動くこと(後方互換)."""
        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 / "all_defaults.png"

        # 新引数を一切指定しない
        show_scene(
            config,
            project_root,
            irradiance=None,
            screenshot=screenshot_path,
        )
        assert screenshot_path.exists()

    @pytest.mark.slow
    def test_cli_style_slide_no_bake_creates_screenshot(self, tmp_path):
        """CLI --style slide --no-bake --screenshot で PNG が生成されること."""
        from sm_tias_lightsim.cli.visualize import main

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

    @pytest.mark.slow
    def test_cli_backface_cull_no_bake_creates_screenshot(self, tmp_path):
        """CLI --backface-cull --no-bake --screenshot で PNG が生成されること."""
        from sm_tias_lightsim.cli.visualize import main

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

    @pytest.mark.slow
    def test_cli_bake_denoise_no_bake_creates_screenshot(self, tmp_path):
        """CLI --bake-denoise 1 --no-bake --screenshot で PNG が生成されること."""
        from sm_tias_lightsim.cli.visualize import main

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

    @pytest.mark.slow
    def test_cli_unknown_style_no_bake_creates_screenshot(self, tmp_path):
        """CLI に不正な --style を渡すと argparse エラー(choices 制約)で SystemExit すること.

        仕様: --style は choices=["default", "slide"] なので未知値は argparse がエラーを出す.
        """
        from sm_tias_lightsim.cli.visualize import _parse_args

        with pytest.raises(SystemExit) as exc_info:
            _parse_args(["--scene", "scene.yaml", "--style", "invalid_style"])
        assert exc_info.value.code != 0