@@ -102,6 +102,196 @@ def test_unlabeled_input_connections_round_trip():
102102 assert input_conn ["id" ] == 0
103103
104104
105+ def test_convert_tool_state_callback_called ():
106+ """Test that convert_tool_state callback is called for tool steps on export."""
107+ sars_example = os .path .join (TEST_PATH , "sars-cov-2-variant-calling.ga" )
108+ with open (sars_example ) as f :
109+ native_wf = json .load (f )
110+
111+ called_tool_ids = []
112+
113+ def _callback (native_step ):
114+ called_tool_ids .append (native_step .get ("tool_id" ))
115+ return {"custom_key" : "custom_value" }
116+
117+ result = from_galaxy_native (native_wf , convert_tool_state = _callback )
118+
119+ # Callback should have been called for each tool step
120+ assert len (called_tool_ids ) > 0
121+ # Tool steps should have "state" not "tool_state"
122+ for step in _tool_steps (result ):
123+ assert "state" in step
124+ assert "tool_state" not in step
125+ assert step ["state" ] == {"custom_key" : "custom_value" }
126+
127+
128+ def test_convert_tool_state_callback_none_fallback ():
129+ """Test that returning None from callback falls back to default tool_state."""
130+ sars_example = os .path .join (TEST_PATH , "sars-cov-2-variant-calling.ga" )
131+ with open (sars_example ) as f :
132+ native_wf = json .load (f )
133+
134+ def _callback (native_step ):
135+ return None
136+
137+ result = from_galaxy_native (native_wf , convert_tool_state = _callback )
138+
139+ # All tool steps should have tool_state (default path)
140+ for step in _tool_steps (result ):
141+ assert "tool_state" in step
142+ assert "state" not in step
143+
144+
145+ def test_convert_tool_state_callback_exception_fallback ():
146+ """Test that callback exceptions fall back to default tool_state."""
147+ sars_example = os .path .join (TEST_PATH , "sars-cov-2-variant-calling.ga" )
148+ with open (sars_example ) as f :
149+ native_wf = json .load (f )
150+
151+ def _callback (native_step ):
152+ raise ValueError ("conversion failed" )
153+
154+ result = from_galaxy_native (native_wf , convert_tool_state = _callback )
155+
156+ # All tool steps should have tool_state (fallback on exception)
157+ for step in _tool_steps (result ):
158+ assert "tool_state" in step
159+ assert "state" not in step
160+
161+
162+ def test_convert_tool_state_callback_selective ():
163+ """Test that callback can convert some steps and fall back on others."""
164+ sars_example = os .path .join (TEST_PATH , "sars-cov-2-variant-calling.ga" )
165+ with open (sars_example ) as f :
166+ native_wf = json .load (f )
167+
168+ target_tool_id = "__MERGE_COLLECTION__"
169+
170+ def _callback (native_step ):
171+ if native_step .get ("tool_id" ) == target_tool_id :
172+ return {"converted" : True }
173+ return None
174+
175+ result = from_galaxy_native (native_wf , convert_tool_state = _callback )
176+
177+ tool_steps = list (_tool_steps (result ))
178+ converted_count = 0
179+ fallback_count = 0
180+ for step in tool_steps :
181+ if step .get ("state" ) == {"converted" : True }:
182+ converted_count += 1
183+ assert "tool_state" not in step
184+ else :
185+ fallback_count += 1
186+ assert "tool_state" in step
187+ assert "state" not in step
188+ assert converted_count >= 1
189+ assert fallback_count >= 1
190+
191+
192+ def test_convert_tool_state_connections_always_present ():
193+ """Test that _convert_input_connections runs regardless of callback."""
194+ sars_example = os .path .join (TEST_PATH , "sars-cov-2-variant-calling.ga" )
195+ with open (sars_example ) as f :
196+ native_wf = json .load (f )
197+
198+ def _callback (native_step ):
199+ return {"converted" : True }
200+
201+ result = from_galaxy_native (native_wf , convert_tool_state = _callback )
202+
203+ # Tool steps with connections should still have "in" populated by _convert_input_connections
204+ has_connections = False
205+ for step in _tool_steps (result ):
206+ if step .get ("in" ):
207+ has_connections = True
208+ break
209+ assert has_connections , "Expected at least one tool step with input connections"
210+
211+
212+ def test_convert_tool_state_no_callback_default_unchanged ():
213+ """Test that omitting convert_tool_state preserves original behavior."""
214+ sars_example = os .path .join (TEST_PATH , "sars-cov-2-variant-calling.ga" )
215+ with open (sars_example ) as f :
216+ native_wf = json .load (f )
217+
218+ result_default = from_galaxy_native (copy .deepcopy (native_wf ))
219+ result_none = from_galaxy_native (copy .deepcopy (native_wf ), convert_tool_state = None )
220+
221+ # Should be identical
222+ assert json .dumps (result_default , sort_keys = True ) == json .dumps (result_none , sort_keys = True )
223+
224+
225+ def test_convert_tool_state_subworkflow_recursion ():
226+ """Test that convert_tool_state callback is passed through to subworkflows."""
227+ from gxformat2 .yaml import ordered_load
228+ from gxformat2 .converter import python_to_workflow
229+
230+ nested_f2 = """
231+ class: GalaxyWorkflow
232+ inputs:
233+ outer_input: data
234+ steps:
235+ first_cat:
236+ tool_id: cat1
237+ in:
238+ input1: outer_input
239+ nested_workflow:
240+ run:
241+ class: GalaxyWorkflow
242+ inputs:
243+ inner_input: data
244+ steps:
245+ inner_cat:
246+ tool_id: cat1
247+ in:
248+ input1: inner_input
249+ in:
250+ inner_input: first_cat/out_file1
251+ """
252+ # Build a native workflow with a subworkflow
253+ f2 = ordered_load (nested_f2 )
254+ native_wf = python_to_workflow (f2 , MockGalaxyInterface (), None )
255+
256+ called_tool_ids = []
257+
258+ def _callback (native_step ):
259+ called_tool_ids .append (native_step .get ("tool_id" ))
260+ return {"from_callback" : True }
261+
262+ result = from_galaxy_native (native_wf , convert_tool_state = _callback )
263+
264+ # Should have been called for outer tool AND inner subworkflow tool
265+ assert len (called_tool_ids ) == 2
266+ assert all (tid == "cat1" for tid in called_tool_ids )
267+
268+ # Check inner subworkflow step also got state from callback
269+ subworkflow_step = None
270+ for step in result .get ("steps" , {}).values () if isinstance (result .get ("steps" ), dict ) else result .get ("steps" , []):
271+ if isinstance (step .get ("run" ), dict ):
272+ subworkflow_step = step
273+ break
274+ assert subworkflow_step is not None
275+ inner_steps = subworkflow_step ["run" ].get ("steps" , {})
276+ if isinstance (inner_steps , dict ):
277+ inner_tool = list (inner_steps .values ())[0 ]
278+ else :
279+ inner_tool = inner_steps [0 ]
280+ assert inner_tool .get ("state" ) == {"from_callback" : True }
281+
282+
283+ def _tool_steps (format2_wf ):
284+ """Yield tool steps from a format2 workflow (handles both dict and list steps)."""
285+ steps = format2_wf .get ("steps" , {})
286+ if isinstance (steps , dict ):
287+ step_list = steps .values ()
288+ else :
289+ step_list = steps
290+ for step in step_list :
291+ if step .get ("tool_id" ):
292+ yield step
293+
294+
105295def _run_example_path (path , compact = False ):
106296 out = _examples_path_for (path )
107297 argv = [path , out ]
0 commit comments