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