归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。 目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。 自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。 任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。 入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。 新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
202 lines
6.5 KiB
Python
202 lines
6.5 KiB
Python
"""ground_link_test.py —— 地面站链路单元测试。
|
|
|
|
覆盖:parse_payload 解析(合法/缺字段/非法 JSON/非对象/类型错误)、
|
|
parse_message 双通道(坐标 + {"cmd"} 命令)、命令队列 drain、
|
|
is_valid 新鲜度判定(未注入/新鲜/超龄边界)、run() 真 UDP 回环。
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from pod_bridge.ground_link import (GroundCommand, GroundInjectConfig,
|
|
GroundLink, GroundTarget)
|
|
|
|
|
|
# --- parse_payload ----------------------------------------------------------
|
|
def test_parse_valid_payload():
|
|
t = GroundLink.parse_payload(
|
|
b'{"lat": 39.9042, "lon": 116.4074, "alt": 50.0}')
|
|
assert t is not None
|
|
assert t.latitude_deg == pytest.approx(39.9042)
|
|
assert t.longitude_deg == pytest.approx(116.4074)
|
|
assert t.abs_alt_m == pytest.approx(50.0)
|
|
assert t.time_ms == 0 # 打戳由接收方(run)负责
|
|
|
|
|
|
def test_parse_default_alt_is_zero():
|
|
t = GroundLink.parse_payload(b'{"lat": 1.0, "lon": 2.0}')
|
|
assert t is not None
|
|
assert t.abs_alt_m == 0.0
|
|
|
|
|
|
def test_parse_missing_lat_returns_none():
|
|
assert GroundLink.parse_payload(b'{"lon": 2.0}') is None
|
|
|
|
|
|
def test_parse_missing_lon_returns_none():
|
|
assert GroundLink.parse_payload(b'{"lat": 1.0}') is None
|
|
|
|
|
|
def test_parse_invalid_json_returns_none():
|
|
assert GroundLink.parse_payload(b'not json') is None
|
|
|
|
|
|
def test_parse_non_object_returns_none():
|
|
assert GroundLink.parse_payload(b'[1, 2, 3]') is None
|
|
|
|
|
|
def test_parse_string_coords_returns_none():
|
|
assert GroundLink.parse_payload(b'{"lat": "x", "lon": 2.0}') is None
|
|
|
|
|
|
def test_parse_bool_coords_returns_none():
|
|
# True 是 bool 不是数值 → 拒绝
|
|
assert GroundLink.parse_payload(b'{"lat": true, "lon": 2.0}') is None
|
|
|
|
|
|
def test_parse_ignores_ts_field():
|
|
"""ts 字段仅诊断,不参与解析(新鲜度用本地接收时刻)。"""
|
|
t = GroundLink.parse_payload(b'{"lat": 1.0, "lon": 2.0, "ts": 999}')
|
|
assert t is not None
|
|
assert t.latitude_deg == pytest.approx(1.0)
|
|
|
|
|
|
# --- is_valid ---------------------------------------------------------------
|
|
def _link(timeout_ms=5000):
|
|
return GroundLink(GroundInjectConfig(timeout_ms=timeout_ms),
|
|
now_ms_fn=lambda: 0)
|
|
|
|
|
|
def _inject(link, time_ms):
|
|
link._target = GroundTarget(latitude_deg=1.0, longitude_deg=2.0,
|
|
time_ms=time_ms)
|
|
|
|
|
|
def test_is_valid_false_when_never_injected():
|
|
assert _link().is_valid(1000) is False
|
|
|
|
|
|
def test_is_valid_true_when_fresh():
|
|
link = _link(timeout_ms=5000)
|
|
_inject(link, 1000)
|
|
assert link.is_valid(1000) is True
|
|
assert link.is_valid(4000) is True
|
|
|
|
|
|
def test_is_valid_timeout_boundary():
|
|
link = _link(timeout_ms=5000)
|
|
_inject(link, 1000)
|
|
assert link.is_valid(6000) is True # 恰好 5000ms,未超龄
|
|
assert link.is_valid(6001) is False # 5001ms,超龄
|
|
|
|
|
|
def test_latest_returns_injected_target():
|
|
link = _link()
|
|
_inject(link, 1000)
|
|
assert link.latest().latitude_deg == pytest.approx(1.0)
|
|
assert link.latest().time_ms == 1000
|
|
|
|
|
|
# --- parse_message(坐标 + 命令双通道)---------------------------------------
|
|
def test_parse_message_command():
|
|
m = GroundLink.parse_message(b'{"cmd": "rtl"}')
|
|
assert isinstance(m, GroundCommand)
|
|
assert m.name == "rtl"
|
|
|
|
|
|
def test_parse_message_command_normalized():
|
|
m = GroundLink.parse_message(b'{"cmd": " RTL "}')
|
|
assert isinstance(m, GroundCommand)
|
|
assert m.name == "rtl"
|
|
|
|
|
|
def test_parse_message_cmd_takes_precedence_over_coords():
|
|
m = GroundLink.parse_message(b'{"cmd": "emergency", "lat": 1.0, "lon": 2.0}')
|
|
assert isinstance(m, GroundCommand)
|
|
assert m.name == "emergency"
|
|
|
|
|
|
def test_parse_message_coords_still_work():
|
|
m = GroundLink.parse_message(b'{"lat": 1.0, "lon": 2.0}')
|
|
assert isinstance(m, GroundTarget)
|
|
assert m.latitude_deg == pytest.approx(1.0)
|
|
|
|
|
|
def test_parse_message_empty_cmd_falls_back_to_coords():
|
|
m = GroundLink.parse_message(b'{"cmd": "", "lat": 1.0, "lon": 2.0}')
|
|
assert isinstance(m, GroundTarget)
|
|
|
|
|
|
def test_parse_message_non_string_cmd_falls_back_to_coords():
|
|
m = GroundLink.parse_message(b'{"cmd": 5, "lat": 1.0, "lon": 2.0}')
|
|
assert isinstance(m, GroundTarget)
|
|
|
|
|
|
def test_parse_message_invalid_returns_none():
|
|
assert GroundLink.parse_message(b"not json") is None
|
|
|
|
|
|
# --- drain_commands ---------------------------------------------------------
|
|
def test_drain_commands_empty():
|
|
assert _link().drain_commands() == []
|
|
|
|
|
|
def test_drain_commands_fifo_and_clear():
|
|
link = _link()
|
|
link._commands.append("rtl")
|
|
link._commands.append("pod_center")
|
|
assert link.drain_commands() == ["rtl", "pod_center"]
|
|
assert link.drain_commands() == [] # 取后清空
|
|
|
|
|
|
def test_commands_queue_drops_oldest_when_full():
|
|
link = _link()
|
|
for i in range(20):
|
|
link._commands.append(f"c{i}")
|
|
cmds = link.drain_commands()
|
|
assert len(cmds) == 16 # maxlen=16
|
|
assert cmds[0] == "c4" and cmds[-1] == "c19"
|
|
|
|
|
|
# --- run() 真 UDP 回环(坐标 + 命令)-----------------------------------------
|
|
def test_run_receives_coords_and_commands():
|
|
import asyncio
|
|
import socket as socket_mod
|
|
|
|
probe = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = probe.getsockname()[1]
|
|
probe.close()
|
|
|
|
link = GroundLink(GroundInjectConfig(host="127.0.0.1", port=port),
|
|
now_ms_fn=lambda: 1234)
|
|
addr = ("127.0.0.1", port)
|
|
|
|
async def scenario():
|
|
stop = asyncio.Event()
|
|
task = asyncio.ensure_future(link.run(stop))
|
|
udp = socket_mod.socket(socket_mod.AF_INET, socket_mod.SOCK_DGRAM)
|
|
try:
|
|
for _ in range(200): # 重发防 bind 竞态
|
|
udp.sendto(b'{"lat": 1.5, "lon": 2.5}', addr)
|
|
await asyncio.sleep(0.01)
|
|
if link.latest().time_ms == 1234:
|
|
break
|
|
udp.sendto(b'{"cmd": "rtl"}', addr)
|
|
for _ in range(100):
|
|
if link._commands:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
finally:
|
|
udp.close()
|
|
stop.set()
|
|
task.cancel() # 收包循环阻塞在 sock_recvfrom,须 cancel 唤醒(同 SITL 入口)
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
asyncio.run(scenario())
|
|
assert link.latest().latitude_deg == pytest.approx(1.5)
|
|
assert link.latest().time_ms == 1234
|
|
assert link.drain_commands() == ["rtl"]
|