"""sim_fc_test.py —— 模拟飞控喂表3 工具的纯软单测(注入 fake serial,无硬件)。 覆盖: * Feeder:静态姿态 / 时戳冻结 / yaw 匀速推进 / 地速位移; * offset_m: East/North 换算方向; * Collector:遥测与表4 计数、锁定跳变、贴地假设、回环帧识别; * stddev / ptpm 统计; * verdict 四种判读分支; * main() 端到端(假串口回回放表4)→ 退出码 0。 """ import sys import types import pytest from pod_bridge.protocol import (CarrierAttitude, TargetGeo, Telemetry, encode_attitude, encode_target) from pod_bridge.sim_fc import (EXIT_LINK_ERROR, EXIT_NO_TARGET_FRAME, EXIT_OK, EXIT_ZERO_GEO, Collector, Feeder, main, offset_m, ptpm, stddev, verdict) _LAT, _LON = 39.9042, 116.4074 class _FakeSerialInstance: """pyserial.Serial 的最小替身:可预设 RX 字节,记录 TX 写入。""" def __init__(self, **kwargs): self.kwargs = kwargs self._buf = b"" self.written = [] self.closed = False def seed_rx(self, data: bytes) -> None: self._buf += data @property def in_waiting(self): return len(self._buf) def read(self, n): data, self._buf = self._buf[:n], self._buf[n:] return data def write(self, data): self.written.append(bytes(data)) return len(data) def close(self): self.closed = True @pytest.fixture def fake_serial(monkeypatch): """注入 serial 模块并返回**共享实例**——PodSerial 内部 Serial(...) 必须拿到 同一个对象,否则外部 seed_rx 预置的字节 main() 读不到。""" mod = types.ModuleType("serial") mod.EIGHTBITS = 8 mod.PARITY_NONE = "N" mod.STOPBITS_ONE = 1 shared = _FakeSerialInstance(port="FAKE") mod.Serial = lambda **kwargs: shared monkeypatch.setitem(sys.modules, "serial", mod) return shared def _locked_telemetry(locked: bool) -> Telemetry: status1 = (1 << 2) if locked else 0 return Telemetry(status1=status1) # --- Feeder ----------------------------------------------------------------- def test_feeder_static_defaults(): f = Feeder(_LAT, _LON, abs_alt_m=50.0, rel_alt_m=50.0) frame = f.build(0.0) assert isinstance(frame, CarrierAttitude) assert (frame.latitude_deg, frame.longitude_deg) == (_LAT, _LON) assert (frame.roll_deg, frame.pitch_deg, frame.yaw_deg) == (0.0, 0.0, 0.0) assert (frame.abs_alt_m, frame.rel_alt_m) == (50.0, 50.0) assert 0 <= frame.centisecond <= 99 def test_feeder_freeze_time_keeps_stamp(): f = Feeder(_LAT, _LON, 50.0, 50.0, freeze_time=True) assert f.build(0.0).second == f.build(30.0).second def test_feeder_yaw_sweep_wraps(): f = Feeder(_LAT, _LON, 50.0, 50.0, yaw_deg=350.0, yaw_rate_dps=20.0) assert abs(f.build(0.0).yaw_deg - 350.0) < 1e-9 assert abs(f.build(1.0).yaw_deg - 10.0) < 1e-9 # 360 → 回绕到 10° def test_feeder_ground_speed_moves_position_north(): f = Feeder(_LAT, _LON, 50.0, 50.0, yaw_deg=0.0, ground_speed_mps=10.0) later = f.build(10.0) # 向北 100m north_m = (later.latitude_deg - _LAT) * 111320.0 assert 95.0 < north_m < 105.0 assert abs((later.longitude_deg - _LON) * 111320.0) < 1.0 # --- 几何 / 统计 ------------------------------------------------------------ def test_offset_m_directions(): east, north = offset_m(0.0, 0.0, 0.0, 0.0001) assert north == pytest.approx(0.0, abs=1e-6) assert east > 10.0 east2, north2 = offset_m(0.0, 0.0, 0.0001, 0.0) assert east2 == pytest.approx(0.0, abs=1e-6) assert north2 > 10.0 def test_stddev_and_ptpm(): assert stddev([1.0]) == 0.0 assert stddev([]) == 0.0 assert stddev([2.0, 2.0, 2.0]) == 0.0 assert stddev([1.0, 3.0]) == pytest.approx(1.0) assert ptpm([]) == 0.0 assert ptpm([1.0, 5.0, 3.0]) == pytest.approx(4.0) # --- Collector -------------------------------------------------------------- def test_collector_counts_and_lock_events(): c = Collector(_LAT, _LON) for t, lk in ((0.1, False), (0.2, True), (0.3, True), (0.4, False)): c.update(_locked_telemetry(lk), t) assert c.n_tele == 4 assert c.n_lock_events == 2 # False→True, True→False assert c.first_lock_s == pytest.approx(0.2) assert c.n_tgt == 0 def test_collector_target_stats_and_first_frame(): c = Collector(_LAT, _LON) assert c.has_valid_geo is False c.update(TargetGeo(latitude_deg=_LAT, longitude_deg=_LON), 1.0) c.update(TargetGeo(latitude_deg=_LAT + 0.0001, longitude_deg=_LON), 2.0) assert c.has_valid_geo is True assert c.first_tgt_s == pytest.approx(1.0) assert c.last_tgt_s == pytest.approx(2.0) north = c.records[-1]["north_m"] assert north == pytest.approx(11.132, abs=0.5) def test_collector_flags_loopback_and_unknown(): c = Collector(_LAT, _LON) c.update(CarrierAttitude(), 0.1) c.update(object(), 0.2) assert c.n_loopback == 1 assert c.n_other == 1 def test_collector_summary_includes_geo_validity(): c = Collector(_LAT, _LON) assert c.summary(1.0, 20, 50.0, 50.0)["has_valid_geo"] is False c.update(TargetGeo(latitude_deg=_LAT, longitude_deg=_LON, rel_alt_m=50.0), 0.5) summary = c.summary(1.0, 20, 50.0, 50.0) assert summary["has_valid_geo"] is True assert summary["target_hz"] == pytest.approx(1.0) assert summary["target_stale_s"] == pytest.approx(0.5) # --- verdict ---------------------------------------------------------------- def _summary(**over): base = { "elapsed_s": 10.0, "target_frames": 0, "target_hz": 0.0, "first_target_s": None, "first_lock_s": None, "has_valid_geo": False, "loopback_frames": 0, "east_mean_m": 0.0, "north_mean_m": 0.0, "spread_east_m": 0.0, "spread_north_m": 0.0, "rel_alt_mean_m": 0.0, "ref_rel_alt_m": 50.0, } base.update(over) return base def test_verdict_no_target_frame(): code, lines = verdict(_summary(), None) assert code == EXIT_NO_TARGET_FRAME assert any("Q1 失败" in ln for ln in lines) def test_verdict_zero_geo(): code, lines = verdict(_summary(target_frames=100), None) assert code == EXIT_ZERO_GEO assert any("有帧无解算" in ln for ln in lines) def test_verdict_ok_and_ground_plane_warning(): code, lines = verdict(_summary(target_frames=100, target_hz=5.5, has_valid_geo=True, first_target_s=3.4, first_lock_s=3.0, rel_alt_mean_m=50.0), None) assert code == EXIT_OK assert any("Q1 通过" in ln for ln in lines) assert any("目标在地面" in ln for ln in lines) # 表4 相对高度 ≈ 喂入值 def test_verdict_link_error_takes_priority(): code, lines = verdict(_summary(target_frames=100, has_valid_geo=True), OSError(5, "Input/output error")) assert code == EXIT_LINK_ERROR assert any("链路中断" in ln for ln in lines) def test_verdict_flags_loopback(): code, lines = verdict(_summary(target_frames=1, has_valid_geo=True, loopback_frames=7), None) assert code == EXIT_OK assert any("回环" in ln for ln in lines) # --- main() 端到端(假串口回环) --------------------------------------------- def test_main_end_to_end_reports_target(fake_serial, tmp_path, capsys): inst = fake_serial target = TargetGeo(latitude_deg=_LAT + 0.0002, longitude_deg=_LON, abs_alt_m=50.0, rel_alt_m=50.0) inst.seed_rx(encode_target(target)) code = main(["FAKE", "--seconds", "0.05", "--rate", "60", "--rel-alt", "50", "--quiet", "--json", str(tmp_path / "r.json")]) out = capsys.readouterr().out assert code == EXIT_OK assert inst.closed is True assert len(inst.written) >= 1 # 至少发过一帧 表3 assert inst.written[0].startswith(b"\xeb\x91") # 同步字 EB 91 assert "Q1 通过" in out assert (tmp_path / "r.json").exists() def test_main_no_target_returns_error(fake_serial, capsys): fake_serial # RX 为空 code = main(["FAKE", "--seconds", "0.05", "--rate", "60", "--quiet"]) assert code == EXIT_NO_TARGET_FRAME def test_main_rejects_bad_rate(fake_serial, capsys): code = main(["FAKE", "--rate", "100", "--quiet"]) out = capsys.readouterr().out assert code == EXIT_LINK_ERROR assert "1~60Hz" in out def test_main_auto_lock_requires_detect(fake_serial, capsys): code = main(["FAKE", "--auto-lock", "--quiet"]) out = capsys.readouterr().out assert code == EXIT_LINK_ERROR assert "--auto-lock 需配合" in out def test_main_sends_detect_sequence(fake_serial, capsys): inst = fake_serial main(["FAKE", "--seconds", "0.02", "--rate", "60", "--quiet", "--detect", "drone", "--auto-lock", "--motor-on"]) codes = [w[2] for w in inst.written] assert 0x27 in codes # 开电机 assert 0x07 in codes # 目标识别开 assert 0x0F in codes # 自动锁定 assert 0x10 in codes # 识别转跟踪 def test_feeder_frame_is_encodable(): """Feeder 产物必须能通过 protocol 编码/解码往返(防止字段对不上规格)。""" from pod_bridge.protocol import decode_attitude f = Feeder(_LAT, _LON, 50.0, 50.0, roll_deg=1.5, pitch_deg=-2.0, yaw_deg=30.0) frame = f.build(0.0) back = decode_attitude(encode_attitude(frame)) assert back.latitude_deg == pytest.approx(frame.latitude_deg, abs=1e-7) assert back.rel_alt_m == pytest.approx(50.0, abs=0.2) # ALT_SCALE=0.2m assert back.yaw_deg == pytest.approx(30.0, abs=0.01)