diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..442bb94 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = src +testpaths = tests diff --git a/requirements_pc.txt b/requirements_pc.txt index 506720a..89bfd92 100644 --- a/requirements_pc.txt +++ b/requirements_pc.txt @@ -3,3 +3,4 @@ pyzmq==27.1.0 numpy==2.4.3 python-dotenv==1.2.2 +pytest==9.0.2 diff --git a/src/common/json_utils.py b/src/common/json_utils.py index 62e214a..0ce705a 100644 --- a/src/common/json_utils.py +++ b/src/common/json_utils.py @@ -34,7 +34,7 @@ path: 書き込み先の JSON ファイルのパス data: 書き込むデータ """ - path.parent.mkdir(exist_ok=True) + path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump( data, f, ensure_ascii=False, indent=2, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5d06702 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,44 @@ +"""テスト共通フィクスチャ""" + +import numpy as np +import pytest + +from common import config + + +@pytest.fixture() +def straight_line_image() -> np.ndarray: + """中央に暗い縦線があるグレースケール画像""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + img = np.full((h, w), 200, dtype=np.uint8) + cx = w // 2 + img[:, cx - 1 : cx + 2] = 30 + return img + + +@pytest.fixture() +def blank_image() -> np.ndarray: + """線のない均一なグレースケール画像""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + return np.full((h, w), 180, dtype=np.uint8) + + +@pytest.fixture() +def binary_with_hole() -> np.ndarray: + """中央に穴がある二値画像(クロージングテスト用)""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + binary[3:h - 3, w // 2 - 2 : w // 2 + 3] = 255 + # 中央に 2px の穴をあける + binary[h // 2 : h // 2 + 2, :] = 0 + return binary + + +@pytest.fixture() +def binary_line() -> np.ndarray: + """中央に太い白線がある二値画像""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + cx = w // 2 + binary[:, cx - 3 : cx + 4] = 255 + return binary diff --git a/tests/test_fitting.py b/tests/test_fitting.py new file mode 100644 index 0000000..5791fbf --- /dev/null +++ b/tests/test_fitting.py @@ -0,0 +1,147 @@ +"""fitting モジュールのテスト""" + +import numpy as np +import pytest + +from pc.vision.fitting import ( + MIN_FIT_ROWS, + clean_and_fit, + ransac_polyfit, + theil_sen_fit, +) + + +class TestTheilSenFit: + """theil_sen_fit のテスト""" + + def test_linear_data(self) -> None: + """直線データから正しい slope と intercept を復元できる""" + y = np.arange(10, dtype=float) + # x = 2.0 * y + 5.0 + x = 2.0 * y + 5.0 + slope, intercept = theil_sen_fit(y, x) + assert slope == pytest.approx(2.0, abs=1e-6) + assert intercept == pytest.approx(5.0, abs=1e-6) + + def test_with_outlier(self) -> None: + """外れ値が1つあっても正しい傾きを推定できる""" + y = np.arange(11, dtype=float) + x = 1.0 * y + 3.0 + # 1点を大きく外す + x[5] = 100.0 + slope, intercept = theil_sen_fit(y, x) + assert slope == pytest.approx(1.0, abs=0.2) + assert intercept == pytest.approx(3.0, abs=1.0) + + def test_two_points(self) -> None: + """2点でも傾きを計算できる""" + y = np.array([0.0, 10.0]) + x = np.array([5.0, 15.0]) + slope, intercept = theil_sen_fit(y, x) + assert slope == pytest.approx(1.0, abs=1e-6) + assert intercept == pytest.approx(5.0, abs=1e-6) + + def test_single_point(self) -> None: + """1点しかない場合は slope=0, intercept=median(x)""" + y = np.array([5.0]) + x = np.array([10.0]) + slope, intercept = theil_sen_fit(y, x) + assert slope == 0.0 + assert intercept == pytest.approx(10.0) + + def test_horizontal_line(self) -> None: + """水平な線(slope=0)を正しく推定できる""" + y = np.arange(10, dtype=float) + x = np.full(10, 7.0) + slope, intercept = theil_sen_fit(y, x) + assert slope == pytest.approx(0.0, abs=1e-6) + assert intercept == pytest.approx(7.0, abs=1e-6) + + +class TestRansacPolyfit: + """ransac_polyfit のテスト""" + + def test_clean_quadratic(self) -> None: + """ノイズなしの2次曲線を正しくフィットできる""" + ys = np.arange(20, dtype=float) + # x = 0.1 * y^2 - 2.0 * y + 10.0 + xs = 0.1 * ys**2 - 2.0 * ys + 10.0 + coeffs = ransac_polyfit(ys, xs, 2, 50, 5.0) + assert coeffs is not None + assert coeffs[0] == pytest.approx(0.1, abs=0.01) + assert coeffs[1] == pytest.approx(-2.0, abs=0.1) + + def test_with_outliers(self) -> None: + """30% の外れ値があっても正しくフィットできる""" + rng = np.random.default_rng(42) + ys = np.arange(30, dtype=float) + xs = 0.05 * ys**2 + 3.0 + # 30% を大きく外す + outlier_idx = rng.choice(30, 9, replace=False) + xs[outlier_idx] += rng.uniform(50, 100, 9) + coeffs = ransac_polyfit(ys, xs, 2, 100, 5.0) + assert coeffs is not None + assert coeffs[0] == pytest.approx(0.05, abs=0.02) + + def test_too_few_points(self) -> None: + """点が不足している場合は None を返す""" + ys = np.array([1.0, 2.0]) + xs = np.array([3.0, 4.0]) + assert ransac_polyfit(ys, xs, 2, 50, 5.0) is None + + +class TestCleanAndFit: + """clean_and_fit のテスト""" + + def test_basic_fit(self) -> None: + """正常なデータでフィッティングできる""" + ys = np.arange(15, dtype=float) + xs = 0.5 * ys + 10.0 + coeffs = clean_and_fit( + ys, xs, + median_ksize=0, + neighbor_thresh=0.0, + ) + assert coeffs is not None + # 2次の係数はほぼ 0,1次はほぼ 0.5 + assert coeffs[-2] == pytest.approx(0.5, abs=0.1) + + def test_too_few_points(self) -> None: + """MIN_FIT_ROWS 未満のデータは None を返す""" + n = MIN_FIT_ROWS - 1 + ys = np.arange(n, dtype=float) + xs = np.arange(n, dtype=float) + assert clean_and_fit( + ys, xs, median_ksize=0, neighbor_thresh=0.0, + ) is None + + def test_neighbor_filter_removes_outlier(self) -> None: + """近傍フィルタが外れ値を除去できる""" + ys = np.arange(20, dtype=float) + xs = np.full(20, 15.0) + xs[10] = 100.0 # 大きな外れ値 + coeffs = clean_and_fit( + ys, xs, + median_ksize=0, + neighbor_thresh=5.0, + ) + assert coeffs is not None + # 外れ値除去後,x ≈ 15.0 の直線になる + poly = np.poly1d(coeffs) + assert poly(10) == pytest.approx(15.0, abs=2.0) + + def test_residual_removal(self) -> None: + """残差除去が外れ値を取り除ける""" + ys = np.arange(20, dtype=float) + xs = 1.0 * ys + 5.0 + xs[3] = 80.0 + xs[17] = -50.0 + coeffs = clean_and_fit( + ys, xs, + median_ksize=0, + neighbor_thresh=0.0, + residual_thresh=10.0, + ) + assert coeffs is not None + poly = np.poly1d(coeffs) + assert poly(10) == pytest.approx(15.0, abs=3.0) diff --git a/tests/test_json_utils.py b/tests/test_json_utils.py new file mode 100644 index 0000000..03020fc --- /dev/null +++ b/tests/test_json_utils.py @@ -0,0 +1,47 @@ +"""json_utils モジュールのテスト""" + +from pathlib import Path + +from common.json_utils import read_json, write_json + + +class TestWriteReadRoundtrip: + """write_json / read_json の往復テスト""" + + def test_dict_roundtrip(self, tmp_path: Path) -> None: + """dict を書き込んで読み込むと一致する""" + path = tmp_path / "test.json" + data = {"key": "value", "number": 42} + write_json(path, data) + assert read_json(path) == data + + def test_list_roundtrip(self, tmp_path: Path) -> None: + """list を書き込んで読み込むと一致する""" + path = tmp_path / "test.json" + data = [{"a": 1}, {"b": 2}] + write_json(path, data) + assert read_json(path) == data + + def test_creates_parent_dir( + self, tmp_path: Path, + ) -> None: + """親ディレクトリが存在しなくても自動作成される""" + path = tmp_path / "sub" / "dir" / "test.json" + data = {"created": True} + write_json(path, data) + assert path.exists() + assert read_json(path) == data + + def test_japanese_text(self, tmp_path: Path) -> None: + """日本語テキストが正しく保存・復元される""" + path = tmp_path / "test.json" + data = {"title": "テスト", "memo": "日本語メモ"} + write_json(path, data) + assert read_json(path) == data + + def test_overwrite(self, tmp_path: Path) -> None: + """既存ファイルを上書きできる""" + path = tmp_path / "test.json" + write_json(path, {"v": 1}) + write_json(path, {"v": 2}) + assert read_json(path) == {"v": 2} diff --git a/tests/test_line_detector.py b/tests/test_line_detector.py new file mode 100644 index 0000000..ce186c7 --- /dev/null +++ b/tests/test_line_detector.py @@ -0,0 +1,204 @@ +"""line_detector モジュールのテスト""" + +import numpy as np +import pytest + +from common import config +from pc.vision.line_detector import ( + ImageParams, + LineDetectResult, + build_result, + detect_line, + fit_row_centers, + no_detection, +) + + +class TestNoDetection: + """no_detection のテスト""" + + def test_returns_not_detected( + self, blank_image: np.ndarray, + ) -> None: + """detected=False で全フィールドがデフォルト値""" + result = no_detection(blank_image) + assert result.detected is False + assert result.position_error == 0.0 + assert result.heading == 0.0 + assert result.curvature == 0.0 + assert result.poly_coeffs is None + assert result.row_centers is None + assert result.binary_image is not None + + +class TestBuildResult: + """build_result のテスト""" + + def test_straight_center_line(self) -> None: + """画像中央の直線は position_error ≈ 0""" + h = config.FRAME_HEIGHT + w = config.FRAME_WIDTH + center_x = w / 2.0 + # x = center_x (定数) → coeffs = [0, 0, center_x] + coeffs = np.array([0.0, 0.0, center_x]) + binary = np.zeros((h, w), dtype=np.uint8) + result = build_result(coeffs, binary) + assert result.detected is True + assert result.position_error == pytest.approx( + 0.0, abs=0.01, + ) + assert result.heading == pytest.approx( + 0.0, abs=0.01, + ) + assert result.curvature == pytest.approx( + 0.0, abs=0.01, + ) + + def test_offset_line(self) -> None: + """左にオフセットした直線は position_error > 0""" + h = config.FRAME_HEIGHT + w = config.FRAME_WIDTH + # 左寄りの直線 + offset_x = w / 4.0 + coeffs = np.array([0.0, 0.0, offset_x]) + binary = np.zeros((h, w), dtype=np.uint8) + result = build_result(coeffs, binary) + assert result.position_error > 0 # 中心より左 + + +class TestDetectLine: + """detect_line のテスト""" + + def test_current_detects_straight_line( + self, straight_line_image: np.ndarray, + ) -> None: + """現行手法で中央の直線を検出できる""" + # 小さいテスト画像用にパラメータを調整 + params = ImageParams( + method="current", + clahe_grid=2, blur_size=3, + open_size=1, close_width=3, + close_height=1, + ) + result = detect_line( + straight_line_image, params, + ) + assert result.detected is True + assert abs(result.position_error) < 0.5 + + def test_current_no_line( + self, blank_image: np.ndarray, + ) -> None: + """均一画像では線を検出しない""" + params = ImageParams(method="current") + result = detect_line(blank_image, params) + assert result.detected is False + + def test_blackhat_detects_straight_line( + self, straight_line_image: np.ndarray, + ) -> None: + """案A で中央の直線を検出できる""" + params = ImageParams( + method="blackhat", + blackhat_ksize=15, + binary_thresh=30, + blur_size=3, + iso_close_size=1, + dist_thresh=0.0, + min_line_width=1, + median_ksize=0, + neighbor_thresh=0.0, + residual_thresh=0.0, + ) + result = detect_line( + straight_line_image, params, + ) + assert result.detected is True + + def test_dual_norm_detects_straight_line( + self, straight_line_image: np.ndarray, + ) -> None: + """案B で中央の直線を検出できる""" + params = ImageParams( + method="dual_norm", + bg_blur_ksize=21, + adaptive_block=11, adaptive_c=5, + iso_close_size=1, + dist_thresh=0.0, + min_line_width=1, + median_ksize=0, + neighbor_thresh=0.0, + residual_thresh=0.0, + ) + result = detect_line( + straight_line_image, params, + ) + assert result.detected is True + + def test_robust_detects_straight_line( + self, straight_line_image: np.ndarray, + ) -> None: + """案C で中央の直線を検出できる""" + params = ImageParams( + method="robust", + blackhat_ksize=15, + adaptive_block=11, adaptive_c=5, + iso_close_size=1, + dist_thresh=0.0, + min_line_width=1, + median_ksize=0, + neighbor_thresh=0.0, + residual_thresh=0.0, + ) + result = detect_line( + straight_line_image, params, + ) + assert result.detected is True + + def test_valley_detects_straight_line( + self, straight_line_image: np.ndarray, + ) -> None: + """案D で中央の直線を検出できる""" + params = ImageParams(method="valley") + result = detect_line( + straight_line_image, params, + ) + assert result.detected is True + + def test_default_params( + self, straight_line_image: np.ndarray, + ) -> None: + """params=None でもデフォルトで動作する""" + result = detect_line(straight_line_image) + assert isinstance(result, LineDetectResult) + + def test_result_has_binary_image( + self, straight_line_image: np.ndarray, + ) -> None: + """結果に二値化画像が含まれる""" + result = detect_line(straight_line_image) + assert result.binary_image is not None + assert result.binary_image.shape == ( + config.FRAME_HEIGHT, config.FRAME_WIDTH, + ) + + +class TestFitRowCenters: + """fit_row_centers のテスト""" + + def test_detects_binary_line( + self, binary_line: np.ndarray, + ) -> None: + """二値画像の白線から中心をフィッティングできる""" + result = fit_row_centers( + binary_line, min_width=1, + ) + assert result.detected is True + assert abs(result.position_error) < 0.3 + + def test_empty_binary(self) -> None: + """白ピクセルがない二値画像では検出しない""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + empty = np.zeros((h, w), dtype=np.uint8) + result = fit_row_centers(empty, min_width=1) + assert result.detected is False diff --git a/tests/test_morphology.py b/tests/test_morphology.py new file mode 100644 index 0000000..1cff783 --- /dev/null +++ b/tests/test_morphology.py @@ -0,0 +1,142 @@ +"""morphology モジュールのテスト""" + +import numpy as np +import pytest + +from common import config +from pc.vision.morphology import ( + apply_dist_mask, + apply_iso_closing, + apply_staged_closing, + apply_width_filter, +) + + +class TestApplyIsoClosing: + """apply_iso_closing のテスト""" + + def test_fills_small_hole( + self, binary_with_hole: np.ndarray, + ) -> None: + """小さい穴をクロージングで埋められる""" + result = apply_iso_closing(binary_with_hole, 5) + h = config.FRAME_HEIGHT + w = config.FRAME_WIDTH + # 穴があった中央付近が白になっている + assert result[h // 2, w // 2] == 255 + + def test_small_size_noop(self) -> None: + """size < 3 の場合は何もしない""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + binary[5, 5] = 255 + result = apply_iso_closing(binary, 1) + assert np.array_equal(result, binary) + + def test_preserves_shape( + self, binary_with_hole: np.ndarray, + ) -> None: + """出力画像のサイズが入力と同じ""" + result = apply_iso_closing(binary_with_hole, 7) + assert result.shape == binary_with_hole.shape + + +class TestApplyStagedClosing: + """apply_staged_closing のテスト""" + + def test_removes_small_regions(self) -> None: + """min_area で小さい領域を除去できる""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + # 大きな領域 + binary[2:h - 2, w // 2 - 3 : w // 2 + 4] = 255 + # 小さな孤立点 + binary[0, 0] = 255 + result = apply_staged_closing( + binary, small_size=3, min_area=5, + large_size=0, + ) + assert result[0, 0] == 0 # 孤立点が除去されている + assert result[h // 2, w // 2] == 255 # 大領域は残る + + def test_no_min_area(self) -> None: + """min_area=0 なら孤立除去をスキップする""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + binary[0, 0] = 255 + result = apply_staged_closing( + binary, small_size=1, min_area=0, + large_size=0, + ) + assert result[0, 0] == 255 + + +class TestApplyWidthFilter: + """apply_width_filter のテスト""" + + def test_keeps_narrow_line(self) -> None: + """期待幅以内の線は残す""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + cx = w // 2 + binary[:, cx - 1 : cx + 2] = 255 # 幅 3 + result = apply_width_filter( + binary, width_near=10, width_far=5, + tolerance=2.0, + ) + # 幅 3 は期待幅内なので残る + assert np.any(result[h // 2] > 0) + + def test_removes_wide_row(self) -> None: + """期待幅を超える行を除去する""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + # 下端に幅いっぱいの白(幅=w) + binary[h - 1, :] = 255 + result = apply_width_filter( + binary, width_near=3, width_far=2, + tolerance=1.5, + ) + # 幅 w >> 3*1.5 なので除去される + assert np.all(result[h - 1] == 0) + + def test_empty_rows_unchanged(self) -> None: + """白ピクセルがない行はそのまま""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + result = apply_width_filter( + binary, width_near=5, width_far=3, + tolerance=2.0, + ) + assert np.array_equal(result, binary) + + +class TestApplyDistMask: + """apply_dist_mask のテスト""" + + def test_keeps_center_of_thick_line(self) -> None: + """太い線の中心部を残す""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + cx = w // 2 + binary[:, cx - 5 : cx + 6] = 255 # 幅 11 + result = apply_dist_mask(binary, thresh=2.0) + # 中心は残る + assert result[h // 2, cx] > 0 + + def test_thresh_zero_noop(self) -> None: + """thresh <= 0 の場合は何もしない""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + binary[5, 5] = 255 + result = apply_dist_mask(binary, thresh=0.0) + assert np.array_equal(result, binary) + + def test_removes_thin_line(self) -> None: + """細い線(幅1px)は距離変換で除去される""" + h, w = config.FRAME_HEIGHT, config.FRAME_WIDTH + binary = np.zeros((h, w), dtype=np.uint8) + binary[:, w // 2] = 255 # 幅 1px + result = apply_dist_mask(binary, thresh=1.0) + # 幅 1px の距離変換最大値は 1.0 未満 + assert np.all(result == 0) diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 0000000..d7bf6fa --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,145 @@ +"""auto_params / param_store モジュールのテスト""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from pc.steering.pd_control import PdParams +from pc.vision.line_detector import ImageParams + + +class TestAutoParams: + """auto_params の保存・読み込みテスト""" + + @pytest.fixture(autouse=True) + def _use_tmp_dir(self, tmp_path: Path) -> None: + """PARAMS_DIR を一時ディレクトリに差し替える""" + with patch( + "pc.steering.auto_params.PARAMS_DIR", + tmp_path, + ): + with patch( + "pc.steering.auto_params._CONTROL_FILE", + tmp_path / "control.json", + ): + yield + + def test_save_load_control_roundtrip(self) -> None: + """PD パラメータと手法を保存→読込で復元できる""" + from pc.steering.auto_params import ( + load_control, + save_control, + ) + params = PdParams( + kp=1.0, kh=0.5, kd=0.2, + max_steer_rate=0.05, + max_throttle=0.6, speed_k=0.4, + ) + save_control(params, "blackhat") + loaded, method = load_control() + assert method == "blackhat" + assert loaded.kp == pytest.approx(1.0) + assert loaded.kh == pytest.approx(0.5) + assert loaded.max_throttle == pytest.approx(0.6) + + def test_load_control_missing_file(self) -> None: + """ファイルがない場合はデフォルト値を返す""" + from pc.steering.auto_params import load_control + params, method = load_control() + assert method == "current" + assert params.kp == PdParams().kp + + def test_save_load_detect_params( + self, tmp_path: Path, + ) -> None: + """検出パラメータを手法別に保存→復元できる""" + from pc.steering.auto_params import ( + load_detect_params, + save_detect_params, + ) + ip = ImageParams( + method="dual_norm", + adaptive_block=31, + adaptive_c=15, + ) + save_detect_params("dual_norm", ip) + loaded = load_detect_params("dual_norm") + assert loaded.method == "dual_norm" + assert loaded.adaptive_block == 31 + assert loaded.adaptive_c == 15 + + def test_load_detect_unknown_method(self) -> None: + """未知の手法は指定手法のデフォルト値を返す""" + from pc.steering.auto_params import ( + load_detect_params, + ) + loaded = load_detect_params("unknown") + assert loaded.method == "unknown" + + +class TestParamStore: + """param_store の保存・読み込みテスト""" + + @pytest.fixture(autouse=True) + def _use_tmp_dir(self, tmp_path: Path) -> None: + """プリセットファイルを一時ディレクトリに差し替える""" + with patch( + "pc.steering.param_store._PD_FILE", + tmp_path / "presets_pd.json", + ): + with patch( + "pc.steering.param_store._IMAGE_FILE", + tmp_path / "presets_image.json", + ): + yield + + def test_pd_preset_add_load_delete(self) -> None: + """PD プリセットの追加・読込・削除""" + from pc.steering.param_store import ( + PdPreset, + add_pd_preset, + delete_pd_preset, + load_pd_presets, + ) + # 追加 + preset = PdPreset( + title="テスト", + memo="メモ", + params=PdParams(kp=2.0), + ) + add_pd_preset(preset) + presets = load_pd_presets() + assert len(presets) == 1 + assert presets[0].title == "テスト" + assert presets[0].params.kp == pytest.approx(2.0) + + # 削除 + delete_pd_preset(0) + assert len(load_pd_presets()) == 0 + + def test_image_preset_add_load(self) -> None: + """画像処理プリセットの追加・読込""" + from pc.steering.param_store import ( + ImagePreset, + add_image_preset, + load_image_presets, + ) + ip = ImageParams( + method="blackhat", blackhat_ksize=51, + ) + add_image_preset(ImagePreset( + title="BH51", memo="テスト", image_params=ip, + )) + presets = load_image_presets() + assert len(presets) == 1 + assert presets[0].image_params.blackhat_ksize == 51 + + def test_load_empty(self) -> None: + """ファイルがない場合は空リストを返す""" + from pc.steering.param_store import ( + load_image_presets, + load_pd_presets, + ) + assert load_pd_presets() == [] + assert load_image_presets() == []