"""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