From 3defc803e98d0384af9c400315506bb3680428ef Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:18:03 -0300 Subject: [PATCH 1/5] test(pylib): cover sound tag path edge cases --- pylib/tests/test_sound.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pylib/tests/test_sound.py b/pylib/tests/test_sound.py index 775928152f6..2a5d549c386 100644 --- a/pylib/tests/test_sound.py +++ b/pylib/tests/test_sound.py @@ -17,6 +17,27 @@ def test_sound_tag_path_absolute_ignores_media_folder(): assert tag.path("/media") == abs_path +def test_sound_tag_path_with_directory_separator_uses_abspath(): + # filename with a separator but not absolute - goes through os.path.abspath, + # so the media folder is ignored and the file's directory is used instead. + tag = SoundOrVideoTag(filename="subdir/audio.mp3") + result = tag.path("/media") + expected = os.path.join(os.path.abspath("subdir"), "audio.mp3") + assert result == expected + + +def test_sound_tag_path_applies_media_file_filter(monkeypatch): + # hooks.media_file_filter is applied to the tail component of the path. + import anki.sound as sound_module + + def fake_filter(fname: str) -> str: + return fname.replace("audio", "renamed") + + monkeypatch.setattr(sound_module.hooks, "media_file_filter", fake_filter) + tag = SoundOrVideoTag(filename="audio.mp3") + assert tag.path("/media") == "/media/renamed.mp3" + + def test_strip_av_refs_removes_play_tag(): assert strip_av_refs("Hello [anki:play:q:0] world") == "Hello world" From 5f610d2c294f08cac66e8645d5b3f72ca3454935 Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:19:08 -0300 Subject: [PATCH 2/5] test(qt): add mpv playback smoke coverage --- qt/tests/test_sound.py | 76 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/qt/tests/test_sound.py b/qt/tests/test_sound.py index 78fec178388..c5de991e4a6 100644 --- a/qt/tests/test_sound.py +++ b/qt/tests/test_sound.py @@ -3,10 +3,12 @@ import shutil import subprocess +import wave from pathlib import Path import pytest +from anki.utils import is_mac, is_win from aqt.sound import _packagedCmd, is_audio_file @@ -15,18 +17,86 @@ def test_is_audio_file_recognizes_common_formats(): assert is_audio_file(f"test.{ext}") +def test_is_audio_file_is_case_insensitive(): + for ext in ("MP3", "WAV", "OGG", "FLAC"): + assert is_audio_file(f"test.{ext}") + + def test_is_audio_file_rejects_non_audio(): + # mp4/avi are video-only; jpg/png/pdf are not media Anki plays via mpv. for ext in ("mp4", "avi", "jpg", "png", "pdf"): assert not is_audio_file(f"test.{ext}") -def test_mpv_binary_runs(): - cmd, env = _packagedCmd(["mpv"]) +def test_is_audio_file_rejects_no_extension(): + assert not is_audio_file("audiofile") + + +def test_packagedcmd_returns_absolute_path_when_anki_audio_available(): + # _packagedCmd should prefer the binary bundled in anki_audio over a + # system-wide one on macOS and Windows. This is the regression caught by + # issue #5015: an updated anki_audio build was not being picked up. + if not (is_mac or is_win): + pytest.skip("anki_audio binary preference is only used on macOS/Windows") + + try: + from pathlib import Path as _Path + + import anki_audio + + audio_pkg_path = _Path(anki_audio.__file__).parent + except ImportError: + pytest.skip("anki_audio package not installed") + + cmd, _env = _packagedCmd(["mpv"]) + mpv_path = Path(cmd[0]) + + assert mpv_path.is_absolute(), "expected an absolute path to the packaged binary" + assert str(audio_pkg_path) in str(mpv_path), ( + f"expected binary inside anki_audio package at {audio_pkg_path}, got {mpv_path}" + ) + + +def _resolved_mpv_command(args: list[str]) -> tuple[list[str], dict[str, str]]: + cmd, env = _packagedCmd(args) mpv = cmd[0] if not Path(mpv).is_absolute(): mpv = shutil.which(mpv) if mpv is None: pytest.skip("mpv not found") + cmd[0] = mpv + + return cmd, env + + +def test_mpv_binary_runs(): + cmd, env = _resolved_mpv_command(["mpv"]) + result = subprocess.run(cmd + ["--version"], env=env, capture_output=True) + assert result.returncode == 0, result.stderr.decode() + + +def test_mpv_can_play_generated_wav(tmp_path: Path): + wav_path = tmp_path / "silence.wav" + with wave.open(str(wav_path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(44_100) + wav.writeframes(b"\0\0" * 4_410) + + cmd, env = _resolved_mpv_command( + [ + "mpv", + "--no-terminal", + "--force-window=no", + "--audio-display=no", + "--keep-open=no", + "--autoload-files=no", + "--ao=null", + "--vo=null", + "--", + str(wav_path), + ] + ) - result = subprocess.run([mpv, "--version"], env=env, capture_output=True) + result = subprocess.run(cmd, env=env, capture_output=True, timeout=10) assert result.returncode == 0, result.stderr.decode() From 57b02f307e9de04569b5ea2e698092c68ac6ef02 Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:09:39 -0300 Subject: [PATCH 3/5] test(pylib): make sound path assertions platform-independent --- pylib/tests/test_sound.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylib/tests/test_sound.py b/pylib/tests/test_sound.py index 2a5d549c386..5e2ae693486 100644 --- a/pylib/tests/test_sound.py +++ b/pylib/tests/test_sound.py @@ -8,7 +8,7 @@ def test_sound_tag_path_relative_joins_media_folder(): tag = SoundOrVideoTag(filename="audio.mp3") - assert tag.path("/media") == "/media/audio.mp3" + assert tag.path("/media") == os.path.join("/media", "audio.mp3") def test_sound_tag_path_absolute_ignores_media_folder(): @@ -35,7 +35,7 @@ def fake_filter(fname: str) -> str: monkeypatch.setattr(sound_module.hooks, "media_file_filter", fake_filter) tag = SoundOrVideoTag(filename="audio.mp3") - assert tag.path("/media") == "/media/renamed.mp3" + assert tag.path("/media") == os.path.join("/media", "renamed.mp3") def test_strip_av_refs_removes_play_tag(): From c21eb6f7e29a7035cb7483fbea759c5d5f794c8b Mon Sep 17 00:00:00 2001 From: Fernando Lins <1887601+fernandolins@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:59:40 -0300 Subject: [PATCH 4/5] fix(tests): use tmp_path for cross-platform media folder paths in test_sound --- pylib/tests/test_sound.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylib/tests/test_sound.py b/pylib/tests/test_sound.py index 5e2ae693486..891048b4337 100644 --- a/pylib/tests/test_sound.py +++ b/pylib/tests/test_sound.py @@ -6,9 +6,9 @@ from anki.sound import AV_REF_RE, SoundOrVideoTag, strip_av_refs -def test_sound_tag_path_relative_joins_media_folder(): +def test_sound_tag_path_relative_joins_media_folder(tmp_path): tag = SoundOrVideoTag(filename="audio.mp3") - assert tag.path("/media") == os.path.join("/media", "audio.mp3") + assert tag.path(str(tmp_path)) == os.path.join(str(tmp_path), "audio.mp3") def test_sound_tag_path_absolute_ignores_media_folder(): @@ -26,7 +26,7 @@ def test_sound_tag_path_with_directory_separator_uses_abspath(): assert result == expected -def test_sound_tag_path_applies_media_file_filter(monkeypatch): +def test_sound_tag_path_applies_media_file_filter(monkeypatch, tmp_path): # hooks.media_file_filter is applied to the tail component of the path. import anki.sound as sound_module @@ -35,7 +35,7 @@ def fake_filter(fname: str) -> str: monkeypatch.setattr(sound_module.hooks, "media_file_filter", fake_filter) tag = SoundOrVideoTag(filename="audio.mp3") - assert tag.path("/media") == os.path.join("/media", "renamed.mp3") + assert tag.path(str(tmp_path)) == os.path.join(str(tmp_path), "renamed.mp3") def test_strip_av_refs_removes_play_tag(): From 4cf8858779a92c854924e5bb12cfccf6b928c34c Mon Sep 17 00:00:00 2001 From: Abdo Date: Thu, 2 Jul 2026 14:28:14 +0300 Subject: [PATCH 5/5] Test MpvManager --- qt/tests/test_sound.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/qt/tests/test_sound.py b/qt/tests/test_sound.py index c5de991e4a6..91dd41b7173 100644 --- a/qt/tests/test_sound.py +++ b/qt/tests/test_sound.py @@ -5,11 +5,14 @@ import subprocess import wave from pathlib import Path +from unittest.mock import MagicMock import pytest +import aqt +from anki.sound import SoundOrVideoTag from anki.utils import is_mac, is_win -from aqt.sound import _packagedCmd, is_audio_file +from aqt.sound import MpvManager, _packagedCmd, is_audio_file def test_is_audio_file_recognizes_common_formats(): @@ -75,14 +78,18 @@ def test_mpv_binary_runs(): assert result.returncode == 0, result.stderr.decode() -def test_mpv_can_play_generated_wav(tmp_path: Path): +@pytest.fixture +def generated_wav(tmp_path: Path) -> Path: wav_path = tmp_path / "silence.wav" with wave.open(str(wav_path), "wb") as wav: wav.setnchannels(1) wav.setsampwidth(2) wav.setframerate(44_100) wav.writeframes(b"\0\0" * 4_410) + return wav_path + +def test_mpv_can_play_generated_wav(generated_wav: Path): cmd, env = _resolved_mpv_command( [ "mpv", @@ -94,9 +101,24 @@ def test_mpv_can_play_generated_wav(tmp_path: Path): "--ao=null", "--vo=null", "--", - str(wav_path), + str(generated_wav), ] ) result = subprocess.run(cmd, env=env, capture_output=True, timeout=10) assert result.returncode == 0, result.stderr.decode() + + +def test_mpvmanager_can_play_generated_wav( + monkeypatch, tmp_path: Path, generated_wav: Path +): + monkeypatch.setattr( + MpvManager, "default_argv", MpvManager.default_argv + ["--ao=null", "--vo=null"] + ) + mock_mw = MagicMock() + mock_mw.taskman.run_in_background.side_effect = ( + lambda task, on_done=None, **kwargs: task() + ) + monkeypatch.setattr(aqt, "mw", mock_mw) + manager = MpvManager(tmp_path, tmp_path) + manager.play(SoundOrVideoTag(filename=str(generated_wav.name)), lambda _: None)