test_pipeline_entry.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. from __future__ import annotations
  2. import tempfile
  3. import unittest
  4. from pathlib import Path
  5. from unittest.mock import patch
  6. from production_build_agents.run.layout import (
  7. global_data_delivery_path,
  8. run_directory,
  9. )
  10. from run_pipeline import _build_parser, main, run_pipeline
  11. class PipelineEntryTest(unittest.TestCase):
  12. def _inputs(self, root: Path) -> tuple[Path, Path, Path]:
  13. source = root / "production_final.json"
  14. source.write_text('{"fixture":true}', encoding="utf-8")
  15. anchor = root / "anchor.png"
  16. anchor.write_bytes(b"anchor")
  17. return source, anchor, root / "runs"
  18. def _completed_global_result(
  19. self,
  20. output_root: Path,
  21. *,
  22. thread_id: str,
  23. ) -> dict[str, object]:
  24. delivery = global_data_delivery_path(
  25. run_directory(output_root, thread_id)
  26. ).resolve()
  27. delivery.parent.mkdir(parents=True, exist_ok=True)
  28. delivery.write_text('{"schema_version":"0.3"}', encoding="utf-8")
  29. return {
  30. "run_id": thread_id,
  31. "status": "COMPLETED",
  32. "phase": "FINALIZE",
  33. "global_data_delivery_path": str(delivery),
  34. }
  35. def test_completed_global_data_starts_production_in_order(self) -> None:
  36. with tempfile.TemporaryDirectory() as temporary:
  37. root = Path(temporary)
  38. source, anchor, output_root = self._inputs(root)
  39. calls: list[str] = []
  40. def global_runner(_source, **kwargs):
  41. calls.append("global_data")
  42. self.assertEqual(kwargs["thread_id"], "v24-global-data")
  43. self.assertEqual(kwargs["pipeline_round_id"], "v24")
  44. return self._completed_global_result(
  45. output_root,
  46. thread_id=kwargs["thread_id"],
  47. )
  48. def production_runner(delivery, **kwargs):
  49. calls.append("production")
  50. self.assertEqual(
  51. delivery,
  52. global_data_delivery_path(
  53. run_directory(output_root, "v24-global-data")
  54. ).resolve(),
  55. )
  56. self.assertEqual(
  57. kwargs["thread_id"],
  58. "v24-production",
  59. )
  60. self.assertEqual(
  61. kwargs["shared_visual_anchor_path"],
  62. anchor,
  63. )
  64. self.assertEqual(kwargs["max_plan_revisions"], 2)
  65. self.assertTrue(
  66. kwargs["pause_after_progress_review"]
  67. )
  68. self.assertEqual(kwargs["pipeline_round_id"], "v24")
  69. self.assertEqual(
  70. kwargs["upstream_global_data_thread_id"],
  71. "v24-global-data",
  72. )
  73. return {
  74. "run_id": "v24-production",
  75. "status": "RUNNING",
  76. "phase": "PREPARE_SEGMENT",
  77. }
  78. with (
  79. patch(
  80. "run_pipeline.run_global_data",
  81. side_effect=global_runner,
  82. ),
  83. patch(
  84. "run_pipeline.run_full_production",
  85. side_effect=production_runner,
  86. ),
  87. ):
  88. result = run_pipeline(
  89. source,
  90. output_root=output_root,
  91. round_id="v24",
  92. shared_visual_anchor_path=anchor,
  93. max_plan_revisions=2,
  94. pause_after_progress_review=True,
  95. )
  96. self.assertEqual(calls, ["global_data", "production"])
  97. self.assertEqual(result["status"], "RUNNING")
  98. self.assertEqual(result["phase"], "PRODUCTION")
  99. self.assertEqual(
  100. result["global_data_thread_id"],
  101. "v24-global-data",
  102. )
  103. self.assertEqual(
  104. result["production_thread_id"],
  105. "v24-production",
  106. )
  107. def test_noncompleted_global_data_never_starts_production(self) -> None:
  108. with tempfile.TemporaryDirectory() as temporary:
  109. root = Path(temporary)
  110. source, anchor, output_root = self._inputs(root)
  111. with (
  112. patch(
  113. "run_pipeline.run_global_data",
  114. return_value={
  115. "run_id": "v24-global-data",
  116. "status": "FAILED",
  117. "phase": "VALIDATE_GLOBAL_DATA",
  118. },
  119. ),
  120. patch(
  121. "run_pipeline.run_full_production"
  122. ) as production,
  123. ):
  124. result = run_pipeline(
  125. source,
  126. output_root=output_root,
  127. round_id="v24",
  128. shared_visual_anchor_path=anchor,
  129. )
  130. production.assert_not_called()
  131. self.assertEqual(result["status"], "FAILED")
  132. self.assertEqual(result["phase"], "GLOBAL_DATA")
  133. self.assertIsNone(result["production_result"])
  134. def test_repeated_round_reuses_both_stable_run_identities(self) -> None:
  135. with tempfile.TemporaryDirectory() as temporary:
  136. root = Path(temporary)
  137. source, anchor, output_root = self._inputs(root)
  138. global_ids: list[str] = []
  139. production_ids: list[str] = []
  140. production_statuses = iter(("RUNNING", "COMPLETED"))
  141. def global_runner(_source, **kwargs):
  142. global_ids.append(kwargs["thread_id"])
  143. return self._completed_global_result(
  144. output_root,
  145. thread_id=kwargs["thread_id"],
  146. )
  147. def production_runner(_delivery, **kwargs):
  148. production_ids.append(kwargs["thread_id"])
  149. status = next(production_statuses)
  150. return {
  151. "run_id": kwargs["thread_id"],
  152. "status": status,
  153. "phase": (
  154. "PREPARE_SEGMENT"
  155. if status == "RUNNING"
  156. else "FINALIZE"
  157. ),
  158. }
  159. with (
  160. patch(
  161. "run_pipeline.run_global_data",
  162. side_effect=global_runner,
  163. ),
  164. patch(
  165. "run_pipeline.run_full_production",
  166. side_effect=production_runner,
  167. ),
  168. ):
  169. first = run_pipeline(
  170. source,
  171. output_root=output_root,
  172. round_id="v24",
  173. shared_visual_anchor_path=anchor,
  174. pause_after_progress_review=True,
  175. )
  176. second = run_pipeline(
  177. source,
  178. output_root=output_root,
  179. round_id="v24",
  180. shared_visual_anchor_path=anchor,
  181. )
  182. self.assertEqual(first["status"], "RUNNING")
  183. self.assertEqual(second["status"], "COMPLETED")
  184. self.assertEqual(
  185. global_ids,
  186. ["v24-global-data", "v24-global-data"],
  187. )
  188. self.assertEqual(
  189. production_ids,
  190. ["v24-production", "v24-production"],
  191. )
  192. def test_completed_result_must_point_to_its_own_delivery(self) -> None:
  193. with tempfile.TemporaryDirectory() as temporary:
  194. root = Path(temporary)
  195. source, anchor, output_root = self._inputs(root)
  196. foreign = root / "foreign-delivery.json"
  197. foreign.write_text("{}", encoding="utf-8")
  198. with (
  199. patch(
  200. "run_pipeline.run_global_data",
  201. return_value={
  202. "run_id": "v24-global-data",
  203. "status": "COMPLETED",
  204. "global_data_delivery_path": str(foreign),
  205. },
  206. ),
  207. patch(
  208. "run_pipeline.run_full_production"
  209. ) as production,
  210. self.assertRaisesRegex(
  211. ValueError,
  212. "非当前 Run",
  213. ),
  214. ):
  215. run_pipeline(
  216. source,
  217. output_root=output_root,
  218. round_id="v24",
  219. shared_visual_anchor_path=anchor,
  220. )
  221. production.assert_not_called()
  222. def test_completed_result_requires_existing_delivery(self) -> None:
  223. with tempfile.TemporaryDirectory() as temporary:
  224. root = Path(temporary)
  225. source, anchor, output_root = self._inputs(root)
  226. expected = global_data_delivery_path(
  227. run_directory(output_root, "v24-global-data")
  228. ).resolve()
  229. with (
  230. patch(
  231. "run_pipeline.run_global_data",
  232. return_value={
  233. "run_id": "v24-global-data",
  234. "status": "COMPLETED",
  235. "global_data_delivery_path": str(expected),
  236. },
  237. ),
  238. patch(
  239. "run_pipeline.run_full_production"
  240. ) as production,
  241. self.assertRaises(FileNotFoundError),
  242. ):
  243. run_pipeline(
  244. source,
  245. output_root=output_root,
  246. round_id="v24",
  247. shared_visual_anchor_path=anchor,
  248. )
  249. production.assert_not_called()
  250. def test_round_id_rejects_path_traversal_before_any_run(self) -> None:
  251. with tempfile.TemporaryDirectory() as temporary:
  252. root = Path(temporary)
  253. source, anchor, output_root = self._inputs(root)
  254. with (
  255. patch("run_pipeline.run_global_data") as global_runner,
  256. self.assertRaisesRegex(ValueError, "round_id"),
  257. ):
  258. run_pipeline(
  259. source,
  260. output_root=output_root,
  261. round_id="../outside",
  262. shared_visual_anchor_path=anchor,
  263. )
  264. global_runner.assert_not_called()
  265. def test_cli_requires_stable_round_and_anchor(self) -> None:
  266. parser = _build_parser()
  267. args = parser.parse_args(
  268. [
  269. "production_final.json",
  270. "--round-id",
  271. "v24",
  272. "--shared-visual-anchor",
  273. "anchor.png",
  274. "--pause-after-progress-review",
  275. ]
  276. )
  277. self.assertEqual(args.round_id, "v24")
  278. self.assertTrue(args.pause_after_progress_review)
  279. def test_cli_treats_requested_production_pause_as_success(self) -> None:
  280. argv = [
  281. "run_pipeline.py",
  282. "production_final.json",
  283. "--round-id",
  284. "v24",
  285. "--shared-visual-anchor",
  286. "anchor.png",
  287. "--pause-after-progress-review",
  288. ]
  289. with (
  290. patch("sys.argv", argv),
  291. patch(
  292. "run_pipeline.run_pipeline",
  293. return_value={
  294. "round_id": "v24",
  295. "status": "RUNNING",
  296. "phase": "PRODUCTION",
  297. },
  298. ),
  299. ):
  300. main()
  301. def test_cli_fails_when_global_data_does_not_complete(self) -> None:
  302. argv = [
  303. "run_pipeline.py",
  304. "production_final.json",
  305. "--round-id",
  306. "v24",
  307. "--shared-visual-anchor",
  308. "anchor.png",
  309. ]
  310. with (
  311. patch("sys.argv", argv),
  312. patch(
  313. "run_pipeline.run_pipeline",
  314. return_value={
  315. "round_id": "v24",
  316. "status": "FAILED",
  317. "phase": "GLOBAL_DATA",
  318. },
  319. ),
  320. self.assertRaises(SystemExit) as raised,
  321. ):
  322. main()
  323. self.assertEqual(raised.exception.code, 1)
  324. if __name__ == "__main__":
  325. unittest.main()