- Connections ▸ Share Library with a Friend… makes a one-time invite code (device id, display name, token) and the inviter's send-only folder. - Connections ▸ Add Friend Library… takes the pasted code: adds the inviter, shares our folder, and accepts theirs receive-only when it's offered back. - Syncthing hides an unknown device's folders (verified on 1.30), so the inviter probes a new knock only while an invite is open: added with nothing shared, completed on the right token, otherwise removed and never probed again. Used and cancelled codes go nowhere. - Sync Settings lists open invites (Cancel Invite) and gives each friend a page: avatar, plain-language status with a next step, last seen, last synced, Remove Friend. Removals are staged until OK. - scripts/cassette_pair.py runs throwaway Syncthings on loopback; pytest --syncthing drives a real three-machine handshake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
4.1 KiB
Python
130 lines
4.1 KiB
Python
import os
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
# Let widget-level tests run headless; harmless for the non-GUI tests.
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_eventlog(tmp_path, monkeypatch):
|
|
"""Keep the control-provenance log out of the real ~/.cache during tests
|
|
(Player/MPRIS methods log unconditionally)."""
|
|
from lintunes import eventlog
|
|
monkeypatch.setattr(eventlog, "_log_path",
|
|
lambda: tmp_path / "control-events.log")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def qapp():
|
|
"""A Qt application so QObject/QTimer and real widgets work in tests.
|
|
|
|
Prefers a GUI QApplication (offscreen) so widget tests are possible;
|
|
QApplication is a QCoreApplication, so existing non-GUI tests are unaffected.
|
|
Falls back to QCoreApplication if no GUI platform is available.
|
|
"""
|
|
from PyQt6.QtCore import QCoreApplication
|
|
inst = QCoreApplication.instance()
|
|
if inst is not None:
|
|
return inst
|
|
try:
|
|
from PyQt6.QtWidgets import QApplication
|
|
return QApplication([])
|
|
except Exception:
|
|
return QCoreApplication([])
|
|
|
|
|
|
def _generate_audio(path, codec_args):
|
|
"""Generate a 1-second sine-wave audio file with ffmpeg."""
|
|
if shutil.which("ffmpeg") is None:
|
|
pytest.skip("ffmpeg not available to generate audio fixtures")
|
|
result = subprocess.run(
|
|
["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1",
|
|
*codec_args, str(path)],
|
|
capture_output=True,
|
|
)
|
|
if result.returncode != 0:
|
|
pytest.skip(f"ffmpeg could not generate {path.suffix} fixture")
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def mp3_file(tmp_path):
|
|
return _generate_audio(tmp_path / "test.mp3", ["-codec:a", "libmp3lame", "-b:a", "64k"])
|
|
|
|
|
|
@pytest.fixture
|
|
def m4a_file(tmp_path):
|
|
return _generate_audio(tmp_path / "test.m4a", ["-codec:a", "aac", "-b:a", "64k"])
|
|
|
|
|
|
@pytest.fixture
|
|
def flac_file(tmp_path):
|
|
return _generate_audio(tmp_path / "test.flac", ["-codec:a", "flac"])
|
|
|
|
|
|
@pytest.fixture
|
|
def jpeg_bytes(tmp_path):
|
|
"""A tiny valid JPEG for artwork tests."""
|
|
if shutil.which("ffmpeg") is None:
|
|
pytest.skip("ffmpeg not available to generate image fixture")
|
|
path = tmp_path / "art.jpg"
|
|
result = subprocess.run(
|
|
["ffmpeg", "-y", "-f", "lavfi", "-i", "color=red:size=8x8",
|
|
"-frames:v", "1", str(path)],
|
|
capture_output=True,
|
|
)
|
|
if result.returncode != 0:
|
|
pytest.skip("ffmpeg could not generate jpeg fixture")
|
|
return path.read_bytes()
|
|
|
|
|
|
# ---- real-Syncthing tests (Cassette) ----
|
|
# Two throwaway Syncthing instances on loopback (scripts/cassette_pair.py).
|
|
# Slow-ish (a handshake takes Syncthing some seconds), so opt-in:
|
|
# python3 -m pytest -m syncthing --syncthing
|
|
|
|
def pytest_addoption(parser):
|
|
parser.addoption("--syncthing", action="store_true",
|
|
help="run tests that start two real Syncthing instances")
|
|
|
|
|
|
def pytest_configure(config):
|
|
config.addinivalue_line(
|
|
"markers", "syncthing: starts two real Syncthing instances "
|
|
"(opt in with --syncthing)")
|
|
|
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
if config.getoption("--syncthing"):
|
|
return
|
|
skip = pytest.mark.skip(reason="needs --syncthing")
|
|
for item in items:
|
|
if "syncthing" in item.keywords:
|
|
item.add_marker(skip)
|
|
|
|
|
|
@pytest.fixture
|
|
def syncthing_pair(tmp_path):
|
|
"""[alice, bob, carol] Instances from scripts/cassette_pair.py, stopped
|
|
after."""
|
|
if shutil.which("syncthing") is None:
|
|
pytest.skip("syncthing isn't installed")
|
|
import importlib.util
|
|
from pathlib import Path
|
|
spec = importlib.util.spec_from_file_location(
|
|
"cassette_pair",
|
|
Path(__file__).resolve().parent.parent / "scripts" / "cassette_pair.py")
|
|
pair = importlib.util.module_from_spec(spec)
|
|
import sys
|
|
sys.modules[spec.name] = pair # dataclasses look their module up there
|
|
spec.loader.exec_module(pair)
|
|
out = tmp_path / "pair"
|
|
instances = pair.start(out, names=("alice", "bob", "carol"))
|
|
try:
|
|
yield instances
|
|
finally:
|
|
pair.stop(out)
|