test_production_progress.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. from __future__ import annotations
  2. import unittest
  3. from pydantic import ValidationError
  4. from production_build_agents.contracts.production_planning_evaluation import (
  5. evaluate_adapt_planning_package,
  6. evaluate_adapt_transition,
  7. evaluate_production_progress_decision,
  8. evaluate_production_progress_package,
  9. )
  10. from production_build_agents.contracts.production_planning_models import (
  11. ProductionPlanningPackage,
  12. ProductionProgressDecision,
  13. ProductionProgressPackage,
  14. )
  15. from tests.support.production_fixtures import (
  16. immutable_ref,
  17. output_profile,
  18. production_plan,
  19. segment_bundle,
  20. shared_anchor,
  21. )
  22. class ProductionProgressContractTest(unittest.TestCase):
  23. def setUp(self) -> None:
  24. self.segment1 = segment_bundle(1)
  25. self.segment2 = segment_bundle(2)
  26. self.plan = production_plan(
  27. [self.segment1[0], self.segment2[0]]
  28. )
  29. self.planning = ProductionPlanningPackage(
  30. run_id="production-run",
  31. mode="INITIAL",
  32. plan_id=self.plan.plan_id,
  33. target_plan_version=1,
  34. production_brief_ref=immutable_ref("brief"),
  35. global_data_delivery_ref=immutable_ref("global-delivery"),
  36. production_input_catalog_ref=immutable_ref("catalog"),
  37. output_profile=output_profile(),
  38. shared_visual_anchor=shared_anchor(),
  39. )
  40. segment_package, _, delivery, _, report, accepted = self.segment1
  41. self.segment_package_ref = accepted.segment_package_ref.model_copy(
  42. update={
  43. "uri": delivery.segment_package_uri,
  44. "document_sha256": delivery.segment_package_sha256,
  45. }
  46. )
  47. self.delivery_ref = accepted.segment_delivery_ref
  48. self.report_ref = accepted.validation_report_ref
  49. self.accepted = accepted.model_copy(
  50. update={"segment_package_ref": self.segment_package_ref}
  51. )
  52. self.progress_ref = immutable_ref("progress-Segment1-v1")
  53. self.progress = ProductionProgressPackage(
  54. run_id="production-run",
  55. plan_id=self.plan.plan_id,
  56. plan_version=1,
  57. segment_id="Segment1",
  58. planning_package_ref=self.plan.planning_package_ref,
  59. production_plan_ref=immutable_ref("production-plan"),
  60. segment_package_ref=self.segment_package_ref,
  61. segment_delivery_ref=self.delivery_ref,
  62. segment_validation_report_ref=self.report_ref,
  63. accepted_segments=[self.accepted],
  64. remaining_plan_revisions=5,
  65. )
  66. @staticmethod
  67. def _codes(issues) -> set[str]:
  68. return {item.code for item in issues}
  69. def _package_issues(self, package=None):
  70. segment_package, _, delivery, _, report, _ = self.segment1
  71. return evaluate_production_progress_package(
  72. package or self.progress,
  73. planning_package=self.planning,
  74. planning_package_ref=self.plan.planning_package_ref,
  75. production_plan=self.plan,
  76. production_plan_ref=immutable_ref("production-plan"),
  77. segment_package=segment_package,
  78. segment_package_ref=self.segment_package_ref,
  79. segment_delivery=delivery,
  80. segment_delivery_ref=self.delivery_ref,
  81. segment_validation_report=report,
  82. segment_validation_report_ref=self.report_ref,
  83. accepted_segments=[self.accepted],
  84. remaining_plan_revisions=5,
  85. )
  86. def _decision(self, action: str, affected=None):
  87. return ProductionProgressDecision(
  88. run_id=self.progress.run_id,
  89. plan_id=self.progress.plan_id,
  90. plan_version=self.progress.plan_version,
  91. segment_id=self.progress.segment_id,
  92. progress_package_ref=self.progress_ref,
  93. action=action,
  94. affected_segment_ids=affected or [],
  95. reason="正式进度审阅",
  96. )
  97. def _decision_codes(self, decision, *, report=None, package=None):
  98. return self._codes(
  99. evaluate_production_progress_decision(
  100. package or self.progress,
  101. decision,
  102. progress_package_ref=self.progress_ref,
  103. production_plan=self.plan,
  104. segment_validation_report=report or self.segment1[4],
  105. )
  106. )
  107. def test_progress_package_binds_formal_pass_chain(self) -> None:
  108. self.assertEqual(self._package_issues(), [])
  109. wrong = self.progress.model_copy(
  110. update={"production_plan_ref": immutable_ref("wrong-plan")}
  111. )
  112. self.assertIn(
  113. "production_progress_document_ref_mismatch",
  114. self._codes(self._package_issues(wrong)),
  115. )
  116. def test_progress_models_reject_duplicate_and_invalid_action_shapes(
  117. self,
  118. ) -> None:
  119. with self.assertRaises(ValidationError):
  120. ProductionProgressPackage(
  121. **self.progress.model_dump(exclude={"accepted_segments"}),
  122. accepted_segments=[self.accepted, self.accepted],
  123. )
  124. with self.assertRaises(ValidationError):
  125. self._decision("ADAPT")
  126. with self.assertRaises(ValidationError):
  127. self._decision("CONTINUE", ["Segment2"])
  128. def test_pass_allows_continue_adapt_stop_but_not_replan(self) -> None:
  129. for action in ("CONTINUE", "STOP"):
  130. with self.subTest(action=action):
  131. self.assertEqual(
  132. self._decision_codes(self._decision(action)),
  133. set(),
  134. )
  135. self.assertEqual(
  136. self._decision_codes(
  137. self._decision("ADAPT", ["Segment2"])
  138. ),
  139. set(),
  140. )
  141. self.assertIn(
  142. "production_progress_action_verdict_mismatch",
  143. self._decision_codes(self._decision("REPLAN")),
  144. )
  145. def test_fail_allows_replan_stop_and_rejects_continue(self) -> None:
  146. report = self.segment1[4].model_copy(update={"verdict": "FAIL"})
  147. failed_progress = self.progress.model_copy(
  148. update={"accepted_segments": []}
  149. )
  150. self.assertEqual(
  151. self._decision_codes(
  152. self._decision("REPLAN"),
  153. report=report,
  154. package=failed_progress,
  155. ),
  156. set(),
  157. )
  158. self.assertIn(
  159. "production_progress_action_verdict_mismatch",
  160. self._decision_codes(
  161. self._decision("CONTINUE"),
  162. report=report,
  163. package=failed_progress,
  164. ),
  165. )
  166. def test_adapt_requires_pending_segment_and_revision_budget(self) -> None:
  167. self.assertIn(
  168. "production_progress_adapt_segment_not_pending",
  169. self._decision_codes(
  170. self._decision("ADAPT", ["Segment1"])
  171. ),
  172. )
  173. exhausted = self.progress.model_copy(
  174. update={"remaining_plan_revisions": 0}
  175. )
  176. self.assertIn(
  177. "production_progress_revision_budget_exhausted",
  178. self._decision_codes(
  179. self._decision("ADAPT", ["Segment2"]),
  180. package=exhausted,
  181. ),
  182. )
  183. def test_adapt_package_and_transition_are_future_only(self) -> None:
  184. decision_ref = immutable_ref("adapt-decision")
  185. package = ProductionPlanningPackage(
  186. **self.planning.model_dump(
  187. exclude={
  188. "mode",
  189. "target_plan_version",
  190. "previous_plan_ref",
  191. "failure_report_ref",
  192. "progress_decision_ref",
  193. "accepted_segments",
  194. "authorized_revision_segment_ids",
  195. }
  196. ),
  197. mode="ADAPT",
  198. target_plan_version=2,
  199. previous_plan_ref=immutable_ref("production-plan"),
  200. progress_decision_ref=decision_ref,
  201. accepted_segments=[self.accepted],
  202. authorized_revision_segment_ids=["Segment2"],
  203. )
  204. changed_segment = self.plan.segments[1].model_copy(
  205. update={"objective": "根据最新进度调整未来段落"}
  206. )
  207. current = self.plan.model_copy(
  208. update={
  209. "plan_version": 2,
  210. "planning_package_ref": immutable_ref("planning-v2"),
  211. "segments": [self.plan.segments[0], changed_segment],
  212. "summary": "未来规划已调整",
  213. }
  214. )
  215. decision = self._decision("ADAPT", ["Segment2"])
  216. self.assertEqual(
  217. evaluate_adapt_planning_package(
  218. self.planning,
  219. package,
  220. progress_package=self.progress,
  221. progress_package_ref=self.progress_ref,
  222. progress_decision=decision,
  223. progress_decision_ref=decision_ref,
  224. ),
  225. [],
  226. )
  227. widened = package.model_copy(
  228. update={
  229. "authorized_revision_segment_ids": [
  230. "Segment1",
  231. "Segment2",
  232. ]
  233. }
  234. )
  235. self.assertIn(
  236. "adapt_authorized_segments_mismatch",
  237. self._codes(
  238. evaluate_adapt_planning_package(
  239. self.planning,
  240. widened,
  241. progress_package=self.progress,
  242. progress_package_ref=self.progress_ref,
  243. progress_decision=decision,
  244. progress_decision_ref=decision_ref,
  245. )
  246. ),
  247. )
  248. stale_snapshot = package.model_copy(
  249. update={"accepted_segments": []}
  250. )
  251. self.assertIn(
  252. "adapt_accepted_snapshot_mismatch",
  253. self._codes(
  254. evaluate_adapt_planning_package(
  255. self.planning,
  256. stale_snapshot,
  257. progress_package=self.progress,
  258. progress_package_ref=self.progress_ref,
  259. progress_decision=decision,
  260. progress_decision_ref=decision_ref,
  261. )
  262. ),
  263. )
  264. self.assertEqual(
  265. evaluate_adapt_transition(self.plan, current, package),
  266. [],
  267. )
  268. no_effect = current.model_copy(
  269. update={"segments": self.plan.segments}
  270. )
  271. self.assertIn(
  272. "adapt_has_no_effect",
  273. self._codes(
  274. evaluate_adapt_transition(
  275. self.plan,
  276. no_effect,
  277. package,
  278. )
  279. ),
  280. )
  281. accepted_changed = current.model_copy(
  282. update={
  283. "segments": [
  284. self.plan.segments[0].model_copy(
  285. update={"objective": "越权修改"}
  286. ),
  287. changed_segment,
  288. ]
  289. }
  290. )
  291. self.assertIn(
  292. "adapt_protected_segment_changed",
  293. self._codes(
  294. evaluate_adapt_transition(
  295. self.plan,
  296. accepted_changed,
  297. package,
  298. )
  299. ),
  300. )
  301. if __name__ == "__main__":
  302. unittest.main()