@@ -172,3 +172,144 @@ def test_get_molecule_container(self):
172172
173173 self .assertSetEqual (set (selected_indices ), set (expected_indices ))
174174 self .assertEqual (len (selected_indices ), len (expected_indices ))
175+
176+ @patch ("CodeEntropy.mda_universe_operations.AnalysisFromFunction" )
177+ @patch ("CodeEntropy.mda_universe_operations.mda.Merge" )
178+ @patch ("CodeEntropy.mda_universe_operations.mda.Universe" )
179+ def test_merge_forces (self , MockUniverse , MockMerge , MockAnalysisFromFunction ):
180+ """
181+ Unit test for UniverseOperations.merge_forces().
182+
183+ This test ensures that:
184+ - Two MDAnalysis Universes are created: one for coordinates
185+ (tprfile + trrfile) and one for forces (tprfile + forcefile).
186+ - Both Universes correctly return AtomGroups via select_atoms("all").
187+ - Coordinates and forces are extracted using AnalysisFromFunction.
188+ - mda.Merge is called with the coordinate AtomGroup.
189+ - The merged Universe receives the correct coordinate and force arrays
190+ through load_new().
191+ - When kcal=False, force values are passed through unchanged
192+ (no kcal→kJ conversion).
193+ - The returned universe is the same object returned by mda.Merge().
194+ """
195+
196+ mock_u_coords = MagicMock ()
197+ mock_u_force = MagicMock ()
198+ MockUniverse .side_effect = [mock_u_coords , mock_u_force ]
199+
200+ # Each universe returns a mock AtomGroup from select_atoms("all")
201+ mock_ag_coords = MagicMock ()
202+ mock_ag_force = MagicMock ()
203+ mock_u_coords .select_atoms .return_value = mock_ag_coords
204+ mock_u_force .select_atoms .return_value = mock_ag_force
205+
206+ coords = np .random .rand (5 , 10 , 3 )
207+ forces = np .random .rand (5 , 10 , 3 )
208+
209+ mock_coords_analysis = MagicMock ()
210+ mock_coords_analysis .run .return_value .results = {"timeseries" : coords }
211+
212+ mock_forces_analysis = MagicMock ()
213+ mock_forces_analysis .run .return_value .results = {"timeseries" : forces }
214+
215+ # Two calls: first for coordinates, second for forces
216+ MockAnalysisFromFunction .side_effect = [
217+ mock_coords_analysis ,
218+ mock_forces_analysis ,
219+ ]
220+
221+ mock_merged = MagicMock ()
222+ MockMerge .return_value = mock_merged
223+
224+ ops = UniverseOperations ()
225+ result = ops .merge_forces (
226+ tprfile = "topol.tpr" ,
227+ trrfile = "traj.trr" ,
228+ forcefile = "forces.trr" ,
229+ fileformat = None ,
230+ kcal = False ,
231+ )
232+
233+ self .assertEqual (MockUniverse .call_count , 2 )
234+ MockUniverse .assert_any_call ("topol.tpr" , "traj.trr" , format = None )
235+ MockUniverse .assert_any_call ("topol.tpr" , "forces.trr" , format = None )
236+
237+ mock_u_coords .select_atoms .assert_called_once_with ("all" )
238+ mock_u_force .select_atoms .assert_called_once_with ("all" )
239+
240+ self .assertEqual (MockAnalysisFromFunction .call_count , 2 )
241+
242+ MockMerge .assert_called_once_with (mock_ag_coords )
243+
244+ mock_merged .load_new .assert_called_once ()
245+ args , kwargs = mock_merged .load_new .call_args
246+
247+ # Coordinates passed positionally
248+ np .testing .assert_array_equal (args [0 ], coords )
249+
250+ # Forces passed via kwargs
251+ np .testing .assert_array_equal (kwargs ["forces" ], forces )
252+
253+ # Finally the function returns the merged universe
254+ self .assertEqual (result , mock_merged )
255+
256+ @patch ("CodeEntropy.mda_universe_operations.AnalysisFromFunction" )
257+ @patch ("CodeEntropy.mda_universe_operations.mda.Merge" )
258+ @patch ("CodeEntropy.mda_universe_operations.mda.Universe" )
259+ def test_merge_forces_kcal_conversion (
260+ self , MockUniverse , MockMerge , MockAnalysisFromFunction
261+ ):
262+ """
263+ Unit test for UniverseOperations.merge_forces() covering the kcal→kJ
264+ conversion branch.
265+
266+ Verifies that:
267+ - Two Universe objects are constructed for coords and forces.
268+ - Each Universe returns an AtomGroup via select_atoms("all").
269+ - AnalysisFromFunction is called twice.
270+ - Forces are multiplied EXACTLY once by 4.184 when kcal=True.
271+ - Merge() is called with the coordinate AtomGroup.
272+ - load_new() receives the correct coordinates and converted forces.
273+ - The returned Universe is the Merge() result.
274+ """
275+ mock_u_coords = MagicMock ()
276+ mock_u_force = MagicMock ()
277+ MockUniverse .side_effect = [mock_u_coords , mock_u_force ]
278+
279+ mock_ag_coords = MagicMock ()
280+ mock_ag_force = MagicMock ()
281+ mock_u_coords .select_atoms .return_value = mock_ag_coords
282+ mock_u_force .select_atoms .return_value = mock_ag_force
283+
284+ coords = np .ones ((2 , 3 , 3 ))
285+
286+ original_forces = np .ones ((2 , 3 , 3 ))
287+ mock_forces_array = original_forces .copy ()
288+
289+ # Mock AnalysisFromFunction return values
290+ mock_coords_analysis = MagicMock ()
291+ mock_coords_analysis .run .return_value .results = {"timeseries" : coords }
292+
293+ mock_forces_analysis = MagicMock ()
294+ mock_forces_analysis .run .return_value .results = {
295+ "timeseries" : mock_forces_array
296+ }
297+
298+ MockAnalysisFromFunction .side_effect = [
299+ mock_coords_analysis ,
300+ mock_forces_analysis ,
301+ ]
302+
303+ mock_merged = MagicMock ()
304+ MockMerge .return_value = mock_merged
305+
306+ ops = UniverseOperations ()
307+ result = ops .merge_forces ("t.tpr" , "c.trr" , "f.trr" , kcal = True )
308+
309+ _ , kwargs = mock_merged .load_new .call_args
310+
311+ expected_forces = original_forces * 4.184
312+ np .testing .assert_array_equal (kwargs ["forces" ], expected_forces )
313+ np .testing .assert_array_equal (mock_merged .load_new .call_args [0 ][0 ], coords )
314+
315+ self .assertEqual (result , mock_merged )
0 commit comments