v0.1.4: playback-control provenance log
LinTunes has spontaneously resumed playback for ~4s (then paused) four times while trav was away — suspected phantom media-key events from the Audioengine USB DAC's HID 'keyboard', but the pathway (MPRIS vs the focused-window key filter) is unproven. Every control path now drops a timestamped line in ~/.cache/lintunes/control-events.log: MPRIS Player methods, the media-key eventFilter (with source input device where the compositor exposes it), and Player.toggle_play/pause with position. New lintunes/eventlog.py, 1MB rotation, never raises; conftest autouse fixture keeps tests off the real log file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -8,6 +8,15 @@ import pytest
|
||||
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.
|
||||
|
||||
105
tests/test_round23.py
Normal file
105
tests/test_round23.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""Round 23: playback-control provenance log.
|
||||
|
||||
Every pathway that can start/stop playback now drops a one-line record via
|
||||
lintunes.eventlog (MPRIS methods with D-Bus sender, the media-key
|
||||
eventFilter with source device, Player transport methods with position), so
|
||||
"the app played by itself at night" incidents are attributable after the
|
||||
fact from ~/.cache/lintunes/control-events.log.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from lintunes import eventlog
|
||||
from lintunes.models import Library, Track
|
||||
from lintunes import player as player_module
|
||||
from lintunes.player import Player
|
||||
|
||||
|
||||
def _patch_log(monkeypatch, tmp_path):
|
||||
path = tmp_path / "control-events.log"
|
||||
monkeypatch.setattr(eventlog, "_log_path", lambda: path)
|
||||
return path
|
||||
|
||||
|
||||
class TestEventLog:
|
||||
def test_writes_timestamped_line(self, monkeypatch, tmp_path):
|
||||
path = _patch_log(monkeypatch, tmp_path)
|
||||
eventlog.log_control("test", "action", "k=v")
|
||||
line = path.read_text().strip()
|
||||
assert line.endswith("[test] action k=v")
|
||||
assert line[:4].isdigit() # starts with a date
|
||||
|
||||
def test_detail_optional(self, monkeypatch, tmp_path):
|
||||
path = _patch_log(monkeypatch, tmp_path)
|
||||
eventlog.log_control("test", "solo")
|
||||
assert path.read_text().strip().endswith("[test] solo")
|
||||
|
||||
def test_rotates_when_large(self, monkeypatch, tmp_path):
|
||||
path = _patch_log(monkeypatch, tmp_path)
|
||||
path.write_bytes(b"x" * (eventlog._MAX_BYTES + 1))
|
||||
eventlog.log_control("test", "rotated")
|
||||
assert path.with_suffix(".log.old").exists()
|
||||
assert "rotated" in path.read_text()
|
||||
|
||||
def test_never_raises_on_io_error(self, monkeypatch):
|
||||
monkeypatch.setattr(eventlog, "_log_path",
|
||||
lambda: type("P", (), {})()) # broken path object
|
||||
eventlog.log_control("test", "no-crash") # must not raise
|
||||
|
||||
|
||||
class TestMprisLogging:
|
||||
def test_playpause_logs_and_forwards(self, qapp, monkeypatch, tmp_path):
|
||||
path = _patch_log(monkeypatch, tmp_path)
|
||||
from lintunes.mpris import MprisService
|
||||
player = MagicMock()
|
||||
service = MprisService(player, MagicMock())
|
||||
service.player_adaptor.PlayPause()
|
||||
player.toggle_play.assert_called_once()
|
||||
text = path.read_text()
|
||||
assert "[mpris] PlayPause" in text
|
||||
|
||||
def test_all_methods_log(self, qapp, monkeypatch, tmp_path):
|
||||
path = _patch_log(monkeypatch, tmp_path)
|
||||
from lintunes.mpris import MprisService
|
||||
service = MprisService(MagicMock(), MagicMock())
|
||||
adaptor = service.player_adaptor
|
||||
adaptor.Play()
|
||||
adaptor.Pause()
|
||||
adaptor.Next()
|
||||
adaptor.Previous()
|
||||
adaptor.Stop()
|
||||
text = path.read_text()
|
||||
for member in ("Play", "Pause", "Next", "Previous", "Stop"):
|
||||
assert f"[mpris] {member}" in text
|
||||
|
||||
|
||||
class TestPlayerLogging:
|
||||
def _player(self, qapp, tracks):
|
||||
library = Library()
|
||||
for t in tracks:
|
||||
library.tracks[t.track_id] = t
|
||||
manager = MagicMock()
|
||||
manager.library = library
|
||||
with patch.multiple(
|
||||
player_module,
|
||||
QMediaPlayer=MagicMock(),
|
||||
QAudioOutput=MagicMock(),
|
||||
QAudioBufferOutput=MagicMock(),
|
||||
QMediaDevices=MagicMock(),
|
||||
):
|
||||
return Player(manager)
|
||||
|
||||
def test_toggle_play_logs_state_and_position(self, qapp, monkeypatch,
|
||||
tmp_path):
|
||||
path = _patch_log(monkeypatch, tmp_path)
|
||||
(tmp_path / "a.mp3").write_bytes(b"x")
|
||||
track = Track(track_id=1, name="Song A",
|
||||
location=str(tmp_path / "a.mp3"), total_time=100_000)
|
||||
player = self._player(qapp, [track])
|
||||
player._media.position.return_value = 4321
|
||||
player._media.playbackState.return_value = None # != PlayingState
|
||||
player.play_queue([1], 0)
|
||||
player.toggle_play()
|
||||
text = path.read_text()
|
||||
assert "[player] toggle_play" in text
|
||||
assert "pos=4321ms" in text and "'Song A'" in text
|
||||
Reference in New Issue
Block a user