Files
behavior-tree/tests/test_navigation_gateway.py
T

493 lines
26 KiB
Python
Raw Normal View History

"""Safety contract tests; no ROS installation or robot required."""
import copy
import json
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
try:
from navigation_gateway.gateway import Gateway, GatewayError, SafetyConfig, validate_goal
from navigation_gateway.backends import MockBackend
from navigation_gateway.server import make_server
IMPORT_ERROR = None
except ImportError as exc:
IMPORT_ERROR = str(exc)
class Clock:
def __init__(self): self.now = 100.0
def __call__(self): return self.now
def advance(self, seconds): self.now += seconds
def request(goal_id="11111111-1111-4111-8111-111111111111"):
return {"goal_id": goal_id, "trace": {"task_id": "task-1", "subtask_id": "navigate", "attempt": 1},
"map_id": "sim-map", "target_pose": {"frame_id": "map", "position": {"x": 1.0, "y": 2.0, "z": 0.0},
"orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}},
"position_tolerance": 0.05, "yaw_tolerance": 0.05, "timeout_sec": 10.0}
class GatewayTests(unittest.TestCase):
def setUp(self):
self.assertIsNone(IMPORT_ERROR, "navigation gateway implementation missing: " + str(IMPORT_ERROR))
self.tmp = tempfile.TemporaryDirectory()
self.clock = Clock()
self.config = SafetyConfig(odom_max_age_sec=0.2, stationary_window_sec=0.3,
linear_stopped_mps=0.005, angular_stopped_radps=0.005, pose_max_age_sec=0.2,
readiness_max_age_sec=0.5, stop_wait_timeout_sec=1.0)
self.backend = MockBackend(self.clock, map_id="sim-map", source_clock=lambda: 1700000000.0 + self.clock())
self.path = str(Path(self.tmp.name) / "journal.sqlite3")
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
self.addCleanup(self.tmp.cleanup)
self.addCleanup(lambda: self.gateway.close())
def sample(self, state="ACTIVE", linear=0.0, pose=None, fresh=True):
self.backend.health_sample(True)
self.backend.set_snapshot(request()["goal_id"], state, linear=linear, angular=0.0,
pose=pose or request()["target_pose"], source_fresh=fresh)
return self.gateway.poll(request()["goal_id"])
def stop_window(self, state="SUCCEEDED", pose=None):
out = self.sample(state, pose=pose)
for _ in range(4):
self.clock.advance(0.1)
out = self.sample(state, pose=pose)
return out
def test_goal_is_sent_once_and_replay_is_immutable(self):
first = self.gateway.submit(request())
self.assertEqual(first["goal_id"], self.gateway.submit(copy.deepcopy(request()))["goal_id"])
self.assertEqual(self.backend.send_count, 1)
changed = request(); changed["timeout_sec"] = 8
with self.assertRaises(GatewayError) as cm: self.gateway.submit(changed)
self.assertEqual(cm.exception.http_status, 409)
self.assertEqual(self.backend.send_count, 1)
def test_goal_validation_rejects_nonfinite_bad_quaternion_and_duration(self):
for path, value in [(('position_tolerance',), float('nan')), (('yaw_tolerance',), -1),
(('timeout_sec',), float('inf')), (('timeout_sec',), 0),
(('target_pose', 'position', 'x'), float('inf')),
(('target_pose', 'orientation', 'w'), 0), (('target_pose', 'frame_id'), 'odom')]:
body = request(); node = body
for key in path[:-1]: node = node[key]
node[path[-1]] = value
with self.subTest(path=path, value=value), self.assertRaises(GatewayError): validate_goal(body)
def test_readiness_cannot_be_claimed_by_http_caller(self):
self.backend.health_sample(False)
with self.assertRaises(GatewayError): self.gateway.submit(request())
self.assertEqual(self.backend.send_count, 0)
body = request(); body["ready"] = True
with self.assertRaises(GatewayError): self.gateway.submit(body)
def test_stale_health_and_wrong_map_reject_before_sending(self):
self.clock.advance(1)
with self.assertRaises(GatewayError): self.gateway.submit(request())
self.backend.health_sample(True)
body = request(); body["map_id"] = "another-map"
with self.assertRaises(GatewayError): self.gateway.submit(body)
self.assertEqual(self.backend.send_count, 0)
def test_cancel_ack_is_not_stop_confirmation_and_cancel_is_idempotent(self):
self.gateway.submit(request())
out = self.gateway.cancel(request()["goal_id"])
self.assertEqual(out["stop_state"], "UNKNOWN")
self.gateway.cancel(request()["goal_id"])
self.assertEqual(self.backend.cancel_count, 1)
self.assertEqual(self.stop_window("ACTIVE")["stop_state"], "UNKNOWN")
stopped = self.stop_window("PREEMPTED")
self.assertEqual((stopped["outcome"], stopped["stop_state"]), ("CANCELED", "CONFIRMED"))
def test_success_requires_terminal_pose_and_continuous_fresh_odom(self):
self.gateway.submit(request())
out = self.sample("SUCCEEDED")
self.assertEqual(out["stop_state"], "UNKNOWN")
self.clock.advance(0.5)
self.assertEqual(self.gateway.poll(request()["goal_id"])["stop_state"], "UNKNOWN")
out = self.stop_window()
self.assertEqual((out["outcome"], out["stop_state"]), ("COMPLETED", "CONFIRMED"))
def test_stale_source_odom_never_confirms_stop(self):
self.gateway.submit(request())
for _ in range(10):
self.clock.advance(0.1)
out = self.sample("SUCCEEDED", fresh=False)
self.assertEqual(out["stop_state"], "UNKNOWN")
def test_moving_sample_resets_stationary_window(self):
self.gateway.submit(request()); self.sample("SUCCEEDED")
self.clock.advance(0.15); self.sample("SUCCEEDED", linear=0.1)
self.clock.advance(0.15); self.sample("SUCCEEDED")
self.clock.advance(0.15)
self.assertEqual(self.sample("SUCCEEDED")["stop_state"], "UNKNOWN")
def test_odom_samples_between_polls_cannot_hide_motion(self):
self.gateway.submit(request()); self.sample("SUCCEEDED")
for _ in range(2):
self.clock.advance(0.1)
self.sample("SUCCEEDED")
self.clock.advance(0.05)
self.backend.set_snapshot(request()["goal_id"], "SUCCEEDED", linear=0.1, pose=request()["target_pose"])
self.clock.advance(0.1)
self.backend.set_snapshot(request()["goal_id"], "SUCCEEDED", linear=0.0, pose=request()["target_pose"])
out = self.gateway.poll(request()["goal_id"])
self.assertEqual(out["stop_state"], "UNKNOWN")
def test_readiness_loss_during_motion_requests_stop(self):
self.gateway.submit(request())
self.backend.health_sample(False)
out = self.gateway.poll(request()["goal_id"])
self.assertEqual(self.backend.cancel_count, 1)
self.assertEqual(out["stop_state"], "UNKNOWN")
out = self.stop_window("PREEMPTED")
self.assertEqual(out["outcome"], "FAILED")
def test_success_with_health_lost_does_not_complete(self):
self.gateway.submit(request()); self.sample("SUCCEEDED")
self.backend.health_sample(False)
self.gateway.poll(request()["goal_id"])
out = self.stop_window("SUCCEEDED")
self.assertEqual(out["outcome"], "FAILED")
def test_success_outside_tolerance_becomes_failed_not_completed(self):
self.gateway.submit(request())
pose = copy.deepcopy(request()["target_pose"]); pose["position"]["x"] = 2.0
out = self.stop_window(pose=pose)
self.assertEqual((out["outcome"], out["stop_state"]), ("FAILED", "CONFIRMED"))
self.assertIn("tolerance", out["message"])
def test_execution_timeout_cancels_once_and_waits_for_actual_stop(self):
body = request(); body["timeout_sec"] = 0.2
self.gateway.submit(body); self.clock.advance(0.3)
out = self.gateway.poll(body["goal_id"])
self.assertEqual(out["stop_state"], "UNKNOWN")
self.assertEqual(self.backend.cancel_count, 1)
out = self.stop_window("PREEMPTED")
self.assertEqual(out["outcome"], "TIMED_OUT")
def test_ambiguous_send_is_quarantined_and_never_retried(self):
self.backend.send_mode = "UNKNOWN"
out = self.gateway.submit(request())
self.assertEqual(out["status"], "STOP_UNKNOWN")
self.gateway.submit(request())
with self.assertRaises(GatewayError): self.gateway.submit(request("22222222-2222-4222-8222-222222222222"))
self.assertEqual(self.backend.send_count, 1)
self.assertEqual(self.stop_window()["stop_state"], "UNKNOWN")
self.assertEqual(self.backend.cancel_count, 1)
def test_controller_state_loss_requests_cancel_and_keeps_lock(self):
self.gateway.submit(request())
self.backend.set_snapshot(request()["goal_id"], "LOST", pose=request()["target_pose"])
out = self.gateway.poll(request()["goal_id"])
self.assertEqual(self.backend.cancel_count, 1)
self.assertEqual(out["stop_state"], "UNKNOWN")
with self.assertRaises(GatewayError): self.gateway.submit(request("22222222-2222-4222-8222-222222222222"))
def test_second_process_cannot_open_the_same_robot_journal(self):
with self.assertRaises(RuntimeError): Gateway(self.path, self.backend, self.config, self.clock)
def test_completed_record_survives_restart_without_resending(self):
self.gateway.submit(request()); expected = self.stop_window()
self.gateway.close()
self.clock.advance(100)
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
actual = self.gateway.submit(request())
self.assertEqual(actual, expected)
self.assertEqual(self.backend.send_count, 1)
def test_stop_proof_keeps_original_source_timestamp_across_queries_and_restart(self):
self.gateway.submit(request())
out = self.stop_window()
self.assertEqual(out["stopped_at"], 1700000000.0 + self.clock())
original_stamp = out["stopped_at"]
self.clock.advance(50)
self.assertEqual(self.gateway.poll(request()["goal_id"])["stopped_at"], original_stamp)
self.gateway.close()
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
self.assertEqual(self.gateway.get(request()["goal_id"])["stopped_at"], original_stamp)
def test_rejected_goal_requires_stop_evidence(self):
self.backend.send_mode = "REJECTED"
out = self.gateway.submit(request())
self.assertEqual(out["stop_state"], "UNKNOWN")
out = self.stop_window("REJECTED")
self.assertEqual((out["outcome"], out["stop_state"]), ("REJECTED", "CONFIRMED"))
def test_restart_locks_unfinished_goal_and_does_not_resend(self):
self.gateway.submit(request()); self.gateway.close()
self.gateway = Gateway(self.path, self.backend, self.config, self.clock)
old = self.gateway.get(request()["goal_id"])
self.assertEqual(old["status"], "STOP_UNKNOWN")
self.assertTrue(old["quarantined"])
self.gateway.submit(request())
with self.assertRaises(GatewayError): self.gateway.submit(request("22222222-2222-4222-8222-222222222222"))
self.assertEqual(self.backend.send_count, 1)
def test_http_all_routes_require_bearer_and_goal_query_cancel_work(self):
server = make_server(self.gateway, "test-secret-token", host="127.0.0.1", port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start()
self.addCleanup(server.server_close); self.addCleanup(server.shutdown)
base = f"http://127.0.0.1:{server.server_address[1]}"
with self.assertRaises(urllib.error.HTTPError) as cm: urllib.request.urlopen(base + "/healthz")
self.assertEqual(cm.exception.code, 401)
def call(path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(base + path, data=data,
headers={"Authorization": "Bearer test-secret-token", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=2) as response: return json.load(response)
self.assertTrue(call("/healthz")["ready"])
self.assertEqual(call("/v1/goals", request())["goal_id"], request()["goal_id"])
self.assertEqual(call("/v1/goals/" + request()["goal_id"])["status"], "ACTIVE")
self.assertEqual(call("/v1/goals/" + request()["goal_id"] + "/cancel", {})["stop_state"], "UNKNOWN")
class RosSourceTimeTests(unittest.TestCase):
"""Exercise actual Noetic callbacks and gateway checks with injected clocks."""
def setUp(self):
from types import SimpleNamespace as NS
from unittest.mock import patch
from navigation_gateway.backends import Ros1MoveBaseBackend
self.NS = NS
self.ros_time, self.monotonic_time = [100.0], [10.0]
self.backend = Ros1MoveBaseBackend.__new__(Ros1MoveBaseBackend)
self.backend.rospy = NS(Time=NS(now=lambda: NS(to_sec=lambda: self.ros_time[0])))
self.backend.max_age = 1.0
self.backend.map_id = "sim-map"
self.backend.lock = threading.RLock()
self.backend.sequence = 0
self.backend.previous_odom_stamp = None
self.backend.odom_queue = []
self.gateway = Gateway.__new__(Gateway)
self.gateway.backend = self.backend
self.gateway.clock = lambda: self.monotonic_time[0]
self.patch = patch("navigation_gateway.backends.time.monotonic", lambda: self.monotonic_time[0])
self.patch.start()
self.addCleanup(self.patch.stop)
def receive_all(self, stamp):
NS = self.NS
header = NS(stamp=NS(to_sec=lambda: stamp), frame_id="map")
self.backend._odom(NS(header=header, twist=NS(twist=NS(linear=NS(x=0., y=0., z=0.), angular=NS(x=0., y=0., z=0.)))))
self.backend._pose(NS(header=header, pose=NS(position=NS(x=1., y=2., z=0.), orientation=NS(x=0., y=0., z=0., w=1.))))
self.backend._health(NS(data=json.dumps({"ready": True, "map_id": "sim-map", "stamp": stamp})))
return (self.backend.odom, self.backend.pose, self.backend.health_value)
def test_source_age_is_rechecked_even_when_receipt_is_fresh(self):
samples = self.receive_all(99.1)
self.assertTrue(all(self.gateway._fresh(sample, 1.0) for sample in samples))
self.ros_time[0], self.monotonic_time[0] = 100.5, 10.5
for sample in samples:
with self.subTest(sample=sample): self.assertFalse(self.gateway._fresh(sample, 1.0))
def test_backward_ros_jump_invalidates_old_epoch_even_when_age_is_in_range(self):
samples = self.receive_all(99.8)
self.ros_time[0], self.monotonic_time[0] = 99.9, 10.05
for sample in samples:
with self.subTest(sample=sample): self.assertFalse(self.gateway._fresh(sample, 1.0))
self.ros_time[0] = 100.1
self.assertTrue(all(not self.gateway._fresh(sample, 1.0) for sample in samples))
def test_future_source_time_is_never_admitted_later_as_a_cached_sample(self):
samples = self.receive_all(100.1)
self.assertTrue(all(not self.gateway._fresh(sample, 1.0) for sample in samples))
self.ros_time[0], self.monotonic_time[0] = 100.2, 10.1
self.assertTrue(all(not self.gateway._fresh(sample, 1.0) for sample in samples))
# HTTP proxy contract tests use real loopback sockets; ROS imports remain lazy.
from navigation_gateway import ros2_proxy as module
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ID = '11111111-1111-4111-8111-111111111111'
BODY = {'goal_id': ID, 'trace': {'task_id': 't', 'subtask_id': 's', 'attempt': 1}, 'map_id': 'map-a', 'target_pose': {'frame_id': 'map', 'position': {'x': 1., 'y': 2., 'z': 0.}, 'orientation': {'x': 0., 'y': 0., 'z': 0., 'w': 1.}}, 'position_tolerance': .1, 'yaw_tolerance': .2, 'timeout_sec': 2.}
def snapshot(**updates):
value = {'goal_id': ID, 'status': 'ACTIVE', 'outcome': None, 'stop_state': 'UNKNOWN', 'controller_state': 'ACTIVE', 'message': '', 'position_error': None, 'yaw_error': None, 'sequence': 1, 'elapsed': .1, 'stopped_at': 1234.5}
value.update(updates)
return value
class ProxyTests(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(module, 'ROS2 proxy behavior is not implemented')
self.requests = []
self.reply = snapshot()
self.delay = 0
self.dribble = False
self.cancel_reply = None
owner = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self): self.respond()
def do_POST(self): self.respond()
def log_message(self, *args): pass
def respond(self):
raw = self.rfile.read(int(self.headers.get('Content-Length', '0')))
owner.requests.append((self.command, self.path, self.headers.get('Authorization'), json.loads(raw) if raw else None))
if owner.delay: time.sleep(owner.delay)
value = {'ready': True, 'reason': '', 'map_id': 'map-a'} if self.path == '/healthz' else copy.deepcopy(owner.reply)
if self.path.endswith('/cancel') and owner.cancel_reply is not None:
value = owner.cancel_reply
data = json.dumps(value).encode()
try:
self.send_response(200)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
if owner.dribble:
for byte in data:
self.wfile.write(bytes([byte]))
self.wfile.flush()
time.sleep(.005)
else:
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError): pass
self.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.server.daemon_threads = True
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.config = module.ProxyConfig(endpoint='http://127.0.0.1:%d' % self.server.server_port, token='test-secret-token', connect_timeout_sec=.1, read_timeout_sec=.1, request_timeout_sec=.2, poll_interval_sec=.01, readiness_max_age_sec=.5, feedback_silence_timeout_sec=.5)
self.client = module.GatewayClient(self.config)
def tearDown(self):
if hasattr(self, 'server'):
self.server.shutdown()
self.server.server_close()
def wait_for(self, predicate):
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
if predicate(): return
time.sleep(.005)
self.fail('condition not reached')
def test_body_is_frozen_and_all_http_routes_are_authenticated(self):
body = copy.deepcopy(BODY)
session = module.GoalSession(self.client, body)
body['trace']['task_id'] = 'changed'
self.client.health()
session.start()
self.wait_for(lambda: len(self.requests) >= 3)
self.assertEqual(self.requests[1][3]['trace']['task_id'], 't')
session.request_cancel()
self.wait_for(lambda: any(r[1].endswith('/cancel') for r in self.requests))
self.assertTrue(all(r[2] == 'Bearer test-secret-token' for r in self.requests))
self.assertEqual([r[3]['goal_id'] for r in self.requests if r[1] == '/v1/goals'], [ID])
self.reply = snapshot(status='TERMINAL', outcome='CANCELED', stop_state='CONFIRMED', controller_state='PREEMPTED', sequence=9)
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.terminal for e in events))
self.assertTrue(any(e.terminal and e.snapshot.outcome == 'CANCELED' for e in events))
def test_cancel_ack_and_unconfirmed_terminal_do_not_finish(self):
session = module.GoalSession(self.client, BODY)
session.start()
self.wait_for(lambda: len(self.requests) >= 2)
self.cancel_reply = snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', stop_state='CONFIRMED', sequence=8)
session.request_cancel()
self.wait_for(lambda: any(r[1].endswith('/cancel') for r in self.requests))
self.reply = snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', sequence=2)
time.sleep(.06)
self.assertFalse(any(e.terminal for e in session.drain_events()))
self.reply = snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', stop_state='CONFIRMED', sequence=3)
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.terminal for e in events))
self.assertEqual([e.snapshot.stop_state for e in events if e.terminal], ['CONFIRMED'])
def test_transport_timeout_produces_unknown_error_and_bounded_wait(self):
self.delay = .7
session = module.GoalSession(self.client, BODY)
started = time.monotonic()
session.start()
self.assertLess(time.monotonic() - started, .05)
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error for e in events))
self.assertLess(time.monotonic() - started, .6)
self.assertFalse(any(e.terminal for e in events))
self.assertTrue(any(e.error for e in events))
def test_completion_outside_tolerance_never_releases(self):
self.reply = snapshot(status='TERMINAL', outcome='COMPLETED', controller_state='SUCCEEDED', stop_state='CONFIRMED', position_error=.9, yaw_error=.01)
session = module.GoalSession(self.client, BODY)
session.start()
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error or e.terminal for e in events))
self.assertFalse(any(e.terminal for e in events))
self.assertTrue(any(e.error for e in events))
def test_native_arrived_with_stop_and_tolerances_completes(self):
self.reply = snapshot(status='TERMINAL', outcome='COMPLETED', controller_state='ARRIVED', stop_state='CONFIRMED', position_error=.02, yaw_error=.01)
session = module.GoalSession(self.client, BODY)
session.start()
events = []
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error or e.terminal for e in events))
self.assertTrue(any(e.terminal for e in events))
self.assertFalse(any(e.error for e in events))
def test_short_token_is_rejected_before_network_io(self):
from dataclasses import replace
with self.assertRaises(ValueError):
replace(self.config, token='short')
def test_actual_pose_reaches_ros_pose_stamped_without_invented_stamp(self):
from types import SimpleNamespace as NS
pose = {'frame_id': 'map', 'position': {'x': 1.0, 'y': 2.0, 'z': 0.0}, 'orientation': {'x': 0.0, 'y': 0.0, 'z': 0.0, 'w': 1.0}}
value = module.GatewaySnapshot.parse(snapshot(pose_valid=True, current_pose=pose, final_pose=pose), ID)
self.assertTrue(hasattr(value, 'pose_valid'), 'actual pose evidence is missing from gateway snapshot')
self.assertTrue(value.pose_valid)
target = NS(header=NS(frame_id='', stamp=NS(sec=0, nanosec=0)), pose=NS(position=NS(x=0., y=0., z=0.), orientation=NS(x=0., y=0., z=0., w=0.)))
module.assign_ros_pose(target, value.final_pose)
self.assertEqual((target.header.frame_id, target.pose.position.x, target.pose.position.y, target.pose.orientation.w), ('map', 1.0, 2.0, 1.0))
self.assertEqual((target.header.stamp.sec, target.header.stamp.nanosec), (0, 0))
def test_invalid_observed_pose_is_rejected(self):
for changes in ({'frame_id': 'odom'}, {'orientation': {'x': 0., 'y': 0., 'z': 0., 'w': 0.}}, {'position': {'x': float('nan'), 'y': 0., 'z': 0.}}):
pose = {'frame_id': 'map', 'position': {'x': 1., 'y': 2., 'z': 0.}, 'orientation': {'x': 0., 'y': 0., 'z': 0., 'w': 1.}}
pose.update(changes)
with self.assertRaises(module.GatewayError):
module.GatewaySnapshot.parse(snapshot(pose_valid=True, final_pose=pose), ID)
def test_confirmed_terminal_rejects_missing_or_invalid_stop_stamp(self):
for stamp in (None, 0.0, -1.0, True, float('nan'), float('inf'), 2147483648.0):
with self.subTest(stamp=stamp), self.assertRaises(module.GatewayError):
module.GatewaySnapshot.parse(snapshot(status='TERMINAL', outcome='CANCELED', controller_state='PREEMPTED', stop_state='CONFIRMED', stopped_at=stamp), ID)
def test_ros_stop_time_normalizes_nanosecond_rounding_and_range(self):
from types import SimpleNamespace as NS
self.assertTrue(hasattr(module, 'assign_ros_time'), 'source stop time mapping is missing')
for source, expected in ((1234.5, (1234, 500000000)), (1.9999999996, (2, 0)), (2147483647.0, (2147483647, 0))):
result = NS(sec=0, nanosec=0)
module.assign_ros_time(result, source)
self.assertEqual((result.sec, result.nanosec), expected)
for invalid in (-1.0, 2147483648.0, float('inf'), True):
with self.assertRaises(module.GatewayError):
module.assign_ros_time(NS(sec=0, nanosec=0), invalid)
def test_repeated_query_preserves_original_stop_evidence_stamp(self):
self.reply = snapshot(status='TERMINAL', outcome='COMPLETED', controller_state='SUCCEEDED', stop_state='CONFIRMED', position_error=.02, yaw_error=.01, stopped_at=1234.5)
first = self.client.query(ID)
second = self.client.query(ID)
self.assertTrue(hasattr(first, 'stopped_at'), 'original stop evidence timestamp was dropped')
self.assertEqual((first.stopped_at, second.stopped_at), (1234.5, 1234.5))
def test_request_deadline_bounds_slow_trickle_response(self):
self.dribble = True
started = time.monotonic()
with self.assertRaises(module.GatewayError):
self.client.query(ID)
self.assertLess(time.monotonic() - started, .4)
def test_mismatched_uuid_never_completes_current_goal(self):
self.reply = snapshot(goal_id='22222222-2222-4222-8222-222222222222', status='TERMINAL', outcome='COMPLETED', controller_state='SUCCEEDED', stop_state='CONFIRMED')
session = module.GoalSession(self.client, BODY)
session.start()
events=[]
self.wait_for(lambda: bool(events.extend(session.drain_events())) or any(e.error for e in events))
self.assertFalse(any(e.terminal for e in events))
self.assertTrue(any(e.error for e in events))
if __name__ == "__main__": unittest.main()