1+ import pytest
2+ from unittest .mock import Mock , patch
3+ from typing import Any
4+
5+ from src .runloop_api_client .lib .polling import poll_until , PollingConfig , PollingTimeout
6+
7+
8+ class TestPollingConfig :
9+ """Test PollingConfig dataclass"""
10+
11+ def test_default_config (self ):
12+ config = PollingConfig ()
13+ assert config .interval_seconds == 1.0
14+ assert config .max_attempts == 120
15+ assert config .timeout_seconds is None
16+
17+ def test_custom_config (self ):
18+ config = PollingConfig (
19+ interval_seconds = 0.5 ,
20+ max_attempts = 10 ,
21+ timeout_seconds = 30.0
22+ )
23+ assert config .interval_seconds == 0.5
24+ assert config .max_attempts == 10
25+ assert config .timeout_seconds == 30.0
26+
27+
28+ class TestPollingTimeout :
29+ """Test PollingTimeout exception"""
30+
31+ def test_polling_timeout_initialization (self ):
32+ last_value = {"status" : "running" }
33+ exception = PollingTimeout ("Test message" , last_value )
34+
35+ assert exception .last_value == last_value
36+ assert "Test message" in str (exception )
37+ assert "Last retrieved value: {'status': 'running'}" in str (exception )
38+
39+
40+ class TestPollUntil :
41+ """Test poll_until function"""
42+
43+ def test_immediate_success (self ):
44+ """Test when condition is met on first attempt"""
45+ retriever = Mock (return_value = "completed" )
46+ is_terminal = Mock (return_value = True )
47+
48+ result = poll_until (retriever , is_terminal )
49+
50+ assert result == "completed"
51+ assert retriever .call_count == 1
52+ assert is_terminal .call_count == 1
53+ is_terminal .assert_called_with ("completed" )
54+
55+ def test_success_after_multiple_attempts (self ):
56+ """Test when condition is met after several attempts"""
57+ values = ["pending" , "running" , "completed" ]
58+ retriever = Mock (side_effect = values )
59+ is_terminal = Mock (side_effect = [False , False , True ])
60+
61+ with patch ('time.sleep' ) as mock_sleep :
62+ result = poll_until (retriever , is_terminal )
63+
64+ assert result == "completed"
65+ assert retriever .call_count == 3
66+ assert is_terminal .call_count == 3
67+ assert mock_sleep .call_count == 2 # Should sleep between attempts
68+
69+ def test_custom_config_interval (self ):
70+ """Test with custom polling interval"""
71+ retriever = Mock (side_effect = ["pending" , "completed" ])
72+ is_terminal = Mock (side_effect = [False , True ])
73+ config = PollingConfig (interval_seconds = 0.1 )
74+
75+ with patch ('time.sleep' ) as mock_sleep :
76+ result = poll_until (retriever , is_terminal , config )
77+
78+ assert result == "completed"
79+ mock_sleep .assert_called_with (0.1 )
80+
81+ def test_max_attempts_exceeded (self ):
82+ """Test when max attempts is exceeded"""
83+ retriever = Mock (return_value = "still_running" )
84+ is_terminal = Mock (return_value = False )
85+ config = PollingConfig (max_attempts = 3 , interval_seconds = 0.01 )
86+
87+ with patch ('time.sleep' ):
88+ with pytest .raises (PollingTimeout ) as exc_info :
89+ poll_until (retriever , is_terminal , config )
90+
91+ assert "Exceeded maximum attempts (3)" in str (exc_info .value )
92+ assert exc_info .value .last_value == "still_running"
93+ assert retriever .call_count == 3
94+
95+ def test_timeout_exceeded (self ):
96+ """Test when timeout is exceeded"""
97+ retriever = Mock (return_value = "still_running" )
98+ is_terminal = Mock (return_value = False )
99+ config = PollingConfig (timeout_seconds = 0.1 , interval_seconds = 0.01 )
100+
101+ # Mock time.time to simulate timeout
102+ start_time = 1000.0
103+ with patch ('time.time' , side_effect = [start_time , start_time + 0.05 , start_time + 0.15 ]):
104+ with patch ('time.sleep' ):
105+ with pytest .raises (PollingTimeout ) as exc_info :
106+ poll_until (retriever , is_terminal , config )
107+
108+ assert "Exceeded timeout of 0.1 seconds" in str (exc_info .value )
109+ assert exc_info .value .last_value == "still_running"
110+
111+ def test_error_without_handler (self ):
112+ """Test that exceptions are re-raised when no error handler is provided"""
113+ retriever = Mock (side_effect = ValueError ("Test error" ))
114+ is_terminal = Mock (return_value = False )
115+
116+ with pytest .raises (ValueError , match = "Test error" ):
117+ poll_until (retriever , is_terminal )
118+
119+ def test_error_with_handler_continue (self ):
120+ """Test error handler that allows polling to continue"""
121+ retriever = Mock (side_effect = [ValueError ("Test error" ), "recovered" ])
122+ is_terminal = Mock (side_effect = [False , True ])
123+
124+ def error_handler (e : Exception ) -> str :
125+ return "error_handled"
126+
127+ with patch ('time.sleep' ):
128+ result = poll_until (retriever , is_terminal , on_error = error_handler )
129+
130+ assert result == "recovered"
131+ assert retriever .call_count == 2
132+ assert is_terminal .call_count == 2
133+
134+ def test_error_with_handler_reraise (self ):
135+ """Test error handler that re-raises the exception"""
136+ retriever = Mock (side_effect = ValueError ("Test error" ))
137+ is_terminal = Mock (return_value = False )
138+
139+ def error_handler (e : Exception ) -> None :
140+ raise e
141+
142+ with pytest .raises (ValueError , match = "Test error" ):
143+ poll_until (retriever , is_terminal , on_error = error_handler )
144+
145+ def test_error_handler_return_terminal_value (self ):
146+ """Test error handler that returns a terminal value"""
147+ retriever = Mock (side_effect = ValueError ("Test error" ))
148+ is_terminal = Mock (side_effect = [True ]) # Terminal condition met on error handler return
149+
150+ def error_handler (e : Exception ) -> str :
151+ return "error_terminal"
152+
153+ result = poll_until (retriever , is_terminal , on_error = error_handler )
154+
155+ assert result == "error_terminal"
156+ assert retriever .call_count == 1
157+ assert is_terminal .call_count == 1
158+
159+ def test_multiple_errors_with_handler (self ):
160+ """Test multiple errors with handler"""
161+ retriever = Mock (side_effect = [
162+ ValueError ("Error 1" ),
163+ RuntimeError ("Error 2" ),
164+ "success"
165+ ])
166+ is_terminal = Mock (side_effect = [False , False , True ])
167+
168+ error_count = 0
169+ def error_handler (e : Exception ) -> str :
170+ nonlocal error_count
171+ error_count += 1
172+ return f"handled_error_{ error_count } "
173+
174+ with patch ('time.sleep' ):
175+ result = poll_until (retriever , is_terminal , on_error = error_handler )
176+
177+ assert result == "success"
178+ assert error_count == 2
179+ assert retriever .call_count == 3
180+
181+ def test_none_values_handling (self ):
182+ """Test handling of None values"""
183+ retriever = Mock (side_effect = [None , None , "final" ])
184+ is_terminal = Mock (side_effect = [False , False , True ])
185+
186+ with patch ('time.sleep' ):
187+ result = poll_until (retriever , is_terminal )
188+
189+ assert result == "final"
190+ assert retriever .call_count == 3
191+
192+ def test_complex_object_polling (self ):
193+ """Test polling with complex objects"""
194+ class Status :
195+ def __init__ (self , state : str , progress : int ):
196+ self .state = state
197+ self .progress = progress
198+
199+ statuses = [
200+ Status ("starting" , 0 ),
201+ Status ("running" , 50 ),
202+ Status ("completed" , 100 )
203+ ]
204+
205+ retriever = Mock (side_effect = statuses )
206+ is_terminal = Mock (side_effect = [False , False , True ])
207+
208+ with patch ('time.sleep' ):
209+ result = poll_until (retriever , is_terminal )
210+
211+ assert result .state == "completed"
212+ assert result .progress == 100
213+
214+ def test_zero_max_attempts (self ):
215+ """Test with zero max attempts"""
216+ retriever = Mock (return_value = "value" )
217+ is_terminal = Mock (return_value = False )
218+ config = PollingConfig (max_attempts = 0 )
219+
220+ with pytest .raises (PollingTimeout ) as exc_info :
221+ poll_until (retriever , is_terminal , config )
222+
223+ assert "Exceeded maximum attempts (0)" in str (exc_info .value )
224+ assert retriever .call_count == 1 # Retriever is called once, then attempts check happens
225+
226+ def test_negative_interval (self ):
227+ """Test with negative interval (should still work)"""
228+ retriever = Mock (side_effect = ["first" , "second" ])
229+ is_terminal = Mock (side_effect = [False , True ])
230+ config = PollingConfig (interval_seconds = - 0.1 )
231+
232+ with patch ('time.sleep' ) as mock_sleep :
233+ result = poll_until (retriever , is_terminal , config )
234+
235+ assert result == "second"
236+ mock_sleep .assert_called_with (- 0.1 )
237+
238+ def test_both_timeout_and_max_attempts (self ):
239+ """Test when both timeout and max_attempts are set"""
240+ retriever = Mock (return_value = "still_running" )
241+ is_terminal = Mock (return_value = False )
242+ config = PollingConfig (max_attempts = 5 , timeout_seconds = 0.1 , interval_seconds = 0.01 )
243+
244+ # Mock time to hit timeout before max_attempts
245+ start_time = 1000.0
246+ with patch ('time.time' , side_effect = [start_time , start_time + 0.05 , start_time + 0.15 ]):
247+ with patch ('time.sleep' ):
248+ with pytest .raises (PollingTimeout ) as exc_info :
249+ poll_until (retriever , is_terminal , config )
250+
251+ # Should hit timeout first
252+ assert "Exceeded timeout of 0.1 seconds" in str (exc_info .value )
253+ assert retriever .call_count == 2 # Called twice before timeout
254+
255+ def test_terminal_condition_changes (self ):
256+ """Test when terminal condition logic changes during polling"""
257+ retriever = Mock (side_effect = ["value1" , "value2" , "value3" ])
258+
259+ call_count = 0
260+ def dynamic_terminal (value : Any ) -> bool :
261+ nonlocal call_count
262+ call_count += 1
263+ # First two calls return False, third returns True
264+ return call_count >= 3
265+
266+ with patch ('time.sleep' ):
267+ result = poll_until (retriever , dynamic_terminal )
268+
269+ assert result == "value3"
270+ assert retriever .call_count == 3
0 commit comments