Skip to content

Commit 9ef7ea2

Browse files
committed
Add unit tests for IncludeLaunchDescription and xml/yaml parsers
Signed-off-by: Taeseung Sohn <taeseung.sohn@tier4.jp>
1 parent 7e28dc0 commit 9ef7ea2

File tree

3 files changed

+305
-0
lines changed

3 files changed

+305
-0
lines changed

launch/test/launch/actions/test_include_launch_description.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from launch.actions import DeclareLaunchArgument
2525
from launch.actions import IncludeLaunchDescription
2626
from launch.actions import OpaqueFunction
27+
from launch.actions import PopEnvironment
28+
from launch.actions import PopLaunchConfigurations
29+
from launch.actions import PushEnvironment
30+
from launch.actions import PushLaunchConfigurations
2731
from launch.actions import ResetLaunchConfigurations
2832
from launch.actions import SetEnvironmentVariable
2933
from launch.actions import SetLaunchConfiguration
@@ -34,6 +38,8 @@
3438

3539
import pytest
3640

41+
from temporary_environment import sandbox_environment_variables
42+
3743

3844
def test_include_launch_description_constructors():
3945
"""Test the constructors for IncludeLaunchDescription class."""
@@ -259,6 +265,176 @@ def test_include_launch_description_launch_arguments():
259265
action2.visit(lc2)
260266

261267

