test_qwen_video_analysis.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. """Regression tests for the bounded Qwen video-analysis pipeline."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. from pathlib import Path
  6. import httpx
  7. import pytest
  8. from agents.find_agent.support import qwen_video_analysis as video_analysis
  9. @pytest.mark.asyncio
  10. async def test_download_retries_once_then_abandons(
  11. monkeypatch: pytest.MonkeyPatch,
  12. tmp_path: Path,
  13. ) -> None:
  14. attempts = 0
  15. destination = tmp_path / "video.mp4"
  16. async def fail_download(url: str, dest: Path, timeout: float) -> None:
  17. nonlocal attempts
  18. attempts += 1
  19. dest.write_bytes(b"partial")
  20. raise httpx.ReadError("connection dropped")
  21. monkeypatch.setattr(video_analysis, "_download_video_once", fail_download)
  22. with pytest.raises(video_analysis.VideoDownloadError) as exc_info:
  23. await video_analysis._download_video(
  24. "https://source.example/video.mp4",
  25. destination,
  26. 1.0,
  27. )
  28. assert attempts == 2
  29. assert "已尝试 2 次" in str(exc_info.value)
  30. assert not destination.exists()
  31. @pytest.mark.asyncio
  32. async def test_download_timeout_is_a_hard_per_attempt_deadline(
  33. monkeypatch: pytest.MonkeyPatch,
  34. tmp_path: Path,
  35. ) -> None:
  36. attempts = 0
  37. async def stalled_download(url: str, dest: Path, timeout: float) -> None:
  38. nonlocal attempts
  39. attempts += 1
  40. await asyncio.sleep(1)
  41. monkeypatch.setattr(video_analysis, "_download_video_once", stalled_download)
  42. with pytest.raises(video_analysis.VideoDownloadError) as exc_info:
  43. await asyncio.wait_for(
  44. video_analysis._download_video(
  45. "https://source.example/video.mp4",
  46. tmp_path / "video.mp4",
  47. 0.01,
  48. ),
  49. timeout=0.2,
  50. )
  51. assert attempts == 2
  52. assert exc_info.value.timed_out is True
  53. @pytest.mark.asyncio
  54. async def test_short_video_is_kept_complete_and_converted_to_oss(
  55. monkeypatch: pytest.MonkeyPatch,
  56. ) -> None:
  57. uploaded: dict[str, object] = {}
  58. async def download(url: str, dest: Path, timeout: float) -> None:
  59. dest.write_bytes(b"complete-short-video")
  60. def upload(path: Path, source_url: str, max_duration: float) -> str:
  61. uploaded["bytes"] = path.read_bytes()
  62. uploaded["max_duration"] = max_duration
  63. return "https://ours.example/video-clips/short_30s.mp4"
  64. monkeypatch.setattr(video_analysis, "_require_ffmpeg", lambda: "ffmpeg")
  65. monkeypatch.setattr(video_analysis, "_ensure_oss_configured", lambda: None)
  66. monkeypatch.setattr(video_analysis, "_download_video", download)
  67. monkeypatch.setattr(video_analysis, "_probe_duration", lambda ffmpeg, path: 12.5)
  68. monkeypatch.setattr(video_analysis, "_upload_clip", upload)
  69. monkeypatch.setattr(
  70. video_analysis,
  71. "_truncate_video",
  72. lambda *args: pytest.fail("short video must not be truncated"),
  73. )
  74. analysis_url, meta = await video_analysis._prepare_analysis_url(
  75. "https://source.example/short.mp4",
  76. None,
  77. 1.0,
  78. )
  79. assert analysis_url == "https://ours.example/video-clips/short_30s.mp4"
  80. assert uploaded == {"bytes": b"complete-short-video", "max_duration": 30.0}
  81. assert meta["truncated"] is False
  82. assert meta["converted_to_oss"] is True
  83. assert meta["original_duration_seconds"] == 12.5
  84. assert meta["max_duration_seconds"] == 30.0
  85. @pytest.mark.asyncio
  86. async def test_long_video_is_clipped_to_30_seconds_and_converted_to_oss(
  87. monkeypatch: pytest.MonkeyPatch,
  88. ) -> None:
  89. truncated_at: list[float] = []
  90. uploaded_bytes: list[bytes] = []
  91. async def download(url: str, dest: Path, timeout: float) -> None:
  92. dest.write_bytes(b"long-video")
  93. def truncate(
  94. ffmpeg: str,
  95. source: Path,
  96. output: Path,
  97. max_duration: float,
  98. ) -> None:
  99. truncated_at.append(max_duration)
  100. output.write_bytes(b"first-30-seconds")
  101. def upload(path: Path, source_url: str, max_duration: float) -> str:
  102. uploaded_bytes.append(path.read_bytes())
  103. return "https://ours.example/video-clips/long_30s.mp4"
  104. monkeypatch.setattr(video_analysis, "_require_ffmpeg", lambda: "ffmpeg")
  105. monkeypatch.setattr(video_analysis, "_ensure_oss_configured", lambda: None)
  106. monkeypatch.setattr(video_analysis, "_download_video", download)
  107. monkeypatch.setattr(video_analysis, "_probe_duration", lambda ffmpeg, path: 90.0)
  108. monkeypatch.setattr(video_analysis, "_truncate_video", truncate)
  109. monkeypatch.setattr(video_analysis, "_upload_clip", upload)
  110. analysis_url, meta = await video_analysis._prepare_analysis_url(
  111. "https://source.example/long.mp4",
  112. 180.0,
  113. 1.0,
  114. )
  115. assert analysis_url == "https://ours.example/video-clips/long_30s.mp4"
  116. assert truncated_at == [30.0]
  117. assert uploaded_bytes == [b"first-30-seconds"]
  118. assert meta["truncated"] is True
  119. assert meta["converted_to_oss"] is True
  120. assert meta["max_duration_seconds"] == 30.0
  121. @pytest.mark.asyncio
  122. async def test_qwen_receives_only_the_converted_url(
  123. monkeypatch: pytest.MonkeyPatch,
  124. ) -> None:
  125. received_urls: list[str] = []
  126. async def prepare(*args: object, **kwargs: object) -> tuple[str, dict[str, object]]:
  127. return "https://ours.example/video.mp4", {
  128. "converted_to_oss": True,
  129. "original_video_url": "https://source.example/video.mp4",
  130. }
  131. def analyze(
  132. video_url: str,
  133. prompt: str,
  134. fps: float,
  135. model: str,
  136. timeout: float,
  137. ) -> str:
  138. received_urls.append(video_url)
  139. return "analysis"
  140. monkeypatch.setattr(video_analysis, "_prepare_analysis_url", prepare)
  141. monkeypatch.setattr(video_analysis, "_analyze_video_sync", analyze)
  142. result = json.loads(
  143. await video_analysis.qwen_video_analyze(
  144. "https://source.example/video.mp4",
  145. tool_timeout=1.0,
  146. )
  147. )
  148. assert received_urls == ["https://ours.example/video.mp4"]
  149. assert result["video_url"] == "https://ours.example/video.mp4"
  150. assert result["original_video_url"] == "https://source.example/video.mp4"
  151. @pytest.mark.asyncio
  152. async def test_whole_tool_has_a_hard_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
  153. async def never_finishes(*args: object, **kwargs: object) -> tuple[str, dict[str, object]]:
  154. await asyncio.Event().wait()
  155. raise AssertionError("unreachable")
  156. monkeypatch.setattr(video_analysis, "_prepare_analysis_url", never_finishes)
  157. result = json.loads(
  158. await asyncio.wait_for(
  159. video_analysis.qwen_video_analyze(
  160. "https://source.example/video.mp4",
  161. tool_timeout=0.01,
  162. ),
  163. timeout=0.2,
  164. )
  165. )
  166. assert result["error_code"] == "tool_timeout"
  167. assert "视频解析工具超时" in result["error"]