576 lines
20 KiB
Python
576 lines
20 KiB
Python
"""Round 81: Export Web Mix can upload to a web server, and the player shuffles.
|
|
|
|
- The server (host + folder) lives in config.json, per machine.
|
|
- Upload = rsync mirror over ssh into <folder>/<slug>/, never the folder
|
|
itself, from a staging tree that is rebuilt every time.
|
|
- No test ever runs ssh or rsync: Popen and which are stubbed.
|
|
"""
|
|
import io
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from PyQt6.QtCore import QObject, pyqtSignal
|
|
|
|
from lintunes import config
|
|
from lintunes.export import exporter, upload
|
|
from lintunes.models import Track
|
|
|
|
HOST = "example-host"
|
|
|
|
|
|
def _track(tid, path):
|
|
return Track(track_id=tid, name=f"Song {tid}", artist="Artist",
|
|
location=str(path), kind="MPEG audio file",
|
|
total_time=180_000)
|
|
|
|
|
|
def _audio(tmp_path, name):
|
|
path = tmp_path / "local" / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"x" * 100)
|
|
return path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _xdg(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
|
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
|
|
|
|
|
# ---- pure helpers ----
|
|
|
|
@pytest.mark.parametrize("title, slug", [
|
|
("Summer Mix 2026!", "summer-mix-2026"),
|
|
("Étoile Filante", "etoile-filante"),
|
|
(" rock / roll ", "rock-roll"),
|
|
("🎵 Night 🎵", "night"),
|
|
("!!!", ""),
|
|
])
|
|
def test_slugify(title, slug):
|
|
assert upload.slugify(title) == slug
|
|
|
|
|
|
@pytest.mark.parametrize("slug, ok", [
|
|
("mix", True), ("", False), (".", False), ("..", False), ("a/b", False)])
|
|
def test_valid_slug(slug, ok):
|
|
assert upload.valid_slug(slug) is ok
|
|
|
|
|
|
def test_server_settings_needs_both():
|
|
assert upload.server_settings({}) is None
|
|
assert upload.server_settings({upload.HOST_KEY: HOST}) is None
|
|
assert upload.server_settings(
|
|
{upload.HOST_KEY: f" {HOST} ", upload.PATH_KEY: "/srv/mixes "}) \
|
|
== (HOST, "/srv/mixes")
|
|
|
|
|
|
def test_remote_dir_is_always_inside_the_folder():
|
|
assert upload.remote_dir("/srv/mixes/", "mix") == "/srv/mixes/mix"
|
|
assert upload.remote_dir("~/www/mixes", "mix") == "www/mixes/mix"
|
|
assert upload.remote_dir("~", "mix") == "mix"
|
|
for bad in ("", "..", "a/b"):
|
|
with pytest.raises(upload.UploadError):
|
|
upload.remote_dir("/srv/mixes", bad)
|
|
|
|
|
|
def test_exists_command_quotes_the_path():
|
|
argv = upload.exists_command(HOST, "/srv/my mixes/a")
|
|
assert argv[0] == "ssh" and "BatchMode=yes" in argv
|
|
assert argv[-3:] == ["test", "-d", "'/srv/my mixes/a'"]
|
|
assert HOST in argv
|
|
|
|
|
|
def test_rsync_command_mirrors_into_the_slug_folder(tmp_path):
|
|
argv = upload.rsync_command(HOST, tmp_path / "mix", "/srv/mixes/mix")
|
|
assert argv[0] == "rsync"
|
|
assert "--delete" in argv and "--checksum" in argv
|
|
assert "BatchMode=yes" in argv[argv.index("-e") + 1]
|
|
assert argv[-2] == str(tmp_path / "mix") + "/" # contents, not folder
|
|
assert argv[-1] == f"{HOST}:/srv/mixes/mix/"
|
|
|
|
|
|
def test_reset_staging_stays_inside_the_staging_root(tmp_path):
|
|
root = upload.staging_root()
|
|
built = root / "mix" / "audios"
|
|
built.mkdir(parents=True)
|
|
(built / "old.mp3").write_bytes(b"x")
|
|
target = upload.reset_staging("mix")
|
|
assert target == root / "mix" and not target.exists()
|
|
assert root.exists()
|
|
for bad in ("", "..", "../x"):
|
|
with pytest.raises(upload.UploadError):
|
|
upload.reset_staging(bad)
|
|
|
|
|
|
@pytest.mark.parametrize("line, percent", [
|
|
(" 32,768 0% 0.00kB/s 0:00:00 (xfr#0, to-chk=10/12)", 0),
|
|
(" 1,234,567 42% 1.23MB/s 0:00:03 (xfr#2, to-chk=5/9)", 42),
|
|
(" 12.34M 100% 10.00MB/s 0:00:01", 100),
|
|
("rsync: [Receiver] mkdir \"/srv/x\" failed: No such file (2)", None),
|
|
("sending incremental file list", None),
|
|
])
|
|
def test_parse_progress2(line, percent):
|
|
assert upload.parse_progress2(line) == percent
|
|
|
|
|
|
def test_plan_export_folder_override(tmp_path):
|
|
tracks = [_track(1, _audio(tmp_path, "a.mp3"))]
|
|
plan = exporter.plan_export("My Mix", tracks, tmp_path / "out",
|
|
exporter.WEB, folder="my-mix")
|
|
assert plan.dest_dir == tmp_path / "out" / "my-mix"
|
|
assert plan.audio_dir == tmp_path / "out" / "my-mix" / "audios"
|
|
assert exporter.plan_export("My Mix", tracks, tmp_path / "out",
|
|
exporter.WEB).dest_dir.name == "My Mix"
|
|
|
|
|
|
# ---- workers, with a fake subprocess ----
|
|
|
|
class _FakeProc:
|
|
def __init__(self, output: bytes, returncode: int):
|
|
self.stdout = io.BufferedReader(io.BytesIO(output))
|
|
self.returncode = None
|
|
self._code = returncode
|
|
self.killed = False
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def wait(self):
|
|
self.returncode = self._code
|
|
return self._code
|
|
|
|
def communicate(self):
|
|
self.returncode = self._code
|
|
return self.stdout.read(), None
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
|
|
def _fake_popen(monkeypatch, output=b"", code=0):
|
|
calls = []
|
|
|
|
def popen(argv, **kwargs):
|
|
proc = _FakeProc(output, code)
|
|
calls.append((argv, proc))
|
|
return proc
|
|
|
|
monkeypatch.setattr(upload.subprocess, "Popen", popen)
|
|
monkeypatch.setattr(upload.shutil, "which", lambda name: f"/usr/bin/{name}")
|
|
return calls
|
|
|
|
|
|
def _collect(worker, *names):
|
|
seen = {n: [] for n in names}
|
|
for n in names:
|
|
getattr(worker, n).connect(lambda *a, n=n: seen[n].append(a))
|
|
return seen
|
|
|
|
|
|
@pytest.mark.parametrize("code, outcome", [(0, True), (1, False)])
|
|
def test_server_check_reports_existence(qapp, monkeypatch, code, outcome):
|
|
calls = _fake_popen(monkeypatch, code=code)
|
|
worker = upload.ServerCheckWorker(HOST, "/srv/mixes/mix")
|
|
seen = _collect(worker, "result", "failed")
|
|
worker._run()
|
|
assert seen["result"] == [(outcome,)] and not seen["failed"]
|
|
assert calls[0][0][0] == "ssh"
|
|
|
|
|
|
def test_server_check_connection_error(qapp, monkeypatch):
|
|
_fake_popen(monkeypatch, b"ssh: Could not resolve hostname\n", 255)
|
|
worker = upload.ServerCheckWorker(HOST, "/srv/mixes/mix")
|
|
seen = _collect(worker, "result", "failed")
|
|
worker._run()
|
|
assert not seen["result"]
|
|
assert "Could not resolve hostname" in seen["failed"][0][0]
|
|
|
|
|
|
def test_missing_rsync_is_an_error_not_a_crash(qapp, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(upload.shutil, "which", lambda name: None)
|
|
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
|
|
seen = _collect(worker, "failed", "finished")
|
|
worker._run()
|
|
assert "rsync" in seen["failed"][0][0] and not seen["finished"]
|
|
|
|
|
|
def test_upload_reports_progress_and_files(qapp, monkeypatch, tmp_path):
|
|
out = (b"sending incremental file list\n"
|
|
b" 32,768 10% 1.00MB/s 0:00:01 (xfr#1, to-chk=3/5)\r"
|
|
b" 327,680 100% 1.00MB/s 0:00:01 (xfr#3, to-chk=0/5)\n")
|
|
_fake_popen(monkeypatch, out, 0)
|
|
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
|
|
seen = _collect(worker, "progress", "finished", "failed")
|
|
worker._run()
|
|
assert [p[0] for p in seen["progress"]] == [10, 100]
|
|
assert seen["finished"][0][0]["files_sent"] == 3
|
|
assert not seen["failed"]
|
|
|
|
|
|
def test_upload_failure_carries_rsyncs_message(qapp, monkeypatch, tmp_path):
|
|
_fake_popen(monkeypatch,
|
|
b'rsync: [Receiver] mkdir "/srv/mixes/mix" failed: '
|
|
b"No such file or directory (2)\n", 11)
|
|
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
|
|
seen = _collect(worker, "failed", "finished")
|
|
worker._run()
|
|
assert "No such file or directory" in seen["failed"][0][0]
|
|
assert "exit 11" in seen["failed"][0][0]
|
|
|
|
|
|
def test_cancel_kills_the_process(qapp, monkeypatch, tmp_path):
|
|
calls = _fake_popen(monkeypatch, b"", 0)
|
|
worker = upload.UploadWorker(HOST, tmp_path, "/srv/mixes/mix")
|
|
worker._spawn(["rsync"])
|
|
worker.cancel()
|
|
assert calls[0][1].killed
|
|
|
|
|
|
# ---- the dialogs ----
|
|
|
|
class _FakeLastFm(QObject):
|
|
login_finished = pyqtSignal(bool, str)
|
|
status_message = pyqtSignal(str)
|
|
|
|
def is_logged_in(self):
|
|
return False
|
|
|
|
|
|
def test_upload_box_is_off_by_default_and_slug_follows_title(qapp):
|
|
from lintunes.gui.export_dialog import WebMixDialog
|
|
dialog = WebMixDialog("Summer Mix", None, {})
|
|
assert not dialog._upload.isChecked()
|
|
assert dialog.values()["slug"] == "summer-mix"
|
|
dialog._title.textEdited.emit("Fall Mix")
|
|
assert dialog.values()["slug"] == "fall-mix"
|
|
dialog._slug.textEdited.emit("custom")
|
|
dialog._slug.setText("custom")
|
|
dialog._title.textEdited.emit("Winter Mix")
|
|
assert dialog.values()["slug"] == "custom"
|
|
|
|
|
|
def test_ticked_without_a_server_stays_open(qapp, monkeypatch):
|
|
from lintunes.gui import export_dialog
|
|
told = []
|
|
monkeypatch.setattr(export_dialog.QMessageBox, "warning",
|
|
lambda *a, **k: told.append(a[2]))
|
|
dialog = export_dialog.WebMixDialog("Mix", None, {})
|
|
dialog._upload.setChecked(True)
|
|
dialog.accept()
|
|
assert told and "Preferences" in told[0] and "uncheck" in told[0]
|
|
assert dialog.result() != dialog.DialogCode.Accepted
|
|
|
|
|
|
def test_ticked_with_a_server_accepts(qapp, monkeypatch):
|
|
from lintunes.gui import export_dialog
|
|
monkeypatch.setattr(export_dialog.QMessageBox, "warning",
|
|
lambda *a, **k: pytest.fail("should not warn"))
|
|
dialog = export_dialog.WebMixDialog(
|
|
"Mix", None, {upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
|
|
dialog._upload.setChecked(True)
|
|
assert f"{HOST}:/srv/mixes/mix" in dialog._upload_note.text()
|
|
dialog.accept()
|
|
assert dialog.result() == dialog.DialogCode.Accepted
|
|
assert dialog.values()["upload"] is True
|
|
|
|
|
|
def test_preferences_save_the_server_to_config_json(qapp):
|
|
from lintunes.gui.preferences_dialog import PreferencesDialog
|
|
|
|
from lintunes.preferences import Preferences
|
|
prefs = Preferences(Path(config.config_path()).parent / "data")
|
|
dialog = PreferencesDialog(prefs, _FakeLastFm())
|
|
dialog._web_host.setText(HOST)
|
|
dialog._web_path.setText("/srv/mixes")
|
|
dialog._save_web_server()
|
|
saved = config.load_config()
|
|
assert saved[upload.HOST_KEY] == HOST
|
|
assert saved[upload.PATH_KEY] == "/srv/mixes"
|
|
# Nothing about the server reaches the synced preferences.
|
|
pref_file = Path(config.config_path()).parent / "data" / "preferences.json"
|
|
assert not pref_file.exists() or HOST not in pref_file.read_text()
|
|
|
|
|
|
# ---- the whole flow through MainWindow, with fake workers ----
|
|
|
|
class _FakeCheck:
|
|
exists = False
|
|
|
|
def __init__(self, host, rdir, parent=None):
|
|
from PyQt6.QtCore import QObject, pyqtSignal
|
|
|
|
class S(QObject):
|
|
result = pyqtSignal(bool)
|
|
failed = pyqtSignal(str)
|
|
cancelled = pyqtSignal()
|
|
self._s = S()
|
|
self.result, self.failed = self._s.result, self._s.failed
|
|
self.cancelled = self._s.cancelled
|
|
|
|
def busy(self):
|
|
return False
|
|
|
|
def cancel(self):
|
|
pass
|
|
|
|
def start(self):
|
|
self.result.emit(_FakeCheck.exists)
|
|
|
|
|
|
class _FakeUpload:
|
|
runs = []
|
|
|
|
def __init__(self, host, local, rdir, parent=None):
|
|
from PyQt6.QtCore import QObject, pyqtSignal
|
|
|
|
class S(QObject):
|
|
progress = pyqtSignal(int, str)
|
|
finished = pyqtSignal(dict)
|
|
failed = pyqtSignal(str)
|
|
cancelled = pyqtSignal()
|
|
self._s = S()
|
|
for n in ("progress", "finished", "failed", "cancelled"):
|
|
setattr(self, n, getattr(self._s, n))
|
|
self.args = (host, Path(local), rdir)
|
|
|
|
def busy(self):
|
|
return False
|
|
|
|
def cancel(self):
|
|
pass
|
|
|
|
def start(self):
|
|
host, local, rdir = self.args
|
|
_FakeUpload.runs.append(
|
|
(host, rdir, sorted(p.relative_to(local).as_posix()
|
|
for p in local.rglob("*") if p.is_file())))
|
|
self.finished.emit({"host": host, "dest": rdir, "files_sent": 2})
|
|
|
|
|
|
@pytest.fixture
|
|
def window(qapp, tmp_path, monkeypatch):
|
|
from lintunes.gui import main_window as mw
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.models import Library, Playlist
|
|
from lintunes.models.playlist import PlaylistType
|
|
from lintunes.preferences import Preferences
|
|
|
|
library = Library(
|
|
tracks={1: _track(1, _audio(tmp_path, "a.mp3")),
|
|
2: _track(2, _audio(tmp_path, "b.mp3"))},
|
|
playlists={"abcd1234": Playlist(
|
|
name="My Mix", persistent_id="abcd1234",
|
|
playlist_type=PlaylistType.REGULAR, track_ids=[1, 2])})
|
|
manager = LibraryManager(library, tmp_path / "data")
|
|
win = mw.MainWindow(manager, Preferences(tmp_path / "data"))
|
|
|
|
class Kind:
|
|
DialogCode = mw.ExportKindDialog.DialogCode
|
|
|
|
def __init__(self, *a):
|
|
pass
|
|
|
|
def exec(self):
|
|
return self.DialogCode.Accepted
|
|
|
|
def kind(self):
|
|
return exporter.WEB
|
|
|
|
class Web(Kind):
|
|
def values(self):
|
|
return {"title": "My Mix", "description": "", "images": [],
|
|
"accent": exporter.DEFAULT_ACCENT, "upload": True,
|
|
"slug": "my-mix"}
|
|
|
|
monkeypatch.setattr(mw, "ExportKindDialog", Kind)
|
|
monkeypatch.setattr(mw, "WebMixDialog", Web)
|
|
monkeypatch.setattr(mw.upload, "ServerCheckWorker", _FakeCheck)
|
|
monkeypatch.setattr(mw.upload, "UploadWorker", _FakeUpload)
|
|
monkeypatch.setattr(mw.QFileDialog, "getExistingDirectory",
|
|
lambda *a, **k: pytest.fail("no folder picker"))
|
|
monkeypatch.setattr(mw.sounds, "play_done", lambda *a: None)
|
|
_FakeUpload.runs = []
|
|
_FakeCheck.exists = False
|
|
yield win
|
|
if win._web_progress is not None:
|
|
win._web_progress.close()
|
|
win.close()
|
|
|
|
|
|
def _wait_for(qapp, cond, timeout=5):
|
|
end = time.monotonic() + timeout
|
|
while not cond() and time.monotonic() < end:
|
|
qapp.processEvents()
|
|
time.sleep(0.01)
|
|
assert cond()
|
|
|
|
|
|
def test_upload_flow_exports_to_staging_then_uploads(qapp, window):
|
|
config.save_config({upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
|
|
window._export_playlist("abcd1234")
|
|
_wait_for(qapp, lambda: _FakeUpload.runs)
|
|
host, rdir, files = _FakeUpload.runs[0]
|
|
assert (host, rdir) == (HOST, "/srv/mixes/my-mix")
|
|
assert "index.html" in files
|
|
assert "audios/Artist - Song 1.mp3" in files
|
|
assert window._web_upload is None
|
|
assert window._web_progress.ended
|
|
|
|
|
|
def test_a_removed_song_doesnt_survive_in_staging(qapp, window):
|
|
config.save_config({upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
|
|
window._export_playlist("abcd1234")
|
|
_wait_for(qapp, lambda: _FakeUpload.runs)
|
|
window._manager.library.playlists["abcd1234"].track_ids = [1]
|
|
window._export_playlist("abcd1234")
|
|
_wait_for(qapp, lambda: len(_FakeUpload.runs) == 2)
|
|
files = _FakeUpload.runs[1][2]
|
|
assert "audios/Artist - Song 2.mp3" not in files
|
|
|
|
|
|
def test_existing_folder_asks_and_no_stops(qapp, window, monkeypatch):
|
|
from lintunes.gui import main_window as mw
|
|
config.save_config({upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
|
|
_FakeCheck.exists = True
|
|
asked = []
|
|
|
|
def question(*a, **k):
|
|
asked.append(a[2])
|
|
return mw.QMessageBox.StandardButton.No
|
|
monkeypatch.setattr(mw.QMessageBox, "question", question)
|
|
window._export_playlist("abcd1234")
|
|
assert asked and "already exists" in asked[0]
|
|
assert window._web_upload is None and not _FakeUpload.runs
|
|
assert window._web_progress.status(2) == "Waiting"
|
|
|
|
|
|
def test_unticked_export_is_the_old_flow(qapp, window, monkeypatch, tmp_path):
|
|
from lintunes.gui import main_window as mw
|
|
picked = []
|
|
monkeypatch.setattr(mw.QFileDialog, "getExistingDirectory",
|
|
lambda *a, **k: picked.append(1) or "")
|
|
original = mw.WebMixDialog.values
|
|
monkeypatch.setattr(mw.WebMixDialog, "values",
|
|
lambda self: {**original(self), "upload": False})
|
|
window._export_playlist("abcd1234")
|
|
assert picked and window._web_upload is None
|
|
|
|
|
|
# ---- the web player's shuffle ----
|
|
|
|
def test_player_ships_a_shuffle_button():
|
|
js = (exporter.templates() / "player.js").read_text()
|
|
css = (exporter.templates() / "player.css").read_text()
|
|
assert 'class="shuffle"' in js and "setShuffle" in js
|
|
assert ".audiojs .shuffle.on" in css
|
|
|
|
|
|
|
|
def test_description_and_image_are_spaced_apart(tmp_path):
|
|
tracks = [_track(1, _audio(tmp_path, "a.mp3"))]
|
|
plan = exporter.plan_export("Mix", tracks, tmp_path / "out", exporter.WEB)
|
|
page = exporter.build_index_html(plan, "placeholder.png")
|
|
gap = page[page.index(plan.description) + len(plan.description):
|
|
page.index("placeholder.png")]
|
|
assert "<br><br>" in gap
|
|
|
|
|
|
def test_finished_window_hides_stop(qapp):
|
|
from lintunes.gui.web_upload_progress import WebUploadProgress
|
|
win = WebUploadProgress("Mix", f"{HOST}:/srv/mixes/mix")
|
|
win.show()
|
|
win.finish("done")
|
|
assert win._stop.isHidden() and win._hide.text() == "Close"
|
|
win.close()
|
|
|
|
|
|
# ---- several images ----
|
|
|
|
def test_choosing_an_image_adds_another_row(qapp, tmp_path):
|
|
from lintunes.gui.export_dialog import WebMixDialog
|
|
dialog = WebMixDialog("Mix", None, {})
|
|
assert len(dialog._images) == 1 and dialog.values()["images"] == []
|
|
dialog._set_image(dialog._images[0], str(tmp_path / "a.png"))
|
|
assert len(dialog._images) == 2 and not dialog._images[1].text()
|
|
dialog._set_image(dialog._images[1], str(tmp_path / "b.png"))
|
|
assert len(dialog._images) == 3
|
|
# Re-choosing an earlier row doesn't add one.
|
|
dialog._set_image(dialog._images[0], str(tmp_path / "c.png"))
|
|
assert len(dialog._images) == 3
|
|
assert dialog.values()["images"] == [tmp_path / "c.png",
|
|
tmp_path / "b.png"]
|
|
|
|
|
|
def test_clearing_a_row_removes_it_but_keeps_an_empty_one(qapp, tmp_path):
|
|
from lintunes.gui.export_dialog import WebMixDialog
|
|
dialog = WebMixDialog("Mix", None, {})
|
|
dialog._set_image(dialog._images[0], str(tmp_path / "a.png"))
|
|
dialog._set_image(dialog._images[1], str(tmp_path / "b.png"))
|
|
dialog._clear_image(dialog._images[0])
|
|
assert dialog.values()["images"] == [tmp_path / "b.png"]
|
|
assert len(dialog._images) == 2 and not dialog._images[-1].text()
|
|
dialog._clear_image(dialog._images[-1]) # the empty one stays
|
|
assert len(dialog._images) == 2
|
|
|
|
|
|
def test_several_images_land_in_order_with_a_gap(tmp_path):
|
|
a = tmp_path / "pics" / "cover.png"
|
|
b = tmp_path / "pics2" / "cover.png" # same name, other folder
|
|
c = tmp_path / "pics" / "band.jpg"
|
|
for path, data in ((a, b"A"), (b, b"B"), (c, b"C")):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
plan = exporter.plan_export(
|
|
"Mix", [_track(1, _audio(tmp_path, "a.mp3"))], tmp_path / "out",
|
|
exporter.WEB)
|
|
plan.images = [a, b, c]
|
|
worker = exporter.ExportWorker(plan)
|
|
worker._run()
|
|
dest = tmp_path / "out" / "Mix"
|
|
page = (dest / "index.html").read_text()
|
|
names = ["cover.png", "cover 2.png", "band.jpg"]
|
|
spots = [page.index(f'src="{n}"') for n in names]
|
|
assert spots == sorted(spots)
|
|
assert page[spots[0]:spots[1]].count("<br><br>") == 1
|
|
assert (dest / "cover 2.png").read_bytes() == b"B"
|
|
assert not (dest / "placeholder.png").exists()
|
|
|
|
|
|
def test_image_filters_see_uppercase_suffixes():
|
|
"""Qt filters are case-sensitive on Linux; a phone's IMG_1897.JPG hid."""
|
|
import fnmatch
|
|
from lintunes.gui.file_filters import IMAGE_FILTER, WEB_IMAGE_FILTER
|
|
for flt in (IMAGE_FILTER, WEB_IMAGE_FILTER):
|
|
patterns = flt[flt.index("(") + 1:-1].split()
|
|
for name in ("IMG_1897.JPG", "a.jpeg", "b.PNG", "c.WebP"[:-4] + "WEBP"):
|
|
assert any(fnmatch.fnmatchcase(name, p) for p in patterns), name
|
|
|
|
|
|
def test_export_reports_the_folder_size(tmp_path):
|
|
plan = exporter.plan_export(
|
|
"Mix", [_track(1, _audio(tmp_path, "a.mp3"))], tmp_path / "out",
|
|
exporter.WEB)
|
|
done = []
|
|
worker = exporter.ExportWorker(plan)
|
|
worker.finished.connect(done.append)
|
|
worker._run()
|
|
assert done[0]["bytes"] == exporter.folder_size(tmp_path / "out" / "Mix")
|
|
assert done[0]["bytes"] > 100 # the song plus page and assets
|
|
|
|
|
|
def test_upload_window_shows_the_size(qapp, window):
|
|
config.save_config({upload.HOST_KEY: HOST, upload.PATH_KEY: "/srv/mixes"})
|
|
window._export_playlist("abcd1234")
|
|
_wait_for(qapp, lambda: _FakeUpload.runs)
|
|
from lintunes.gui import web_upload_progress as wp
|
|
assert "KB" in window._web_progress.status(wp.EXPORT)
|
|
|
|
|
|
def test_player_ships_previous_and_next_buttons():
|
|
js = (exporter.templates() / "player.js").read_text()
|
|
css = (exporter.templates() / "player.css").read_text()
|
|
assert 'class="skip prev"' in js and 'class="skip next"' in js
|
|
assert "button(player.prevButton, goPrev)" in js
|
|
assert ".audiojs .skip" in css
|