实现预定流程全链路: 任务状态机 + 编排器 + 地面站 + 真机/SITL入口

- utils/task_runner: 6态状态机(WAIT→ARM_TAKEOFF→GOTO(L2)→TRACK(L1)→APPROACH→RTL)
  + 编排器(快照采集/状态动作) + 双层日志(事件JSONL/遥测CSV) + 吊舱控制指令(0x07/0x0F/0x0E/0x08)
- pod_bridge: ground_link 地面坐标UDP注入(超龄判无效,无激光替代表4);
  set_heading_servo_enabled() 运行时L1/L2切换(切换即reset清积分防输出跳变);
  pod_locked 提为公共门控; config 新增 ground_inject 段
- virtual_pod 模拟吊舱(无电机语义) + verify_px4 增量④仅飞控验证 +
  pod_sitl_test L1 SITL全流程(飞行编排不塞进 run_bridge, 保持纯offboard闭环入口)
- 入口: task_runner_sitl(VirtualPod+虚拟目标, SITL端到端跑通约1m24s) /
  task_runner_real(真串口+UDP注入+吊舱控制)
- 地面站 ground_station: 坐标注入 + 状态可视化(Flask)
- S1标定回填(2026-10-09): beta_sign=+1 / alpha_sign=-1, 加标定回归测试守护
- 新增测试: task_runner x4 + ground_link/virtual_pod*/bridge/px4_gateway 增量; 全量 483 passed
- 文档: 预定流程(需求) + 预定流程-操作指南(操作/待办) + L1方案补S1结论与实现状态
This commit is contained in:
keita
2026-10-13 15:50:32 +08:00
parent 5a944af4f5
commit ded17d7995
30 changed files with 3552 additions and 27 deletions
+123
View File
@@ -0,0 +1,123 @@
"""log_test.py —— 任务日志模块单元测试。
覆盖:open 建目录/写表头、事件 JSONL、遥测 CSV、telemetry_every_n 节流、
disabled 时零副作用、close 收尾。
"""
import csv
import json
import pytest
from task_runner.log import TaskLogConfig, TaskLogger, TelemetryRecord
def _cfg(tmp_path, **kw):
c = TaskLogConfig(dir=str(tmp_path), **kw)
return c
def test_open_creates_dir_and_csv_header(tmp_path):
logger = TaskLogger(_cfg(tmp_path))
logger.open()
logger.close()
assert (tmp_path / "task_events.jsonl").exists()
assert (tmp_path / "task_telemetry.csv").exists()
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
header = next(csv.reader(f))
assert header[0] == "t_s"
assert "state" in header
assert "distance_to_target_m" in header
def test_log_event_writes_jsonl(tmp_path):
logger = TaskLogger(_cfg(tmp_path))
logger.open()
logger.log_event(1000, "state", from_state="wait",
to_state="arm_takeoff", reason="收到地面坐标")
logger.log_event(2000, "emergency", reason="RC 接管")
logger.close()
with open(tmp_path / "task_events.jsonl", encoding="utf-8") as f:
lines = [json.loads(x) for x in f if x.strip()]
assert len(lines) == 2
assert lines[0]["type"] == "state"
assert lines[0]["from_state"] == "wait"
assert lines[0]["to_state"] == "arm_takeoff"
assert lines[0]["ts_ms"] == 1000
assert lines[0]["t_s"] == 0.0 # 首个事件 t_s = 0
assert lines[1]["t_s"] == 1.0 # 第二个事件 t_s = 1.0s
def test_log_telemetry_writes_csv_rows(tmp_path):
logger = TaskLogger(_cfg(tmp_path))
logger.open()
rec = TelemetryRecord(state="goto", pos_valid=True, latitude_deg=47.39,
distance_to_target_m=33.5, mode="VELOCITY_HEADING",
yaw_rate_dps=-30.0, vx_body_mps=1.7)
logger.log_telemetry(1000, rec)
logger.log_telemetry(1100, rec)
logger.close()
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 2
assert rows[0]["state"] == "goto"
assert rows[0]["pos_valid"] == "True"
assert rows[0]["distance_to_target_m"] == "33.5"
assert rows[0]["t_s"] == "0.0"
assert rows[1]["t_s"] == "0.1"
def test_telemetry_throttled_by_every_n(tmp_path):
logger = TaskLogger(_cfg(tmp_path, telemetry_every_n=5))
logger.open()
rec = TelemetryRecord(state="goto")
for i in range(12):
logger.log_telemetry(1000 + i * 100, rec)
logger.close()
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 2 # 第 5、10 次各记一次
def test_disabled_logs_nothing(tmp_path):
logger = TaskLogger(_cfg(tmp_path, enabled=False))
logger.open()
logger.log_event(1000, "state", from_state="wait", to_state="arm_takeoff")
logger.log_telemetry(1000, TelemetryRecord(state="goto"))
logger.close()
# 未 open(disabled)→ 不建目录、不写文件
assert not (tmp_path / "task_events.jsonl").exists()
assert not (tmp_path / "task_telemetry.csv").exists()
def test_log_before_open_is_noop(tmp_path):
logger = TaskLogger(_cfg(tmp_path))
logger.log_event(1000, "state", from_state="wait", to_state="arm_takeoff")
logger.log_telemetry(1000, TelemetryRecord(state="goto"))
logger.close()
assert not (tmp_path / "task_events.jsonl").exists()
def test_append_keeps_existing_rows(tmp_path):
"""重复 open(追加模式)不清空既有内容,表头不重复写。"""
logger = TaskLogger(_cfg(tmp_path))
logger.open()
logger.log_telemetry(1000, TelemetryRecord(state="wait"))
logger.close()
logger2 = TaskLogger(_cfg(tmp_path))
logger2.open()
logger2.log_telemetry(2000, TelemetryRecord(state="goto"))
logger2.close()
with open(tmp_path / "task_telemetry.csv", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 2
assert rows[0]["state"] == "wait"
assert rows[1]["state"] == "goto"
@@ -0,0 +1,45 @@
"""pod_control_test.py —— 吊舱目标识别/锁定控制器单元测试。
覆盖:arm_detection 发 0x07(识别,目标类型)+0x0F(自动锁定)、
stop_tracking 发 0x0E(停止)+0x08(识别关)。
"""
from pod_bridge.protocol import ControlCode
from task_runner.pod_control import PodController
class FakeBridge:
def __init__(self):
self.sent = []
def send_control(self, cmd):
self.sent.append(cmd)
def test_arm_detection_sends_detect_and_autolock():
bridge = FakeBridge()
pc = PodController(bridge, target_type=1)
pc.arm_detection()
assert len(bridge.sent) == 2
assert bridge.sent[0].code == ControlCode.TARGET_DETECT_ON
assert bridge.sent[0].param_x == 1 # 目标类型 1 = 无人机
assert bridge.sent[1].code == ControlCode.AUTO_LOCK_ON_FOUND
def test_arm_detection_default_target_type_is_person_vehicle():
bridge = FakeBridge()
pc = PodController(bridge) # 默认 target_type=0 = 人/车
pc.arm_detection()
assert bridge.sent[0].code == ControlCode.TARGET_DETECT_ON
assert bridge.sent[0].param_x == 0
def test_stop_tracking_sends_stop_and_detect_off():
bridge = FakeBridge()
pc = PodController(bridge)
pc.stop_tracking()
assert len(bridge.sent) == 2
assert bridge.sent[0].code == ControlCode.STOP_TRACK
assert bridge.sent[1].code == ControlCode.TARGET_DETECT_OFF
+311
View File
@@ -0,0 +1,311 @@
"""runner_test.py —— 任务编排器单元测试(mock px4/bridge/ground/drone,无硬件)。
覆盖:输入采集(注入坐标新鲜/超龄、起飞到位、锁定、居中累加)、
状态进入动作(ARM_TAKEOFF 触发起飞、GOTO 切 L2、TRACK 切 L1)。
"""
import asyncio
from dataclasses import replace
import pytest
from pod_bridge.config import BridgeConfig
from pod_bridge.ground_link import GroundTarget
from pod_bridge.px4_gateway import OwnState
from pod_bridge.protocol import Telemetry
from task_runner.runner import TaskRunner
from task_runner.state_machine import (TaskState, TaskStateConfig,
TaskStateMachine)
# --- mocks ------------------------------------------------------------------
class FakePx4:
def __init__(self, state=None):
self._state = state or OwnState()
self.telemetry_started = 0
self.offboard_started = False
self.brake_calls = []
def own_state(self):
return replace(self._state)
async def start_telemetry(self):
self.telemetry_started += 1
async def stop_telemetry(self):
pass
async def start_offboard(self):
self.offboard_started = True
return True
async def stop_offboard(self):
pass
async def send_velocity_heading(self, out, t=None):
return True
async def send_brake_hover(self, t=None):
self.brake_calls.append(t)
return True
class FakeBridge:
def __init__(self):
self.started = False
self.stopped = False
self.law = None # set_heading_servo_enabled 最后值
self.steps = 0
self._locked = False
self._telemetry = None
async def start(self):
self.started = True
return True
async def stop(self):
self.stopped = True
def set_heading_servo_enabled(self, enabled):
self.law = enabled
def pod_locked(self, now_ms):
return self._locked
async def step(self, now_ms):
self.steps += 1
@property
def latest_telemetry(self):
return self._telemetry
class FakeGround:
def __init__(self, target=None):
self._target = target
def latest(self):
return self._target or GroundTarget()
class FakeDrone:
def __init__(self):
self.armed = False
self.took_off = False
self.takeoff_alt = 0.0
self.rtl = False
self.landed = False
self.disarmed = False
class _Action:
def __init__(self, d):
self._d = d
async def arm(self):
self._d.armed = True
async def set_takeoff_altitude(self, a):
self._d.takeoff_alt = a
async def takeoff(self):
self._d.took_off = True
async def return_to_launch(self):
self._d.rtl = True
async def land(self):
self._d.landed = True
async def disarm(self):
self._d.disarmed = True
@property
def action(self):
return FakeDrone._Action(self)
def _state(now_ms, lat=0.0, lon=0.0, rel_alt=0.0):
return OwnState(latitude_deg=lat, longitude_deg=lon,
relative_altitude_m=rel_alt, fix_type=3, num_satellites=12,
has_position=True, has_attitude=True, position_valid=True,
time_ms=now_ms, position_time_ms=now_ms)
def _make_runner(px4=None, bridge=None, ground=None, drone=None,
sm_cfg=None, vision_fn=None, pod_control=None):
cfg = BridgeConfig()
cfg.ground_inject.enabled = True
cfg.ground_inject.timeout_ms = 5000
return TaskRunner(
cfg, sm_cfg or TaskStateConfig(),
px4 or FakePx4(_state(1000)),
bridge or FakeBridge(),
TaskStateMachine(sm_cfg or TaskStateConfig()),
ground or FakeGround(),
drone or FakeDrone(),
vision_fn=vision_fn,
now_ms_fn=lambda: 1000,
pod_control=pod_control,
)
# --- 输入采集 ---------------------------------------------------------------
def test_collect_injected_fresh_sets_distance():
ground = FakeGround(GroundTarget(latitude_deg=0.001, longitude_deg=0.0,
time_ms=1000))
px4 = FakePx4(_state(1000))
r = _make_runner(px4=px4, ground=ground)
inp = r._collect_inputs(1000)
assert inp.target_injected is True
assert inp.distance_to_target_m == pytest.approx(111.19, rel=1e-2) # ~111m
assert inp.takeoff_reached is False # rel_alt=0 < 5
def test_collect_injected_stale_is_invalid():
ground = FakeGround(GroundTarget(latitude_deg=0.001, longitude_deg=0.0,
time_ms=1000))
r = _make_runner(ground=ground) # timeout=5000
inp = r._collect_inputs(7000) # 6000ms 差值 > 5000 → 超龄
assert inp.target_injected is False
assert inp.distance_to_target_m == float("inf")
def test_collect_takeoff_reached():
px4 = FakePx4(_state(1000, rel_alt=5.0))
sm_cfg = TaskStateConfig(takeoff_altitude_m=5.0)
r = _make_runner(px4=px4, sm_cfg=sm_cfg)
assert r._collect_inputs(1000).takeoff_reached is True
def test_collect_locked_from_bridge():
bridge = FakeBridge()
bridge._locked = True
r = _make_runner(bridge=bridge)
assert r._collect_inputs(1000).target_locked is True
def test_collect_vision_second_judgement():
r = _make_runner(vision_fn=lambda: True)
assert r._collect_inputs(1000).vision_has_target is True
r2 = _make_runner(vision_fn=lambda: False)
assert r2._collect_inputs(1000).vision_has_target is False
# --- 居中累加 ---------------------------------------------------------------
def test_centered_accumulates_duration():
bridge = FakeBridge()
bridge._telemetry = (Telemetry(off_target_x_deg=0.1, off_target_y_deg=0.1), 1000)
sm_cfg = TaskStateConfig(center_threshold_deg=1.0)
r = _make_runner(bridge=bridge, sm_cfg=sm_cfg)
assert r._update_centered(1000) == (True, 0.0) # 开始居中
assert r._update_centered(3000) == (True, 2.0) # 持续 2s
assert r._update_centered(3500) == (True, 2.5) # 持续 2.5s
def test_centered_resets_on_large_offset():
bridge = FakeBridge()
bridge._telemetry = (Telemetry(off_target_x_deg=0.1, off_target_y_deg=0.1), 1000)
r = _make_runner(bridge=bridge)
r._update_centered(1000)
bridge._telemetry = (Telemetry(off_target_x_deg=5.0, off_target_y_deg=0.1), 2000)
assert r._update_centered(2000) == (False, 0.0) # 脱靶量大 → 清零
def test_centered_no_telemetry_returns_false():
r = _make_runner() # bridge.latest_telemetry is None
assert r._update_centered(1000) == (False, 0.0)
# --- 状态进入动作 -----------------------------------------------------------
def test_on_enter_arm_takeoff_arms_and_takes_off():
drone = FakeDrone()
r = _make_runner(drone=drone, sm_cfg=TaskStateConfig(takeoff_altitude_m=5.0))
asyncio.run(r._on_enter(TaskState.ARM_TAKEOFF, 1000))
assert drone.armed is True
assert drone.took_off is True
assert drone.takeoff_alt == pytest.approx(5.0)
def test_on_enter_goto_switches_to_l2():
bridge = FakeBridge()
r = _make_runner(bridge=bridge)
asyncio.run(r._on_enter(TaskState.GOTO, 1000))
assert bridge.started is True
assert bridge.law is False # L2 地理引导
def test_on_enter_track_switches_to_l1():
bridge = FakeBridge()
r = _make_runner(bridge=bridge)
asyncio.run(r._on_enter(TaskState.TRACK, 1000))
assert bridge.law is True # L1 航向伺服
def test_on_enter_rtl_stops_bridge_and_returns():
bridge = FakeBridge()
drone = FakeDrone()
r = _make_runner(bridge=bridge, drone=drone)
asyncio.run(r._on_enter(TaskState.RTL, 1000))
assert bridge.stopped is True
assert drone.rtl is True
# --- 持续动作 ---------------------------------------------------------------
def test_tick_goto_steps_bridge():
bridge = FakeBridge()
r = _make_runner(bridge=bridge)
asyncio.run(r._tick(1000))
# WAIT 状态不 step;切到 GOTO 才 step
r._sm._state = TaskState.GOTO
asyncio.run(r._tick(1000))
assert bridge.steps == 1
def test_tick_approach_brakes():
px4 = FakePx4(_state(1000))
r = _make_runner(px4=px4)
r._sm._state = TaskState.APPROACH
asyncio.run(r._tick(1000))
assert len(px4.brake_calls) == 1
# --- 吊舱控制集成(真机)----------------------------------------------------
class FakePodControl:
def __init__(self):
self.armed = False
self.stopped = False
def arm_detection(self):
self.armed = True
def stop_tracking(self):
self.stopped = True
def test_on_enter_goto_arms_detection():
pod_control = FakePodControl()
r = _make_runner(pod_control=pod_control)
asyncio.run(r._on_enter(TaskState.GOTO, 1000))
assert pod_control.armed is True
assert pod_control.stopped is False
def test_on_enter_rtl_stops_tracking_before_teardown():
pod_control = FakePodControl()
r = _make_runner(pod_control=pod_control)
asyncio.run(r._on_enter(TaskState.RTL, 1000))
assert pod_control.stopped is True
def test_no_pod_control_is_safe():
"""未注入 pod_control(SITL)→ GOTO/RTL 进入动作不触发吊舱控制、不抛异常。"""
r = _make_runner(pod_control=None)
asyncio.run(r._on_enter(TaskState.GOTO, 1000))
asyncio.run(r._on_enter(TaskState.RTL, 1000))
@@ -0,0 +1,233 @@
"""state_machine_test.py —— 任务级状态机单元测试。
覆盖:完整状态链 WAIT→ARM_TAKEOFF→GOTO→TRACK→APPROACH→RTL、各状态超时、
GOTO→TRACK 的锁定/视觉守卫、TRACK 丢目标退回 GOTO、紧急/返航全局终止、
RTL 终态与 reset。
"""
import pytest
from task_runner.state_machine import (TaskInputs, TaskState, TaskStateConfig,
TaskStateMachine, TaskTransition)
def _inp(**kw) -> TaskInputs:
return TaskInputs(**kw)
def _sm(cfg=None) -> TaskStateMachine:
return TaskStateMachine(cfg or TaskStateConfig())
# --- 初始态与 reset ---------------------------------------------------------
def test_initial_state_is_wait():
assert _sm().state is TaskState.WAIT
def test_reset_returns_to_wait():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
assert sm.state is TaskState.ARM_TAKEOFF
sm.reset()
assert sm.state is TaskState.WAIT
# --- WAIT -------------------------------------------------------------------
def test_wait_without_coords_stays():
tr = _sm().update(_inp(), 1000)
assert not tr.occurred
assert tr.to_state is TaskState.WAIT
def test_wait_receives_coords_goes_arm_takeoff():
sm = _sm()
tr = sm.update(_inp(target_injected=True), 1000)
assert tr.occurred
assert tr.from_state is TaskState.WAIT
assert tr.to_state is TaskState.ARM_TAKEOFF
assert sm.state is TaskState.ARM_TAKEOFF
# --- ARM_TAKEOFF ------------------------------------------------------------
def test_arm_takeoff_reaches_altitude_goes_goto():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
tr = sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
assert tr.occurred and tr.to_state is TaskState.GOTO
def test_arm_takeoff_timeout_goes_rtl():
cfg = TaskStateConfig(arm_takeoff_timeout_s=60.0)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
# 60s 未到高度 → 超时返航
tr = sm.update(_inp(target_injected=True), 1000 + 61_000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- GOTO -------------------------------------------------------------------
def test_goto_far_away_stays():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=100.0,
target_locked=True), 3000)
assert not tr.occurred
assert sm.state is TaskState.GOTO
def test_goto_within_radius_and_locked_goes_track():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
assert tr.occurred and tr.to_state is TaskState.TRACK
def test_goto_within_radius_but_unlocked_stays():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=False), 3000)
assert not tr.occurred
assert sm.state is TaskState.GOTO
def test_goto_requires_vision_when_enabled():
cfg = TaskStateConfig(require_vision=True)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
# 有锁定但无视觉第二判据 → 不切换
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True, vision_has_target=False), 3000)
assert not tr.occurred
# 补上视觉 → 切换
tr = sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True, vision_has_target=True), 4000)
assert tr.occurred and tr.to_state is TaskState.TRACK
def test_goto_loses_coords_goes_rtl():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=False, distance_to_target_m=10.0), 3000)
assert tr.occurred and tr.to_state is TaskState.RTL
def test_goto_timeout_goes_rtl():
cfg = TaskStateConfig(goto_timeout_s=120.0)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
tr = sm.update(_inp(target_injected=True, distance_to_target_m=100.0), 2000 + 121_000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- TRACK ------------------------------------------------------------------
def test_track_centered_stable_goes_approach():
sm = _sm(TaskStateConfig(center_hold_s=3.0))
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
assert sm.state is TaskState.TRACK
# 居中但未满时长 → 停留
tr = sm.update(_inp(target_injected=True, target_locked=True,
target_centered=True, centered_duration_s=1.0), 4000)
assert not tr.occurred
# 居中稳定满 3s → APPROACH
tr = sm.update(_inp(target_injected=True, target_locked=True,
target_centered=True, centered_duration_s=3.0), 5000)
assert tr.occurred and tr.to_state is TaskState.APPROACH
def test_track_lost_with_coords_goes_back_to_goto():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
tr = sm.update(_inp(target_injected=True, target_locked=False), 4000)
assert tr.occurred and tr.to_state is TaskState.GOTO
def test_track_lost_without_coords_goes_rtl():
sm = _sm()
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
tr = sm.update(_inp(target_injected=False, target_locked=False), 4000)
assert tr.occurred and tr.to_state is TaskState.RTL
def test_track_timeout_goes_rtl():
cfg = TaskStateConfig(track_timeout_s=120.0)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
tr = sm.update(_inp(target_injected=True, target_locked=True), 3000 + 121_000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- APPROACH ---------------------------------------------------------------
def test_approach_hover_done_goes_rtl():
cfg = TaskStateConfig(center_hold_s=0.0, approach_hover_s=10.0)
sm = _sm(cfg)
sm.update(_inp(target_injected=True), 1000)
sm.update(_inp(target_injected=True, takeoff_reached=True), 2000)
sm.update(_inp(target_injected=True, distance_to_target_m=10.0,
target_locked=True), 3000)
sm.update(_inp(target_injected=True, target_locked=True, target_centered=True,
centered_duration_s=0.0), 4000)
assert sm.state is TaskState.APPROACH
# 悬停未满 10s → 停留
tr = sm.update(_inp(target_injected=True, target_locked=True), 4000 + 5000)
assert not tr.occurred
# 满 10s → RTL
tr = sm.update(_inp(target_injected=True, target_locked=True), 4000 + 10_000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- 全局终止(紧急 / 返航)-------------------------------------------------
@pytest.mark.parametrize("state_kw", [
{}, # WAIT
{"target_injected": True}, # 会先转 ARM_TAKEOFF
])
def test_emergency_goes_rtl_from_any_state(state_kw):
sm = _sm()
sm.update(_inp(**state_kw), 1000)
tr = sm.update(_inp(emergency=True), 2000)
assert tr.occurred and tr.to_state is TaskState.RTL
def test_rtl_requested_goes_rtl():
sm = _sm()
tr = sm.update(_inp(rtl_requested=True), 1000)
assert tr.occurred and tr.to_state is TaskState.RTL
# --- RTL 终态 ---------------------------------------------------------------
def test_rtl_is_terminal():
sm = _sm()
sm.update(_inp(rtl_requested=True), 1000)
assert sm.state is TaskState.RTL
# 终态不再转移(连紧急/坐标也不动)
tr = sm.update(_inp(target_injected=True, emergency=True), 2000)
assert not tr.occurred
assert sm.state is TaskState.RTL
# --- 转移结果结构 -----------------------------------------------------------
def test_transition_dataclass_defaults():
tr = TaskTransition()
assert tr.from_state is TaskState.WAIT
assert tr.to_state is TaskState.WAIT
assert tr.occurred is False
assert tr.reason == ""