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