Skip to main content

test_glancecast.py

1"""
2Tests for glancecast.
3"""
5from pathlib import Path
6from subprocess import CalledProcessError
8import eyed3
9from mutagen.mp3 import MP3
10from PIL import Image
11import pytest
12from pytest import CaptureFixture
14from glancecast import (
15 Frame,
16 add_chapters_to_mp3,
17 convert_video_to_glanceable_mp3,
18 convert_video_to_mp3,
19 ensure_tool_installed,
20 extract_images_from_video,
21 ffmpeg,
25@pytest.fixture
26def tmp_dir(tmp_path: Path) -> Path:
27 """
28 Temporary directory; alias for the pytest `tmp_path` fixture.
29 """
30 return tmp_path
33class TestConvertVideoToGlanceableMP3:
34 """
35 Tests for `convert_video_to_glanceable_mp3`.
36 """
38 def test_e2e(self, tmp_dir: Path) -> None:
39 """
40 End-to-end test for `convert_video_to_glanceable_mp3`.
41 """
42 mp3_path = convert_video_to_glanceable_mp3(
43 video_path="tests/fixtures/lego_age_picker.mp4",
44 interval_sec=5,
45 max_dimension=100,
46 out_dir=tmp_dir,
47 )
49 assert mp3_path == tmp_dir / "lego_age_picker.mp3"
50 assert mp3_path.exists()
52 # Use a different MP3 library to load the metadata, and check
53 # there are four chapters which all have an APIC subframe
54 mp3 = eyed3.load(mp3_path)
55 assert mp3 is not None
57 assert len(mp3.tag.chapters) == 4
59 for c in mp3.tag.chapters:
60 assert c.sub_frames.keys() == {b"APIC"}
62 def test_does_not_overwrite(self, tmp_dir: Path) -> None:
63 """
64 If an MP3 path already exists at the output location, it picks
65 an alternative name.
66 """
67 mp3_paths = [
68 convert_video_to_glanceable_mp3(
69 video_path="tests/fixtures/lego_age_picker.mp4",
70 interval_sec=5,
71 max_dimension=100,
72 out_dir=tmp_dir,
73 )
74 for _ in range(5)
75 ]
77 assert mp3_paths == [
78 tmp_dir / "lego_age_picker.mp3",
79 tmp_dir / "lego_age_picker-1.mp3",
80 tmp_dir / "lego_age_picker-2.mp3",
81 tmp_dir / "lego_age_picker-3.mp3",
82 tmp_dir / "lego_age_picker-4.mp3",
83 ]
84 assert all(p.exists() for p in mp3_paths)
87class TestEnsureToolInstalled:
88 """
89 Tests for `ensure_tool_installed`.
90 """
92 def test_installed_tool(self) -> None:
93 """
94 Ensuring that an extant tool is installed doesn't raise an error.
95 """
96 ensure_tool_installed("python")
98 def test_missing_tool(self) -> None:
99 """
100 Ensuring that a non-existent tool is installed raises an error.
101 """
102 with pytest.raises(
103 SystemExit,
104 match="required tool 'does_not_exist' is not installed or not in PATH",
105 ):
106 ensure_tool_installed("does_not_exist")
109class TestFFMPEG:
110 """
111 Tests for `ffmpeg`.
112 """
114 def test_success_has_no_output(
115 self, capsys: CaptureFixture[str], tmp_dir: Path
116 ) -> None:
117 """
118 If the FFmpeg command succeeds, nothing is printed to stdout or stderr.
119 """
120 ffmpeg(
121 "-i",
122 "tests/fixtures/lego_age_picker.mp4",
123 str(tmp_dir / "lego_age_picker.mp3"),
124 )
126 captured = capsys.readouterr()
127 assert captured.out == ""
128 assert captured.err == ""
130 def test_failing_command_prints_output(self, capsys: CaptureFixture[str]) -> None:
131 """
132 If the FFmpeg command fails, the FFmpeg output is printed to stderr.
133 """
134 with pytest.raises(CalledProcessError):
135 ffmpeg("-i", "does_not_exist.mp4", "does_not_exist.mp3")
137 captured = capsys.readouterr()
138 assert captured.out == ""
139 assert captured.err != ""
142class TestConvertVideoToMP3:
143 """
144 Tests for `convert_video_to_mp3`.
145 """
147 def test_creates_mp3_file(self, tmp_dir: Path) -> None:
148 """
149 Convert a video file to an MP3, and check it's the expected length.
150 """
151 p = Path("tests/fixtures/lego_age_picker.mp4")
153 mp3_path = convert_video_to_mp3(p, tmp_dir)
154 assert mp3_path.exists()
155 assert mp3_path.suffix == ".mp3"
157 mp3 = MP3(mp3_path)
158 assert mp3.info is not None
159 assert mp3.info.length == 19.536
161 def test_missing_video_file(self, tmp_dir: Path) -> None:
162 """
163 Trying to convert a non-existent video file is an error.
164 """
165 p = Path("does_not_exist.mkv")
167 with pytest.raises(FileNotFoundError):
168 convert_video_to_mp3(p, tmp_dir)
170 def test_convert_non_video_file(self, tmp_dir: Path) -> None:
171 """
172 Trying to convert a non-video file is an error.
173 """
174 p = Path("README.md")
176 with pytest.raises(CalledProcessError):
177 convert_video_to_mp3(p, tmp_dir)
180class TestExtractImagesFromVideo:
181 """
182 Tests for `extract_images_from_video`.
183 """
185 def test_extract_images_from_video(self, tmp_dir: Path) -> None:
186 """
187 Extract images from a video file, and check we get the correct frames.
188 """
189 p = Path("tests/fixtures/lego_age_picker.mp4")
190 frames = extract_images_from_video(
191 p, tmp_dir, interval_sec=5, max_dimension=100
192 )
194 for f in frames:
195 f.path = Path(f.path.name)
197 assert frames == [
198 Frame(
199 path=Path("thumbnail_0001.jpg"),
200 mime_type="image/jpeg",
201 start_time_ms=0,
202 end_time_ms=5000,
203 ),
204 Frame(
205 path=Path("thumbnail_0002.jpg"),
206 mime_type="image/jpeg",
207 start_time_ms=5000,
208 end_time_ms=10000,
209 ),
210 Frame(
211 path=Path("thumbnail_0003.jpg"),
212 mime_type="image/jpeg",
213 start_time_ms=10000,
214 end_time_ms=15000,
215 ),
216 Frame(
217 path=Path("thumbnail_0004.jpg"),
218 mime_type="image/jpeg",
219 start_time_ms=15000,
220 end_time_ms=20000,
221 ),
222 ]
224 @pytest.mark.parametrize(
225 "name, suffix, mime_type",
226 [
227 ("lego_age_picker.mp4", ".jpg", "image/jpeg"),
228 ("animated_squares.mp4", ".png", "image/png"),
229 ],
230 )
231 def test_chooses_png_or_jpeg(
232 self, tmp_dir: Path, name: str, suffix: str, mime_type: str
233 ) -> None:
234 """
235 The sampler picks PNG or JPEG depending on which is smaller.
236 """
237 frames = extract_images_from_video(
238 Path("tests/fixtures") / name,
239 tmp_dir,
240 interval_sec=5,
241 max_dimension=100,
242 )
244 assert frames[0].path.suffix == suffix
245 assert frames[0].mime_type == mime_type
247 @pytest.mark.parametrize(
248 "interval_sec, frame_count",
249 # The video is ~19 seconds long
250 [(1, 19), (5, 4), (6, 3), (10, 2)],
251 )
252 def test_correct_frame_count(
253 self, tmp_dir: Path, interval_sec: int, frame_count: int
254 ) -> None:
255 """
256 We get the correct number of frames based on interval_sec.
257 """
258 p = Path("tests/fixtures/lego_age_picker.mp4")
260 frames = extract_images_from_video(
261 p,
262 tmp_dir,
263 interval_sec=interval_sec,
264 max_dimension=100,
265 )
267 assert len(frames) == frame_count
269 @pytest.mark.parametrize(
270 "name, max_dimension, actual_size",
271 [
272 # lego_age_picker.mp4 is 1173×1080 pixels, and it has
273 # a storage aspect ratio which isn't 1
274 # See https://alexwlchan.net/2025/square-pixels/
275 ("lego_age_picker.mp4", 100, (100, 92)),
276 ("lego_age_picker.mp4", 1080, (1080, 995)),
277 ("lego_age_picker.mp4", 1173, (1172, 1080)),
278 ("lego_age_picker.mp4", 2000, (1172, 1080)),
279 # lego_age_picker_rotated.mov is 588×640 pixels
280 # For some reason this isn't expanding to the full size.
281 # TODO: Investigate further.
282 ("lego_age_picker_rotated.mov", 100, (92, 100)),
283 ("lego_age_picker_rotated.mov", 588, (540, 588)),
284 ("lego_age_picker_rotated.mov", 640, (540, 588)),
285 ("lego_age_picker_rotated.mov", 1000, (540, 588)),
286 ],
287 )
288 def test_landscape_images_are_correct_size(
289 self,
290 tmp_dir: Path,
291 name: str,
292 max_dimension: int,
293 actual_size: tuple[int, int],
294 ) -> None:
295 """
296 The image preserves its aspect ratio, and is clamped to `max_dimension`
297 or the original size, whichever is smaller.
298 """
299 frames = extract_images_from_video(
300 Path("tests/fixtures") / name,
301 tmp_dir,
302 interval_sec=20,
303 max_dimension=max_dimension,
304 )
306 with Image.open(frames[0].path) as im:
307 assert im.size == actual_size
310class TestAddChaptersToMP3:
311 """
312 Tests for `add_chapters_to_mp3`.
313 """
315 @pytest.mark.parametrize(
316 "name, mime_type",
317 [
318 ("noise.jpg", "image/jpeg"),
319 ("stripes.png", "image/png"),
320 ],
321 )
322 def test_mime_type(self, tmp_dir: Path, name: str, mime_type: str) -> None:
323 """
324 The per-chapter cover art has the correct MIME type.
325 """
326 p = Path("tests/fixtures/lego_age_picker.mp4")
327 mp3_path = convert_video_to_mp3(p, tmp_dir)
329 frame = Frame(
330 path=Path("tests/fixtures") / name,
331 mime_type=mime_type,
332 start_time_ms=0,
333 end_time_ms=5000,
334 )
336 add_chapters_to_mp3(mp3_path, frames=[frame])
338 mp3 = eyed3.load(mp3_path)
339 assert mp3 is not None
341 assert len(mp3.tag.chapters) == 1
343 for c in mp3.tag.chapters:
344 assert c.sub_frames[b"APIC"][0].mime_type == mime_type