feat(app-binding): lier les presets aux applications
Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires. Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
290
tests/system/gui/test_app_bindings.py
Normal file
290
tests/system/gui/test_app_bindings.py
Normal file
@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import Gtk, GLib
|
||||
|
||||
from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType
|
||||
from inputremapper.gui.components.app_bindings import (
|
||||
AppBindingsEditor,
|
||||
_BindingRow,
|
||||
)
|
||||
from inputremapper.gui.controller import Controller
|
||||
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
|
||||
from inputremapper.gui.messages.message_data import (
|
||||
AppBindingsData,
|
||||
FocusAppData,
|
||||
GroupsData,
|
||||
)
|
||||
from inputremapper.gui.utils import gtk_iteration
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestAppBindingsEditor(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.message_broker = MessageBroker()
|
||||
self.controller_mock: Controller = MagicMock()
|
||||
self.controller_mock.get_presets_for_group.return_value = ("preset1", "preset2")
|
||||
|
||||
self.enabled_switch = Gtk.Switch()
|
||||
self.status_label = Gtk.Label()
|
||||
self.listbox = Gtk.ListBox()
|
||||
self.add_button = Gtk.Button()
|
||||
|
||||
self.editor = AppBindingsEditor(
|
||||
self.message_broker,
|
||||
self.controller_mock,
|
||||
self.enabled_switch,
|
||||
self.status_label,
|
||||
self.listbox,
|
||||
self.add_button,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
super().tearDown()
|
||||
self.message_broker.signal(MessageType.terminate)
|
||||
for widget in (
|
||||
self.enabled_switch,
|
||||
self.status_label,
|
||||
self.listbox,
|
||||
self.add_button,
|
||||
):
|
||||
GLib.timeout_add(0, widget.destroy)
|
||||
GLib.timeout_add(0, Gtk.main_quit)
|
||||
Gtk.main()
|
||||
|
||||
def _publish_bindings(self, bindings=(), enabled=False, **kwargs):
|
||||
defaults = dict(backend="xorg", supported=True, reachable=True)
|
||||
defaults.update(kwargs)
|
||||
self.message_broker.publish(
|
||||
AppBindingsData(enabled=enabled, bindings=tuple(bindings), **defaults)
|
||||
)
|
||||
gtk_iteration()
|
||||
|
||||
def _binding(self, app_id="firefox", presets=()):
|
||||
return AppBinding(
|
||||
app_id=app_id,
|
||||
presets=[BoundPreset(group_key=g, preset=p) for g, p in presets],
|
||||
)
|
||||
|
||||
# -- enable toggle -----------------------------------------------------
|
||||
|
||||
def test_toggle_enabled_calls_controller(self):
|
||||
self.enabled_switch.emit("state-set", True)
|
||||
gtk_iteration()
|
||||
self.controller_mock.set_app_binding_enabled.assert_called_with(True)
|
||||
|
||||
def test_app_bindings_message_updates_switch_without_callback(self):
|
||||
# the incoming state must not echo back into the controller
|
||||
self.controller_mock.set_app_binding_enabled.reset_mock()
|
||||
self._publish_bindings(enabled=True)
|
||||
self.assertTrue(self.enabled_switch.get_active())
|
||||
self.controller_mock.set_app_binding_enabled.assert_not_called()
|
||||
|
||||
# -- add / remove binding ----------------------------------------------
|
||||
|
||||
def test_add_binding(self):
|
||||
self.add_button.emit("clicked")
|
||||
gtk_iteration()
|
||||
self.assertEqual(len(self.listbox.get_children()), 1)
|
||||
self.assertEqual(len(self.editor._rows), 1)
|
||||
self.controller_mock.update_app_bindings.assert_called()
|
||||
|
||||
def test_rebuild_from_message(self):
|
||||
self._publish_bindings(
|
||||
[
|
||||
self._binding("firefox", [("Foo Device", "preset1")]),
|
||||
self._binding("kitty"),
|
||||
],
|
||||
enabled=True,
|
||||
)
|
||||
self.assertEqual(len(self.listbox.get_children()), 2)
|
||||
self.assertEqual(len(self.editor._rows), 2)
|
||||
|
||||
def test_remove_binding(self):
|
||||
self._publish_bindings([self._binding("firefox")], enabled=True)
|
||||
row = self.editor._rows[0]
|
||||
self.controller_mock.update_app_bindings.reset_mock()
|
||||
self.editor.remove_row(row)
|
||||
gtk_iteration()
|
||||
self.assertEqual(len(self.editor._rows), 0)
|
||||
self.assertEqual(len(self.listbox.get_children()), 0)
|
||||
self.controller_mock.update_app_bindings.assert_called()
|
||||
|
||||
def test_editing_guard_prevents_rebuild(self):
|
||||
self._publish_bindings([self._binding("firefox")], enabled=True)
|
||||
self.assertEqual(len(self.editor._rows), 1)
|
||||
# simulate being mid-save: the echo of our own write must not rebuild
|
||||
self.editor._editing = True
|
||||
self._publish_bindings([self._binding("a"), self._binding("b")], enabled=True)
|
||||
self.assertEqual(len(self.editor._rows), 1)
|
||||
|
||||
# -- preset rows -------------------------------------------------------
|
||||
|
||||
def test_add_and_remove_preset(self):
|
||||
self._publish_bindings([self._binding("firefox")], enabled=True)
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
self.assertEqual(len(row._preset_rows), 0)
|
||||
|
||||
row._on_add_preset_clicked()
|
||||
gtk_iteration()
|
||||
self.assertEqual(len(row._preset_rows), 1)
|
||||
|
||||
preset_row = row._preset_rows[0]
|
||||
row.remove_preset(preset_row)
|
||||
gtk_iteration()
|
||||
self.assertEqual(len(row._preset_rows), 0)
|
||||
|
||||
def test_binding_to_model(self):
|
||||
self._publish_bindings(
|
||||
[self._binding("firefox", [("Foo Device", "preset1")])], enabled=True
|
||||
)
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
model = row.to_model()
|
||||
self.assertIsNotNone(model)
|
||||
self.assertEqual(model.app_id, "firefox")
|
||||
self.assertEqual(model.match, MatchType.wm_class)
|
||||
self.assertEqual(model.presets[0].group_key, "Foo Device")
|
||||
|
||||
def test_empty_app_id_row_raises(self):
|
||||
self.add_button.emit("clicked")
|
||||
gtk_iteration()
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
row.set_app_id("")
|
||||
with self.assertRaises(ValueError):
|
||||
row.to_model()
|
||||
|
||||
def test_save_rejects_empty_app_id_with_visible_feedback(self):
|
||||
self.add_button.emit("clicked")
|
||||
gtk_iteration()
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
row.set_app_id("")
|
||||
|
||||
self.controller_mock.update_app_bindings.reset_mock()
|
||||
self.editor.save()
|
||||
gtk_iteration()
|
||||
|
||||
self.controller_mock.update_app_bindings.assert_not_called()
|
||||
self.assertTrue(row._error_label.get_visible())
|
||||
self.assertIn("identifier", row._error_label.get_text())
|
||||
|
||||
def test_save_rejects_invalid_title_regex_with_visible_feedback(self):
|
||||
self._publish_bindings([self._binding("firefox")], enabled=True)
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
row.set_app_id("(")
|
||||
row._match_combo.set_active_id(MatchType.title_regex.value)
|
||||
|
||||
self.controller_mock.update_app_bindings.reset_mock()
|
||||
self.editor.save()
|
||||
gtk_iteration()
|
||||
|
||||
self.controller_mock.update_app_bindings.assert_not_called()
|
||||
self.assertTrue(row._error_label.get_visible())
|
||||
self.assertIn("valid title regex", row._error_label.get_text())
|
||||
|
||||
def test_save_rejects_partial_preset_with_visible_feedback(self):
|
||||
self.message_broker.publish(GroupsData({"Foo Device": []}))
|
||||
gtk_iteration()
|
||||
self._publish_bindings([self._binding("firefox")], enabled=True)
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
row._on_add_preset_clicked()
|
||||
preset_row = row._preset_rows[0]
|
||||
preset_row._device_combo.set_active_id("Foo Device")
|
||||
|
||||
self.controller_mock.update_app_bindings.reset_mock()
|
||||
self.editor.save()
|
||||
gtk_iteration()
|
||||
|
||||
self.controller_mock.update_app_bindings.assert_not_called()
|
||||
self.assertTrue(row._error_label.get_visible())
|
||||
self.assertIn("device and a preset", row._error_label.get_text())
|
||||
|
||||
# -- detection ---------------------------------------------------------
|
||||
|
||||
def test_detect_flow(self):
|
||||
self._publish_bindings([self._binding("firefox")], enabled=True)
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
|
||||
self.editor.request_detection(row)
|
||||
self.controller_mock.start_app_detection.assert_called_once()
|
||||
self.assertIs(self.editor._detecting_row, row)
|
||||
|
||||
# a transient (empty) focus is ignored, detection keeps running
|
||||
self.message_broker.publish(FocusAppData(app_id="", title=""))
|
||||
gtk_iteration()
|
||||
self.assertIs(self.editor._detecting_row, row)
|
||||
|
||||
# a real focus fills the app_id and stops detection
|
||||
self.controller_mock.update_app_bindings.reset_mock()
|
||||
self.message_broker.publish(FocusAppData(app_id="kitty", title="Terminal"))
|
||||
gtk_iteration()
|
||||
self.assertIsNone(self.editor._detecting_row)
|
||||
self.controller_mock.stop_app_detection.assert_called()
|
||||
self.controller_mock.update_app_bindings.assert_called()
|
||||
self.assertEqual(row.to_model().app_id, "kitty")
|
||||
|
||||
def test_detect_toggle_off(self):
|
||||
self._publish_bindings([self._binding("firefox")], enabled=True)
|
||||
row: _BindingRow = self.editor._rows[0]
|
||||
self.editor.request_detection(row)
|
||||
# requesting again on the same row toggles detection off
|
||||
self.editor.request_detection(row)
|
||||
self.assertIsNone(self.editor._detecting_row)
|
||||
self.controller_mock.stop_app_detection.assert_called()
|
||||
|
||||
# -- status label ------------------------------------------------------
|
||||
|
||||
def test_status_label_disabled(self):
|
||||
self._publish_bindings(enabled=False)
|
||||
self.assertIn("Enable application bindings", self.status_label.get_text())
|
||||
|
||||
def test_status_label_starting(self):
|
||||
self._publish_bindings(enabled=True, reachable=False, supported=False)
|
||||
self.assertIn("Starting", self.status_label.get_text())
|
||||
|
||||
def test_status_label_unsupported(self):
|
||||
self._publish_bindings(
|
||||
enabled=True, reachable=True, supported=False, backend=None
|
||||
)
|
||||
self.assertIn("not supported", self.status_label.get_text())
|
||||
|
||||
def test_status_label_active(self):
|
||||
self._publish_bindings(
|
||||
enabled=True, reachable=True, supported=True, backend="xorg"
|
||||
)
|
||||
self.assertIn("xorg", self.status_label.get_text())
|
||||
|
||||
# -- groups ------------------------------------------------------------
|
||||
|
||||
def test_groups_message_updates_available_groups(self):
|
||||
self.message_broker.publish(GroupsData({"Foo Device": [], "Bar": []}))
|
||||
gtk_iteration()
|
||||
self.assertEqual(self.editor.groups, ("Foo Device", "Bar"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
154
tests/unit/test_app_binding.py
Normal file
154
tests/unit/test_app_binding.py
Normal file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import unittest
|
||||
|
||||
try:
|
||||
from pydantic.v1 import ValidationError
|
||||
except ImportError:
|
||||
from pydantic import ValidationError # type: ignore[assignment, no-redef]
|
||||
|
||||
from inputremapper.configs.app_binding import (
|
||||
AppBinding,
|
||||
BoundPreset,
|
||||
MatchType,
|
||||
normalize_app_id,
|
||||
)
|
||||
from inputremapper.focus.focus_backend import FocusEvent
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
def _event(app_id="", title="", backend="fake") -> FocusEvent:
|
||||
return FocusEvent(app_id=app_id, title=title, backend=backend)
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestNormalizeAppId(unittest.TestCase):
|
||||
def test_lower_and_strip(self):
|
||||
self.assertEqual(normalize_app_id(" FireFox "), "firefox")
|
||||
self.assertEqual(normalize_app_id("ALACRITTY"), "alacritty")
|
||||
self.assertEqual(normalize_app_id(""), "")
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestBoundPreset(unittest.TestCase):
|
||||
def test_valid(self):
|
||||
bound = BoundPreset(group_key="Foo Device", preset="preset1")
|
||||
self.assertEqual(bound.group_key, "Foo Device")
|
||||
self.assertEqual(bound.preset, "preset1")
|
||||
|
||||
def test_empty_group_key_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
BoundPreset(group_key="", preset="preset1")
|
||||
with self.assertRaises(ValidationError):
|
||||
BoundPreset(group_key=" ", preset="preset1")
|
||||
|
||||
def test_empty_preset_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
BoundPreset(group_key="Foo Device", preset="")
|
||||
with self.assertRaises(ValidationError):
|
||||
BoundPreset(group_key="Foo Device", preset=" ")
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestAppBinding(unittest.TestCase):
|
||||
def test_defaults(self):
|
||||
binding = AppBinding(app_id="firefox")
|
||||
self.assertEqual(binding.match, MatchType.wm_class)
|
||||
self.assertEqual(binding.presets, [])
|
||||
|
||||
def test_default_presets_are_not_shared(self):
|
||||
binding_a = AppBinding(app_id="firefox")
|
||||
binding_b = AppBinding(app_id="chromium")
|
||||
binding_a.presets.append(BoundPreset(group_key="Foo Device", preset="preset1"))
|
||||
self.assertEqual(binding_b.presets, [])
|
||||
|
||||
def test_empty_app_id_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
AppBinding(app_id="")
|
||||
with self.assertRaises(ValidationError):
|
||||
AppBinding(app_id=" ")
|
||||
|
||||
def test_invalid_regex_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
AppBinding(app_id="(unclosed", match=MatchType.title_regex)
|
||||
|
||||
def test_invalid_regex_allowed_for_wm_class(self):
|
||||
# an unbalanced parenthesis is a fine literal wm_class, only title_regex
|
||||
# compiles it as a pattern.
|
||||
binding = AppBinding(app_id="(unclosed", match=MatchType.wm_class)
|
||||
self.assertEqual(binding.app_id, "(unclosed")
|
||||
|
||||
def test_with_presets(self):
|
||||
binding = AppBinding(
|
||||
app_id="firefox",
|
||||
presets=[BoundPreset(group_key="Foo Device", preset="preset1")],
|
||||
)
|
||||
self.assertEqual(len(binding.presets), 1)
|
||||
self.assertEqual(binding.presets[0].group_key, "Foo Device")
|
||||
|
||||
# -- matches() ---------------------------------------------------------
|
||||
|
||||
def test_matches_wm_class_normalized(self):
|
||||
binding = AppBinding(app_id="FireFox", match=MatchType.wm_class)
|
||||
self.assertTrue(binding.matches(_event(app_id="firefox")))
|
||||
self.assertTrue(binding.matches(_event(app_id=" FIREFOX ")))
|
||||
self.assertFalse(binding.matches(_event(app_id="chromium")))
|
||||
|
||||
def test_matches_wm_class_ignores_title(self):
|
||||
binding = AppBinding(app_id="firefox", match=MatchType.wm_class)
|
||||
self.assertFalse(
|
||||
binding.matches(_event(app_id="chromium", title="firefox - page"))
|
||||
)
|
||||
|
||||
def test_matches_title_regex(self):
|
||||
binding = AppBinding(app_id="Privat.*Browsing", match=MatchType.title_regex)
|
||||
self.assertTrue(
|
||||
binding.matches(_event(app_id="firefox", title="Private Browsing"))
|
||||
)
|
||||
self.assertFalse(
|
||||
binding.matches(_event(app_id="firefox", title="Normal Window"))
|
||||
)
|
||||
|
||||
def test_matches_title_regex_searches_substring(self):
|
||||
binding = AppBinding(app_id="vim", match=MatchType.title_regex)
|
||||
# re.search, not fullmatch
|
||||
self.assertTrue(binding.matches(_event(title="file.py - nvim")))
|
||||
|
||||
def test_matches_title_regex_case_sensitive(self):
|
||||
# unlike wm_class, the title regex is not normalized
|
||||
binding = AppBinding(app_id="Private", match=MatchType.title_regex)
|
||||
self.assertFalse(binding.matches(_event(title="private browsing")))
|
||||
|
||||
def test_roundtrip_through_json(self):
|
||||
binding = AppBinding(
|
||||
app_id="firefox",
|
||||
match=MatchType.wm_class,
|
||||
presets=[BoundPreset(group_key="Foo Device", preset="preset1")],
|
||||
)
|
||||
import json
|
||||
|
||||
as_dict = json.loads(binding.json())
|
||||
restored = AppBinding(**as_dict)
|
||||
self.assertEqual(restored, binding)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -1625,3 +1625,73 @@ class TestController(unittest.TestCase):
|
||||
output_type=None,
|
||||
output_code=None,
|
||||
)
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestControllerAppBindings(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.message_broker = MessageBroker()
|
||||
self.data_manager = MagicMock()
|
||||
self.controller = Controller(self.message_broker, self.data_manager)
|
||||
self.user_interface = MagicMock()
|
||||
self.controller.set_gui(self.user_interface)
|
||||
|
||||
def test_load_app_bindings(self):
|
||||
self.controller.load_app_bindings()
|
||||
self.data_manager.publish_app_bindings.assert_called_once()
|
||||
|
||||
def test_set_app_binding_enabled_true_starts_service(self):
|
||||
with patch(
|
||||
"inputremapper.gui.controller.ensure_focus_service_running"
|
||||
) as ensure, patch(
|
||||
"inputremapper.gui.controller.GLib.timeout_add"
|
||||
) as timeout_add:
|
||||
self.controller.set_app_binding_enabled(True)
|
||||
|
||||
ensure.assert_called_once()
|
||||
self.data_manager.set_app_binding_enabled.assert_called_once_with(True)
|
||||
# a delayed status refresh is scheduled
|
||||
timeout_add.assert_called_once()
|
||||
|
||||
def test_set_app_binding_enabled_false_does_not_start_service(self):
|
||||
with patch(
|
||||
"inputremapper.gui.controller.ensure_focus_service_running"
|
||||
) as ensure, patch(
|
||||
"inputremapper.gui.controller.GLib.timeout_add"
|
||||
) as timeout_add:
|
||||
self.controller.set_app_binding_enabled(False)
|
||||
|
||||
ensure.assert_not_called()
|
||||
self.data_manager.set_app_binding_enabled.assert_called_once_with(False)
|
||||
timeout_add.assert_not_called()
|
||||
|
||||
def test_refresh_app_bindings_once(self):
|
||||
result = self.controller._refresh_app_bindings_once()
|
||||
self.assertFalse(result) # GLib: do not repeat
|
||||
self.data_manager.publish_app_bindings.assert_called_once()
|
||||
|
||||
def test_update_app_bindings(self):
|
||||
from inputremapper.configs.app_binding import AppBinding
|
||||
|
||||
bindings = [AppBinding(app_id="firefox")]
|
||||
self.controller.update_app_bindings(bindings)
|
||||
self.data_manager.set_app_bindings.assert_called_once_with(bindings)
|
||||
|
||||
def test_update_app_bindings_permission_error(self):
|
||||
self.data_manager.set_app_bindings.side_effect = PermissionError("nope")
|
||||
with patch.object(self.controller, "show_status") as show_status:
|
||||
self.controller.update_app_bindings([])
|
||||
show_status.assert_called_once()
|
||||
self.assertEqual(show_status.call_args[0][0], CTX_ERROR)
|
||||
|
||||
def test_get_presets_for_group(self):
|
||||
self.data_manager.get_presets_for_group.return_value = ("a", "b")
|
||||
result = self.controller.get_presets_for_group("Foo Device")
|
||||
self.assertEqual(result, ("a", "b"))
|
||||
self.data_manager.get_presets_for_group.assert_called_once_with("Foo Device")
|
||||
|
||||
def test_start_and_stop_app_detection(self):
|
||||
self.controller.start_app_detection()
|
||||
self.data_manager.start_app_detection.assert_called_once()
|
||||
self.controller.stop_app_detection()
|
||||
self.data_manager.stop_app_detection.assert_called_once()
|
||||
|
||||
@ -969,3 +969,168 @@ class TestDataManager(unittest.TestCase):
|
||||
|
||||
def test_cannot_get_injector_state_without_group(self):
|
||||
self.assertRaises(DataManagementError, self.data_manager.get_state)
|
||||
|
||||
|
||||
class FakeFocusServiceClient:
|
||||
"""A FocusServiceClient stand-in that never touches the session bus."""
|
||||
|
||||
def __init__(self, status=None):
|
||||
self._status = status
|
||||
self.calls: List = []
|
||||
self.handler = None
|
||||
|
||||
def get_status(self):
|
||||
self.calls.append("get_status")
|
||||
return self._status
|
||||
|
||||
def reload(self):
|
||||
self.calls.append("reload")
|
||||
|
||||
def set_enabled(self, enabled):
|
||||
self.calls.append(("set_enabled", enabled))
|
||||
|
||||
def connect_focus_changed(self, callback):
|
||||
self.handler = callback
|
||||
self.calls.append("connect")
|
||||
return True
|
||||
|
||||
def disconnect_focus_changed(self):
|
||||
self.calls.append("disconnect")
|
||||
self.handler = None
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestDataManagerAppBindings(unittest.TestCase):
|
||||
def _make_data_manager(self, status=None) -> DataManager:
|
||||
message_broker = MessageBroker()
|
||||
uinputs = GlobalUInputs(FrontendUInput)
|
||||
uinputs.prepare_all()
|
||||
self.focus_service = FakeFocusServiceClient(status=status)
|
||||
return DataManager(
|
||||
message_broker,
|
||||
GlobalConfig(),
|
||||
ReaderClient(message_broker, _Groups()),
|
||||
FakeDaemonProxy(),
|
||||
uinputs,
|
||||
keyboard_layout,
|
||||
focus_service=self.focus_service,
|
||||
)
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.data_manager = self._make_data_manager()
|
||||
self.message_broker = self.data_manager.message_broker
|
||||
|
||||
@staticmethod
|
||||
def _binding(app_id="firefox", presets=()):
|
||||
from inputremapper.configs.app_binding import AppBinding, BoundPreset
|
||||
|
||||
return AppBinding(
|
||||
app_id=app_id,
|
||||
presets=[BoundPreset(group_key=g, preset=p) for g, p in presets],
|
||||
)
|
||||
|
||||
def test_publish_app_bindings_unreachable(self):
|
||||
from inputremapper.gui.messages.message_data import AppBindingsData
|
||||
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.app_bindings, listener)
|
||||
self.data_manager.publish_app_bindings()
|
||||
|
||||
data: AppBindingsData = listener.calls[0]
|
||||
self.assertFalse(data.reachable)
|
||||
self.assertFalse(data.supported)
|
||||
self.assertIsNone(data.backend)
|
||||
self.assertFalse(data.enabled)
|
||||
self.assertEqual(data.bindings, ())
|
||||
|
||||
def test_publish_app_bindings_reachable(self):
|
||||
self.data_manager = self._make_data_manager(status={"backend": "xorg"})
|
||||
self.message_broker = self.data_manager.message_broker
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.app_bindings, listener)
|
||||
self.data_manager.publish_app_bindings()
|
||||
|
||||
data = listener.calls[0]
|
||||
self.assertTrue(data.reachable)
|
||||
self.assertTrue(data.supported)
|
||||
self.assertEqual(data.backend, "xorg")
|
||||
|
||||
def test_publish_app_bindings_reachable_but_unsupported(self):
|
||||
# service reachable but reports no backend (e.g. GNOME-Wayland / null)
|
||||
self.data_manager = self._make_data_manager(status={"backend": None})
|
||||
self.message_broker = self.data_manager.message_broker
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.app_bindings, listener)
|
||||
self.data_manager.publish_app_bindings()
|
||||
|
||||
data = listener.calls[0]
|
||||
self.assertTrue(data.reachable)
|
||||
self.assertFalse(data.supported)
|
||||
|
||||
def test_set_and_get_app_bindings(self):
|
||||
bindings = [self._binding("firefox", [("Foo Device", "preset1")])]
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.app_bindings, listener)
|
||||
|
||||
self.data_manager.set_app_bindings(bindings)
|
||||
|
||||
self.assertEqual(self.data_manager.get_app_bindings(), bindings)
|
||||
self.assertIn("reload", self.focus_service.calls)
|
||||
# publishes an app_bindings update
|
||||
self.assertEqual(listener.calls[-1].bindings, tuple(bindings))
|
||||
|
||||
def test_set_and_get_app_binding_enabled(self):
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.app_bindings, listener)
|
||||
|
||||
self.data_manager.set_app_binding_enabled(True)
|
||||
|
||||
self.assertTrue(self.data_manager.get_app_binding_enabled())
|
||||
self.assertIn(("set_enabled", True), self.focus_service.calls)
|
||||
self.assertTrue(listener.calls[-1].enabled)
|
||||
|
||||
def test_start_and_stop_app_detection(self):
|
||||
self.data_manager.start_app_detection()
|
||||
self.assertIn("connect", self.focus_service.calls)
|
||||
self.assertIsNotNone(self.focus_service.handler)
|
||||
|
||||
self.data_manager.stop_app_detection()
|
||||
self.assertIn("disconnect", self.focus_service.calls)
|
||||
self.assertIsNone(self.focus_service.handler)
|
||||
|
||||
def test_on_focus_changed_publishes_focused_app(self):
|
||||
from inputremapper.gui.messages.message_data import FocusAppData
|
||||
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.focused_app, listener)
|
||||
|
||||
self.data_manager.start_app_detection()
|
||||
# simulate the service emitting a focus_changed signal
|
||||
self.focus_service.handler({"app_id": "firefox", "title": "Mozilla"})
|
||||
|
||||
data: FocusAppData = listener.calls[0]
|
||||
self.assertEqual(data.app_id, "firefox")
|
||||
self.assertEqual(data.title, "Mozilla")
|
||||
|
||||
def test_on_focus_changed_handles_missing_fields(self):
|
||||
from inputremapper.gui.messages.message_data import FocusAppData
|
||||
|
||||
listener = Listener()
|
||||
self.message_broker.subscribe(MessageType.focused_app, listener)
|
||||
self.data_manager.start_app_detection()
|
||||
self.focus_service.handler({})
|
||||
|
||||
data: FocusAppData = listener.calls[0]
|
||||
self.assertEqual(data.app_id, "")
|
||||
self.assertEqual(data.title, "")
|
||||
|
||||
def test_get_presets_for_group(self):
|
||||
prepare_presets()
|
||||
presets = self.data_manager.get_presets_for_group("Foo Device")
|
||||
# newest first
|
||||
self.assertEqual(presets, ("preset3", "preset2", "preset1"))
|
||||
|
||||
def test_get_presets_for_unknown_group(self):
|
||||
self.assertEqual(
|
||||
self.data_manager.get_presets_for_group("Does Not Exist"), tuple()
|
||||
)
|
||||
|
||||
342
tests/unit/test_focus_service.py
Normal file
342
tests/unit/test_focus_service.py
Normal file
@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from typing import List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType
|
||||
from inputremapper.configs.global_config import GlobalConfig
|
||||
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent
|
||||
from inputremapper.focus.focus_service import FocusService
|
||||
from tests.lib.patches import FakeDaemonProxy
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
def _event(app_id="firefox", title="", backend="fake") -> FocusEvent:
|
||||
return FocusEvent(app_id=app_id, title=title, backend=backend)
|
||||
|
||||
|
||||
class FakeBackend(FocusBackend):
|
||||
name = "fake"
|
||||
|
||||
def __init__(self, current: Optional[FocusEvent] = None):
|
||||
self._current = current
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self._on_focus = None
|
||||
self.calls = []
|
||||
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
return True
|
||||
|
||||
def start(self, on_focus) -> None:
|
||||
self.started = True
|
||||
self._on_focus = on_focus
|
||||
self.calls.append("start")
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
def get_current(self) -> Optional[FocusEvent]:
|
||||
self.calls.append("get_current")
|
||||
return self._current
|
||||
|
||||
def emit(self, event: FocusEvent) -> None:
|
||||
assert self._on_focus is not None
|
||||
self._on_focus(event)
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestFocusService(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.daemon = FakeDaemonProxy()
|
||||
self.backend = FakeBackend()
|
||||
|
||||
def _make_service(
|
||||
self,
|
||||
bindings: Optional[List[AppBinding]] = None,
|
||||
enabled: bool = True,
|
||||
) -> FocusService:
|
||||
config = GlobalConfig()
|
||||
config.set_app_bindings(bindings or [])
|
||||
config.set_app_binding_enabled(enabled)
|
||||
service = FocusService(config, self.backend, self.daemon, debounce_ms=10)
|
||||
# never touch the real session bus in tests
|
||||
service.publish = MagicMock()
|
||||
self.addCleanup(service.stop)
|
||||
return service
|
||||
|
||||
@staticmethod
|
||||
def _binding(app_id, presets, match=MatchType.wm_class) -> AppBinding:
|
||||
return AppBinding(
|
||||
app_id=app_id,
|
||||
match=match,
|
||||
presets=[BoundPreset(group_key=g, preset=p) for g, p in presets],
|
||||
)
|
||||
|
||||
# -- reconciliation ----------------------------------------------------
|
||||
|
||||
def test_loads_config_on_init(self):
|
||||
service = self._make_service(
|
||||
[self._binding("firefox", [("devX", "p1")])], enabled=True
|
||||
)
|
||||
self.assertTrue(service._enabled)
|
||||
self.assertEqual(len(service._bindings), 1)
|
||||
|
||||
def test_focus_bound_app_starts_injecting(self):
|
||||
service = self._make_service(
|
||||
[self._binding("firefox", [("devX", "p1"), ("devY", "p2")])]
|
||||
)
|
||||
service._reconcile(_event("firefox"))
|
||||
self.assertCountEqual(
|
||||
self.daemon.calls["start_injecting"],
|
||||
[("devX", "p1"), ("devY", "p2")],
|
||||
)
|
||||
self.assertEqual(service._app_controlled, {"devX": "p1", "devY": "p2"})
|
||||
|
||||
def test_switching_apps_replaces_and_stops(self):
|
||||
service = self._make_service(
|
||||
[
|
||||
self._binding("a", [("devX", "p1")]),
|
||||
self._binding("b", [("devX", "p2"), ("devY", "p3")]),
|
||||
self._binding("c", [("devY", "p3")]),
|
||||
]
|
||||
)
|
||||
service._reconcile(_event("a"))
|
||||
self.assertEqual(service._app_controlled, {"devX": "p1"})
|
||||
|
||||
# switch to b: devX replaced (p1->p2), devY added, nothing stopped
|
||||
self.daemon.calls["start_injecting"].clear()
|
||||
service._reconcile(_event("b"))
|
||||
self.assertCountEqual(
|
||||
self.daemon.calls["start_injecting"], [("devX", "p2"), ("devY", "p3")]
|
||||
)
|
||||
self.assertEqual(self.daemon.calls["stop_injecting"], [])
|
||||
self.assertEqual(service._app_controlled, {"devX": "p2", "devY": "p3"})
|
||||
|
||||
# switch to c: only devY wanted (already correct), devX stopped
|
||||
self.daemon.calls["start_injecting"].clear()
|
||||
service._reconcile(_event("c"))
|
||||
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
||||
# devY p3 already running -> not restarted
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [])
|
||||
self.assertEqual(service._app_controlled, {"devY": "p3"})
|
||||
|
||||
def test_focus_unbound_app_stops_everything(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service._reconcile(_event("firefox"))
|
||||
self.daemon.calls["start_injecting"].clear()
|
||||
|
||||
service._reconcile(_event("someuncontrolledapp"))
|
||||
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
||||
self.assertEqual(service._app_controlled, {})
|
||||
|
||||
def test_replace_does_not_restart_identical(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service._reconcile(_event("firefox"))
|
||||
service._reconcile(_event("firefox"))
|
||||
# only injected once even though reconciled twice
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
||||
|
||||
def test_start_injecting_failure_not_tracked(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
self.daemon.start_injecting = MagicMock(return_value=False)
|
||||
service._reconcile(_event("firefox"))
|
||||
self.assertEqual(service._app_controlled, {})
|
||||
|
||||
# -- manual override ---------------------------------------------------
|
||||
|
||||
def test_manual_preset_is_not_stopped(self):
|
||||
# a preset applied manually is not in _app_controlled, so focusing an
|
||||
# unbound app must not stop it.
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
# devY has a manually applied preset, unknown to the service
|
||||
service._reconcile(_event("unbound"))
|
||||
self.assertEqual(self.daemon.calls["stop_injecting"], [])
|
||||
|
||||
def test_bound_app_reclaims_manually_used_device(self):
|
||||
# another bound app taking focus replaces whatever is on the device
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p9")])])
|
||||
service._reconcile(_event("firefox"))
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p9")])
|
||||
|
||||
# -- enable / disable --------------------------------------------------
|
||||
|
||||
def test_set_enabled_false_stops_app_controlled(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service._current_event = _event("firefox")
|
||||
service._reconcile(service._current_event)
|
||||
self.assertEqual(service._app_controlled, {"devX": "p1"})
|
||||
|
||||
service.set_enabled(False)
|
||||
self.assertFalse(service._enabled)
|
||||
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
||||
self.assertEqual(service._app_controlled, {})
|
||||
|
||||
def test_set_enabled_true_reconciles_current(self):
|
||||
service = self._make_service(
|
||||
[self._binding("firefox", [("devX", "p1")])], enabled=False
|
||||
)
|
||||
service._current_event = _event("firefox")
|
||||
service.set_enabled(True)
|
||||
self.assertTrue(service._enabled)
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
||||
|
||||
def test_set_enabled_true_without_current_event(self):
|
||||
service = self._make_service(
|
||||
[self._binding("firefox", [("devX", "p1")])], enabled=False
|
||||
)
|
||||
service.set_enabled(True)
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [])
|
||||
|
||||
# -- reload ------------------------------------------------------------
|
||||
|
||||
def test_reload_picks_up_new_bindings(self):
|
||||
service = self._make_service([], enabled=True)
|
||||
service._current_event = _event("firefox")
|
||||
|
||||
service.global_config.set_app_bindings(
|
||||
[self._binding("firefox", [("devX", "p1")])]
|
||||
)
|
||||
service.reload()
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
||||
|
||||
def test_reload_when_disabled_stops_app_controlled(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service._current_event = _event("firefox")
|
||||
service._reconcile(service._current_event)
|
||||
|
||||
service.global_config.set_app_binding_enabled(False)
|
||||
service.reload()
|
||||
self.assertEqual(self.daemon.calls["stop_injecting"], ["devX"])
|
||||
self.assertEqual(service._app_controlled, {})
|
||||
|
||||
# -- debounce / transient focus ---------------------------------------
|
||||
|
||||
def test_transient_empty_focus_is_ignored(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service._on_focus(_event(app_id=""))
|
||||
self.assertIsNone(service._pending_event)
|
||||
self.assertIsNone(service._debounce_id)
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [])
|
||||
|
||||
def test_debounce_collapses_rapid_focus(self):
|
||||
service = self._make_service(
|
||||
[
|
||||
self._binding("a", [("devX", "p1")]),
|
||||
self._binding("b", [("devX", "p2")]),
|
||||
]
|
||||
)
|
||||
service._on_focus(_event("a"))
|
||||
service._on_focus(_event("b"))
|
||||
# nothing reconciled until the debounce flushes
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [])
|
||||
self.assertEqual(service._pending_event, _event("b"))
|
||||
|
||||
service._flush()
|
||||
# only the most recent focus is applied
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p2")])
|
||||
self.assertEqual(service._current_event, _event("b"))
|
||||
|
||||
def test_flush_without_pending_is_noop(self):
|
||||
service = self._make_service([])
|
||||
self.assertFalse(service._flush())
|
||||
|
||||
def test_flush_emits_focus_changed_on_change(self):
|
||||
service = self._make_service([])
|
||||
service.focus_changed = MagicMock()
|
||||
service._pending_event = _event("firefox", title="Mozilla")
|
||||
service._flush()
|
||||
service.focus_changed.assert_called_once()
|
||||
payload = json.loads(service.focus_changed.call_args[0][0])
|
||||
self.assertEqual(payload, {"app_id": "firefox", "title": "Mozilla"})
|
||||
|
||||
def test_flush_no_emit_when_unchanged(self):
|
||||
service = self._make_service([])
|
||||
service._current_event = _event("firefox")
|
||||
service.focus_changed = MagicMock()
|
||||
service._pending_event = _event("firefox")
|
||||
service._flush()
|
||||
service.focus_changed.assert_not_called()
|
||||
|
||||
# -- lifecycle / dbus surface -----------------------------------------
|
||||
|
||||
def test_start_reconciles_initial_focus(self):
|
||||
self.backend = FakeBackend(current=_event("firefox"))
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service.start()
|
||||
self.assertTrue(self.backend.started)
|
||||
self.assertEqual(self.backend.calls[:2], ["start", "get_current"])
|
||||
self.assertEqual(self.daemon.calls["start_injecting"], [("devX", "p1")])
|
||||
|
||||
def test_start_stops_on_publish_failure(self):
|
||||
service = self._make_service([])
|
||||
service.publish = MagicMock(side_effect=RuntimeError("dbus failed"))
|
||||
with self.assertRaises(RuntimeError):
|
||||
service.start()
|
||||
self.assertFalse(self.backend.started)
|
||||
|
||||
def test_stop_releases_app_controlled(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service._reconcile(_event("firefox"))
|
||||
service.stop()
|
||||
self.assertTrue(self.backend.stopped)
|
||||
self.assertIn("devX", self.daemon.calls["stop_injecting"])
|
||||
self.assertEqual(service._app_controlled, {})
|
||||
|
||||
def test_get_focused_app_json(self):
|
||||
service = self._make_service([])
|
||||
self.assertEqual(
|
||||
json.loads(service.get_focused_app()), {"app_id": "", "title": ""}
|
||||
)
|
||||
service._current_event = _event("firefox", title="Moz")
|
||||
self.assertEqual(
|
||||
json.loads(service.get_focused_app()),
|
||||
{"app_id": "firefox", "title": "Moz"},
|
||||
)
|
||||
|
||||
def test_get_status_json(self):
|
||||
service = self._make_service([self._binding("firefox", [("devX", "p1")])])
|
||||
service._reconcile(_event("firefox", title="Moz"))
|
||||
status = json.loads(service.get_status())
|
||||
self.assertEqual(status["backend"], "fake")
|
||||
self.assertTrue(status["enabled"])
|
||||
self.assertEqual(status["focused"], {"app_id": "", "title": ""})
|
||||
self.assertEqual(status["app_controlled"], {"devX": "p1"})
|
||||
|
||||
def test_compute_desired_merges_multiple_bindings(self):
|
||||
# two bindings can match the same event (wm_class + title regex)
|
||||
service = self._make_service(
|
||||
[
|
||||
self._binding("firefox", [("devX", "p1")]),
|
||||
self._binding(
|
||||
"Moz", [("devY", "p2")], match=MatchType.title_regex
|
||||
),
|
||||
]
|
||||
)
|
||||
desired = service._compute_desired(_event("firefox", title="Mozilla"))
|
||||
self.assertEqual(desired, {"devX": "p1", "devY": "p2"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
173
tests/unit/test_focus_service_client.py
Normal file
173
tests/unit/test_focus_service_client.py
Normal file
@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from inputremapper.gui.focus_service_client import (
|
||||
FocusServiceClient,
|
||||
ensure_focus_service_running,
|
||||
)
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestFocusServiceClient(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.bus = MagicMock()
|
||||
self.proxy = MagicMock()
|
||||
self.bus.get_proxy.return_value = self.proxy
|
||||
self.client = FocusServiceClient(bus=self.bus)
|
||||
|
||||
# -- get_status --------------------------------------------------------
|
||||
|
||||
def test_get_status_success(self):
|
||||
self.proxy.get_status.return_value = '{"backend": "xorg", "enabled": true}'
|
||||
status = self.client.get_status()
|
||||
self.assertEqual(status, {"backend": "xorg", "enabled": True})
|
||||
|
||||
def test_get_status_failure_returns_none_and_resets(self):
|
||||
self.proxy.get_status.side_effect = Exception("no service")
|
||||
self.assertIsNone(self.client.get_status())
|
||||
# the cached proxy is dropped so the next call reconnects
|
||||
self.assertIsNone(self.client._proxy)
|
||||
|
||||
def test_get_focused_app_success(self):
|
||||
self.proxy.get_focused_app.return_value = (
|
||||
'{"app_id": "firefox", "title": "Moz"}'
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get_focused_app(), {"app_id": "firefox", "title": "Moz"}
|
||||
)
|
||||
|
||||
def test_get_focused_app_failure_returns_empty(self):
|
||||
self.proxy.get_focused_app.side_effect = Exception("boom")
|
||||
self.assertEqual(
|
||||
self.client.get_focused_app(), {"app_id": "", "title": ""}
|
||||
)
|
||||
|
||||
# -- reload / set_enabled (fire and forget) ----------------------------
|
||||
|
||||
def test_reload_success(self):
|
||||
self.client.reload()
|
||||
self.proxy.reload.assert_called_once()
|
||||
|
||||
def test_reload_failure_is_swallowed(self):
|
||||
self.proxy.reload.side_effect = Exception("boom")
|
||||
# must not raise
|
||||
self.client.reload()
|
||||
self.assertIsNone(self.client._proxy)
|
||||
|
||||
def test_set_enabled(self):
|
||||
self.client.set_enabled(True)
|
||||
self.proxy.set_enabled.assert_called_once_with(True)
|
||||
|
||||
def test_set_enabled_failure_is_swallowed(self):
|
||||
self.proxy.set_enabled.side_effect = Exception("boom")
|
||||
self.client.set_enabled(False)
|
||||
self.assertIsNone(self.client._proxy)
|
||||
|
||||
# -- focus_changed signal ---------------------------------------------
|
||||
|
||||
def test_connect_focus_changed_success(self):
|
||||
received = []
|
||||
ok = self.client.connect_focus_changed(received.append)
|
||||
self.assertTrue(ok)
|
||||
self.proxy.focus_changed.connect.assert_called_once()
|
||||
|
||||
# the wrapped handler parses the json payload before calling back
|
||||
handler = self.proxy.focus_changed.connect.call_args[0][0]
|
||||
handler('{"app_id": "firefox", "title": "Moz"}')
|
||||
self.assertEqual(received, [{"app_id": "firefox", "title": "Moz"}])
|
||||
|
||||
def test_connect_focus_changed_invalid_payload_does_not_raise(self):
|
||||
received = []
|
||||
self.client.connect_focus_changed(received.append)
|
||||
handler = self.proxy.focus_changed.connect.call_args[0][0]
|
||||
# invalid json must be swallowed (no callback, no exception)
|
||||
handler("not json")
|
||||
self.assertEqual(received, [])
|
||||
|
||||
def test_connect_focus_changed_failure(self):
|
||||
self.proxy.focus_changed.connect.side_effect = Exception("boom")
|
||||
ok = self.client.connect_focus_changed(lambda app: None)
|
||||
self.assertFalse(ok)
|
||||
self.assertIsNone(self.client._proxy)
|
||||
|
||||
def test_disconnect_without_handler_is_noop(self):
|
||||
self.client.disconnect_focus_changed()
|
||||
self.proxy.focus_changed.disconnect.assert_not_called()
|
||||
|
||||
def test_disconnect_after_connect(self):
|
||||
self.client.connect_focus_changed(lambda app: None)
|
||||
self.client.disconnect_focus_changed()
|
||||
self.proxy.focus_changed.disconnect.assert_called_once()
|
||||
self.assertIsNone(self.client._focus_changed_handler)
|
||||
|
||||
def test_disconnect_failure_clears_handler(self):
|
||||
self.client.connect_focus_changed(lambda app: None)
|
||||
self.proxy.focus_changed.disconnect.side_effect = Exception("boom")
|
||||
self.client.disconnect_focus_changed()
|
||||
# handler is cleared even if the disconnect failed
|
||||
self.assertIsNone(self.client._focus_changed_handler)
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestEnsureFocusServiceRunning(unittest.TestCase):
|
||||
def test_does_not_start_when_already_running(self):
|
||||
with patch(
|
||||
"inputremapper.gui.focus_service_client.ProcessUtils."
|
||||
"count_python_processes",
|
||||
return_value=1,
|
||||
), patch(
|
||||
"inputremapper.gui.focus_service_client.subprocess.Popen"
|
||||
) as popen:
|
||||
ensure_focus_service_running()
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_starts_when_not_running(self):
|
||||
with patch(
|
||||
"inputremapper.gui.focus_service_client.ProcessUtils."
|
||||
"count_python_processes",
|
||||
return_value=0,
|
||||
), patch(
|
||||
"inputremapper.gui.focus_service_client.subprocess.Popen"
|
||||
) as popen:
|
||||
ensure_focus_service_running()
|
||||
popen.assert_called_once()
|
||||
self.assertEqual(
|
||||
popen.call_args[0][0], ["input-remapper-focus-service"]
|
||||
)
|
||||
|
||||
def test_start_failure_is_swallowed(self):
|
||||
with patch(
|
||||
"inputremapper.gui.focus_service_client.ProcessUtils."
|
||||
"count_python_processes",
|
||||
return_value=0,
|
||||
), patch(
|
||||
"inputremapper.gui.focus_service_client.subprocess.Popen",
|
||||
side_effect=OSError("boom"),
|
||||
):
|
||||
# must not raise
|
||||
ensure_focus_service_running()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
326
tests/unit/test_focus_watcher.py
Normal file
326
tests/unit/test_focus_watcher.py
Normal file
@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
# input-remapper - GUI for device specific keyboard mappings
|
||||
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||
#
|
||||
# This file is part of input-remapper.
|
||||
#
|
||||
# input-remapper is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# input-remapper is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from inputremapper.focus.backends.hyprland import HyprlandFocusBackend
|
||||
from inputremapper.focus.backends.null import NullBackend
|
||||
from inputremapper.focus.backends.sway import (
|
||||
SwayFocusBackend,
|
||||
_container_to_event,
|
||||
_find_focused,
|
||||
)
|
||||
from inputremapper.focus.backends.wlr_foreign_toplevel import (
|
||||
WlrForeignToplevelBackend,
|
||||
)
|
||||
from inputremapper.focus.backends.xorg import XorgFocusBackend
|
||||
from inputremapper.focus.focus_watcher import focus_backend_classes, select_backend
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestSelectBackend(unittest.TestCase):
|
||||
def _patch_availability(self, available, exclude=()):
|
||||
"""Patch is_available of every backend class from a {name: bool} map."""
|
||||
patchers = []
|
||||
excluded = set(exclude)
|
||||
for backend_class in focus_backend_classes:
|
||||
if backend_class.name in excluded:
|
||||
continue
|
||||
value = available.get(backend_class.name, False)
|
||||
patcher = patch.object(
|
||||
backend_class,
|
||||
"is_available",
|
||||
staticmethod(lambda v=value: v),
|
||||
)
|
||||
patcher.start()
|
||||
patchers.append(patcher)
|
||||
self.addCleanup(lambda: [p.stop() for p in patchers])
|
||||
|
||||
def test_priority_order(self):
|
||||
# registry order: sway, hyprland, wlr, xorg, null
|
||||
self.assertEqual(
|
||||
[c.name for c in focus_backend_classes],
|
||||
["sway", "hyprland", "wlr-foreign-toplevel", "xorg", "null"],
|
||||
)
|
||||
|
||||
def test_selects_sway_first(self):
|
||||
self._patch_availability({"sway": True, "xorg": True})
|
||||
self.assertIsInstance(select_backend(), SwayFocusBackend)
|
||||
|
||||
def test_selects_hyprland_when_no_sway(self):
|
||||
self._patch_availability({"hyprland": True, "xorg": True})
|
||||
self.assertIsInstance(select_backend(), HyprlandFocusBackend)
|
||||
|
||||
def test_selects_xorg_when_only_xorg(self):
|
||||
self._patch_availability({"xorg": True})
|
||||
self.assertIsInstance(select_backend(), XorgFocusBackend)
|
||||
|
||||
def test_falls_back_to_null(self):
|
||||
self._patch_availability({})
|
||||
self.assertIsInstance(select_backend(), NullBackend)
|
||||
|
||||
def test_availability_exception_is_swallowed(self):
|
||||
def boom():
|
||||
raise RuntimeError("nope")
|
||||
|
||||
with patch.object(SwayFocusBackend, "is_available", staticmethod(boom)):
|
||||
self._patch_availability({"xorg": True}, exclude={"sway"})
|
||||
# sway raises -> skipped, xorg wins
|
||||
self.assertIsInstance(select_backend(), XorgFocusBackend)
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestNullBackend(unittest.TestCase):
|
||||
def test_always_available(self):
|
||||
self.assertTrue(NullBackend.is_available())
|
||||
|
||||
def test_get_current_is_none(self):
|
||||
backend = NullBackend()
|
||||
self.assertIsNone(backend.get_current())
|
||||
|
||||
def test_start_and_stop_do_nothing(self):
|
||||
backend = NullBackend()
|
||||
calls = []
|
||||
backend.start(lambda event: calls.append(event))
|
||||
backend.stop()
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestSwayParsing(unittest.TestCase):
|
||||
def test_container_to_event_app_id(self):
|
||||
event = _container_to_event({"app_id": "Alacritty", "name": "shell"})
|
||||
self.assertEqual(event.app_id, "Alacritty")
|
||||
self.assertEqual(event.title, "shell")
|
||||
self.assertEqual(event.backend, "sway")
|
||||
|
||||
def test_container_to_event_xwayland_class_fallback(self):
|
||||
# XWayland windows have no app_id but a window_properties.class
|
||||
event = _container_to_event(
|
||||
{
|
||||
"app_id": None,
|
||||
"name": "Firefox",
|
||||
"window_properties": {"class": "firefox"},
|
||||
}
|
||||
)
|
||||
self.assertEqual(event.app_id, "firefox")
|
||||
self.assertEqual(event.title, "Firefox")
|
||||
|
||||
def test_container_to_event_empty_container(self):
|
||||
self.assertIsNone(_container_to_event({}))
|
||||
|
||||
def test_container_to_event_missing_fields(self):
|
||||
event = _container_to_event({"app_id": "x"})
|
||||
self.assertEqual(event.app_id, "x")
|
||||
self.assertEqual(event.title, "")
|
||||
|
||||
def test_find_focused_nested(self):
|
||||
tree = {
|
||||
"focused": False,
|
||||
"nodes": [
|
||||
{"focused": False, "nodes": []},
|
||||
{
|
||||
"focused": False,
|
||||
"nodes": [{"focused": True, "app_id": "target", "nodes": []}],
|
||||
},
|
||||
],
|
||||
"floating_nodes": [],
|
||||
}
|
||||
found = _find_focused(tree)
|
||||
self.assertIsNotNone(found)
|
||||
self.assertEqual(found["app_id"], "target")
|
||||
|
||||
def test_find_focused_floating(self):
|
||||
tree = {
|
||||
"focused": False,
|
||||
"nodes": [],
|
||||
"floating_nodes": [{"focused": True, "app_id": "floater"}],
|
||||
}
|
||||
self.assertEqual(_find_focused(tree)["app_id"], "floater")
|
||||
|
||||
def test_find_focused_none(self):
|
||||
tree = {"focused": False, "nodes": [{"focused": False}]}
|
||||
self.assertIsNone(_find_focused(tree))
|
||||
|
||||
def test_handle_event_emits_on_focus_change(self):
|
||||
backend = SwayFocusBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
payload = json.dumps(
|
||||
{"change": "focus", "container": {"app_id": "kitty", "name": "term"}}
|
||||
).encode()
|
||||
backend._handle_event(payload)
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(received[0].app_id, "kitty")
|
||||
|
||||
def test_handle_event_ignores_unrelated_change(self):
|
||||
backend = SwayFocusBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
payload = json.dumps(
|
||||
{"change": "move", "container": {"app_id": "kitty"}}
|
||||
).encode()
|
||||
backend._handle_event(payload)
|
||||
self.assertEqual(received, [])
|
||||
|
||||
def test_handle_event_invalid_json(self):
|
||||
backend = SwayFocusBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
backend._handle_event(b"\xff not json")
|
||||
self.assertEqual(received, [])
|
||||
|
||||
def test_is_available_without_env(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
self.assertFalse(SwayFocusBackend.is_available())
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestHyprlandParsing(unittest.TestCase):
|
||||
def test_handle_line_activewindow(self):
|
||||
backend = HyprlandFocusBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
backend._handle_line("activewindow>>firefox,Mozilla Firefox")
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(received[0].app_id, "firefox")
|
||||
self.assertEqual(received[0].title, "Mozilla Firefox")
|
||||
self.assertEqual(received[0].backend, "hyprland")
|
||||
|
||||
def test_handle_line_title_with_comma(self):
|
||||
backend = HyprlandFocusBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
backend._handle_line("activewindow>>code,file.py, project - VSCode")
|
||||
self.assertEqual(received[0].app_id, "code")
|
||||
# only the first comma splits app_id from title
|
||||
self.assertEqual(received[0].title, "file.py, project - VSCode")
|
||||
|
||||
def test_handle_line_ignores_other_events(self):
|
||||
backend = HyprlandFocusBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
backend._handle_line("workspace>>2")
|
||||
backend._handle_line("openwindow>>0x1,1,firefox,Title")
|
||||
self.assertEqual(received, [])
|
||||
|
||||
def test_handle_line_no_separator(self):
|
||||
backend = HyprlandFocusBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
backend._handle_line("garbage line")
|
||||
self.assertEqual(received, [])
|
||||
|
||||
def test_is_available_without_signature(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
self.assertFalse(HyprlandFocusBackend.is_available())
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestXorgBackend(unittest.TestCase):
|
||||
def test_is_available_without_display(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
self.assertFalse(XorgFocusBackend.is_available())
|
||||
|
||||
def test_is_available_without_xlib(self):
|
||||
with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True):
|
||||
with patch.dict("sys.modules", {"Xlib": None, "Xlib.display": None}):
|
||||
# importing Xlib.display raises ImportError -> not available
|
||||
self.assertFalse(XorgFocusBackend.is_available())
|
||||
|
||||
def test_get_current_without_display(self):
|
||||
backend = XorgFocusBackend()
|
||||
self.assertIsNone(backend.get_current())
|
||||
|
||||
def test_stop_without_start(self):
|
||||
backend = XorgFocusBackend()
|
||||
# must not raise even though nothing was started
|
||||
backend.stop()
|
||||
|
||||
|
||||
@test_setup
|
||||
class TestWlrBackend(unittest.TestCase):
|
||||
def test_is_available_without_wayland(self):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
self.assertFalse(WlrForeignToplevelBackend.is_available())
|
||||
|
||||
def test_is_available_without_pywayland(self):
|
||||
# pywayland is not installed in the dev environment, so even with
|
||||
# WAYLAND_DISPLAY set the backend must report itself unavailable cleanly.
|
||||
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
|
||||
with patch(
|
||||
"inputremapper.focus.backends.wlr_foreign_toplevel._import_protocol",
|
||||
return_value=None,
|
||||
):
|
||||
self.assertFalse(WlrForeignToplevelBackend.is_available())
|
||||
|
||||
def test_get_current_initially_none(self):
|
||||
backend = WlrForeignToplevelBackend()
|
||||
self.assertIsNone(backend.get_current())
|
||||
|
||||
def test_toplevel_activation_flow(self):
|
||||
backend = WlrForeignToplevelBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
|
||||
handle = object()
|
||||
backend._toplevels[handle] = {"app_id": "", "title": "", "activated": False}
|
||||
backend._on_app_id(handle, "firefox")
|
||||
backend._on_title(handle, "Mozilla")
|
||||
backend._on_state(handle, [2]) # _STATE_ACTIVATED
|
||||
backend._on_done(handle)
|
||||
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(received[0].app_id, "firefox")
|
||||
self.assertEqual(received[0].title, "Mozilla")
|
||||
self.assertEqual(backend.get_current().app_id, "firefox")
|
||||
|
||||
def test_done_without_activation_is_ignored(self):
|
||||
backend = WlrForeignToplevelBackend()
|
||||
received = []
|
||||
backend._on_focus = received.append
|
||||
handle = object()
|
||||
backend._toplevels[handle] = {
|
||||
"app_id": "x",
|
||||
"title": "y",
|
||||
"activated": False,
|
||||
}
|
||||
backend._on_done(handle)
|
||||
self.assertEqual(received, [])
|
||||
|
||||
def test_closed_removes_toplevel(self):
|
||||
backend = WlrForeignToplevelBackend()
|
||||
handle = object()
|
||||
backend._toplevels[handle] = {"app_id": "x", "title": "", "activated": True}
|
||||
backend._on_closed(handle)
|
||||
self.assertNotIn(handle, backend._toplevels)
|
||||
|
||||
def test_state_unknown_handle_ignored(self):
|
||||
backend = WlrForeignToplevelBackend()
|
||||
# must not raise for a handle we never registered
|
||||
backend._on_app_id(object(), "x")
|
||||
backend._on_state(object(), [2])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -19,10 +19,12 @@
|
||||
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from inputremapper.configs.global_config import GlobalConfig
|
||||
from inputremapper.logging.logger import VERSION
|
||||
from tests.lib.test_setup import test_setup
|
||||
|
||||
|
||||
@ -81,8 +83,16 @@ class TestGlobalConfig(unittest.TestCase):
|
||||
self.assertTrue(os.path.exists(global_config.path))
|
||||
|
||||
with open(global_config.path, "r") as file:
|
||||
contents = file.read()
|
||||
self.assertIn('"autoload": {}', contents)
|
||||
config = json.load(file)
|
||||
self.assertEqual(
|
||||
config,
|
||||
{
|
||||
"version": VERSION,
|
||||
"autoload": {},
|
||||
"app_binding_enabled": False,
|
||||
"app_bindings": [],
|
||||
},
|
||||
)
|
||||
|
||||
def test_save_load(self):
|
||||
global_config = GlobalConfig()
|
||||
@ -101,6 +111,71 @@ class TestGlobalConfig(unittest.TestCase):
|
||||
[("d1", "a"), ("d2", "b")],
|
||||
)
|
||||
|
||||
# -- app bindings ------------------------------------------------------
|
||||
|
||||
def test_app_binding_enabled_default_false(self):
|
||||
global_config = GlobalConfig()
|
||||
self.assertFalse(global_config.get_app_binding_enabled())
|
||||
|
||||
def test_set_app_binding_enabled_roundtrip(self):
|
||||
global_config = GlobalConfig()
|
||||
global_config.set_app_binding_enabled(True)
|
||||
self.assertTrue(global_config.get_app_binding_enabled())
|
||||
|
||||
# persisted and reloadable
|
||||
reloaded = GlobalConfig()
|
||||
reloaded.load_config()
|
||||
self.assertTrue(reloaded.get_app_binding_enabled())
|
||||
|
||||
global_config.set_app_binding_enabled(False)
|
||||
self.assertFalse(global_config.get_app_binding_enabled())
|
||||
|
||||
def test_app_bindings_default_empty(self):
|
||||
global_config = GlobalConfig()
|
||||
self.assertEqual(global_config.get_app_bindings(), [])
|
||||
|
||||
def test_set_app_bindings_roundtrip(self):
|
||||
from inputremapper.configs.app_binding import (
|
||||
AppBinding,
|
||||
BoundPreset,
|
||||
MatchType,
|
||||
)
|
||||
|
||||
bindings = [
|
||||
AppBinding(
|
||||
app_id="firefox",
|
||||
match=MatchType.wm_class,
|
||||
presets=[BoundPreset(group_key="Foo Device", preset="preset1")],
|
||||
),
|
||||
AppBinding(app_id="term.*", match=MatchType.title_regex),
|
||||
]
|
||||
global_config = GlobalConfig()
|
||||
global_config.set_app_bindings(bindings)
|
||||
|
||||
# serialized as plain dicts in config.json
|
||||
with open(global_config.path, "r") as file:
|
||||
raw = json.load(file)
|
||||
self.assertEqual(len(raw["app_bindings"]), 2)
|
||||
self.assertEqual(raw["app_bindings"][0]["app_id"], "firefox")
|
||||
|
||||
# reloaded into model objects
|
||||
reloaded = GlobalConfig()
|
||||
reloaded.load_config()
|
||||
result = reloaded.get_app_bindings()
|
||||
self.assertEqual(result, bindings)
|
||||
|
||||
def test_get_app_bindings_skips_invalid(self):
|
||||
global_config = GlobalConfig()
|
||||
global_config.load_config()
|
||||
# inject an invalid binding (empty app_id) directly into the raw config
|
||||
global_config._config["app_bindings"] = [
|
||||
{"app_id": "valid", "match": "wm_class", "presets": []},
|
||||
{"app_id": "", "match": "wm_class", "presets": []},
|
||||
]
|
||||
result = global_config.get_app_bindings()
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].app_id, "valid")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -659,7 +659,13 @@ class TestMigrations(unittest.TestCase):
|
||||
with open(PathUtils.get_config_path("config.json"), "r") as f:
|
||||
config_json = json.load(f)
|
||||
self.assertDictEqual(
|
||||
config_json, {"autoload": {device_name: "foo"}, "version": VERSION}
|
||||
config_json,
|
||||
{
|
||||
"autoload": {device_name: "foo"},
|
||||
"version": VERSION,
|
||||
"app_binding_enabled": False,
|
||||
"app_bindings": [],
|
||||
},
|
||||
)
|
||||
with open(PathUtils.get_preset_path(device_name, "foo.json"), "r") as f:
|
||||
os.system(f'cat { PathUtils.get_preset_path(device_name, "foo.json") }')
|
||||
@ -702,7 +708,13 @@ class TestMigrations(unittest.TestCase):
|
||||
with open(PathUtils.get_config_path("config.json"), "r") as f:
|
||||
config_json = json.load(f)
|
||||
self.assertDictEqual(
|
||||
config_json, {"autoload": {device_name: "bar"}, "version": VERSION}
|
||||
config_json,
|
||||
{
|
||||
"autoload": {device_name: "bar"},
|
||||
"version": VERSION,
|
||||
"app_binding_enabled": False,
|
||||
"app_bindings": [],
|
||||
},
|
||||
)
|
||||
with open(PathUtils.get_preset_path(device_name, "bar.json"), "r") as f:
|
||||
os.system(f'cat { PathUtils.get_preset_path(device_name, "bar.json") }')
|
||||
@ -721,6 +733,60 @@ class TestMigrations(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def _write_config(self, data):
|
||||
config_file = os.path.join(PathUtils.config_path(), "config.json")
|
||||
PathUtils.touch(config_file)
|
||||
with open(config_file, "w") as file:
|
||||
json.dump(data, file, indent=4)
|
||||
return config_file
|
||||
|
||||
def _read_config(self, config_file):
|
||||
with open(config_file, "r") as file:
|
||||
return json.load(file)
|
||||
|
||||
def test_add_app_binding_config(self):
|
||||
# an old config without the app-binding keys gets them added
|
||||
config_file = self._write_config(
|
||||
{"version": "2.2.1", "autoload": {"Foo Device 2": "preset2"}}
|
||||
)
|
||||
self.migrations.migrate()
|
||||
|
||||
config = self._read_config(config_file)
|
||||
self.assertEqual(config["app_binding_enabled"], False)
|
||||
self.assertEqual(config["app_bindings"], [])
|
||||
# additive: existing keys are preserved
|
||||
self.assertEqual(config["autoload"], {"Foo Device 2": "preset2"})
|
||||
self.assertEqual(config["version"], VERSION)
|
||||
|
||||
def test_add_app_binding_config_preserves_existing(self):
|
||||
# a config already carrying app-binding data must not be reset
|
||||
existing_binding = {
|
||||
"app_id": "firefox",
|
||||
"match": "wm_class",
|
||||
"presets": [{"group_key": "Foo Device", "preset": "preset1"}],
|
||||
}
|
||||
config_file = self._write_config(
|
||||
{
|
||||
"version": "2.2.1",
|
||||
"autoload": {},
|
||||
"app_binding_enabled": True,
|
||||
"app_bindings": [existing_binding],
|
||||
}
|
||||
)
|
||||
self.migrations.migrate()
|
||||
|
||||
config = self._read_config(config_file)
|
||||
self.assertEqual(config["app_binding_enabled"], True)
|
||||
self.assertEqual(config["app_bindings"], [existing_binding])
|
||||
|
||||
def test_add_app_binding_config_no_file(self):
|
||||
# no config.json yet -> migration is a no-op and does not create one
|
||||
config_file = os.path.join(PathUtils.config_path(), "config.json")
|
||||
if os.path.exists(config_file):
|
||||
os.remove(config_file)
|
||||
self.migrations._add_app_binding_config()
|
||||
self.assertFalse(os.path.exists(config_file))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user