返回 ViMax
test_hang_guards.py
根目录 / tests / test_hang_guards.py
1 """Regression tests for unbounded retry/polling loops and LLM-trusted graphs.
2
3 The bugs under test previously looped forever. Each test's fakes succeed after
4 N calls, so the buggy code produces a fast assertion failure (extra calls or a
5 missing exception) rather than hanging the suite; the fixed code must give up
6 before the fake ever succeeds.
7 """
8
9 import unittest
10 from unittest.mock import AsyncMock, MagicMock, patch
11
12 import requests
13
14 from agents.camera_image_generator import _validate_camera_tree
15 from agents.event_extractor import EventExtractor
16 from interfaces.camera import Camera
17 from interfaces.event import Event
18 from pipelines.novel2movie_pipeline import _ensure_extraction_cap
19 from tools.video_generator_doubao_seedance_yunwu_api import VideoGeneratorDoubaoSeedanceYunwuAPI
20 from tools.video_generator_omni_yunwu_api import VideoGeneratorOmniYunwuAPI
21 from utils.image import download_image
22 from utils.video import download_video
23
24
25 def _no_sleep(fn):
26 retrying = getattr(fn, "retry", None)
27 if retrying is not None:
28 retrying.sleep = lambda seconds: None
29
30
31 class TestDownloadRetries(unittest.TestCase):
32 def setUp(self):
33 _no_sleep(download_image)
34 _no_sleep(download_video)
35
36 def test_download_image_gives_up_on_persistent_network_error(self):
37 calls = {"n": 0}
38
39 def flaky_get(url, **kwargs):
40 calls["n"] += 1
41 if calls["n"] < 10:
42 raise requests.ConnectionError("connection refused")
43 return MagicMock()
44
45 with patch("utils.image.requests.get", side_effect=flaky_get):
46 with self.assertRaises(requests.ConnectionError):
47 download_image("http://example.com/a.png", "/tmp/a.png")
48 self.assertLessEqual(calls["n"], 5, "retry must be bounded, not retry-until-success")
49
50 def test_download_image_does_not_retry_client_errors(self):
51 calls = {"n": 0}
52 gone = MagicMock()
53 gone.raise_for_status.side_effect = requests.HTTPError(
54 "404", response=MagicMock(status_code=404)
55 )
56
57 def expired_then_ok(url, **kwargs):
58 calls["n"] += 1
59 if calls["n"] < 3:
60 return gone
61 return MagicMock()
62
63 with patch("utils.image.requests.get", side_effect=expired_then_ok):
64 with self.assertRaises(requests.HTTPError):
65 download_image("http://example.com/expired.png", "/tmp/a.png")
66 self.assertEqual(calls["n"], 1, "4xx responses must fail fast, not be retried")
67
68 def test_download_image_sets_a_timeout(self):
69 captured = {}
70
71 def record_get(url, **kwargs):
72 captured.update(kwargs)
73 return MagicMock()
74
75 with patch("utils.image.requests.get", side_effect=record_get):
76 download_image("http://example.com/a.png", "/tmp/a.png")
77 self.assertIsNotNone(captured.get("timeout"), "requests.get must not wait forever")
78
79 def test_download_video_gives_up_on_persistent_network_error(self):
80 calls = {"n": 0}
81
82 def flaky_get(url, **kwargs):
83 calls["n"] += 1
84 if calls["n"] < 10:
85 raise requests.ConnectionError("connection refused")
86 return MagicMock()
87
88 with patch("utils.video.requests.get", side_effect=flaky_get):
89 with self.assertRaises(requests.ConnectionError):
90 download_video("http://example.com/a.mp4", "/tmp/a.mp4")
91 self.assertLessEqual(calls["n"], 5, "retry must be bounded, not retry-until-success")
92
93
94 class _FakeResponse:
95 def __init__(self, payload, status=200):
96 self.payload = payload
97 self.status = status
98
99 async def __aenter__(self):
100 return self
101
102 async def __aexit__(self, exc_type, exc, tb):
103 return False
104
105 async def json(self):
106 return self.payload
107
108
109 class _FakeSession:
110 """Returns each scripted (payload, status) in turn, repeating the last one."""
111
112 def __init__(self, scripted):
113 self.scripted = list(scripted)
114 self.calls = 0
115
116 async def __aenter__(self):
117 return self
118
119 async def __aexit__(self, exc_type, exc, tb):
120 return False
121
122 def _next(self):
123 response = self.scripted[min(self.calls, len(self.scripted) - 1)]
124 self.calls += 1
125 return _FakeResponse(*response)
126
127 def post(self, url, **kwargs):
128 return self._next()
129
130 def get(self, url, **kwargs):
131 return self._next()
132
133
134 class TestSeedanceClientBounds(unittest.IsolatedAsyncioTestCase):
135 async def test_create_task_fails_fast_on_auth_error(self):
136 session = _FakeSession([
137 ({"error": "invalid api key"}, 401),
138 ({"error": "invalid api key"}, 401),
139 ({"id": "task-1"}, 200),
140 ])
141 generator = VideoGeneratorDoubaoSeedanceYunwuAPI(api_key="bad-key")
142 with patch("tools.video_generator_doubao_seedance_yunwu_api.aiohttp.ClientSession", return_value=session), \
143 patch("tools.video_generator_doubao_seedance_yunwu_api.asyncio.sleep", new=AsyncMock()):
144 with self.assertRaises(RuntimeError):
145 await generator.create_video_generation_task("a prompt", [])
146 self.assertEqual(session.calls, 1, "4xx must not be retried")
147
148 async def test_query_task_polling_is_bounded(self):
149 session = _FakeSession([({"status": "queued"}, 200)])
150 generator = VideoGeneratorDoubaoSeedanceYunwuAPI(api_key="key", max_poll_attempts=3)
151 with patch("tools.video_generator_doubao_seedance_yunwu_api.aiohttp.ClientSession", return_value=session), \
152 patch("tools.video_generator_doubao_seedance_yunwu_api.asyncio.sleep", new=AsyncMock()):
153 with self.assertRaises(TimeoutError):
154 await generator.query_video_generation_task("task-1")
155 self.assertLessEqual(session.calls, 3)
156
157
158 class TestOmniClientBounds(unittest.IsolatedAsyncioTestCase):
159 def test_polling_is_bounded_by_default(self):
160 generator = VideoGeneratorOmniYunwuAPI(api_key="key")
161 self.assertIsNotNone(generator.max_poll_attempts, "default polling must have a deadline")
162
163 async def test_create_task_fails_fast_on_auth_error(self):
164 session = _FakeSession([
165 ({"error": "invalid api key"}, 401),
166 ({"error": "invalid api key"}, 401),
167 ({"id": "task-1"}, 200),
168 ])
169 generator = VideoGeneratorOmniYunwuAPI(api_key="bad-key")
170 with patch("tools.video_generator_omni_yunwu_api.aiohttp.ClientSession", return_value=session), \
171 patch("tools.video_generator_omni_yunwu_api.asyncio.sleep", new=AsyncMock()):
172 with self.assertRaises(RuntimeError):
173 await generator.create_video_generation_task("a prompt", [])
174 self.assertEqual(session.calls, 1, "4xx must not be retried")
175
176
177 class TestEventExtractionCap(unittest.TestCase):
178 def test_extraction_aborts_when_model_never_emits_is_last(self):
179 extractor = object.__new__(EventExtractor)
180 calls = {"n": 0}
181
182 def never_last(novel_text, extracted_events):
183 calls["n"] += 1
184 if calls["n"] > 200:
185 raise AssertionError("loop was not capped")
186 return Event(
187 index=len(extracted_events),
188 is_last=False,
189 description="an event",
190 process_chain=["something happens"],
191 )
192
193 extractor.extract_next_event = never_last
194 with self.assertRaisesRegex(RuntimeError, "[Mm]ax|[Cc]ap|exceed"):
195 extractor("some novel text")
196
197 def test_pipeline_extraction_cap_helper(self):
198 _ensure_extraction_cap(0, 50, "events")
199 _ensure_extraction_cap(49, 50, "events")
200 with self.assertRaisesRegex(RuntimeError, "events"):
201 _ensure_extraction_cap(50, 50, "events")
202
203
204 class TestCameraTreeValidation(unittest.TestCase):
205 def _camera(self, idx, parent=None):
206 return Camera(idx=idx, active_shot_idxs=[idx], parent_cam_idx=parent, parent_shot_idx=idx if parent is not None else None)
207
208 def test_valid_tree_passes(self):
209 cameras = [self._camera(0), self._camera(1, parent=0), self._camera(2, parent=1)]
210 _validate_camera_tree(cameras)
211
212 def test_cycle_is_rejected(self):
213 cameras = [self._camera(0, parent=1), self._camera(1, parent=0)]
214 with self.assertRaisesRegex(ValueError, "[Cc]ycle"):
215 _validate_camera_tree(cameras)
216
217 def test_self_parent_is_rejected(self):
218 cameras = [self._camera(0, parent=0)]
219 with self.assertRaises(ValueError):
220 _validate_camera_tree(cameras)
221
222 def test_unknown_parent_index_is_rejected(self):
223 cameras = [self._camera(0), self._camera(1, parent=7)]
224 with self.assertRaises(ValueError):
225 _validate_camera_tree(cameras)
226
227
228 if __name__ == "__main__":
229 unittest.main()
230
230 lines PYTHON