"""protocol_test.py —— 光电吊舱协议编解码单元测试(规格:docs/光电吊舱控制协议G版-PB.docx)。 覆盖:文档样例校验和、四帧 encode/decode 回环、量纲换算、遥测位域属性、 分帧器(粘包/半帧/噪声前缀/坏校验恢复)、异常帧拒绝。 """ import pytest from pod_bridge.protocol import ( CarrierAttitude, ControlCode, ControlCommand, FrameParser, Telemetry, TargetGeo, checksum, decode_attitude, decode_control, decode_target, decode_telemetry, encode_attitude, encode_control, encode_target, encode_telemetry, ) # --- 校验和:文档第 589 行样例 --------------------------------------------- def test_checksum_matches_doc_example(): # 文档样例 eb 90 2b 00..00(15 字节 body)累加取低 8 位应为 0xa6 body = bytes.fromhex("eb902b000000000000000000000000") assert len(body) == 15 assert checksum(body) == 0xA6 def test_encode_control_center_matches_doc_bytes(): frame = encode_control(ControlCommand(code=ControlCode.CENTER)) assert frame == bytes.fromhex("eb902b000000000000000000000000a6") assert len(frame) == 16 # --- 表2-1 控制指令回环 ----------------------------------------------------- def test_control_roundtrip_box_track(): cmd = ControlCommand(code=ControlCode.BOX_TRACK, param_x=-120, param_y=340, param3=2, file_save_mode=1, param_x2=500, param_y2=-600) decoded = decode_control(encode_control(cmd)) assert decoded == cmd def test_control_signed_params_preserved(): cmd = ControlCommand(code=ControlCode.POINT_TRACK, param_x=-32768, param_y=32767) decoded = decode_control(encode_control(cmd)) assert decoded.param_x == -32768 assert decoded.param_y == 32767 # --- 表3 载机姿态回环(含量纲换算)---------------------------------------- def test_attitude_roundtrip(): att = CarrierAttitude( roll_deg=1.23, pitch_deg=-4.56, yaw_deg=179.99, latitude_deg=31.2345678, longitude_deg=121.4737012, abs_alt_m=123.4, rel_alt_m=56.8, year=2026, month=9, day=22, hour=14, minute=30, second=15, centisecond=42, airspeed_mps=15.5, ground_speed_mps=12.0, ) d = decode_attitude(encode_attitude(att)) assert d.roll_deg == pytest.approx(1.23, abs=0.01) assert d.pitch_deg == pytest.approx(-4.56, abs=0.01) assert d.yaw_deg == pytest.approx(179.99, abs=0.01) assert d.latitude_deg == pytest.approx(31.2345678, abs=1e-7) assert d.longitude_deg == pytest.approx(121.4737012, abs=1e-7) assert d.abs_alt_m == pytest.approx(123.4, abs=0.2) assert d.rel_alt_m == pytest.approx(56.8, abs=0.2) assert (d.year, d.month, d.day) == (2026, 9, 22) assert (d.hour, d.minute, d.second, d.centisecond) == (14, 30, 15, 42) assert d.airspeed_mps == pytest.approx(15.5, abs=0.5) assert d.ground_speed_mps == pytest.approx(12.0, abs=0.5) def test_attitude_sync_and_length(): frame = encode_attitude(CarrierAttitude()) assert len(frame) == 32 assert frame[0] == 0xEB and frame[1] == 0x91 # --- 表4 目标经纬度回环 ----------------------------------------------------- def test_target_roundtrip(): tgt = TargetGeo(latitude_deg=31.2345678, longitude_deg=121.4737012, abs_alt_m=100.2, rel_alt_m=33.4, year=2026, month=9, day=22, hour=8, minute=5, second=30, centisecond=99) d = decode_target(encode_target(tgt)) assert d.latitude_deg == pytest.approx(31.2345678, abs=1e-7) assert d.longitude_deg == pytest.approx(121.4737012, abs=1e-7) assert d.abs_alt_m == pytest.approx(100.2, abs=0.2) assert d.rel_alt_m == pytest.approx(33.4, abs=0.2) assert d.year == 2026 and d.centisecond == 99 def test_target_negative_longitude(): tgt = TargetGeo(latitude_deg=-33.8688, longitude_deg=-70.6693) d = decode_target(encode_target(tgt)) assert d.longitude_deg == pytest.approx(-70.6693, abs=1e-7) assert d.latitude_deg == pytest.approx(-33.8688, abs=1e-7) # --- 表2-2 遥测:物理量 + 位域 --------------------------------------------- def test_telemetry_roundtrip_physical_values(): t = Telemetry(status1=0b10000100, status2=0b00101010, status3=0b01000000, off_target_x_deg=1.23, off_target_y_deg=-4.56, frame_roll_deg=10.0, frame_pitch_deg=-20.0, frame_yaw_deg=90.5, att_roll_deg=2.0, att_pitch_deg=-3.0, roll_rate_dps=1.5, pitch_rate_dps=-2.5, yaw_rate_dps=30.0, laser_range_m=123.4, self_check=0) d = decode_telemetry(encode_telemetry(t)) assert d.off_target_x_deg == pytest.approx(1.23, abs=0.01) assert d.off_target_y_deg == pytest.approx(-4.56, abs=0.01) assert d.frame_yaw_deg == pytest.approx(90.5, abs=0.01) assert d.yaw_rate_dps == pytest.approx(30.0, abs=0.01) assert d.laser_range_m == pytest.approx(123.4, abs=0.1) def test_telemetry_laser_zero_is_none(): d = decode_telemetry(encode_telemetry(Telemetry(laser_range_m=None))) assert d.laser_range_m is None def test_telemetry_bitfield_properties(): t = Telemetry(status1=0b11000100, status2=0b00101110, status3=0b01100000) d = decode_telemetry(encode_telemetry(t)) assert d.track_source == 0b11 # 红外2 assert d.is_target_locked is True # status1 Bit2 = 1 assert d.motor_on is True # status2 Bit3 = 1 assert d.follow_on is True # status2 Bit2 = 1 assert d.big_picture == 0b01 assert d.small_picture == 0b10 # --- 异常帧拒绝 ------------------------------------------------------------ def test_decode_rejects_bad_sync(): frame = bytearray(encode_control(ControlCommand(code=ControlCode.CENTER))) frame[1] = 0x91 # 破坏同步字 with pytest.raises(ValueError, match="同步字"): decode_control(bytes(frame)) def test_decode_rejects_bad_checksum(): frame = bytearray(encode_target(TargetGeo(latitude_deg=10.0))) frame[31] ^= 0xFF with pytest.raises(ValueError, match="校验和"): decode_target(bytes(frame)) def test_decode_rejects_wrong_length(): with pytest.raises(ValueError, match="帧长"): decode_telemetry(b"\xee\x16" + b"\x00" * 10) # --- 分帧器 ---------------------------------------------------------------- def test_parser_single_frame(): parser = FrameParser() frames = parser.feed(encode_target(TargetGeo(latitude_deg=31.0, longitude_deg=121.0))) assert len(frames) == 1 assert isinstance(frames[0], TargetGeo) assert frames[0].latitude_deg == pytest.approx(31.0, abs=1e-7) def test_parser_byte_by_byte_half_frame(): parser = FrameParser() raw = encode_telemetry(Telemetry(laser_range_m=50.0)) collected = [] for i in range(len(raw)): collected.extend(parser.feed(raw[i:i + 1])) assert len(collected) == 1 assert collected[0].laser_range_m == pytest.approx(50.0, abs=0.1) def test_parser_concatenated_and_mixed_frames(): parser = FrameParser() blob = (encode_telemetry(Telemetry(laser_range_m=10.0)) + encode_target(TargetGeo(latitude_deg=1.0)) + encode_telemetry(Telemetry(laser_range_m=20.0))) frames = parser.feed(blob) assert [type(f) for f in frames] == [Telemetry, TargetGeo, Telemetry] assert frames[0].laser_range_m == pytest.approx(10.0, abs=0.1) assert frames[2].laser_range_m == pytest.approx(20.0, abs=0.1) def test_parser_skips_noise_prefix(): parser = FrameParser() blob = b"\x00\xff\x12\x34" + encode_target(TargetGeo(latitude_deg=5.0)) frames = parser.feed(blob) assert len(frames) == 1 assert frames[0].latitude_deg == pytest.approx(5.0, abs=1e-7) def test_parser_recovers_from_bad_checksum(): parser = FrameParser() bad = bytearray(encode_telemetry(Telemetry(laser_range_m=1.0))) bad[31] ^= 0xFF # 坏校验 good = encode_target(TargetGeo(latitude_deg=7.0)) frames = parser.feed(bytes(bad) + good) # 坏帧被丢弃,紧随的好帧仍能解析 assert len(frames) == 1 assert isinstance(frames[0], TargetGeo) def test_parser_split_across_feeds(): parser = FrameParser() raw = encode_target(TargetGeo(latitude_deg=42.0)) assert parser.feed(raw[:5]) == [] # 半帧,未输出 frames = parser.feed(raw[5:]) assert len(frames) == 1 assert frames[0].latitude_deg == pytest.approx(42.0, abs=1e-7) def test_parser_accepts_configured_kinds(): parser = FrameParser(kinds=((0xEB, 0x90),)) # 只识别控制帧(模拟器侧) frames = parser.feed(encode_control(ControlCommand(code=ControlCode.CENTER))) assert len(frames) == 1 assert isinstance(frames[0], ControlCommand) assert frames[0].code == ControlCode.CENTER