"""Round 56: one held arrow key hard-froze the desktop. Incident 2026-09-17: a single Right press became 1,676 track skips in 81 s. On Wayland key repeat is generated by the *client* until the compositor delivers the release, and every skip sent gnome-shell an MPRIS Metadata whose xesam:artist was typed "av" rather than "as" — an error plus a cover reload per skip, which kept gnome-shell too busy to deliver that release. Both halves are pinned here: transport keys act once per press, and xesam:artist goes out as a real string array. """ import threading from types import SimpleNamespace from unittest.mock import MagicMock import pytest from PyQt6.QtCore import QCoreApplication, QEvent, Qt from PyQt6.QtGui import QKeyEvent from lintunes.gui import main_window from lintunes.gui.main_window import MainWindow # ---- transport keys ignore auto-repeat ---- def _window_stub(): return SimpleNamespace( isActiveWindow=lambda: True, play_pause=MagicMock(), player=MagicMock(), _log_key=MagicMock(), ) def _press(key, repeat): return QKeyEvent(QEvent.Type.KeyPress, key, Qt.KeyboardModifier.NoModifier, "", repeat) @pytest.fixture def no_focus_widget(monkeypatch): monkeypatch.setattr(main_window.QApplication, "focusWidget", staticmethod(lambda: None)) @pytest.mark.parametrize("key, action", [ (Qt.Key.Key_Right, lambda w: w.player.next), (Qt.Key.Key_Left, lambda w: w.player.previous), (Qt.Key.Key_Space, lambda w: w.play_pause), ]) def test_held_key_acts_once(qapp, no_focus_widget, key, action): win = _window_stub() assert MainWindow.eventFilter(win, None, _press(key, repeat=False)) for _ in range(50): # Swallowed, not passed through: a held Right mustn't walk the # table's cursor instead. assert MainWindow.eventFilter(win, None, _press(key, repeat=True)) assert action(win).call_count == 1 assert win._log_key.call_count == 1 def test_separate_presses_each_act(qapp, no_focus_widget): win = _window_stub() for _ in range(3): MainWindow.eventFilter(win, None, _press(Qt.Key.Key_Right, False)) assert win.player.next.call_count == 3 # ---- xesam:artist is "as" ---- def _adaptor_metadata(monkeypatch): from lintunes import mpris monkeypatch.setattr(mpris, "export_artwork", lambda track: None) track = SimpleNamespace(track_id=7, name="I Know", artist="Dionne Farris", album="Wild Seed — Wild Flower", total_time=229000, location="/music/i-know.mp3", persistent_id="AB12") adaptor = SimpleNamespace(_player=SimpleNamespace(current_track=track)) return mpris.MprisPlayerAdaptor._metadata(adaptor) def test_artist_is_typed_string_array(qapp, monkeypatch): # A plain [str] is what went out as "av"; only a typed QDBusArgument # carries "as". (The wire type itself is checked over the bus below.) from PyQt6.QtDBus import QDBusArgument meta = _adaptor_metadata(monkeypatch) assert isinstance(meta["xesam:artist"], QDBusArgument) def test_gdbus_receives_artist_as_string_array(qapp, monkeypatch): """Round-trip over the real session bus into GDBus — the same library gnome-shell validates with — and check the type it actually sees.""" Gio = pytest.importorskip("gi.repository.Gio") GLib = pytest.importorskip("gi.repository.GLib") from PyQt6.QtDBus import QDBusConnection from lintunes.mpris import OBJECT_PATH, build_properties_changed bus = QDBusConnection.sessionBus() if not bus.isConnected(): pytest.skip("no D-Bus session bus in this environment") try: gbus = Gio.bus_get_sync(Gio.BusType.SESSION, None) except GLib.Error: pytest.skip("GDBus can't reach the session bus") received = [] sub = gbus.signal_subscribe( bus.baseService(), "org.freedesktop.DBus.Properties", "PropertiesChanged", OBJECT_PATH, None, Gio.DBusSignalFlags.NONE, lambda *args: received.append(args[5])) try: meta = _adaptor_metadata(monkeypatch) assert bus.send(build_properties_changed({"Metadata": meta})) ctx = GLib.MainContext.default() for _ in range(200): QCoreApplication.processEvents() while ctx.iteration(False): pass if received: break threading.Event().wait(0.01) finally: gbus.signal_unsubscribe(sub) assert received, "signal was not delivered over the bus" params = received[0] assert params.get_type_string() == "(sa{sv}as)" metadata = params.get_child_value(1).lookup_value("Metadata", None) artist = metadata.lookup_value("xesam:artist", None) assert artist.get_type_string() == "as" assert artist.unpack() == ["Dionne Farris"]