test_checker.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import io
  2. import json
  3. import subprocess
  4. import sys
  5. import tempfile
  6. from contextlib import redirect_stdout
  7. import unittest
  8. from pathlib import Path
  9. from unittest.mock import patch
  10. from PIL import Image
  11. import fixtures
  12. class CheckerPolicyRegression(unittest.TestCase):
  13. def check(self, expected_exit, *options, audio=True, levels=None,
  14. embedded=None, sidecar=None, extraction_exit=0):
  15. module = fixtures.load_script("check-movie")
  16. with tempfile.TemporaryDirectory() as directory:
  17. root = Path(directory)
  18. movie = root / "movie.mp4"
  19. movie.write_bytes(b"metadata fixture only")
  20. if sidecar is not None:
  21. movie.with_suffix(".srt").write_text(sidecar, encoding="utf-8-sig")
  22. streams = [{"index": 0, "codec_type": "video", "codec_name": "h264",
  23. "width": 640, "height": 360}]
  24. if audio:
  25. streams.append({"index": 1, "codec_type": "audio", "codec_name": "aac"})
  26. if embedded is not None:
  27. streams.append({"index": 2, "codec_type": "subtitle", "codec_name": "mov_text"})
  28. levels = levels if levels is not None else [-20.0] * 20
  29. info = {"format": {"duration": str(len(levels))}, "streams": streams}
  30. def media_command(cmd, **kwargs):
  31. if cmd[0] == "ffprobe":
  32. return subprocess.CompletedProcess(cmd, 0, json.dumps(info), "")
  33. if cmd[0] == "ffmpeg" and embedded is not None:
  34. self.assertEqual(cmd[cmd.index("-i") + 1], str(movie))
  35. self.assertEqual(cmd[cmd.index("-map") + 1], "0:s:0")
  36. self.assertEqual(cmd[cmd.index("-f") + 1], "srt")
  37. return subprocess.CompletedProcess(cmd, extraction_exit, embedded,
  38. "subtitle decode failed" if extraction_exit else "")
  39. raise AssertionError(f"unexpected media command: {cmd}")
  40. output = root / "report"
  41. argv = ["check-movie", str(movie), "--out", str(output), "--json", *options]
  42. stdout = io.StringIO()
  43. with patch.object(sys, "argv", argv), \
  44. patch.object(module.shutil, "which", return_value="test-tool"), \
  45. patch.object(module.subprocess, "run", side_effect=media_command), \
  46. patch.object(module, "sample_picture", return_value=([], [0.1] * (len(levels) - 1))), \
  47. patch.object(module, "sample_sound", return_value=levels if audio else []), \
  48. patch.object(module, "contact_sheet", return_value=[]), \
  49. redirect_stdout(stdout):
  50. try:
  51. code = module.main()
  52. except SystemExit as error:
  53. code = error.code
  54. self.assertEqual(code, expected_exit, stdout.getvalue())
  55. report = output / "check.json"
  56. failures = json.loads(report.read_text(encoding="utf-8"))["failures"] if report.exists() else []
  57. return failures, stdout.getvalue()
  58. def test_silent_encoded_track_passes_when_audio_is_not_expected(self):
  59. failures, _ = self.check(0, "--no-expect-audio", levels=[-120.0] * 20)
  60. self.assertEqual(failures, [])
  61. def test_absent_audio_passes_when_audio_is_not_expected(self):
  62. failures, _ = self.check(0, "--no-expect-audio", audio=False)
  63. self.assertEqual(failures, [])
  64. def test_silent_encoded_track_fails_when_audio_is_expected(self):
  65. failures, _ = self.check(1, levels=[-120.0] * 20)
  66. self.assertTrue(any("silent" in failure for failure in failures))
  67. def test_audio_opt_out_still_requires_captions_for_audible_speech(self):
  68. failures, _ = self.check(1, "--no-expect-audio")
  69. self.assertTrue(any("no subtitles" in failure for failure in failures))
  70. def test_subtitle_opt_out_allows_audible_speech_without_captions(self):
  71. failures, _ = self.check(0, "--no-expect-audio", "--no-expect-subtitles")
  72. self.assertEqual(failures, [])
  73. def test_empty_embedded_track_fails_even_for_short_narration(self):
  74. for seconds in (2, 20):
  75. with self.subTest(seconds=seconds):
  76. failures, _ = self.check(1, embedded="", levels=[-20.0] * seconds)
  77. self.assertTrue(any("subtitle" in failure for failure in failures))
  78. def test_sidecar_and_embedded_cues_must_reach_the_end_of_speech(self):
  79. for source in ("sidecar", "embedded"):
  80. for end, expected_exit in (("06,000", 1), ("10,000", 0)):
  81. with self.subTest(source=source, end=end):
  82. subtitles = f"1\n00:00:00,000 --> 00:00:{end}\nUnicode λ caption\n"
  83. failures, _ = self.check(expected_exit, levels=[-20.0] * 10 + [-120.0] * 10,
  84. **{source: subtitles})
  85. self.assertEqual(bool(failures), bool(expected_exit))
  86. def test_embedded_extraction_failure_is_not_accepted(self):
  87. _, diagnostics = self.check(2, embedded="", extraction_exit=1)
  88. self.assertIn("subtitle", diagnostics)
  89. def test_malformed_embedded_cue_is_a_reported_failure(self):
  90. _, diagnostics = self.check(2, embedded="1\n00:00:00,000 --> invalid\ncaption\n")
  91. self.assertIn("subtitle", diagnostics)
  92. class CheckerRegression(unittest.TestCase):
  93. @classmethod
  94. def setUpClass(cls) -> None:
  95. missing = fixtures.missing_executables("uv", "ffmpeg", "ffprobe")
  96. if missing:
  97. raise unittest.SkipTest(
  98. f"required executable(s) not on PATH: {', '.join(missing)}"
  99. )
  100. cls._temporary_directory = tempfile.TemporaryDirectory()
  101. cls.addClassCleanup(cls._temporary_directory.cleanup)
  102. cls.work = Path(cls._temporary_directory.name)
  103. cls.movies = fixtures.checker_fixture(cls.work)
  104. def check(
  105. self, expected_exit: int, needle: str, movie: Path, *extra_args: str
  106. ) -> Path:
  107. output_directory = self.work / f"{movie.stem}-check"
  108. result = fixtures.run_tool(
  109. "check-movie",
  110. [str(movie), "--out", str(output_directory), *extra_args],
  111. cwd=self.work,
  112. )
  113. output = fixtures.output_text(result)
  114. self.assertEqual(result.returncode, expected_exit, output)
  115. self.assertIn(needle.casefold(), output.casefold())
  116. return output_directory
  117. def test_front_loaded_action_is_rejected(self):
  118. self.check(
  119. 1,
  120. "every visible change happens in the first",
  121. self.movies["front-loaded"],
  122. )
  123. def test_paced_with_subtitles_is_accepted(self):
  124. self.check(0, "Mechanical checks pass", self.movies["paced"])
  125. def test_narrated_without_subtitles_is_rejected(self):
  126. self.check(
  127. 1,
  128. "no subtitles",
  129. self.movies["paced"],
  130. "--subs",
  131. str(self.work / "nope.srt"),
  132. )
  133. def test_subtitles_that_stop_early_are_rejected(self):
  134. self.check(1, "subtitles stop at", self.movies["short"])
  135. def test_subtitle_check_is_opt_outable(self):
  136. self.check(
  137. 0,
  138. "Mechanical checks pass",
  139. self.movies["paced"],
  140. "--subs",
  141. str(self.work / "nope.srt"),
  142. "--no-expect-subtitles",
  143. )
  144. def test_still_with_audio_is_rejected(self):
  145. self.check(1, "never reaches a new state", self.movies["still"])
  146. def test_missing_narration_is_rejected(self):
  147. self.check(1, "no audio stream", self.movies["silent"])
  148. def test_silent_movie_passes_when_unnarrated(self):
  149. self.check(
  150. 0,
  151. "Mechanical checks pass",
  152. self.movies["silent"],
  153. "--no-expect-audio",
  154. )
  155. def test_contact_sheet_is_always_written(self):
  156. output_directory = self.check(
  157. 0, "contact-sheet.png", self.movies["paced"]
  158. )
  159. sheet = output_directory / "contact-sheet.png"
  160. self.assertTrue(sheet.is_file(), f"missing contact sheet: {sheet}")
  161. with Image.open(sheet) as image:
  162. image.load()
  163. self.assertEqual(image.format, "PNG")
  164. if __name__ == "__main__":
  165. unittest.main()