Files
code-test-drone/utils/tests/ground_station/app_test.py
T
keita df91b1110e 精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航
归档 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。
2026-10-21 14:48:41 +08:00

300 lines
11 KiB
Python

"""app_test.py —— 地面站 Flask 端点测试(test_client + 真实 UDP 回环监听)。
覆盖:POST /api/inject 合法/非法(UDP 实收断言)、局部坐标注入换算与拒绝、
POST /api/command 白名单校验与 UDP 下发、POST /api/continuous 服务端持续注入启停、
GET /api/link 链路目标读写、GET /api/state 空日志目录兜底与 continuous 标志。
"""
import json
import math
import socket
import sys
import time
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parents[3] # utils/tests/ground_station → 仓库根
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from ground_station.app import create_app # noqa: E402
@pytest.fixture
def udp_listener():
"""真实 UDP 回环监听:返回 (port, recv_one)。"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", 0))
s.settimeout(1.5)
def recv_one():
data, _addr = s.recvfrom(2048)
return json.loads(data.decode("utf-8"))
yield s.getsockname()[1], recv_one
s.close()
@pytest.fixture
def client(tmp_path, udp_listener):
port, _recv = udp_listener
app = create_app(udp_port=port, log_dir=str(tmp_path),
inject_interval_s=0.05) # 测试用短周期
app.config["TESTING"] = True
return app.test_client()
# --- /api/inject ------------------------------------------------------------
def test_inject_ok_sends_udp(client, udp_listener):
_port, recv_one = udp_listener
r = client.post("/api/inject", json={"lat": 1.5, "lon": 2.5, "alt": 10})
assert r.get_json()["ok"] is True
assert recv_one() == {"lat": 1.5, "lon": 2.5, "alt": 10.0}
def test_inject_missing_lat_400(client):
r = client.post("/api/inject", json={"lon": 2.5})
assert r.status_code == 400
assert r.get_json()["ok"] is False
def test_inject_non_numeric_400(client):
r = client.post("/api/inject", json={"lat": "x", "lon": 2.5})
assert r.status_code == 400
# --- 局部坐标注入 -------------------------------------------------------------
_TEL_HEADER = ("t_s,state,pos_valid,fix_type,num_satellites,latitude_deg,"
"longitude_deg,relative_altitude_m,yaw_deg,target_injected,"
"distance_to_target_m,target_locked,off_target_x_deg,"
"off_target_y_deg,mode,yaw_rate_dps,vx_body_mps,vz_mps\n")
def _write_telemetry(log_dir, lat=47.0, lon=8.0, pos_valid="True"):
with open(Path(log_dir) / "task_telemetry.csv", "w", encoding="utf-8") as f:
f.write(_TEL_HEADER)
f.write(f"1.0,wait,{pos_valid},3,10,{lat},{lon},0,0,False,inf,False,"
"0,0,,0,0,0\n")
def test_inject_local_converts_from_vehicle_position(client, udp_listener, tmp_path):
_write_telemetry(tmp_path, lat=47.0, lon=8.0)
_port, recv_one = udp_listener
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15, "alt": 0})
j = r.get_json()
assert j["ok"] is True
exp_lat = 47.0 + 30 / 111320.0
exp_lon = 8.0 + 15 / (111320.0 * math.cos(math.radians(47.0)))
msg = recv_one()
assert msg["lat"] == pytest.approx(exp_lat, abs=1e-9)
assert msg["lon"] == pytest.approx(exp_lon, abs=1e-9)
assert j["local"]["north_m"] == 30 and j["local"]["east_m"] == 15
assert j["local"]["origin_lat"] == pytest.approx(47.0)
def test_inject_local_rejected_without_telemetry(client):
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
assert r.status_code == 400
assert "无法换算" in r.get_json()["error"]
def test_inject_local_rejected_when_pos_invalid(client, tmp_path):
_write_telemetry(tmp_path, pos_valid="False")
r = client.post("/api/inject", json={"north_m": 30, "east_m": 15})
assert r.status_code == 400
def test_inject_local_bad_numbers_400(client, tmp_path):
_write_telemetry(tmp_path)
assert client.post("/api/inject",
json={"north_m": "x", "east_m": 15}).status_code == 400
def test_inject_local_home_origin_uses_session_start(client, udp_listener, tmp_path):
# 同一段:起始有效定位 47.0/8.0(≈起飞点),之后移动到 47.5/8.5
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
f.write(_TEL_HEADER)
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
f.write("60.0,goto,True,3,10,47.5,8.5,0,0,False,inf,False,0,0,,0,0,0\n")
_port, recv_one = udp_listener
j = client.post("/api/inject",
json={"north_m": 0, "east_m": 0, "origin": "home"}).get_json()
assert j["ok"] is True and j["local"]["origin"] == "home"
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 段首而非最后行
assert j["local"]["origin_lon"] == pytest.approx(8.0)
recv_one()
j2 = client.post("/api/inject",
json={"north_m": 0, "east_m": 0}).get_json() # current
assert j2["local"]["origin"] == "current"
assert j2["local"]["origin_lat"] == pytest.approx(47.5) # 当前位置
recv_one()
def test_inject_local_home_origin_skips_invalid_rows(client, tmp_path):
with open(tmp_path / "task_telemetry.csv", "w", encoding="utf-8") as f:
f.write(_TEL_HEADER)
f.write("0.5,wait,False,0,0,0.0,0.0,0,0,False,inf,False,0,0,,0,0,0\n")
f.write("1.0,wait,True,3,10,47.0,8.0,0,0,False,inf,False,0,0,,0,0,0\n")
j = client.post("/api/inject",
json={"north_m": 30, "east_m": 15,
"origin": "home"}).get_json()
assert j["ok"] is True
assert j["local"]["origin_lat"] == pytest.approx(47.0) # 取首个有效定位
def test_inject_local_bad_origin_400(client, tmp_path):
_write_telemetry(tmp_path)
r = client.post("/api/inject",
json={"north_m": 1, "east_m": 1, "origin": "mars"})
assert r.status_code == 400
def test_inject_local_home_origin_rejected_without_telemetry(client):
r = client.post("/api/inject",
json={"north_m": 30, "east_m": 15, "origin": "home"})
assert r.status_code == 400
assert "起飞点" in r.get_json()["error"]
# --- /api/command -----------------------------------------------------------
def test_command_whitelist_ok_sends_udp(client, udp_listener):
_port, recv_one = udp_listener
r = client.post("/api/command", json={"cmd": "rtl"})
assert r.get_json() == {"ok": True, "sent": {"cmd": "rtl"}}
assert recv_one() == {"cmd": "rtl"}
def test_command_all_whitelist_entries_accepted(client, udp_listener):
_port, recv_one = udp_listener
for cmd in ("rtl", "emergency", "pod_detect_on", "pod_auto_lock",
"pod_stop_track", "pod_detect_off", "pod_center"):
r = client.post("/api/command", json={"cmd": cmd})
assert r.get_json()["ok"] is True
assert recv_one() == {"cmd": cmd}
def test_command_unknown_rejected_no_udp(client, udp_listener):
_port, recv_one = udp_listener
r = client.post("/api/command", json={"cmd": "reboot"})
assert r.status_code == 400
assert r.get_json()["ok"] is False
with pytest.raises(socket.timeout):
recv_one() # 未下发任何 UDP → 监听超时即证明
def test_command_non_string_rejected(client):
r = client.post("/api/command", json={"cmd": 1})
assert r.status_code == 400
# --- /api/continuous --------------------------------------------------------
def test_continuous_requires_prior_inject(client):
r = client.post("/api/continuous", json={"on": True})
assert r.status_code == 400
assert r.get_json()["ok"] is False
def test_continuous_non_bool_rejected(client):
r = client.post("/api/continuous", json={"on": "yes"})
assert r.status_code == 400
def test_continuous_sends_repeatedly_and_stops(client, udp_listener):
_port, recv_one = udp_listener
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 手动单发
r = client.post("/api/continuous", json={"on": True})
assert r.get_json() == {"ok": True, "continuous": True}
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0} # 服务端自动重发
assert recv_one() == {"lat": 1.0, "lon": 2.0, "alt": 0.0}
r = client.post("/api/continuous", json={"on": False})
assert r.get_json() == {"ok": True, "continuous": False}
time.sleep(0.2)
while True: # 排空在途包
try:
recv_one()
except socket.timeout:
break
with pytest.raises(socket.timeout):
recv_one() # 停止后不再发
def test_continuous_uses_latest_inject(client, udp_listener):
_port, recv_one = udp_listener
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
recv_one()
client.post("/api/inject", json={"lat": 3.0, "lon": 4.0}) # 中途改坐标
recv_one()
client.post("/api/continuous", json={"on": True})
assert recv_one() == {"lat": 3.0, "lon": 4.0, "alt": 0.0}
client.post("/api/continuous", json={"on": False})
# --- /api/link --------------------------------------------------------------
def test_link_get_default_and_options(client, udp_listener):
port, _recv = udp_listener
j = client.get("/api/link").get_json()
assert j["host"] == "127.0.0.1"
assert j["port"] == port
assert any(o["host"] == "127.0.0.1" for o in j["options"])
def test_link_set_bad_host_or_port_400(client):
assert client.post("/api/link", json={"host": " "}).status_code == 400
assert client.post("/api/link",
json={"host": "1.2.3.4", "port": "x"}).status_code == 400
assert client.post("/api/link",
json={"host": "1.2.3.4", "port": 70000}).status_code == 400
def test_link_switch_redirects_inject_and_command(client, udp_listener):
_port_a, recv_a = udp_listener
s_b = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s_b.bind(("127.0.0.1", 0))
s_b.settimeout(1.5)
port_b = s_b.getsockname()[1]
try:
r = client.post("/api/link", json={"host": "127.0.0.1", "port": port_b})
assert r.get_json() == {"ok": True, "host": "127.0.0.1", "port": port_b}
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
data, _addr = s_b.recvfrom(2048)
assert json.loads(data.decode("utf-8")) == {"lat": 1.0, "lon": 2.0,
"alt": 0.0}
client.post("/api/command", json={"cmd": "rtl"})
data, _addr = s_b.recvfrom(2048)
assert json.loads(data.decode("utf-8")) == {"cmd": "rtl"}
with pytest.raises(socket.timeout):
recv_a() # 旧目标不再收到任何包
finally:
s_b.close()
# --- /api/state -------------------------------------------------------------
def test_state_empty_log_dir(client):
j = client.get("/api/state").get_json()
assert j["state"] == "unknown"
assert j["telemetry"] is None
assert j["continuous"] is False
def test_video_disabled_by_default(client):
assert client.get("/api/state").get_json()["video"] == {
"enabled": False, "online": False}
assert client.get("/video_feed").status_code == 503
def test_state_reports_continuous(client):
client.post("/api/inject", json={"lat": 1.0, "lon": 2.0})
client.post("/api/continuous", json={"on": True})
assert client.get("/api/state").get_json()["continuous"] is True
client.post("/api/continuous", json={"on": False})
assert client.get("/api/state").get_json()["continuous"] is False