268+
@sandbox_environment_variables
269+
def test_include_launch_description_scoped_execute():
270+
"""Test scoped=True: Push/Pop wrapping, forwarding, and isolation of launch configurations."""
271+
ld_child = LaunchDescription([])
272+
action = IncludeLaunchDescription(
273+
LaunchDescriptionSource(ld_child),
274+
launch_arguments={'bar': 'BAR'}.items(),
275+
scoped=True,
276+
)
277+
278+
lc = LaunchContext()
279+
lc.launch_configurations['foo'] = 'FOO'
280+
281+
result = action.visit(lc)
282+
283+
# Expected: Push, Push, SetLaunchConfig, LaunchDescription, OpaqueFunction, Pop, Pop
284+
assert len(result) == 7
285+
assert isinstance(result[0], PushLaunchConfigurations)
286+
assert isinstance(result[1], PushEnvironment)
287+
assert isinstance(result[2], SetLaunchConfiguration)
288+
assert result[3] == ld_child
289+
assert isinstance(result[4], OpaqueFunction)
290+
assert isinstance(result[5], PopEnvironment)
291+
assert isinstance(result[6], PopLaunchConfigurations)
292+
293+
# Step through and verify intermediate state
294+
result[0].visit(lc) # PushLaunchConfigurations
295+
assert lc.launch_configurations['foo'] == 'FOO' # forwarded to child scope
296+
297+
result[1].visit(lc) # PushEnvironment
298+
299+
result[2].visit(lc) # SetLaunchConfiguration('bar', 'BAR')
300+
assert lc.launch_configurations['bar'] == 'BAR'
301+
assert lc.launch_configurations['foo'] == 'FOO' # still visible
302+
303+
# Simulate what the child launch description would do
304+
lc.launch_configurations['baz'] = 'BAZ'
305+
assert lc.launch_configurations['baz'] == 'BAZ'
306+
307+
# result[3] (LaunchDescription) and result[4] (OpaqueFunction) skipped — they don't affect
308+
# launch_configurations directly in this test
309+
310+
result[5].visit(lc) # PopEnvironment
311+
result[6].visit(lc) # PopLaunchConfigurations
312+
# After pop, child's configs are gone, parent's are restored
313+
assert lc.launch_configurations['foo'] == 'FOO'
314+
assert 'baz' not in lc.launch_configurations
315+
assert 'bar' not in lc.launch_configurations
316+
assert len(lc.launch_configurations) == 1
317+
318+
319+
@sandbox_environment_variables
320+
def test_include_launch_description_unscoped_execute():
321+
"""Test scoped=False (default): no Push/Pop, configurations leak to parent."""
322+
ld_child = LaunchDescription([])
323+
action = IncludeLaunchDescription(
324+
LaunchDescriptionSource(ld_child),
325+
launch_arguments={'bar': 'BAR'}.items(),
326+
)
327+
328+
lc = LaunchContext()
329+
lc.launch_configurations['foo'] = 'FOO'
330+
331+
result = action.visit(lc)
332+
333+
# Expected: SetLaunchConfig, LaunchDescription, OpaqueFunction (no Push/Pop)
334+
assert len(result) == 3
335+
assert isinstance(result[0], SetLaunchConfiguration)
336+
assert result[1] == ld_child
337+
assert isinstance(result[2], OpaqueFunction)
338+
assert not any(isinstance(r, PushLaunchConfigurations) for r in result)
339+
assert not any(isinstance(r, PopLaunchConfigurations) for r in result)
340+
341+
# Step through
342+
result[0].visit(lc) # SetLaunchConfiguration('bar', 'BAR')
343+
assert lc.launch_configurations['bar'] == 'BAR'
344+
assert lc.launch_configurations['foo'] == 'FOO' # untouched
345+
346+
# After all actions, bar persists — it leaked to the parent scope
347+
assert len(lc.launch_configurations) == 2
348+
assert lc.launch_configurations['bar'] == 'BAR'
349+
350+
351+
@sandbox_environment_variables
352+
def test_include_launch_description_scoped_isolates_environment():
353+
"""Test scoped=True: environment variable changes do not leak to parent."""
354+
ld_child = LaunchDescription([])
355+
action = IncludeLaunchDescription(
356+
LaunchDescriptionSource(ld_child),
357+
scoped=True,
358+
)
359+
360+
lc = LaunchContext()
361+
assert 'env_foo' not in lc.environment
362+
363+
result = action.visit(lc)
364+
365+
assert isinstance(result[0], PushLaunchConfigurations)
366+
assert isinstance(result[1], PushEnvironment)
367+
368+
result[0].visit(lc) # PushLaunchConfigurations
369+
result[1].visit(lc) # PushEnvironment
370+
371+
# Simulate child setting an environment variable
372+
lc.environment['env_foo'] = 'FOO'
373+
assert lc.environment['env_foo'] == 'FOO'
374+
375+
assert isinstance(result[-2], PopEnvironment)
376+
assert isinstance(result[-1], PopLaunchConfigurations)
377+
378+
result[-2].visit(lc) # PopEnvironment
379+
assert 'env_foo' not in lc.environment # rolled back
380+
381+
result[-1].visit(lc) # PopLaunchConfigurations
382+
383+
384+
@sandbox_environment_variables
385+
def test_include_launch_description_unscoped_leaks_environment():
386+
"""Test scoped=False (default): environment variable changes leak to parent."""
387+
ld_child = LaunchDescription([])
388+
action = IncludeLaunchDescription(
389+
LaunchDescriptionSource(ld_child),
390+
)
391+
392+
lc = LaunchContext()
393+
result = action.visit(lc)
394+
395+
# No Push/Pop — environment mutations persist
396+
assert len(result) == 2 # LaunchDescription, OpaqueFunction (no launch_arguments)
397+
398+
# Simulate child setting an environment variable
399+
lc.environment['env_foo'] = 'FOO'
400+
401+
# After all actions, the env var persists — no Pop to roll it back
402+
assert lc.environment['env_foo'] == 'FOO'
403+
404+
405+
@sandbox_environment_variables
406+
def test_include_launch_description_scoped_with_overwrite():
407+
"""Test scoped=True: child overwrites parent config, but parent value is restored after pop."""
408+
ld_child = LaunchDescription([])
409+
action = IncludeLaunchDescription(
410+
LaunchDescriptionSource(ld_child),
411+
launch_arguments={'foo': 'OOF'}.items(),
412+
scoped=True,
413+
)
414+
415+
lc = LaunchContext()
416+
lc.launch_configurations['foo'] = 'FOO'
417+
lc.launch_configurations['bar'] = 'BAR'
418+
419+
result = action.visit(lc)
420+
421+
result[0].visit(lc) # PushLaunchConfigurations
422+
assert lc.launch_configurations['foo'] == 'FOO' # copied to new scope
423+
assert lc.launch_configurations['bar'] == 'BAR' # forwarded
424+
425+
result[1].visit(lc) # PushEnvironment
426+
427+
result[2].visit(lc) # SetLaunchConfiguration('foo', 'OOF')
428+
assert lc.launch_configurations['foo'] == 'OOF' # overwritten in child scope
429+
assert lc.launch_configurations['bar'] == 'BAR' # untouched
430+
431+
result[-2].visit(lc) # PopEnvironment
432+
result[-1].visit(lc) # PopLaunchConfigurations
433+
assert lc.launch_configurations['foo'] == 'FOO' # restored
434+
assert lc.launch_configurations['bar'] == 'BAR' # still there
435+
assert len(lc.launch_configurations) == 2
436+
437+
262438
def test_include_python():
263439
"""Test including Python, with and without explicit PythonLaunchDescriptionSource."""
264440
this_dir = Path(__file__).parent

launch_xml/test/launch_xml/test_include.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,78 @@ def test_include():
5454
assert ls.context.launch_configurations['baz'] == 'BAZ'
5555

5656

