diff --git a/test_cli_remapping/CMakeLists.txt b/test_cli_remapping/CMakeLists.txt new file mode 100644 index 00000000..2da0892f --- /dev/null +++ b/test_cli_remapping/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.5) + +project(test_cli_remapping) + +find_package(ament_cmake_auto REQUIRED) + +if(BUILD_TESTING) + # Default to C++14 + if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 14) + endif() + + find_package(ament_cmake REQUIRED) + find_package(ament_lint_auto REQUIRED) + find_package(ament_cmake_pytest REQUIRED) + find_package(rclcpp REQUIRED) + find_package(test_msgs REQUIRED) + + ament_lint_auto_find_test_dependencies() + + add_executable(name_maker_rclcpp + test/name_maker.cpp) + ament_target_dependencies(name_maker_rclcpp + "rclcpp" + "test_msgs") + + ament_add_pytest_test(test_cli_remapping + test/test_cli_remapping.py + ENV + NAME_MAKER_RCLCPP=$ + NAME_MAKER_RCLPY=${CMAKE_CURRENT_SOURCE_DIR}/test/name_maker.py + TIMEOUT 120) + set_tests_properties(test_cli_remapping + PROPERTIES DEPENDS + name_maker_rclcpp) +endif() + +ament_auto_package() diff --git a/test_cli_remapping/package.xml b/test_cli_remapping/package.xml new file mode 100644 index 00000000..72c9f291 --- /dev/null +++ b/test_cli_remapping/package.xml @@ -0,0 +1,27 @@ + + + + test_cli_remapping + 0.4.0 + + Test command line remapping of topic names, service names, node namespace, and node name. + + Shane Loretz + Apache License 2.0 + + ament_cmake_auto + + ament_cmake + + ament_cmake_pytest + ament_lint_auto + ament_lint_common + launch + rclcpp + rclpy + test_msgs + + + ament_cmake + + diff --git a/test_cli_remapping/test/__init__.py b/test_cli_remapping/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_cli_remapping/test/name_maker.cpp b/test_cli_remapping/test/name_maker.cpp new file mode 100644 index 00000000..a0c0f6ce --- /dev/null +++ b/test_cli_remapping/test/name_maker.cpp @@ -0,0 +1,51 @@ +// Copyright 2018 Open Source Robotics Foundation, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include + +#include "rclcpp/rclcpp.hpp" + +#include "test_msgs/msg/empty.hpp" +#include "test_msgs/srv/empty.hpp" + +int main(int argc, char ** argv) +{ + rclcpp::init(argc, argv); + + std::string node_name = "original_node_name"; + std::string namespace_ = "/original/namespace"; + auto node = rclcpp::Node::make_shared(node_name, namespace_); + + auto pub1 = node->create_publisher("~/private/name"); + auto pub2 = node->create_publisher("relative/name"); + auto pub3 = node->create_publisher("/fully/qualified/name"); + + auto do_nothing = []( + const test_msgs::srv::Empty::Request::SharedPtr request, + test_msgs::srv::Empty::Response::SharedPtr response) -> void + { + static_cast(request); + static_cast(response); + }; + + auto srv1 = node->create_service("~/private/name", do_nothing); + auto srv2 = node->create_service("relative/name", do_nothing); + auto srv3 = node->create_service("/fully/qualified/name", do_nothing); + + rclcpp::spin(node); + + rclcpp::shutdown(); + return 0; +} diff --git a/test_cli_remapping/test/name_maker.py b/test_cli_remapping/test/name_maker.py new file mode 100644 index 00000000..f495556f --- /dev/null +++ b/test_cli_remapping/test/name_maker.py @@ -0,0 +1,48 @@ +# Copyright 2018 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import rclpy +import rclpy.node +from test_msgs.msg import Empty as EmptyMsg +from test_msgs.srv import Empty as EmptySrv + + +class NameMaker(rclpy.node.Node): + + def __init__(self): + super().__init__('original_node_name', namespace='/original/namespace') + + self._pubs = [] + self._pubs.append(self.create_publisher(EmptyMsg, '~/private/name')) + self._pubs.append(self.create_publisher(EmptyMsg, 'relative/name')) + self._pubs.append(self.create_publisher(EmptyMsg, '/fully/qualified/name')) + + self._srvs = [] + self._srvs.append(self.create_service(EmptySrv, '~/private/name', lambda x: None)) + self._srvs.append(self.create_service(EmptySrv, 'relative/name', lambda x: None)) + self._srvs.append(self.create_service(EmptySrv, '/fully/qualified/name', lambda x: None)) + + +if __name__ == '__main__': + rclpy.init() + + node = NameMaker() + + try: + rclpy.spin(node) + except KeyboardInterrupt: + print('Shutting down name_maker.py') + finally: + node.destroy_node() + rclpy.shutdown() diff --git a/test_cli_remapping/test/test_cli_remapping.py b/test_cli_remapping/test/test_cli_remapping.py new file mode 100644 index 00000000..94ef30ac --- /dev/null +++ b/test_cli_remapping/test/test_cli_remapping.py @@ -0,0 +1,173 @@ +# Copyright 2018 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import functools +import os +import random +import sys +import time + +from launch import LaunchDescriptor +from launch.exit_handler import primary_exit_handler +from launch.launcher import DefaultLauncher +import pytest +import rclpy + + +def get_environment_variable(name): + """Get environment variable or raise if it does not exist.""" + path = os.getenv(name) + if not path: + raise EnvironmentError('Missing environment variable "%s"' % name) + return path + + +CLIENT_LIBRARY_EXECUTABLES = ( + get_environment_variable('NAME_MAKER_RCLCPP'), + get_environment_variable('NAME_MAKER_RCLPY') +) + + +@pytest.fixture(scope='module', params=CLIENT_LIBRARY_EXECUTABLES) +def node_fixture(request): + """Create a fixture with a node, name_maker executable, and random string.""" + rclpy.init() + node = rclpy.create_node('test_cli_remapping') + try: + yield { + 'node': node, + 'executable': request.param, + 'random_string': '%d_%s' % ( + random.randint(0, 9999), time.strftime('%H_%M_%S', time.gmtime())) + } + finally: + node.destroy_node() + rclpy.shutdown() + + +def remapping_test(*, cli_args): + """Return a decorator that returns a test function.""" + def real_decorator(coroutine_test): + """Return a test function that runs a coroutine test in a loop with a launched process.""" + nonlocal cli_args + + @functools.wraps(coroutine_test) + def test_func(node_fixture): + """Run an executable with cli_args and coroutine test in the same asyncio loop.""" + nonlocal cli_args + + # Create a command launching a name_maker executable specified by the pytest fixture + command = [node_fixture['executable']] + # format command line arguments with random string from test fixture + for arg in cli_args: + command.append(arg.format(random_string=node_fixture['random_string'])) + + # Execute python files using same python used to start this test + env = dict(os.environ) + if command[0][-3:] == '.py': + command.insert(0, sys.executable) + env['PYTHONUNBUFFERED'] = '1' + + ld = LaunchDescriptor() + ld.add_process( + cmd=command, + name='name_maker_' + coroutine_test.__name__, + env=env + ) + ld.add_coroutine( + coroutine_test(node_fixture), + name=coroutine_test.__name__, + exit_handler=primary_exit_handler + ) + launcher = DefaultLauncher() + launcher.add_launch_descriptor(ld) + return_code = launcher.launch() + assert return_code == 0, 'Launch failed with exit code %r' % (return_code,) + return test_func + return real_decorator + + +def get_topics(node_fixture): + topic_names_and_types = node_fixture['node'].get_topic_names_and_types() + return [name for name, _ in topic_names_and_types] + + +def get_services(node_fixture): + service_names_and_types = node_fixture['node'].get_service_names_and_types() + return [name for name, _ in service_names_and_types] + + +ATTEMPTS = 10 +TIME_BETWEEN_ATTEMPTS = 1 + + +@remapping_test(cli_args=('__node:=node_{random_string}',)) +async def test_node_name_replacement_new(node_fixture): + node_name = 'node_{random_string}'.format(**node_fixture) + + for attempt in range(ATTEMPTS): + if node_name in node_fixture['node'].get_node_names(): + break + await asyncio.sleep(TIME_BETWEEN_ATTEMPTS) + rclpy.spin_once(node_fixture['node'], timeout_sec=0) + assert node_name in node_fixture['node'].get_node_names() + + +@remapping_test(cli_args=('__ns:=/ns/s{random_string}',)) +async def test_namespace_replacement(node_fixture): + name = '/ns/s{random_string}/relative/name'.format(**node_fixture) + + for attempt in range(ATTEMPTS): + if name in get_topics(node_fixture) and name in get_services(node_fixture): + break + await asyncio.sleep(TIME_BETWEEN_ATTEMPTS) + rclpy.spin_once(node_fixture['node'], timeout_sec=0) + assert name in get_topics(node_fixture) and name in get_services(node_fixture) + + +@remapping_test(cli_args=('/fully/qualified/name:=/remapped/s{random_string}',)) +async def test_topic_and_service_replacement(node_fixture): + name = '/remapped/s{random_string}'.format(**node_fixture) + + for attempt in range(ATTEMPTS): + if name in get_topics(node_fixture) and name in get_services(node_fixture): + break + await asyncio.sleep(TIME_BETWEEN_ATTEMPTS) + rclpy.spin_once(node_fixture['node'], timeout_sec=0) + assert name in get_topics(node_fixture) and name in get_services(node_fixture) + + +@remapping_test(cli_args=('rostopic://~/private/name:=/remapped/s{random_string}',)) +async def test_topic_replacement(node_fixture): + name = '/remapped/s{random_string}'.format(**node_fixture) + + for attempt in range(ATTEMPTS): + if name in get_topics(node_fixture): + break + await asyncio.sleep(TIME_BETWEEN_ATTEMPTS) + rclpy.spin_once(node_fixture['node'], timeout_sec=0) + assert name in get_topics(node_fixture) and name not in get_services(node_fixture) + + +@remapping_test(cli_args=('rosservice://~/private/name:=/remapped/s{random_string}',)) +async def test_service_replacement(node_fixture): + name = '/remapped/s{random_string}'.format(**node_fixture) + + for attempt in range(ATTEMPTS): + if name in get_services(node_fixture): + break + await asyncio.sleep(TIME_BETWEEN_ATTEMPTS) + rclpy.spin_once(node_fixture['node'], timeout_sec=0) + assert name not in get_topics(node_fixture) and name in get_services(node_fixture)