"""vtc_config_test.py —— TrackingConfig::load_from_json 单元测试。 移植自 reference-c++/visual_track_control/tests/vtc_config_test.cpp。 覆盖(物理追踪思路 §8、需求分析 NFR-007):真实配置文件加载成功与取值 正确(含 A1/A2 修改后的字段结构)、缺字段 / 缺分组 / 非法枚举 / 非法数值 / 文件缺失均抛带字段名的 ValueError。 C++ 版用字符串替换构造非法变体;Python 版改为 dict 变异(更稳健), 临时文件经 pytest 的 tmp_path 提供。 """ import json from pathlib import Path import pytest from visual_track_control.config import (HeadingMode, NoDistanceAction, load_from_json) _REPO_ROOT = Path(__file__).resolve().parents[3] REAL_CONFIG = _REPO_ROOT / "reference-c++" / "visual_track_control" / "config" / "track_control.json" def full_config() -> dict: """与真实配置同构的完整合法配置(用于派生各非法变体)。""" return { "control": { "frequency_hz": 20.0, "visual_stale_ms": 200, "radar_stale_ms": 500, "attitude_stale_ms": 300, }, "camera": { "image_width": 1920, "image_height": 1080, "fov_h_deg": 60.0, "fov_v_deg": 35.0, }, "heading": { "mode": "rate", "gain": 1.0, "yaw_rate_limit_dps": 30.0, "yaw_slew_limit_dps2": 60.0, }, "vertical": { "vz_gain_mps_per_deg": 0.1, "vz_limit_mps": 2.0, }, "distance": { "d_exp_m": 10.0, "kp": 0.5, "ki": 0.05, "kd": 0.1, "integral_limit": 2.0, "derivative_filter_coef": 0.2, "approach_velocity_limit_mps": 3.0, "retreat_velocity_limit_mps": 1.0, "no_distance_action": "hold", "no_distance_approach_limit_mps": 0.5, }, "accel_limit": { "ax_mps2": 2.0, "ay_mps2": 2.0, "az_mps2": 1.5, }, } def write_temp_json(tmp_path: Path, config: dict, name: str) -> str: path = tmp_path / name path.write_text(json.dumps(config), encoding="utf-8") return str(path) def test_load_real_config_succeeds(): cfg = load_from_json(str(REAL_CONFIG)) assert cfg.control.frequency_hz == 20.0 assert cfg.control.visual_stale_ms == 200 assert cfg.camera.image_width == 1920 assert cfg.camera.image_height == 1080 assert cfg.camera.fov_h_deg == 60.0 assert cfg.heading.mode is HeadingMode.RATE # A2:默认速率式 assert cfg.heading.yaw_rate_limit_dps == 30.0 assert cfg.vertical.vz_gain_mps_per_deg == 0.1 assert cfg.vertical.vz_limit_mps == 2.0 assert cfg.distance.d_exp_m == 10.0 assert cfg.distance.no_distance_action is NoDistanceAction.HOLD assert cfg.accel_limit.ax_mps2 == 2.0 def test_full_json_round_trip_matches_expected(tmp_path): # 内嵌合法配置与真实配置文件行为一致 path = write_temp_json(tmp_path, full_config(), "vtc_cfg_full.json") cfg = load_from_json(path) assert cfg.heading.mode is HeadingMode.RATE assert cfg.distance.kp == 0.5 def test_position_mode_accepted(tmp_path): config = full_config() config["heading"]["mode"] = "position" path = write_temp_json(tmp_path, config, "vtc_cfg_position.json") cfg = load_from_json(path) assert cfg.heading.mode is HeadingMode.POSITION def test_missing_file_throws_with_path(): with pytest.raises(ValueError) as exc_info: load_from_json("/nonexistent/vtc_no_such_file.json") assert "vtc_no_such_file.json" in str(exc_info.value) def test_malformed_json_throws(tmp_path): path = tmp_path / "vtc_cfg_bad.json" path.write_text("{ this is not json", encoding="utf-8") with pytest.raises(ValueError): load_from_json(str(path)) def test_missing_field_throws_with_field_name(tmp_path): config = full_config() del config["heading"]["gain"] path = write_temp_json(tmp_path, config, "vtc_cfg_missing.json") with pytest.raises(ValueError) as exc_info: load_from_json(path) assert "heading.gain" in str(exc_info.value) def test_missing_section_throws(tmp_path): config = full_config() del config["accel_limit"] path = write_temp_json(tmp_path, config, "vtc_cfg_nosection.json") with pytest.raises(ValueError) as exc_info: load_from_json(path) assert "accel_limit" in str(exc_info.value) def test_invalid_heading_mode_throws(tmp_path): config = full_config() config["heading"]["mode"] = "magic" path = write_temp_json(tmp_path, config, "vtc_cfg_badmode.json") with pytest.raises(ValueError) as exc_info: load_from_json(path) assert "heading.mode" in str(exc_info.value) def test_invalid_no_distance_action_throws(tmp_path): config = full_config() config["distance"]["no_distance_action"] = "zoom" path = write_temp_json(tmp_path, config, "vtc_cfg_badaction.json") with pytest.raises(ValueError) as exc_info: load_from_json(path) assert "no_distance_action" in str(exc_info.value) def test_negative_frequency_throws(tmp_path): config = full_config() config["control"]["frequency_hz"] = -1.0 path = write_temp_json(tmp_path, config, "vtc_cfg_negfreq.json") with pytest.raises(ValueError) as exc_info: load_from_json(path) assert "control.frequency_hz" in str(exc_info.value) def test_fov_out_of_range_throws(tmp_path): config = full_config() config["camera"]["fov_h_deg"] = 200.0 path = write_temp_json(tmp_path, config, "vtc_cfg_badfov.json") with pytest.raises(ValueError) as exc_info: load_from_json(path) assert "camera.fov_h_deg" in str(exc_info.value)