diff --git a/Makefile b/Makefile index 9045eb9..8b2ba76 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHELL := /bin/bash COMPOSE := docker compose --env-file deployment/env/versions.env \ -f deployment/compose/compose.dev.yaml -BASE_PATHS := packages agents tasks evaluators tests/fixtures +BASE_PATHS := packages agents tasks evaluators tests COLCON_ENV := COLCON_DEFAULTS_FILE=$(CURDIR)/colcon.defaults.yaml CURRENT_USER := $(shell id -un) @@ -57,6 +57,6 @@ test-local: build-local lint-local: python3 tools/validation/check_repository.py - ruff check tools + ruff check tools tests check-local: lint-local list-local test-local diff --git a/packages/README.md b/packages/README.md index 60e27d2..3aeaf3d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -4,5 +4,6 @@ This domain contains shared RoboHarness platform and communication packages. Packages must depend toward stable layers and must not statically depend on a specific simulator, robot, agent, task, or evaluator implementation. -PR 01 intentionally contains no placeholder ROS packages. `rh_interfaces`, -`rh_core`, `rh_ros`, and `rh_experiment` are introduced by their roadmap PRs. +The first platform package is [`rh_interfaces`](rh_interfaces/README.md), which +owns the implementation-independent ROS wire contract. `rh_core`, `rh_ros`, +and `rh_experiment` are introduced by their roadmap PRs. diff --git a/packages/rh_interfaces/CMakeLists.txt b/packages/rh_interfaces/CMakeLists.txt new file mode 100644 index 0000000..99d6546 --- /dev/null +++ b/packages/rh_interfaces/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.8) +project(rh_interfaces) + +find_package(ament_cmake REQUIRED) +find_package(builtin_interfaces REQUIRED) +find_package(geometry_msgs REQUIRED) +find_package(rosidl_default_generators REQUIRED) + +rosidl_generate_interfaces(${PROJECT_NAME} + "msg/ComponentStatus.msg" + "msg/EpisodeResult.msg" + "msg/EpisodeState.msg" + "msg/PointNavTask.msg" + "srv/AbortEpisode.srv" + "srv/ResetAgent.srv" + "srv/ResetEnv.srv" + "srv/StartEpisode.srv" + DEPENDENCIES + builtin_interfaces + geometry_msgs +) + +ament_export_dependencies(rosidl_default_runtime) + +if(BUILD_TESTING) + find_package(ament_cmake_lint_cmake REQUIRED) + find_package(ament_cmake_xmllint REQUIRED) + + ament_lint_cmake() + ament_xmllint() +endif() + +ament_package() diff --git a/packages/rh_interfaces/README.md b/packages/rh_interfaces/README.md new file mode 100644 index 0000000..4385aef --- /dev/null +++ b/packages/rh_interfaces/README.md @@ -0,0 +1,48 @@ +# `rh_interfaces` + +`rh_interfaces` is the implementation-independent ROS 2 wire contract shared +by the RoboHarness Environment, Agent, and Experiment containers. It contains +messages and services only: no nodes, transport helpers, state machines, or +simulator-specific code. + +## Control-plane interfaces + +| ROS name | Interface | Producer/server | Consumer/client | +|---|---|---|---| +| `/roboharness/env/status` | `ComponentStatus` | Environment | Experiment | +| `/roboharness/agent/status` | `ComponentStatus` | Agent | Experiment | +| `/roboharness/env/reset_episode` | `ResetEnv` | Environment | Experiment | +| `/roboharness/agent/reset_episode` | `ResetAgent` | Agent | Experiment | +| `/roboharness/episode/state` | `EpisodeState` | Experiment | Environment, Agent, Evaluator | +| `/roboharness/task/pointnav` | `PointNavTask` | Experiment | Environment, Agent, Evaluator | +| `/roboharness/episode/start` | `StartEpisode` | Experiment | Operator or automation | +| `/roboharness/episode/abort` | `AbortEpisode` | Experiment | Operator | +| `/roboharness/episode/result` | `EpisodeResult` | Experiment | Reporting tools | + +The standard robot data plane continues to use ROS types such as +`geometry_msgs/Twist`, `nav_msgs/Odometry`, and `sensor_msgs/Imu`; this package +does not wrap those observations in a generic message. + +## Contract rules + +- Identifiers and request IDs are opaque non-empty strings. Validation belongs + to the core/runtime layers rather than the generated interface classes. +- `PointNavTask.start`, `PointNavTask.goal`, and `ResetEnv.start` use the `map` + frame in the MVP. Task start and goal frames must match. +- Durations use seconds, distances use metres, and Episode metrics use + simulation time and the `map` frame. +- `ComponentStatus.stamp` records the last transition time. Repeated status + heartbeats retain it instead of replacing it with publication time. +- `EpisodeState.sequence` increases on every authoritative transition within an + Episode. Consumers reject stale Episode IDs or sequence values. +- `EpisodeResult` is deliberately compact. `result_uri` is opaque and points to + the durable JSON result; its storage scheme is not part of this ROS contract. +- A reset `request_id` is an idempotency key. Runtime behavior for duplicate + requests is implemented in a later protocol-helper PR. +- Numeric state and termination values are stable wire values. New values may + be appended, but existing values must never be reordered or reused. +- `detail`, abort `reason`, and `result_uri` are opaque strings. Correctness must + never depend on parsing human-readable text. + +QoS, deadlines, heartbeat behavior, validation, and node implementations are +intentionally outside this package. diff --git a/packages/rh_interfaces/msg/ComponentStatus.msg b/packages/rh_interfaces/msg/ComponentStatus.msg new file mode 100644 index 0000000..09462c2 --- /dev/null +++ b/packages/rh_interfaces/msg/ComponentStatus.msg @@ -0,0 +1,23 @@ +# Lifecycle state reported by an Environment or Agent component. +# Existing numeric values are part of the wire contract and must not change. +uint8 STARTING=0 +uint8 RESETTING=1 +uint8 READY=2 +uint8 ERROR=3 + +# Time of the latest state transition, not the heartbeat publication time. +builtin_interfaces/Time stamp + +# Stable identifier of the component instance within an experiment. +string component_id + +uint8 state + +# Zero means no error. Non-zero code catalogs are owned by each component. +uint32 error_code + +# Short human-readable context; consumers must not parse this field. +string detail + +# True when an operator must restart or redeploy the component before reuse. +bool restart_required diff --git a/packages/rh_interfaces/msg/EpisodeResult.msg b/packages/rh_interfaces/msg/EpisodeResult.msg new file mode 100644 index 0000000..4105244 --- /dev/null +++ b/packages/rh_interfaces/msg/EpisodeResult.msg @@ -0,0 +1,17 @@ +# Compact completion event. Detailed results remain in the referenced artifact. +string experiment_id +string episode_id + +# Uses the termination reason constants defined by EpisodeState. +uint8 termination_reason + +# Convenience summary; true exactly when termination_reason is SUCCESS. +bool success + +# Metrics are measured in simulation time and the map frame. +float64 elapsed_time_s +float64 path_length_m +float64 final_distance_to_goal_m + +# Opaque URI for the durable episode result document. +string result_uri diff --git a/packages/rh_interfaces/msg/EpisodeState.msg b/packages/rh_interfaces/msg/EpisodeState.msg new file mode 100644 index 0000000..77c7bab --- /dev/null +++ b/packages/rh_interfaces/msg/EpisodeState.msg @@ -0,0 +1,32 @@ +# Authoritative lifecycle state published by the Experiment orchestrator. +# Existing numeric values are part of the wire contract and must not change. +uint8 PREPARING=0 +uint8 READY=1 +uint8 RUNNING=2 +uint8 TERMINATING=3 +uint8 FINISHED=4 + +# A reason is final only when state is FINISHED. NONE is used before then. +uint8 NONE=0 +uint8 SUCCESS=1 +uint8 TIMEOUT=2 +uint8 ABORTED=3 +uint8 FAILURE=4 +uint8 ENV_ERROR=5 +uint8 AGENT_ERROR=6 +uint8 INVALID_TASK=7 + +# Simulation time of this state transition. +builtin_interfaces/Time stamp + +string experiment_id +string episode_id + +# Strictly increases for every transition within an episode. +uint64 sequence + +uint8 state +uint8 termination_reason + +# Short human-readable context; consumers must not parse this field. +string detail diff --git a/packages/rh_interfaces/msg/PointNavTask.msg b/packages/rh_interfaces/msg/PointNavTask.msg new file mode 100644 index 0000000..a1ebf54 --- /dev/null +++ b/packages/rh_interfaces/msg/PointNavTask.msg @@ -0,0 +1,16 @@ +# Immutable PointNav task snapshot. start and goal must use the same frame. +# RoboHarness MVP requires that frame to be "map". +string experiment_id +string episode_id + +geometry_msgs/PoseStamped start +geometry_msgs/PoseStamped goal + +# Goal is reached when planar distance is less than or equal to this value. +float64 success_radius_m + +# Episode timeout measured in simulation seconds. +float64 timeout_s + +# Reproducibility seed; interpretation is owned by the selected backend/task. +int64 seed diff --git a/packages/rh_interfaces/package.xml b/packages/rh_interfaces/package.xml new file mode 100644 index 0000000..07f122b --- /dev/null +++ b/packages/rh_interfaces/package.xml @@ -0,0 +1,27 @@ + + + + rh_interfaces + 0.0.0 + Implementation-independent ROS 2 interfaces for RoboHarness. + Staaaaaaaaar + + NOASSERTION + + ament_cmake + rosidl_default_generators + + builtin_interfaces + geometry_msgs + + rosidl_default_runtime + + ament_cmake_lint_cmake + ament_cmake_xmllint + + rosidl_interface_packages + + + ament_cmake + + diff --git a/packages/rh_interfaces/srv/AbortEpisode.srv b/packages/rh_interfaces/srv/AbortEpisode.srv new file mode 100644 index 0000000..1a695a3 --- /dev/null +++ b/packages/rh_interfaces/srv/AbortEpisode.srv @@ -0,0 +1,12 @@ +string experiment_id +string episode_id + +# Human-readable operator intent. The committed result uses EpisodeState.ABORTED. +string reason +--- + +# Acceptance only acknowledges the transition request; finalization is asynchronous. +bool accepted + +# Short human-readable context; clients must not parse this field. +string detail diff --git a/packages/rh_interfaces/srv/ResetAgent.srv b/packages/rh_interfaces/srv/ResetAgent.srv new file mode 100644 index 0000000..da7d9f1 --- /dev/null +++ b/packages/rh_interfaces/srv/ResetAgent.srv @@ -0,0 +1,12 @@ +# Opaque idempotency key. Repeating a completed request_id must not reset twice. +string request_id +string experiment_id +string episode_id +--- +bool success + +# Zero means no error. Non-zero values are component-specific. +uint32 error_code + +# Short human-readable context; clients must not parse this field. +string detail diff --git a/packages/rh_interfaces/srv/ResetEnv.srv b/packages/rh_interfaces/srv/ResetEnv.srv new file mode 100644 index 0000000..b839c98 --- /dev/null +++ b/packages/rh_interfaces/srv/ResetEnv.srv @@ -0,0 +1,18 @@ +# Opaque idempotency key. Repeating a completed request_id must not reset twice. +string request_id +string experiment_id +string episode_id + +# Requested robot root pose. RoboHarness MVP requires the "map" frame. +geometry_msgs/PoseStamped start + +# Reproducibility seed supplied to the environment backend. +int64 seed +--- +bool success + +# Zero means no error. Non-zero values are component-specific. +uint32 error_code + +# Short human-readable context; clients must not parse this field. +string detail diff --git a/packages/rh_interfaces/srv/StartEpisode.srv b/packages/rh_interfaces/srv/StartEpisode.srv new file mode 100644 index 0000000..6b3d65f --- /dev/null +++ b/packages/rh_interfaces/srv/StartEpisode.srv @@ -0,0 +1,9 @@ +string experiment_id +string episode_id +--- + +# Acceptance only acknowledges the transition request; it is not an episode result. +bool accepted + +# Short human-readable context; clients must not parse this field. +string detail diff --git a/tests/README.md b/tests/README.md index 92d2028..bbe80dc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -3,5 +3,7 @@ This directory is reserved for contract, cross-process, cross-container, and end-to-end tests. Unit tests stay with their owning package. -`fixtures/` will contain deterministic CPU-only mock components; it is a Colcon -base path but contains no placeholder package in PR 01. +- [`contracts/`](contracts/README.md) verifies public interfaces across + language bindings and package boundaries. +- `fixtures/` will contain deterministic CPU-only mock components in later + roadmap PRs. diff --git a/tests/contracts/README.md b/tests/contracts/README.md new file mode 100644 index 0000000..8aed671 --- /dev/null +++ b/tests/contracts/README.md @@ -0,0 +1,6 @@ +# Contract tests + +This domain contains black-box tests for public RoboHarness contracts. The +`rh_interfaces_contract_tests` package verifies generated C++ and Python ROS 2 +types, stable field/constant definitions, serialization round trips, and the +interface package dependency boundary. diff --git a/tests/contracts/rh_interfaces_contract_tests/CMakeLists.txt b/tests/contracts/rh_interfaces_contract_tests/CMakeLists.txt new file mode 100644 index 0000000..4e4e800 --- /dev/null +++ b/tests/contracts/rh_interfaces_contract_tests/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.8) +project(rh_interfaces_contract_tests) + +find_package(ament_cmake REQUIRED) +find_package(ament_cmake_gtest REQUIRED) +find_package(ament_cmake_pytest REQUIRED) +find_package(rh_interfaces REQUIRED) +find_package(rosidl_typesupport_cpp REQUIRED) + +ament_add_gtest(test_cpp_typesupport test/test_cpp_typesupport.cpp) +ament_target_dependencies(test_cpp_typesupport + rh_interfaces + rosidl_typesupport_cpp +) + +ament_add_pytest_test(test_python_contract test/test_python_contract.py + TIMEOUT 30 +) + +ament_package() diff --git a/tests/contracts/rh_interfaces_contract_tests/package.xml b/tests/contracts/rh_interfaces_contract_tests/package.xml new file mode 100644 index 0000000..6462b88 --- /dev/null +++ b/tests/contracts/rh_interfaces_contract_tests/package.xml @@ -0,0 +1,23 @@ + + + + rh_interfaces_contract_tests + 0.0.0 + Cross-language contract tests for the RoboHarness ROS interfaces. + Staaaaaaaaar + + NOASSERTION + + ament_cmake + + ament_cmake_gtest + ament_cmake_pytest + ament_index_python + rclpy + rh_interfaces + rosidl_typesupport_cpp + + + ament_cmake + + diff --git a/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp b/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp new file mode 100644 index 0000000..baec43c --- /dev/null +++ b/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp @@ -0,0 +1,114 @@ +#include + +#include "rh_interfaces/msg/component_status.hpp" +#include "rh_interfaces/msg/episode_result.hpp" +#include "rh_interfaces/msg/episode_state.hpp" +#include "rh_interfaces/msg/point_nav_task.hpp" +#include "rh_interfaces/srv/abort_episode.hpp" +#include "rh_interfaces/srv/reset_agent.hpp" +#include "rh_interfaces/srv/reset_env.hpp" +#include "rh_interfaces/srv/start_episode.hpp" +#include "rosidl_typesupport_cpp/message_type_support.hpp" +#include "rosidl_typesupport_cpp/service_type_support.hpp" + +TEST(RhInterfacesContract, StableNumericConstants) +{ + using ComponentStatus = rh_interfaces::msg::ComponentStatus; + EXPECT_EQ(ComponentStatus::STARTING, 0u); + EXPECT_EQ(ComponentStatus::RESETTING, 1u); + EXPECT_EQ(ComponentStatus::READY, 2u); + EXPECT_EQ(ComponentStatus::ERROR, 3u); + + using EpisodeState = rh_interfaces::msg::EpisodeState; + EXPECT_EQ(EpisodeState::PREPARING, 0u); + EXPECT_EQ(EpisodeState::READY, 1u); + EXPECT_EQ(EpisodeState::RUNNING, 2u); + EXPECT_EQ(EpisodeState::TERMINATING, 3u); + EXPECT_EQ(EpisodeState::FINISHED, 4u); + + EXPECT_EQ(EpisodeState::NONE, 0u); + EXPECT_EQ(EpisodeState::SUCCESS, 1u); + EXPECT_EQ(EpisodeState::TIMEOUT, 2u); + EXPECT_EQ(EpisodeState::ABORTED, 3u); + EXPECT_EQ(EpisodeState::FAILURE, 4u); + EXPECT_EQ(EpisodeState::ENV_ERROR, 5u); + EXPECT_EQ(EpisodeState::AGENT_ERROR, 6u); + EXPECT_EQ(EpisodeState::INVALID_TASK, 7u); +} + +TEST(RhInterfacesContract, CppTypesupportIsLinked) +{ + EXPECT_NE( + rosidl_typesupport_cpp::get_message_type_support_handle< + rh_interfaces::msg::ComponentStatus>(), + nullptr); + EXPECT_NE( + rosidl_typesupport_cpp::get_message_type_support_handle< + rh_interfaces::msg::EpisodeResult>(), + nullptr); + EXPECT_NE( + rosidl_typesupport_cpp::get_message_type_support_handle< + rh_interfaces::msg::EpisodeState>(), + nullptr); + EXPECT_NE( + rosidl_typesupport_cpp::get_message_type_support_handle< + rh_interfaces::msg::PointNavTask>(), + nullptr); + + EXPECT_NE( + rosidl_typesupport_cpp::get_service_type_support_handle< + rh_interfaces::srv::AbortEpisode>(), + nullptr); + EXPECT_NE( + rosidl_typesupport_cpp::get_service_type_support_handle< + rh_interfaces::srv::ResetAgent>(), + nullptr); + EXPECT_NE( + rosidl_typesupport_cpp::get_service_type_support_handle< + rh_interfaces::srv::ResetEnv>(), + nullptr); + EXPECT_NE( + rosidl_typesupport_cpp::get_service_type_support_handle< + rh_interfaces::srv::StartEpisode>(), + nullptr); +} + +TEST(RhInterfacesContract, GeneratedCppTypesAreUsable) +{ + rh_interfaces::msg::ComponentStatus status; + status.component_id = "env"; + status.state = status.READY; + EXPECT_EQ(status.component_id, "env"); + + rh_interfaces::msg::EpisodeState state; + state.experiment_id = "experiment-1"; + state.episode_id = "episode-1"; + state.sequence = 4; + state.state = state.FINISHED; + state.termination_reason = state.SUCCESS; + EXPECT_EQ(state.sequence, 4u); + + rh_interfaces::msg::PointNavTask task; + task.start.header.frame_id = "map"; + task.goal.header.frame_id = "map"; + task.success_radius_m = 0.5; + EXPECT_DOUBLE_EQ(task.success_radius_m, 0.5); + + rh_interfaces::srv::ResetEnv::Request reset_request; + reset_request.request_id = "request-1"; + reset_request.start.header.frame_id = "map"; + EXPECT_EQ(reset_request.start.header.frame_id, "map"); + + rh_interfaces::srv::ResetAgent::Response reset_response; + reset_response.success = true; + reset_response.error_code = 0; + EXPECT_TRUE(reset_response.success); + + rh_interfaces::srv::StartEpisode::Response start_response; + start_response.accepted = true; + EXPECT_TRUE(start_response.accepted); + + rh_interfaces::srv::AbortEpisode::Request abort_request; + abort_request.reason = "operator request"; + EXPECT_EQ(abort_request.reason, "operator request"); +} diff --git a/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py b/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py new file mode 100644 index 0000000..183269c --- /dev/null +++ b/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest +from ament_index_python.packages import get_package_share_directory +from rclpy.serialization import deserialize_message, serialize_message +from rh_interfaces.msg import ComponentStatus, EpisodeResult, EpisodeState, PointNavTask +from rh_interfaces.srv import AbortEpisode, ResetAgent, ResetEnv, StartEpisode + + +def test_stable_numeric_constants() -> None: + assert { + "STARTING": ComponentStatus.STARTING, + "RESETTING": ComponentStatus.RESETTING, + "READY": ComponentStatus.READY, + "ERROR": ComponentStatus.ERROR, + } == {"STARTING": 0, "RESETTING": 1, "READY": 2, "ERROR": 3} + + assert { + "PREPARING": EpisodeState.PREPARING, + "READY": EpisodeState.READY, + "RUNNING": EpisodeState.RUNNING, + "TERMINATING": EpisodeState.TERMINATING, + "FINISHED": EpisodeState.FINISHED, + } == {"PREPARING": 0, "READY": 1, "RUNNING": 2, "TERMINATING": 3, "FINISHED": 4} + + assert { + "NONE": EpisodeState.NONE, + "SUCCESS": EpisodeState.SUCCESS, + "TIMEOUT": EpisodeState.TIMEOUT, + "ABORTED": EpisodeState.ABORTED, + "FAILURE": EpisodeState.FAILURE, + "ENV_ERROR": EpisodeState.ENV_ERROR, + "AGENT_ERROR": EpisodeState.AGENT_ERROR, + "INVALID_TASK": EpisodeState.INVALID_TASK, + } == { + "NONE": 0, + "SUCCESS": 1, + "TIMEOUT": 2, + "ABORTED": 3, + "FAILURE": 4, + "ENV_ERROR": 5, + "AGENT_ERROR": 6, + "INVALID_TASK": 7, + } + + +@pytest.mark.parametrize( + ("interface_type", "expected_fields"), + [ + ( + ComponentStatus, + { + "stamp": "builtin_interfaces/Time", + "component_id": "string", + "state": "uint8", + "error_code": "uint32", + "detail": "string", + "restart_required": "boolean", + }, + ), + ( + EpisodeState, + { + "stamp": "builtin_interfaces/Time", + "experiment_id": "string", + "episode_id": "string", + "sequence": "uint64", + "state": "uint8", + "termination_reason": "uint8", + "detail": "string", + }, + ), + ( + PointNavTask, + { + "experiment_id": "string", + "episode_id": "string", + "start": "geometry_msgs/PoseStamped", + "goal": "geometry_msgs/PoseStamped", + "success_radius_m": "double", + "timeout_s": "double", + "seed": "int64", + }, + ), + ( + EpisodeResult, + { + "experiment_id": "string", + "episode_id": "string", + "termination_reason": "uint8", + "success": "boolean", + "elapsed_time_s": "double", + "path_length_m": "double", + "final_distance_to_goal_m": "double", + "result_uri": "string", + }, + ), + ( + ResetEnv.Request, + { + "request_id": "string", + "experiment_id": "string", + "episode_id": "string", + "start": "geometry_msgs/PoseStamped", + "seed": "int64", + }, + ), + ( + ResetEnv.Response, + {"success": "boolean", "error_code": "uint32", "detail": "string"}, + ), + ( + ResetAgent.Request, + {"request_id": "string", "experiment_id": "string", "episode_id": "string"}, + ), + ( + ResetAgent.Response, + {"success": "boolean", "error_code": "uint32", "detail": "string"}, + ), + ( + StartEpisode.Request, + {"experiment_id": "string", "episode_id": "string"}, + ), + ( + StartEpisode.Response, + {"accepted": "boolean", "detail": "string"}, + ), + ( + AbortEpisode.Request, + {"experiment_id": "string", "episode_id": "string", "reason": "string"}, + ), + ( + AbortEpisode.Response, + {"accepted": "boolean", "detail": "string"}, + ), + ], +) +def test_field_names_order_and_types_are_stable( + interface_type: type, expected_fields: dict[str, str] +) -> None: + assert interface_type.get_fields_and_field_types() == expected_fields + + +@pytest.mark.parametrize( + "instance", + [ + ComponentStatus(component_id="env", state=ComponentStatus.READY), + EpisodeState( + experiment_id="experiment-1", + episode_id="episode-1", + sequence=4, + state=EpisodeState.FINISHED, + termination_reason=EpisodeState.SUCCESS, + ), + PointNavTask( + experiment_id="experiment-1", + episode_id="episode-1", + success_radius_m=0.5, + timeout_s=60.0, + seed=42, + ), + EpisodeResult( + experiment_id="experiment-1", + episode_id="episode-1", + termination_reason=EpisodeState.SUCCESS, + success=True, + elapsed_time_s=12.5, + path_length_m=4.25, + final_distance_to_goal_m=0.3, + result_uri="results/experiment-1/episode-1/result.json", + ), + ResetEnv.Request(request_id="request-1", episode_id="episode-1", seed=42), + ResetEnv.Response(success=True), + ResetAgent.Request(request_id="request-2", episode_id="episode-1"), + ResetAgent.Response(success=True), + StartEpisode.Request(experiment_id="experiment-1", episode_id="episode-1"), + StartEpisode.Response(accepted=True), + AbortEpisode.Request(episode_id="episode-1", reason="operator request"), + AbortEpisode.Response(accepted=True), + ], +) +def test_python_serialization_round_trip(instance: object) -> None: + restored = deserialize_message(serialize_message(instance), type(instance)) + assert restored == instance + + +def test_interface_package_has_only_allowed_dependencies() -> None: + manifest = Path(get_package_share_directory("rh_interfaces")) / "package.xml" + root = ET.parse(manifest).getroot() + dependency_tags = { + "buildtool_depend", + "build_depend", + "build_export_depend", + "depend", + "exec_depend", + } + dependencies = { + element.text.strip() + for element in root + if element.tag in dependency_tags and element.text is not None + } + + assert dependencies == { + "ament_cmake", + "builtin_interfaces", + "geometry_msgs", + "rosidl_default_generators", + "rosidl_default_runtime", + }