mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-03 04:23:59 +03:00
refactor: remove old tests
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_config():
|
||||
"""Return a fresh DummyConfig instance for tests to customize."""
|
||||
return DummyConfig()
|
||||
@@ -1,19 +0,0 @@
|
||||
import os
|
||||
|
||||
|
||||
class DummyConfig:
|
||||
"""Small test configuration object used by unit and integration tests.
|
||||
|
||||
Adjust attributes via environment variables where appropriate.
|
||||
"""
|
||||
yt_dlp_path = os.getenv("YTDLP_PATH", "yt-dlp")
|
||||
ffmpeg_path = os.getenv("FFMPEG_PATH", "ffmpeg")
|
||||
aria2c_path = os.getenv("ARIA2C_PATH", "aria2c")
|
||||
max_parallel_downloads = int(os.getenv("TEST_MAX_PARALLEL", "2"))
|
||||
aria2c_connections = int(os.getenv("TEST_ARIA2C_CONN", "2"))
|
||||
download_mode = os.getenv("TEST_DOWNLOAD_MODE", "audio")
|
||||
max_download_quality = os.getenv("TEST_MAX_DOWNLOAD_QUALITY", "1080p")
|
||||
# runtime flags
|
||||
debug = False
|
||||
non_interactive = False
|
||||
prune = False
|
||||
@@ -1,154 +0,0 @@
|
||||
"""
|
||||
Full integration test (opt-in):
|
||||
- Set environment variable INTEGRATION_TEST=1 to enable
|
||||
- Optionally set TEST_PLAYLIST_URL to a full playlist URL; otherwise the built-in playlist id will be used
|
||||
|
||||
This script will attempt to download real audio/video for a small playlist (3 items).
|
||||
It will run three modes: audio, video, and both. It is intentionally opt-in to avoid accidental large downloads.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
# Make imports robust when running the script directly from different working directories.
|
||||
# Ensure the repository root is on sys.path so the script can import `src`.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
if not os.getenv("INTEGRATION_TEST"):
|
||||
print("Skipping full integration test (set INTEGRATION_TEST=1 to enable)")
|
||||
sys.exit(0)
|
||||
|
||||
# Prefer local ./bin/ executables for integration runs when available.
|
||||
# Set environment variables before importing TempConfig so its class attributes
|
||||
# pick up these overridden paths.
|
||||
bin_dir = REPO_ROOT / "bin" / "linux"
|
||||
if bin_dir.exists():
|
||||
ytdlp_path = bin_dir / "yt-dlp"
|
||||
if ytdlp_path.exists():
|
||||
os.environ.setdefault("YTDLP_PATH", str(ytdlp_path))
|
||||
print(f"Using local yt-dlp at: {ytdlp_path}")
|
||||
ffmpeg_path = bin_dir / "ffmpeg"
|
||||
if ffmpeg_path.exists():
|
||||
os.environ.setdefault("FFMPEG_PATH", str(ffmpeg_path))
|
||||
print(f"Using local ffmpeg at: {ffmpeg_path}")
|
||||
aria2c_path = bin_dir / "aria2c"
|
||||
if aria2c_path.exists():
|
||||
os.environ.setdefault("ARIA2C_PATH", str(aria2c_path))
|
||||
print(f"Using local aria2c at: {aria2c_path}")
|
||||
|
||||
# allow caller to override playlist url via env
|
||||
playlist_url = os.getenv("TEST_PLAYLIST_URL")
|
||||
if not playlist_url:
|
||||
# Use provided playlist id (3 videos)
|
||||
playlist_id = "PLUmRr21IDW9WCW87FnbWAbIwwZHbf-lAz"
|
||||
playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}"
|
||||
|
||||
print(f"Using playlist URL: {playlist_url}")
|
||||
|
||||
cfg_base = DummyConfig()
|
||||
|
||||
# ensure yt-dlp exists
|
||||
if not shutil.which(str(cfg_base.yt_dlp_path)):
|
||||
print(f"yt-dlp binary not found at '{cfg_base.yt_dlp_path}'. Please install yt-dlp or set YTDLP_PATH environment variable.")
|
||||
sys.exit(2)
|
||||
|
||||
MODES = ["audio", "video", "both"]
|
||||
|
||||
root_tmp = Path("./tests/tmp_integration_full")
|
||||
root_tmp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
failed = False
|
||||
for mode in MODES:
|
||||
print(f"\n=== Running mode: {mode} ===")
|
||||
cfg = DummyConfig()
|
||||
# Allow enabling verbose subprocess output from CI by setting YTPL_DEBUG=1
|
||||
cfg.debug = bool(os.getenv("YTPL_DEBUG", "0") == "1")
|
||||
cfg.download_mode = mode
|
||||
# make downloads single-threaded for predictability
|
||||
cfg.max_parallel_downloads = 1
|
||||
cfg.aria2c_connections = 1
|
||||
|
||||
save_path = root_tmp / mode
|
||||
# ensure a clean directory per run
|
||||
if save_path.exists():
|
||||
try:
|
||||
shutil.rmtree(save_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
playlist = {"url": playlist_url, "save_path": str(save_path), "archive": f"archive_{mode}.txt"}
|
||||
|
||||
downloader = PlaylistDownloader(cfg, playlist, 0)
|
||||
# Print resolved binary paths for debugging
|
||||
try:
|
||||
print(f"Resolved yt-dlp path: {cfg.yt_dlp_path}")
|
||||
print(f"Resolved ffmpeg path: {cfg.ffmpeg_path}")
|
||||
print(f"Resolved aria2c path: {cfg.aria2c_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
start = time.time()
|
||||
downloader.update()
|
||||
dur = time.time() - start
|
||||
print(f"Mode {mode} completed in {dur:.1f}s")
|
||||
|
||||
# basic verifications
|
||||
if mode in ("audio", "both"):
|
||||
audio_folder = save_path / "audio"
|
||||
mp3s = list(audio_folder.glob("*.mp3")) if audio_folder.exists() else []
|
||||
print(f"Found {len(mp3s)} mp3 files in {audio_folder}")
|
||||
if len(mp3s) < 3:
|
||||
print(f"Expected >=3 mp3 files for mode={mode}, found {len(mp3s)}")
|
||||
failed = True
|
||||
if mode in ("video", "both"):
|
||||
video_folder = save_path / "video"
|
||||
mp4s = list(video_folder.glob("*.mp4")) if video_folder.exists() else []
|
||||
print(f"Found {len(mp4s)} mp4 files in {video_folder}")
|
||||
if len(mp4s) < 3:
|
||||
print(f"Expected >=3 mp4 files for mode={mode}, found {len(mp4s)}")
|
||||
failed = True
|
||||
|
||||
# check archive has entries
|
||||
archive_file = (save_path / f"archive_{mode}.txt")
|
||||
if archive_file.exists():
|
||||
lines = [line for line in archive_file.read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
print(f"Archive {archive_file} contains {len(lines)} lines")
|
||||
if len(lines) < 3:
|
||||
print(f"Expected archive to contain >=3 lines, found {len(lines)}")
|
||||
# Not necessarily fatal; mark failure but continue
|
||||
failed = True
|
||||
else:
|
||||
print(f"Archive file {archive_file} not found")
|
||||
failed = True
|
||||
|
||||
except Exception as ex:
|
||||
print(f"Exception during mode {mode}: {ex}")
|
||||
failed = True
|
||||
|
||||
# cleanup to avoid leaving large files around
|
||||
try:
|
||||
if save_path.exists():
|
||||
shutil.rmtree(save_path)
|
||||
print(f"Cleaned up {save_path}")
|
||||
except Exception as ex:
|
||||
print(f"Failed to clean up {save_path}: {ex}")
|
||||
|
||||
# final cleanup
|
||||
try:
|
||||
if root_tmp.exists():
|
||||
shutil.rmtree(root_tmp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if failed:
|
||||
print("Integration full workflow test encountered failures.")
|
||||
sys.exit(3)
|
||||
|
||||
print("Integration full workflow test completed successfully")
|
||||
sys.exit(0)
|
||||
@@ -1,23 +0,0 @@
|
||||
import logging
|
||||
from src.old.manager import PlaylistManager
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
def test_run_with_prune_disabled():
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
|
||||
cfg = DummyConfig()
|
||||
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
|
||||
m = PlaylistManager(cfg, debug=False)
|
||||
# should complete without raising
|
||||
m.run()
|
||||
|
||||
|
||||
def test_run_with_prune_enabled_non_interactive():
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
|
||||
cfg = DummyConfig()
|
||||
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
|
||||
cfg.prune = True
|
||||
cfg.non_interactive = True
|
||||
m = PlaylistManager(cfg, debug=False)
|
||||
# should complete without raising
|
||||
m.run()
|
||||
@@ -1,45 +0,0 @@
|
||||
import logging
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
import src.old.cli as cli_mod
|
||||
|
||||
|
||||
class DummyCompleted(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
def test_update_yt_dlp_success(monkeypatch, caplog):
|
||||
called = {"count": 0}
|
||||
|
||||
def fake_run(args, check=True, **kw):
|
||||
called["count"] += 1
|
||||
return DummyCompleted(returncode=0)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
caplog.set_level(logging.INFO)
|
||||
cli_mod.update_yt_dlp("yt-dlp", debug=False)
|
||||
assert called["count"] == 1
|
||||
assert any("up to date" in r.message.lower() for r in caplog.records)
|
||||
|
||||
|
||||
def test_update_yt_dlp_failure(monkeypatch, caplog):
|
||||
def raise_called(*a, **k):
|
||||
raise subprocess.CalledProcessError(1, cmd=a[0])
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", raise_called)
|
||||
caplog.set_level(logging.WARNING)
|
||||
cli_mod.update_yt_dlp("yt-dlp", debug=False)
|
||||
assert any("could not update yt-dlp" in r.message.lower() or "could not update" in r.message.lower() for r in caplog.records)
|
||||
|
||||
|
||||
def test_configure_logging_sets_levels():
|
||||
# ensure calling configure_logging flips global root logger level
|
||||
# clear existing handlers so basicConfig can take effect in test
|
||||
logging.root.handlers.clear()
|
||||
cli_mod.configure_logging(True)
|
||||
assert logging.getLogger().getEffectiveLevel() == logging.DEBUG
|
||||
logging.root.handlers.clear()
|
||||
cli_mod.configure_logging(False)
|
||||
assert logging.getLogger().getEffectiveLevel() == logging.INFO
|
||||
@@ -1,28 +0,0 @@
|
||||
import json
|
||||
|
||||
from src.old.config import ConfigLoader
|
||||
|
||||
|
||||
def test_config_loader_reads_properties(tmp_path, monkeypatch):
|
||||
# create a minimal config file with known binary names that exist on PATH
|
||||
cfg = {
|
||||
"playlists": [{"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": "./tmp", "archive": "archive.txt"}],
|
||||
"yt_dlp_path": "python",
|
||||
"ffmpeg_path": "python",
|
||||
"aria2c_path": "python",
|
||||
"max_parallel_downloads": 3,
|
||||
"aria2c_connections": 2,
|
||||
}
|
||||
|
||||
p = tmp_path / "yt-playlist-config.json"
|
||||
p.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
|
||||
# Use absolute path so ConfigLoader doesn't try to create ./config
|
||||
loader = ConfigLoader(str(p))
|
||||
|
||||
assert loader.playlists == cfg["playlists"]
|
||||
assert loader.yt_dlp_path == "python"
|
||||
assert loader.ffmpeg_path == "python"
|
||||
assert loader.aria2c_path == "python"
|
||||
assert loader.max_parallel_downloads == 3
|
||||
assert loader.aria2c_connections == 2
|
||||
@@ -1,40 +0,0 @@
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
def test_download_video_invalid_mode(tmp_path):
|
||||
cfg = DummyConfig()
|
||||
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
|
||||
dl = PlaylistDownloader(cfg, playlist, 0)
|
||||
dl.download_mode = "invalid_mode"
|
||||
video = {"id": "X1", "title": "Test"}
|
||||
assert dl.download_video(video, 1) is False
|
||||
|
||||
|
||||
def test_download_video_both_mode_ffmpeg_missing(monkeypatch, tmp_path, caplog):
|
||||
cfg = DummyConfig()
|
||||
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
|
||||
dl = PlaylistDownloader(cfg, playlist, 0)
|
||||
dl.download_mode = "both"
|
||||
|
||||
video = {"id": "X1", "title": "Test"}
|
||||
|
||||
# monkeypatch _run to simulate successful video download and ffmpeg extraction failure path
|
||||
def fake_run(*args, **kwargs):
|
||||
# accept label or other kwargs; simulate successful call
|
||||
return subprocess.CompletedProcess(args, 0)
|
||||
|
||||
monkeypatch.setattr(PlaylistDownloader, "_run", fake_run)
|
||||
|
||||
# Ensure ffmpeg is not found
|
||||
monkeypatch.setattr(shutil, "which", lambda p: None)
|
||||
|
||||
# Should not raise; will log a warning about ffmpeg missing
|
||||
caplog.set_level("WARNING")
|
||||
ok = dl.download_video(video, 1)
|
||||
# For 'both' mode the function returns True when video download succeeded (we simulate that)
|
||||
assert ok is True
|
||||
assert any("ffmpeg not found" in r.message.lower() or "ffmpeg failed" in r.message.lower() for r in caplog.records) or True
|
||||
@@ -1,24 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
def test_sanitize_title_and_get_file_path(tmp_path):
|
||||
cfg = DummyConfig()
|
||||
playlist = {"url": None, "save_path": str(tmp_path)}
|
||||
dl = PlaylistDownloader(cfg, playlist, 0)
|
||||
|
||||
# illegal chars should be replaced and trimmed; fallback_id used when title becomes empty
|
||||
title = ' My: <>:"/\\|?*Title '
|
||||
safe = dl.sanitize_title(title, "ABC123")
|
||||
# ensure no illegal characters remain
|
||||
assert all(c not in safe for c in dl.illegal_chars)
|
||||
|
||||
# empty title should return fallback id
|
||||
assert dl.sanitize_title(" ", "FALLBACK") == "FALLBACK"
|
||||
|
||||
# get_file_path uses save_path and zero-padded index
|
||||
path = dl.get_file_path(5, "SongName")
|
||||
assert isinstance(path, Path)
|
||||
assert path.name.startswith("005 - SongName")
|
||||
@@ -1,48 +0,0 @@
|
||||
import json
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
class DummyCompleted(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
def test_fetch_videos_parses_entries(monkeypatch, tmp_path):
|
||||
cfg = DummyConfig()
|
||||
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
|
||||
dl = PlaylistDownloader(cfg, playlist, 0)
|
||||
|
||||
entries = [{"id": "A1", "title": "Song 1"}, {"id": "B2", "title": "Song 2"}]
|
||||
out = json.dumps({"entries": entries})
|
||||
|
||||
def fake_run(args, capture_output=True, text=True, check=True):
|
||||
return DummyCompleted(stdout=out)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
res = dl.fetch_videos()
|
||||
assert isinstance(res, list)
|
||||
assert len(res) == 2
|
||||
assert res[0]["id"] == "A1"
|
||||
|
||||
|
||||
def test_fetch_videos_handles_private_and_errors(monkeypatch, tmp_path, caplog):
|
||||
cfg = DummyConfig()
|
||||
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
|
||||
dl = PlaylistDownloader(cfg, playlist, 0)
|
||||
|
||||
# simulate CalledProcessError with 'private' message
|
||||
def raise_called(*a, **k):
|
||||
e = subprocess.CalledProcessError(1, cmd=a[0])
|
||||
e.stderr = "This playlist is private"
|
||||
raise e
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", raise_called)
|
||||
|
||||
caplog.set_level("WARNING")
|
||||
res = dl.fetch_videos()
|
||||
assert res == []
|
||||
assert dl.skip is True
|
||||
@@ -1,26 +0,0 @@
|
||||
import logging
|
||||
from tests.dummy_config import DummyConfig
|
||||
from src.old.manager import PlaylistManager
|
||||
|
||||
|
||||
def test_manager_warns_and_sleeps(monkeypatch, caplog):
|
||||
# Avoid actually sleeping during the test
|
||||
slept = {"called": False}
|
||||
|
||||
def fake_sleep(sec):
|
||||
slept["called"] = True
|
||||
|
||||
# monkeypatch the sleep used inside the manager module
|
||||
monkeypatch.setattr("src.manager.time.sleep", fake_sleep)
|
||||
|
||||
caplog.set_level(logging.WARNING)
|
||||
cfg = DummyConfig()
|
||||
cfg.max_parallel_downloads = 11
|
||||
cfg.aria2c_connections = 10
|
||||
cfg.playlists = []
|
||||
|
||||
m = PlaylistManager(cfg, debug=False)
|
||||
m.run()
|
||||
|
||||
assert slept["called"] is True
|
||||
assert any("may overload your network" in rec.getMessage() for rec in caplog.records)
|
||||
@@ -1,23 +0,0 @@
|
||||
import logging
|
||||
from src.old.manager import PlaylistManager
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
def test_run_with_prune_disabled():
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
|
||||
cfg = DummyConfig()
|
||||
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
|
||||
m = PlaylistManager(cfg, debug=False)
|
||||
# should complete without raising
|
||||
m.run()
|
||||
|
||||
|
||||
def test_run_with_prune_enabled_non_interactive():
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
|
||||
cfg = DummyConfig()
|
||||
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
|
||||
cfg.prune = True
|
||||
cfg.non_interactive = True
|
||||
m = PlaylistManager(cfg, debug=False)
|
||||
# should complete without raising
|
||||
m.run()
|
||||
@@ -1,78 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
def touch(p: Path):
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text("x")
|
||||
|
||||
|
||||
def test_renumber_audio_and_cleanup(tmp_path, monkeypatch):
|
||||
cfg = DummyConfig()
|
||||
playlist = {"url": "FAKE", "save_path": str(tmp_path)}
|
||||
dl = PlaylistDownloader(cfg, playlist, 0)
|
||||
# set download mode to audio and create only audio files
|
||||
dl.download_mode = "audio"
|
||||
|
||||
entries = [
|
||||
{"id": "ID1", "title": "First Song"},
|
||||
{"id": "ID2", "title": "Second Song"},
|
||||
]
|
||||
|
||||
a1 = tmp_path / "audio" / "oldname First Song.mp3"
|
||||
a2 = tmp_path / "audio" / "zzz Second Song.mp3"
|
||||
touch(a1)
|
||||
touch(a2)
|
||||
|
||||
# On Windows os.rename may fail when target exists; use os.replace to allow
|
||||
# overwrite semantics for the duration of this test.
|
||||
import os as _os
|
||||
|
||||
monkeypatch.setattr(Path, "rename", lambda self, target: _os.replace(self, target))
|
||||
dl.renumber_all_tracks(entries)
|
||||
|
||||
audio_files = list((tmp_path / "audio").glob("*.mp3"))
|
||||
# On some platforms the renaming logic may overwrite targets; assert at least
|
||||
# one audio file was produced and that its name contains one of the titles.
|
||||
assert audio_files
|
||||
assert any("First Song" in f.name or "Second Song" in f.name for f in audio_files)
|
||||
|
||||
# Now test cleanup_removed_tracks: create a stray file not in entries
|
||||
stray = tmp_path / "audio" / "999 - NotInPlaylist.mp3"
|
||||
touch(stray)
|
||||
# ensure prune=False -> no deletion
|
||||
dl.prune = False
|
||||
dl.cleanup_removed_tracks(entries)
|
||||
assert stray.exists()
|
||||
|
||||
# Now enable prune and non_interactive so deletion occurs without input
|
||||
dl.prune = True
|
||||
dl.non_interactive = True
|
||||
dl.cleanup_removed_tracks(entries)
|
||||
assert not stray.exists()
|
||||
|
||||
|
||||
def test_renumber_video(tmp_path, monkeypatch):
|
||||
cfg = DummyConfig()
|
||||
playlist = {"url": "FAKE", "save_path": str(tmp_path)}
|
||||
dl = PlaylistDownloader(cfg, playlist, 0)
|
||||
dl.download_mode = "video"
|
||||
|
||||
entries = [
|
||||
{"id": "ID1", "title": "Alpha"},
|
||||
{"id": "ID2", "title": "Beta"},
|
||||
]
|
||||
|
||||
v1 = tmp_path / "video" / "something Alpha.mp4"
|
||||
v2 = tmp_path / "video" / "something Beta.mp4"
|
||||
touch(v1)
|
||||
touch(v2)
|
||||
|
||||
import os as _os
|
||||
monkeypatch.setattr(Path, "rename", lambda self, target: _os.replace(self, target))
|
||||
dl.renumber_all_tracks(entries)
|
||||
video_files = list((tmp_path / "video").glob("*.mp4"))
|
||||
assert video_files
|
||||
assert any("Alpha" in f.name or "Beta" in f.name for f in video_files)
|
||||
Reference in New Issue
Block a user