57+
def test_include_scoped_true():
58+
"""Parse include with scoped="true" — child configs do not leak to parent."""
59+
path = (Path(__file__).parent / 'executable.xml').as_posix()
60+
xml_file = \
61+
"""\
62+
<launch>
63+
<let name="bar" value="BAR" />
64+
<include file="{}" scoped="true">
65+
<let name="foo" value="FOO" />
66+
</include>
67+
</launch>
68+
""".format(path) # noqa: E501
69+
xml_file = textwrap.dedent(xml_file)
70+
root_entity, parser = load_no_extensions(io.StringIO(xml_file))
71+
ld = parser.parse_description(root_entity)
72+
include = ld.entities[1]
73+
assert isinstance(include, IncludeLaunchDescription)
74+
ls = LaunchService(debug=True)
75+
ls.include_launch_description(ld)
76+
assert 0 == ls.run()
77+
# bar persists, but foo from scoped include does not leak
78+
assert ls.context.launch_configurations['bar'] == 'BAR'
79+
assert 'foo' not in ls.context.launch_configurations
80+
81+
82+
def test_include_scoped_false():
83+
"""Parse include with scoped="false" — child configs leak to parent (default behavior)."""
84+
path = (Path(__file__).parent / 'executable.xml').as_posix()
85+
xml_file = \
86+
"""\
87+
<launch>
88+
<let name="bar" value="BAR" />
89+
<include file="{}" scoped="false">
90+
<let name="foo" value="FOO" />
91+
</include>
92+
</launch>
93+
""".format(path) # noqa: E501
94+
xml_file = textwrap.dedent(xml_file)
95+
root_entity, parser = load_no_extensions(io.StringIO(xml_file))
96+
ld = parser.parse_description(root_entity)
97+
include = ld.entities[1]
98+
assert isinstance(include, IncludeLaunchDescription)
99+
ls = LaunchService(debug=True)
100+
ls.include_launch_description(ld)
101+
assert 0 == ls.run()
102+
# Both bar and foo are visible
103+
assert ls.context.launch_configurations['bar'] == 'BAR'
104+
assert ls.context.launch_configurations['foo'] == 'FOO'
105+
106+
107+
def test_include_default_is_unscoped():
108+
"""Parse include without scoped attribute — defaults to unscoped (backward compatible)."""
109+
path = (Path(__file__).parent / 'executable.xml').as_posix()
110+
xml_file = \
111+
"""\
112+
<launch>
113+
<include file="{}">
114+
<let name="foo" value="FOO" />
115+
</include>
116+
</launch>
117+
""".format(path) # noqa: E501
118+
xml_file = textwrap.dedent(xml_file)
119+
root_entity, parser = load_no_extensions(io.StringIO(xml_file))
120+
ld = parser.parse_description(root_entity)
121+
include = ld.entities[0]
122+
assert isinstance(include, IncludeLaunchDescription)
123+
ls = LaunchService(debug=True)
124+
ls.include_launch_description(ld)
125+
assert 0 == ls.run()
126+
# foo leaks, same as before
127+
assert ls.context.launch_configurations['foo'] == 'FOO'
128+
129+
57130
if __name__ == '__main__':
58131
test_include()

launch_yaml/test/launch_yaml/test_include.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,61 @@ def test_include():
6161
assert ls.context.launch_configurations['baz'] == 'BAZ'
6262

6363

64+
def test_include_scoped_true():
65+
"""Parse include with scoped: true — child configs do not leak to parent."""
66+
path = (Path(__file__).parent / 'executable.yaml').as_posix()
67+
yaml_file = \
68+
"""\
69+
launch:
70+
- let:
71+
name: 'bar'
72+
value: 'BAR'
73+
- include:
74+
file: '{}'
75+
scoped: true
76+
let:
77+
- name: 'foo'
78+
value: 'FOO'
79+
""".format(path) # noqa: E501
80+
yaml_file = textwrap.dedent(yaml_file)
81+
root_entity, parser = load_no_extensions(io.StringIO(yaml_file))
82+
ld = parser.parse_description(root_entity)
83+
include = ld.entities[1]
84+
assert isinstance(include, IncludeLaunchDescription)
85+
ls = LaunchService(debug=True)
86+
ls.include_launch_description(ld)
87+
assert 0 == ls.run()
88+
assert ls.context.launch_configurations['bar'] == 'BAR'
89+
assert 'foo' not in ls.context.launch_configurations
90+
91+
92+
def test_include_scoped_false():
93+
"""Parse include with scoped: false — child configs leak to parent."""
94+
path = (Path(__file__).parent / 'executable.yaml').as_posix()
95+
yaml_file = \
96+
"""\
97+
launch:
98+
- let:
99+
name: 'bar'
100+
value: 'BAR'
101+
- include:
102+
file: '{}'
103+
scoped: false
104+
let:
105+
- name: 'foo'
106+
value: 'FOO'
107+
""".format(path) # noqa: E501
108+
yaml_file = textwrap.dedent(yaml_file)
109+
root_entity, parser = load_no_extensions(io.StringIO(yaml_file))
110+
ld = parser.parse_description(root_entity)
111+
include = ld.entities[1]
112+
assert isinstance(include, IncludeLaunchDescription)
113+
ls = LaunchService(debug=True)
114+
ls.include_launch_description(ld)
115+
assert 0 == ls.run()
116+
assert ls.context.launch_configurations['bar'] == 'BAR'
117+
assert ls.context.launch_configurations['foo'] == 'FOO'
118+
119+
64120
if __name__ == '__main__':
65121
test_include()

0 commit comments

Comments
 (0)