diff --git a/test_launch_ros/package.xml b/test_launch_ros/package.xml index 9ce13b862..03816ca01 100644 --- a/test_launch_ros/package.xml +++ b/test_launch_ros/package.xml @@ -14,6 +14,7 @@ ament_pep257 demo_nodes_py launch_ros + launch_testing_ros launch_xml launch_yaml python3-pytest diff --git a/test_launch_ros/setup.cfg b/test_launch_ros/setup.cfg new file mode 100644 index 000000000..78f135f5b --- /dev/null +++ b/test_launch_ros/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script-dir=$base/lib/test_launch_ros +[install] +install-scripts=$base/lib/test_launch_ros diff --git a/test_launch_ros/setup.py b/test_launch_ros/setup.py index f8bdb3a6a..5dbcffd6c 100644 --- a/test_launch_ros/setup.py +++ b/test_launch_ros/setup.py @@ -37,4 +37,8 @@ ), license='Apache License, Version 2.0', tests_require=['pytest'], + entry_points={ + 'console_scripts': [ + 'mock-composable-container=test_launch_ros.mock_composable_container:main'], + } ) diff --git a/test_launch_ros/test/rostest/composition.test.py b/test_launch_ros/test/rostest/composition.test.py new file mode 100644 index 000000000..992cfe21f --- /dev/null +++ b/test_launch_ros/test/rostest/composition.test.py @@ -0,0 +1,175 @@ +# Copyright 2019 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 os +import unittest + +from composition_interfaces.srv import LoadNode +from launch import LaunchDescription +from launch import LaunchService +from launch.actions import ExecuteProcess +from launch.actions import OpaqueFunction +from launch.actions import RegisterEventHandler +from launch.event_handlers.on_process_start import OnProcessStart +from launch_ros import get_default_launch_description +from launch_ros.actions import ComposableNodeContainer +from launch_ros.actions import LoadComposableNodes +from launch_ros.descriptions import ComposableNode +import launch_testing +import launch_testing.asserts +from rcl_interfaces.msg import Parameter +from rcl_interfaces.msg import ParameterType + + +def generate_test_description(ready_fn): + # Necessary to get real-time stdout from python processes: + proc_env = os.environ.copy() + proc_env['PYTHONUNBUFFERED'] = '1' + + launch_description = LaunchDescription() + + mock_container = ComposableNodeContainer( + env=proc_env, + node_name='my_container', + node_namespace='/my_ns', + package='test_launch_ros', + node_executable='mock-composable-container', + composable_node_descriptions=[ + ComposableNode(package='fake_package', node_plugin='successfully_load'), + ComposableNode(package='fake_package', node_plugin='fail_to_load'), + ComposableNode( + package='fake_package', node_plugin='node_name', + node_name='my_talker' + ), + ComposableNode( + package='fake_package', node_plugin='node_namespace', + node_namespace='my_namespace' + ), + ComposableNode( + package='fake_package', node_plugin='remap_rules', + remappings=[('~/foo', '/bar')] + ), + ComposableNode( + package='fake_package', node_plugin='parameters', + parameters=[{'foo': {'bar': 'baz'}}] + ), + ComposableNode( + package='fake_package', node_plugin='extra_arguments', + extra_arguments=[{'ping.pong': 5}] + ), + # TODO(sloretz) log level + # ComposableNode( + # package='fake_package', node_plugin='log_level', + # log_level=1 + # ), + ]) + + launch_description.add_action(get_default_launch_description()) + launch_description.add_action(mock_container) + launch_description.add_action( + RegisterEventHandler( + event_handler=OnProcessStart( + target_action=mock_container, + on_start=[ + LoadComposableNodes( + composable_node_descriptions=[ + ComposableNode( + package='fake_package', node_plugin='node_name_on_event', + node_name='my_talker_on_event' + ), + ], + target_container=mock_container + ) + ] + ) + ) + ) + launch_description.add_action( + OpaqueFunction(function=lambda context: ready_fn()) + ) + + return launch_description, {'container': mock_container} + + +class TestComposition(unittest.TestCase): + + def test_successfully_load(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'successfully_load' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_fail_to_load(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'fail_to_load' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_node_name(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'node_name' + request.node_name = 'my_talker' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_node_name_post_launch(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'node_name_on_event' + request.node_name = 'my_talker_on_event' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_node_namespace(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'node_namespace' + request.node_namespace = 'my_namespace' + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_remap_rules(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'remap_rules' + request.remap_rules = ['~/foo:=/bar'] + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_parameters(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'parameters' + p = Parameter() + p.name = 'foo.bar' + p.value.string_value = 'baz' + p.value.type = ParameterType.PARAMETER_STRING + request.parameters = [p] + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + def test_custom_extra_arguments(self, container): + request = LoadNode.Request() + request.package_name = 'fake_package' + request.plugin_name = 'extra_arguments' + p = Parameter() + p.name = 'ping.pong' + p.value.integer_value = 5 + p.value.type = ParameterType.PARAMETER_INTEGER + request.extra_arguments = [p] + self.proc_output.assertWaitFor(expected_output=repr(request), process=container) + + # TODO(sloretz) log level + # def test_custom_log_level(self, container): + # request = LoadNode.Request() + # request.package_name = 'fake_package' + # request.plugin_name = 'log_level' + # request.log_level = 1 + # self.proc_output.assertWaitFor(expected_output=repr(request), process=container) diff --git a/test_launch_ros/test_launch_ros/__init__.py b/test_launch_ros/test_launch_ros/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_launch_ros/test_launch_ros/mock_composable_container.py b/test_launch_ros/test_launch_ros/mock_composable_container.py new file mode 100644 index 000000000..5a280e34f --- /dev/null +++ b/test_launch_ros/test_launch_ros/mock_composable_container.py @@ -0,0 +1,133 @@ +# Copyright 2019 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 sys + +from composition_interfaces.srv import LoadNode +import rclpy +from rclpy.node import Node + + +class MockComposableNodeContainer(Node): + + def __enter__(self): + self.__load_service = self.create_service( + LoadNode, '~/_container/load_node', self._on_load_node) + return self + + def __exit__(self, type_, value, traceback): + self.destroy_service(self.__load_service) + self.destroy_node() + + def __init__(self, name, namespace): + """ + Initialize a mock container process. + + :param load_node_responses: responses to a load node request for a given package/plugin + :type load_node_responses: {(str, str): composition_interfaces.LoadNode.Response, ...} + """ + super().__init__(name, namespace=namespace) + + # Canned responses to load service + fail_to_load = LoadNode.Response() + fail_to_load.success = False + fail_to_load.error_message = 'intentional failure' + + successfully_load = LoadNode.Response() + successfully_load.success = True + successfully_load.full_node_name = '/a_nodename' + successfully_load.unique_id = 2 + + node_name = LoadNode.Response() + node_name.success = True + node_name.full_node_name = '/my_talker' + node_name.unique_id = 4 + + node_namespace = LoadNode.Response() + node_namespace.success = True + node_namespace.full_node_name = '/my_namespace/my_talker' + node_namespace.unique_id = 8 + + log_level = LoadNode.Response() + log_level.success = True + log_level.full_node_name = '/a_nodename' + log_level.unique_id = 16 + + remap_rules = LoadNode.Response() + remap_rules.success = True + remap_rules.full_node_name = '/a_nodename' + remap_rules.unique_id = 32 + + parameters = LoadNode.Response() + parameters.success = True + parameters.full_node_name = '/a_nodename' + parameters.unique_id = 64 + + extra_arguments = LoadNode.Response() + extra_arguments.success = True + extra_arguments.full_node_name = '/a_nodename' + extra_arguments.unique_id = 128 + + node_name_on_event = LoadNode.Response() + node_name_on_event.success = True + node_name_on_event.full_node_name = '/my_talker_on_event' + node_name_on_event.unique_id = 256 + + self.__load_node_responses = { + ('fake_package', 'fail_to_load'): fail_to_load, + ('fake_package', 'successfully_load'): successfully_load, + ('fake_package', 'node_name'): node_name, + ('fake_package', 'node_namespace'): node_namespace, + ('fake_package', 'log_level'): log_level, + ('fake_package', 'remap_rules'): remap_rules, + ('fake_package', 'parameters'): parameters, + ('fake_package', 'extra_arguments'): extra_arguments, + ('fake_package', 'node_name_on_event'): node_name_on_event, + } + + self.unexpected_request = False + + def _on_load_node(self, request, response): + key = (request.package_name, request.plugin_name) + if key not in self.__load_node_responses: + self.unexpected_request = True + unexpected_load = LoadNode.Response() + unexpected_load.success = False + unexpected_load.error_message = 'unexpected load request' + return response + else: + print(repr(request)) + return self.__load_node_responses[key] + return response + + +def main(): + rclpy.init() + container = MockComposableNodeContainer(name='mock_container', namespace='/') + with container: + try: + rclpy.spin(container) + except KeyboardInterrupt: + print('Got SIGINT, shutting down') + except: + import traceback + traceback.print_exc() + if container.unexpected_request: + sys.stderr.write('failing due to unexpected request\n') + sys.exit(1) + rclpy.shutdown() + + +if __name__ == '__main__': + main()