#!/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 time
import unittest
from typing import Optional, Tuple, Union
from unittest.mock import MagicMock, call

import evdev
import gi
from evdev.ecodes import KEY_A, KEY_B, KEY_C

gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
gi.require_version("GtkSource", "4")
from gi.repository import Gtk, GLib, GtkSource, Gdk

from tests.lib.spy import spy
from tests.lib.logger import logger

from inputremapper.gui.controller import Controller
from inputremapper.configs.keyboard_layout import XKB_KEYCODE_OFFSET
from inputremapper.gui.utils import CTX_ERROR, CTX_WARNING, gtk_iteration
from inputremapper.gui.messages.message_broker import (
    MessageBroker,
    MessageType,
)
from inputremapper.gui.messages.message_data import (
    UInputsData,
    GroupsData,
    GroupData,
    PresetData,
    StatusData,
    CombinationUpdate,
    DoStackSwitch,
)
from inputremapper.groups import DeviceType
from inputremapper.gui.components.editor import (
    TargetSelection,
    MappingListBox,
    MappingSelectionLabel,
    CodeEditor,
    RecordingToggle,
    AutoloadSwitch,
    ReleaseCombinationSwitch,
    CombinationListbox,
    InputConfigEntry,
    AnalogInputSwitch,
    TriggerThresholdInput,
    ReleaseTimeoutInput,
    OutputAxisSelector,
    KeyAxisStackSwitcher,
    Sliders,
    TransformationDrawArea,
    RelativeInputCutoffInput,
    RecordingStatus,
    RequireActiveMapping,
    GdkEventRecorder,
)
from inputremapper.gui.components.main import Stack, StatusBar
from inputremapper.gui.components.common import FlowBoxEntry, Breadcrumbs
from inputremapper.gui.components.presets import PresetSelection
from inputremapper.gui.components.device_groups import (
    DeviceGroupEntry,
    DeviceGroupSelection,
)
from inputremapper.configs.mapping import MappingData
from inputremapper.configs.input_config import InputCombination, InputConfig
from tests.lib.test_setup import test_setup


class ComponentBaseTest(unittest.TestCase):
    """Test a gui component."""

    def setUp(self) -> None:
        self.message_broker = MessageBroker()
        self.controller_mock: Controller = MagicMock()

    def destroy_all_member_widgets(self):
        # destroy all Gtk Widgets that are stored in self
        # TODO why is this necessary?
        for attribute in dir(self):
            stuff = getattr(self, attribute, None)
            if isinstance(stuff, Gtk.Widget):
                logger.info('destroying member "%s" %s', attribute, stuff)
                GLib.timeout_add(0, stuff.destroy)
                setattr(self, attribute, None)

    def tearDown(self) -> None:
        super().tearDown()
        self.message_broker.signal(MessageType.terminate)

        # Shut down the gui properly
        self.destroy_all_member_widgets()
        GLib.timeout_add(0, Gtk.main_quit)

        # Gtk.main() will start the Gtk event loop and process all pending events.
        # So the gui will do whatever is queued up this ensures that the next tests
        # starts without pending events.
        Gtk.main()


class FlowBoxTestUtils:
    """Methods to test the FlowBoxes that contain presets and devices.

    Those are only used in tests, so I moved them here instead.
    """

    @staticmethod
    def set_active(flow_box: Gtk.FlowBox, name: str):
        """Change the currently selected group."""
        for child in flow_box.get_children():
            flow_box_entry: FlowBoxEntry = child.get_children()[0]
            flow_box_entry.set_active(flow_box_entry.name == name)

    @staticmethod
    def get_active_entry(flow_box: Gtk.FlowBox) -> Union[DeviceGroupEntry, None]:
        """Find the currently selected DeviceGroupEntry."""
        children = flow_box.get_children()

        if len(children) == 0:
            return None

        for child in children:
            flow_box_entry: FlowBoxEntry = child.get_children()[0]

            if flow_box_entry.get_active():
                return flow_box_entry

        raise AssertionError("Expected one entry to be selected.")

    @staticmethod
    def get_child_names(flow_box: Gtk.FlowBox):
        names = []
        for child in flow_box.get_children():
            flow_box_entry: FlowBoxEntry = child.get_children()[0]
            names.append(flow_box_entry.name)

        return names

    @staticmethod
    def get_child_icons(flow_box: Gtk.FlowBox):
        icon_names = []
        for child in flow_box.get_children():
            flow_box_entry: FlowBoxEntry = child.get_children()[0]
            icon_names.append(flow_box_entry.icon_name)

        return icon_names


@test_setup
class TestDeviceGroupSelection(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.FlowBox()
        self.selection = DeviceGroupSelection(
            self.message_broker,
            self.controller_mock,
            self.gui,
        )
        self.message_broker.publish(
            GroupsData(
                {
                    "foo": [DeviceType.GAMEPAD, DeviceType.KEYBOARD],
                    "bar": [],
                    "baz": [DeviceType.GRAPHICS_TABLET],
                }
            )
        )

    def get_displayed_group_keys_and_icons(self):
        """Get a list of all group_keys and icons of the displayed groups."""
        group_keys = []
        icons = []
        for child in self.gui.get_children():
            device_group_entry = child.get_children()[0]
            group_keys.append(device_group_entry.group_key)
            icons.append(device_group_entry.icon_name)

        return group_keys, icons

    def test_populates_devices(self):
        # tests that all devices sent via the broker end up in the gui
        group_keys, icons = self.get_displayed_group_keys_and_icons()
        self.assertEqual(group_keys, ["foo", "bar", "baz"])
        self.assertEqual(icons, ["input-gaming", None, "input-tablet"])

        self.message_broker.publish(
            GroupsData(
                {
                    "kuu": [DeviceType.KEYBOARD],
                    "qux": [DeviceType.GAMEPAD],
                }
            )
        )

        group_keys, icons = self.get_displayed_group_keys_and_icons()
        self.assertEqual(group_keys, ["kuu", "qux"])
        self.assertEqual(icons, ["input-keyboard", "input-gaming"])

    def test_selects_correct_device(self):
        self.message_broker.publish(GroupData("bar", ()))
        self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "bar")
        self.message_broker.publish(GroupData("baz", ()))
        self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "baz")

    def test_loads_group(self):
        FlowBoxTestUtils.set_active(self.gui, "bar")
        self.controller_mock.load_group.assert_called_once_with("bar")

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(GroupData("bar", ()))
        self.controller_mock.load_group.assert_not_called()


@test_setup
class TestTargetSelection(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.ComboBox()
        self.selection = TargetSelection(
            self.message_broker, self.controller_mock, self.gui
        )
        self.message_broker.publish(
            UInputsData(
                {
                    "foo": {},
                    "bar": {},
                    "baz": {},
                }
            )
        )

    def test_populates_devices(self):
        names = [row[0] for row in self.gui.get_model()]
        self.assertEqual(names, ["foo", "bar", "baz"])

        self.message_broker.publish(
            UInputsData(
                {
                    "kuu": {},
                    "qux": {},
                }
            )
        )
        names = [row[0] for row in self.gui.get_model()]
        self.assertEqual(names, ["kuu", "qux"])

    def test_updates_mapping(self):
        self.gui.set_active_id("baz")
        self.controller_mock.update_mapping.assert_called_once_with(target_uinput="baz")

    def test_selects_correct_target(self):
        self.message_broker.publish(MappingData(target_uinput="baz"))
        self.assertEqual(self.gui.get_active_id(), "baz")
        self.message_broker.publish(MappingData(target_uinput="bar"))
        self.assertEqual(self.gui.get_active_id(), "bar")

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(MappingData(target_uinput="baz"))
        self.controller_mock.update_mapping.assert_not_called()


@test_setup
class TestPresetSelection(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.FlowBox()
        self.selection = PresetSelection(
            self.message_broker, self.controller_mock, self.gui
        )
        self.message_broker.publish(GroupData("foo", ("preset1", "preset2")))

    def test_populates_presets(self):
        names = FlowBoxTestUtils.get_child_names(self.gui)
        self.assertEqual(names, ["preset1", "preset2"])

        self.message_broker.publish(GroupData("foo", ("preset3", "preset4")))
        names = FlowBoxTestUtils.get_child_names(self.gui)
        self.assertEqual(names, ["preset3", "preset4"])

    def test_selects_preset(self):
        self.message_broker.publish(
            PresetData(
                "preset2",
                (
                    MappingData(
                        name="m1",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=2)]
                        ),
                    ),
                ),
            )
        )
        self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset2")

        self.message_broker.publish(
            PresetData(
                "preset1",
                (
                    MappingData(
                        name="m1",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=2)]
                        ),
                    ),
                ),
            )
        )
        self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset1")

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(
            PresetData(
                "preset2",
                (
                    MappingData(
                        name="m1",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=2)]
                        ),
                    ),
                ),
            )
        )
        self.controller_mock.load_preset.assert_not_called()

    def test_loads_preset(self):
        FlowBoxTestUtils.set_active(self.gui, "preset2")
        self.controller_mock.load_preset.assert_called_once_with("preset2")


@test_setup
class TestMappingListbox(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.ListBox()
        self.listbox = MappingListBox(
            self.message_broker, self.controller_mock, self.gui
        )

        self.message_broker.publish(
            PresetData(
                "preset1",
                (
                    MappingData(
                        name="mapping1",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=KEY_C)]
                        ),
                    ),
                    MappingData(
                        name="",
                        input_combination=InputCombination(
                            [
                                InputConfig(type=1, code=KEY_A),
                                InputConfig(type=1, code=KEY_B),
                            ]
                        ),
                    ),
                    MappingData(
                        name="mapping2",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=KEY_B)]
                        ),
                    ),
                ),
            )
        )

    def get_selected_row(self) -> MappingSelectionLabel:
        for label in self.gui.get_children():
            if label.is_selected():
                return label

        raise Exception("Expected one MappingSelectionLabel to be selected")

    def select_row(self, combination: InputCombination):
        def select(label_: MappingSelectionLabel):
            if label_.combination == combination:
                self.gui.select_row(label_)

        for label in self.gui.get_children():
            select(label)

    def test_populates_listbox(self):
        labels = {row.name for row in self.gui.get_children()}
        self.assertEqual(labels, {"mapping1", "mapping2", "a + b"})

    def test_alphanumerically_sorted(self):
        labels = [row.name for row in self.gui.get_children()]
        self.assertEqual(labels, ["a + b", "mapping1", "mapping2"])

    def test_activates_correct_row(self):
        self.message_broker.publish(
            MappingData(
                name="mapping1",
                input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]),
            )
        )
        selected = self.get_selected_row()
        self.assertEqual(selected.name, "mapping1")
        self.assertEqual(
            selected.combination,
            InputCombination([InputConfig(type=1, code=KEY_C)]),
        )

    def test_loads_mapping(self):
        self.select_row(InputCombination([InputConfig(type=1, code=KEY_B)]))
        self.controller_mock.load_mapping.assert_called_once_with(
            InputCombination([InputConfig(type=1, code=KEY_B)])
        )

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(
            MappingData(
                name="mapping1",
                input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]),
            )
        )
        self.controller_mock.load_mapping.assert_not_called()

    def test_sorts_empty_mapping_to_bottom(self):
        self.message_broker.publish(
            PresetData(
                "preset1",
                (
                    MappingData(
                        name="qux",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=KEY_C)]
                        ),
                    ),
                    MappingData(
                        name="foo",
                        input_combination=InputCombination.empty_combination(),
                    ),
                    MappingData(
                        name="bar",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=KEY_B)]
                        ),
                    ),
                ),
            )
        )
        bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2)
        self.assertEqual(bottom_row.combination, InputCombination.empty_combination())
        self.message_broker.publish(
            PresetData(
                "preset1",
                (
                    MappingData(
                        name="foo",
                        input_combination=InputCombination.empty_combination(),
                    ),
                    MappingData(
                        name="qux",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=KEY_C)]
                        ),
                    ),
                    MappingData(
                        name="bar",
                        input_combination=InputCombination(
                            [InputConfig(type=1, code=KEY_B)]
                        ),
                    ),
                ),
            )
        )
        bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2)
        self.assertEqual(bottom_row.combination, InputCombination.empty_combination())


@test_setup
class TestMappingSelectionLabel(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.ListBox()
        self.mapping_selection_label = MappingSelectionLabel(
            self.message_broker,
            self.controller_mock,
            "",
            InputCombination(
                [
                    InputConfig(type=1, code=KEY_A),
                    InputConfig(type=1, code=KEY_B),
                ]
            ),
        )
        self.gui.insert(self.mapping_selection_label, -1)

    def assert_edit_mode(self):
        self.assertTrue(self.mapping_selection_label.name_input.get_visible())
        self.assertFalse(self.mapping_selection_label.label.get_visible())

    def assert_selected(self):
        self.assertTrue(self.mapping_selection_label.label.get_visible())
        self.assertFalse(self.mapping_selection_label.name_input.get_visible())

    def test_repr(self):
        self.mapping_selection_label.name = "name"
        self.assertIn("name", repr(self.mapping_selection_label))
        self.assertIn("KEY_A", repr(self.mapping_selection_label))
        self.assertIn("KEY_B", repr(self.mapping_selection_label))

    def test_shows_combination_without_name(self):
        self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b")

    def test_shows_name_when_given(self):
        self.gui = MappingSelectionLabel(
            self.message_broker,
            self.controller_mock,
            "foo",
            InputCombination(
                (
                    InputConfig(type=1, code=KEY_A),
                    InputConfig(type=1, code=KEY_B),
                )
            ),
        )
        self.assertEqual(self.gui.label.get_label(), "foo")

    def test_updates_combination_when_selected(self):
        self.gui.select_row(self.mapping_selection_label)
        self.assertEqual(
            self.mapping_selection_label.combination,
            InputCombination(
                (
                    InputConfig(type=1, code=KEY_A),
                    InputConfig(type=1, code=KEY_B),
                )
            ),
        )
        self.message_broker.publish(
            CombinationUpdate(
                InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
                InputCombination([InputConfig(type=1, code=KEY_A)]),
            )
        )
        self.assertEqual(
            self.mapping_selection_label.combination,
            InputCombination([InputConfig(type=1, code=KEY_A)]),
        )

    def test_doesnt_update_combination_when_not_selected(self):
        self.assertEqual(
            self.mapping_selection_label.combination,
            InputCombination(
                (
                    InputConfig(type=1, code=KEY_A),
                    InputConfig(type=1, code=KEY_B),
                )
            ),
        )
        self.message_broker.publish(
            CombinationUpdate(
                InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
                InputCombination([InputConfig(type=1, code=KEY_A)]),
            )
        )
        self.assertEqual(
            self.mapping_selection_label.combination,
            InputCombination(
                (
                    InputConfig(type=1, code=KEY_A),
                    InputConfig(type=1, code=KEY_B),
                )
            ),
        )

    def test_updates_name_when_mapping_changed_and_combination_matches(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
                name="foo",
            )
        )
        self.assertEqual(self.mapping_selection_label.label.get_label(), "foo")

    def test_ignores_mapping_when_combination_does_not_match(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_C),
                    )
                ),
                name="foo",
            )
        )
        self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b")

    def test_edit_button_visibility(self):
        # start off invisible
        self.assertFalse(self.mapping_selection_label.edit_btn.get_visible())

        # load the mapping associated with the ListBoxRow
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
            )
        )
        self.assertTrue(self.mapping_selection_label.edit_btn.get_visible())

        # load a different row
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_C),
                    )
                ),
            )
        )
        self.assertFalse(self.mapping_selection_label.edit_btn.get_visible())

    def test_enter_edit_mode_focuses_name_input(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
            )
        )
        self.mapping_selection_label.edit_btn.clicked()
        self.controller_mock.set_focus.assert_called_once_with(
            self.mapping_selection_label.name_input
        )

    def test_enter_edit_mode_updates_visibility(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
            )
        )
        self.assert_selected()
        self.mapping_selection_label.edit_btn.clicked()
        self.assert_edit_mode()
        self.mapping_selection_label.name_input.activate()  # aka hit the return key
        self.assert_selected()

    def test_leaves_edit_mode_on_esc(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
            )
        )
        self.mapping_selection_label.edit_btn.clicked()
        self.assert_edit_mode()
        self.mapping_selection_label.name_input.set_text("foo")

        event = Gdk.Event()
        event.key.keyval = Gdk.KEY_Escape
        # send the "key-press-event"
        self.mapping_selection_label._on_gtk_rename_abort(None, event.key)
        self.assert_selected()
        self.assertEqual(self.mapping_selection_label.label.get_text(), "a + b")
        self.controller_mock.update_mapping.assert_not_called()

    def test_update_name(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
            )
        )
        self.mapping_selection_label.edit_btn.clicked()

        self.mapping_selection_label.name_input.set_text("foo")
        self.mapping_selection_label.name_input.activate()
        self.controller_mock.update_mapping.assert_called_once_with(name="foo")

    def test_name_input_contains_combination_when_name_not_set(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
            )
        )
        self.mapping_selection_label.edit_btn.clicked()
        self.assertEqual(self.mapping_selection_label.name_input.get_text(), "a + b")

    def test_name_input_contains_name(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
                name="foo",
            )
        )
        self.mapping_selection_label.edit_btn.clicked()
        self.assertEqual(self.mapping_selection_label.name_input.get_text(), "foo")

    def test_removes_name_when_name_matches_combination(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=KEY_A),
                        InputConfig(type=1, code=KEY_B),
                    )
                ),
                name="foo",
            )
        )
        self.mapping_selection_label.edit_btn.clicked()
        self.mapping_selection_label.name_input.set_text("a + b")
        self.mapping_selection_label.name_input.activate()
        self.controller_mock.update_mapping.assert_called_once_with(name="")


@test_setup
class TestGdkEventRecorder(ComponentBaseTest):
    def _emit_key(self, window, code, type_):
        event = Gdk.Event()
        event.type = type_
        event.hardware_keycode = code + XKB_KEYCODE_OFFSET
        window.emit("event", event)
        gtk_iteration()

    def _emit_button(self, window, button, type_):
        event = Gdk.Event()
        event.type = type_
        event.button = button
        window.emit("event", event)
        gtk_iteration()

    def test_records_combinations(self):
        label = Gtk.Label()
        window = Gtk.Window()
        GdkEventRecorder(window, label)

        self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS)
        self._emit_key(window, KEY_B, Gdk.EventType.KEY_PRESS)
        self.assertEqual(label.get_text(), "a + b")

        self._emit_key(window, KEY_A, Gdk.EventType.KEY_RELEASE)
        self._emit_key(window, KEY_B, Gdk.EventType.KEY_RELEASE)
        self.assertEqual(label.get_text(), "a + b")

        self._emit_key(window, KEY_C, Gdk.EventType.KEY_PRESS)
        self.assertEqual(label.get_text(), "c")

        # buttons
        self._emit_button(window, Gdk.BUTTON_PRIMARY, Gdk.EventType.BUTTON_PRESS)
        self._emit_button(window, Gdk.BUTTON_SECONDARY, Gdk.EventType.BUTTON_PRESS)
        self._emit_button(window, Gdk.BUTTON_MIDDLE, Gdk.EventType.BUTTON_PRESS)
        # no constants seem to exist, but this is the value that was observed during
        # usage:
        self._emit_button(window, 8, Gdk.EventType.BUTTON_PRESS)
        self._emit_button(window, 9, Gdk.EventType.BUTTON_PRESS)
        self.assertEqual(
            label.get_text(),
            "c + BTN_LEFT + BTN_RIGHT + BTN_MIDDLE + BTN_SIDE + BTN_EXTRA",
        )

        # releasing anything resets the combination
        self._emit_button(window, 9, Gdk.EventType.BUTTON_RELEASE)
        self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS)
        self.assertEqual(label.get_text(), "a")


@test_setup
class TestCodeEditor(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = GtkSource.View()
        self.editor = CodeEditor(self.message_broker, self.controller_mock, self.gui)
        # TODO why is mocking this to False needed?
        self.controller_mock.is_empty_mapping.return_value = False

    def get_text(self) -> str:
        buffer = self.gui.get_buffer()
        return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True)

    def test_shows_output_symbol(self):
        self.message_broker.publish(MappingData(output_symbol="foo"))
        self.assertEqual(self.get_text(), "foo")

    def test_shows_record_input_first_message_when_mapping_is_empty(self):
        self.controller_mock.is_empty_mapping.return_value = True
        self.message_broker.publish(MappingData(output_symbol="foo"))
        self.assertEqual(self.get_text(), "Record the input first")

    def test_active_when_mapping_is_not_empty(self):
        self.message_broker.publish(MappingData(output_symbol="foo"))
        self.assertTrue(self.gui.get_sensitive())
        self.assertEqual(self.gui.get_opacity(), 1)

    def test_expands_to_multiline(self):
        self.message_broker.publish(MappingData(output_symbol="foo\nbar"))
        self.assertIn("multiline", self.gui.get_style_context().list_classes())

    def test_shows_line_numbers_when_multiline(self):
        self.message_broker.publish(MappingData(output_symbol="foo\nbar"))
        self.assertTrue(self.gui.get_show_line_numbers())

    def test_no_multiline_when_macro_not_multiline(self):
        self.message_broker.publish(MappingData(output_symbol="foo"))
        self.assertNotIn("multiline", self.gui.get_style_context().list_classes())

    def test_no_line_numbers_macro_not_multiline(self):
        self.message_broker.publish(MappingData(output_symbol="foo"))
        self.assertFalse(self.gui.get_show_line_numbers())

    def test_shows_placeholder_when_mapping_has_no_output_symbol(self):
        self.message_broker.publish(MappingData())
        self.assertEqual(self.get_text(), self.editor.placeholder)

        # there are no side-effects because the placeholder is inserted:
        self.controller_mock.update_mapping.assert_not_called()

    def test_updates_mapping(self):
        self.message_broker.publish(MappingData())
        buffer = self.gui.get_buffer()
        self.controller_mock.update_mapping.assert_not_called()
        buffer.set_text("foo")
        call_args_list = self.controller_mock.update_mapping.call_args_list
        # this test emits 2 events for whatever reason, the first one with an empty
        # symbol. this doesn't actually seem to happen when using it.
        self.assertEqual(call_args_list[-1], call(output_symbol="foo"))

    def test_avoids_infinite_recursion_when_loading_mapping(self):
        self.message_broker.publish(MappingData(output_symbol="foo"))
        self.controller_mock.update_mapping.assert_not_called()

    def test_gets_focus_when_input_recording_finises(self):
        self.message_broker.signal(MessageType.recording_finished)
        self.controller_mock.set_focus.assert_called_once_with(self.gui)

    def test_placeholder(self):
        self.assertEqual(self.get_text(), self.editor.placeholder)

        window = Gtk.Window()
        window.add(self.gui)
        window.show_all()

        def focus():
            self.gui.grab_focus()
            # Do as many iterations as needed to make it work.
            gtk_iteration(15)

        def unfocus():
            window.set_focus(None)
            gtk_iteration(15)

        # clears the input when we enter the editor widget
        focus()
        self.assertEqual(self.get_text(), "")
        self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes())

        # adds the placeholder back when we leave it
        unfocus()
        self.assertEqual(self.get_text(), self.editor.placeholder)
        self.assertIn("opaque-text", self.gui.get_style_context().list_classes())

        # if we enter text and then leave, it won't show the placeholder
        focus()
        self.assertEqual(self.get_text(), "")
        buffer = self.gui.get_buffer()
        buffer.set_text("foo")
        self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes())
        unfocus()
        self.assertEqual(self.get_text(), "foo")
        self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes())


@test_setup
class TestRecordingToggle(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()

        self.toggle_button = Gtk.ToggleButton()
        self.recording_toggle = RecordingToggle(
            self.message_broker,
            self.controller_mock,
            self.toggle_button,
        )

        self.label = Gtk.Label()
        self.recording_status = RecordingStatus(self.message_broker, self.label)

    def assert_not_recording(self):
        self.assertFalse(self.label.get_visible())
        self.assertFalse(self.toggle_button.get_active())

    def test_starts_recording(self):
        self.toggle_button.set_active(True)
        self.controller_mock.start_key_recording.assert_called_once()

    def test_stops_recording_when_clicked(self):
        self.toggle_button.set_active(True)
        self.toggle_button.set_active(False)
        self.controller_mock.stop_key_recording.assert_called_once()

    def test_not_recording_initially(self):
        self.assert_not_recording()

    def test_shows_recording_when_message_sent(self):
        self.assertFalse(self.label.get_visible())
        self.message_broker.signal(MessageType.recording_started)
        self.assertTrue(self.label.get_visible())

    def test_shows_not_recording_after_toggle(self):
        self.toggle_button.set_active(True)
        self.toggle_button.set_active(False)
        self.assert_not_recording()

    def test_shows_not_recording_when_recording_finished(self):
        self.toggle_button.set_active(True)
        self.message_broker.signal(MessageType.recording_finished)
        self.assert_not_recording()


@test_setup
class TestStatusBar(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.Statusbar()
        self.err_icon = Gtk.Image()
        self.warn_icon = Gtk.Image()
        self.statusbar = StatusBar(
            self.message_broker,
            self.controller_mock,
            self.gui,
            self.err_icon,
            self.warn_icon,
        )
        self.message_broker.signal(MessageType.init)

    def assert_empty(self):
        self.assertFalse(self.err_icon.get_visible())
        self.assertFalse(self.warn_icon.get_visible())
        self.assertEqual(self.get_text(), "")
        self.assertIsNone(self.get_tooltip())

    def assert_error_status(self):
        self.assertTrue(self.err_icon.get_visible())
        self.assertFalse(self.warn_icon.get_visible())

    def assert_warning_status(self):
        self.assertFalse(self.err_icon.get_visible())
        self.assertTrue(self.warn_icon.get_visible())

    def get_text(self) -> str:
        return self.gui.get_message_area().get_children()[0].get_text()

    def get_tooltip(self) -> Optional[str]:
        return self.gui.get_tooltip_text()

    def test_starts_empty(self):
        self.assert_empty()

    def test_shows_error_status(self):
        self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip"))
        self.assertEqual(self.get_text(), "msg")
        self.assertEqual(self.get_tooltip(), "tooltip")
        self.assert_error_status()

    def test_shows_warning_status(self):
        self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip"))
        self.assertEqual(self.get_text(), "msg")
        self.assertEqual(self.get_tooltip(), "tooltip")
        self.assert_warning_status()

    def test_shows_newest_message(self):
        self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip"))
        self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2"))
        self.assertEqual(self.get_text(), "msg2")
        self.assertEqual(self.get_tooltip(), "tooltip2")
        self.assert_warning_status()

    def test_data_without_message_removes_messages(self):
        self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip"))
        self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2"))
        self.message_broker.publish(StatusData(CTX_WARNING))
        self.assert_empty()

    def test_restores_message_from_not_removed_ctx_id(self):
        self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip"))
        self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2"))
        self.message_broker.publish(StatusData(CTX_WARNING))
        self.assertEqual(self.get_text(), "msg")
        self.assert_error_status()

        # works also the other way round
        self.message_broker.publish(StatusData(CTX_ERROR))
        self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip"))
        self.message_broker.publish(StatusData(CTX_ERROR, "msg2", "tooltip2"))
        self.message_broker.publish(StatusData(CTX_ERROR))
        self.assertEqual(self.get_text(), "msg")
        self.assert_warning_status()

    def test_sets_msg_as_tooltip_if_tooltip_is_none(self):
        self.message_broker.publish(StatusData(CTX_ERROR, "msg"))
        self.assertEqual(self.get_tooltip(), "msg")


@test_setup
class TestAutoloadSwitch(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.Switch()
        self.switch = AutoloadSwitch(
            self.message_broker, self.controller_mock, self.gui
        )

    def test_sets_autoload(self):
        self.gui.set_active(True)
        self.controller_mock.set_autoload.assert_called_once_with(True)
        self.controller_mock.reset_mock()
        self.gui.set_active(False)
        self.controller_mock.set_autoload.assert_called_once_with(False)

    def test_updates_state(self):
        self.message_broker.publish(PresetData(None, None, autoload=True))
        self.assertTrue(self.gui.get_active())
        self.message_broker.publish(PresetData(None, None, autoload=False))
        self.assertFalse(self.gui.get_active())

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(PresetData(None, None, autoload=True))
        self.message_broker.publish(PresetData(None, None, autoload=False))
        self.controller_mock.set_autoload.assert_not_called()


@test_setup
class TestReleaseCombinationSwitch(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.Switch()
        self.switch = ReleaseCombinationSwitch(
            self.message_broker, self.controller_mock, self.gui
        )

    def test_updates_mapping(self):
        self.gui.set_active(True)
        self.controller_mock.update_mapping.assert_called_once_with(
            release_combination_keys=True
        )
        self.controller_mock.reset_mock()
        self.gui.set_active(False)
        self.controller_mock.update_mapping.assert_called_once_with(
            release_combination_keys=False
        )

    def test_updates_state(self):
        self.message_broker.publish(MappingData(release_combination_keys=True))
        self.assertTrue(self.gui.get_active())
        self.message_broker.publish(MappingData(release_combination_keys=False))
        self.assertFalse(self.gui.get_active())

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(MappingData(release_combination_keys=True))
        self.message_broker.publish(MappingData(release_combination_keys=False))
        self.controller_mock.update_mapping.assert_not_called()


@test_setup
class TestEventEntry(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = InputConfigEntry(
            InputConfig(type=3, code=0, analog_threshold=1), self.controller_mock
        )

    def test_move_event(self):
        self.gui._up_btn.clicked()
        self.controller_mock.move_input_config_in_combination.assert_called_once_with(
            InputConfig(type=3, code=0, analog_threshold=1), "up"
        )
        self.controller_mock.reset_mock()

        self.gui._down_btn.clicked()
        self.controller_mock.move_input_config_in_combination.assert_called_once_with(
            InputConfig(type=3, code=0, analog_threshold=1), "down"
        )


@test_setup
class TestCombinationListbox(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.ListBox()
        self.listbox = CombinationListbox(
            self.message_broker, self.controller_mock, self.gui
        )
        self.controller_mock.is_empty_mapping.return_value = False
        combination = InputCombination(
            (
                InputConfig(type=1, code=1),
                InputConfig(type=3, code=0, analog_threshold=1),
                InputConfig(type=1, code=2),
            )
        )
        self.message_broker.publish(
            MappingData(
                input_combination=combination.to_config(), target_uinput="keyboard"
            )
        )

    def get_selected_row(self) -> InputConfigEntry:
        for entry in self.gui.get_children():
            if entry.is_selected():
                return entry

        raise Exception("Expected one InputConfigEntry to be selected")

    def select_row(self, input_cfg: InputConfig):
        for entry in self.gui.get_children():
            if entry.input_event == input_cfg:
                self.gui.select_row(entry)

    def test_loads_selected_row(self):
        self.select_row(InputConfig(type=1, code=2))
        self.controller_mock.load_input_config.assert_called_once_with(
            InputConfig(type=1, code=2)
        )

    def test_does_not_create_rows_when_mapping_is_empty(self):
        self.controller_mock.is_empty_mapping.return_value = True
        combination = InputCombination(
            (
                InputConfig(type=1, code=1),
                InputConfig(type=3, code=0, analog_threshold=1),
            )
        )
        self.message_broker.publish(MappingData(input_combination=combination))
        self.assertEqual(len(self.gui.get_children()), 0)

    def test_selects_row_when_selected_event_message_arrives(self):
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1))
        self.assertEqual(
            self.get_selected_row().input_event,
            InputConfig(type=3, code=0, analog_threshold=1),
        )

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1))
        self.controller_mock.load_event.assert_not_called()


@test_setup
class TestAnalogInputSwitch(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.Switch()
        self.switch = AnalogInputSwitch(
            self.message_broker, self.controller_mock, self.gui
        )

    def test_updates_event_as_analog(self):
        self.gui.set_active(True)
        self.controller_mock.set_event_as_analog.assert_called_once_with(True)
        self.controller_mock.reset_mock()
        self.gui.set_active(False)
        self.controller_mock.set_event_as_analog.assert_called_once_with(False)

    def test_updates_state(self):
        self.message_broker.publish(InputConfig(type=3, code=0))
        self.assertTrue(self.gui.get_active())
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10))
        self.assertFalse(self.gui.get_active())

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(InputConfig(type=3, code=0))
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10))
        self.controller_mock.set_event_as_analog.assert_not_called()

    def test_disables_switch_when_key_event(self):
        self.message_broker.publish(InputConfig(type=1, code=1))
        self.assertLess(self.gui.get_opacity(), 0.6)
        self.assertFalse(self.gui.get_sensitive())

    def test_enables_switch_when_axis_event(self):
        self.message_broker.publish(InputConfig(type=1, code=1))
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10))
        self.assertEqual(self.gui.get_opacity(), 1)
        self.assertTrue(self.gui.get_sensitive())

        self.message_broker.publish(InputConfig(type=1, code=1))
        self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=10))
        self.assertEqual(self.gui.get_opacity(), 1)
        self.assertTrue(self.gui.get_sensitive())


@test_setup
class TestTriggerThresholdInput(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.SpinButton()
        self.input = TriggerThresholdInput(
            self.message_broker, self.controller_mock, self.gui
        )
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10))

    def assert_abs_event_config(self):
        self.assertEqual(self.gui.get_range(), (-99, 99))
        self.assertTrue(self.gui.get_sensitive())
        self.assertEqual(self.gui.get_opacity(), 1)

    def assert_rel_event_config(self):
        self.assertEqual(self.gui.get_range(), (-999, 999))
        self.assertTrue(self.gui.get_sensitive())
        self.assertEqual(self.gui.get_opacity(), 1)

    def assert_key_event_config(self):
        self.assertFalse(self.gui.get_sensitive())
        self.assertLess(self.gui.get_opacity(), 0.6)

    def test_updates_event(self):
        self.gui.set_value(15)
        self.controller_mock.update_input_config.assert_called_once_with(
            InputConfig(type=3, code=0, analog_threshold=15)
        )

    def test_sets_value_on_selected_event_message(self):
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10))
        self.assertEqual(self.gui.get_value(), 10)

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10))
        self.controller_mock.update_input_config.assert_not_called()

    def test_updates_configuration_according_to_selected_event(self):
        self.assert_abs_event_config()
        self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=-10))
        self.assert_rel_event_config()
        self.message_broker.publish(InputConfig(type=1, code=1))
        self.assert_key_event_config()


@test_setup
class TestReleaseTimeoutInput(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.SpinButton()
        self.input = ReleaseTimeoutInput(
            self.message_broker, self.controller_mock, self.gui
        )
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    [InputConfig(type=2, code=0, analog_threshold=1)]
                ),
                target_uinput="keyboard",
            )
        )

    def test_updates_timeout_on_mapping_message(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    [InputConfig(type=2, code=0, analog_threshold=1)]
                ),
                release_timeout=1,
            )
        )
        self.assertEqual(self.gui.get_value(), 1)

    def test_updates_mapping(self):
        self.gui.set_value(0.5)
        self.controller_mock.update_mapping.assert_called_once_with(release_timeout=0.5)

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    [InputConfig(type=2, code=0, analog_threshold=1)]
                ),
                release_timeout=1,
            )
        )
        self.controller_mock.update_mapping.assert_not_called()

    def test_disables_input_based_on_input_combination(self):
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=2, code=0, analog_threshold=1),
                        InputConfig(type=1, code=1),
                    )
                )
            )
        )
        self.assertTrue(self.gui.get_sensitive())
        self.assertEqual(self.gui.get_opacity(), 1)

        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=1, code=1),
                        InputConfig(type=1, code=2),
                    )
                )
            )
        )
        self.assertFalse(self.gui.get_sensitive())
        self.assertLess(self.gui.get_opacity(), 0.6)

        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=2, code=0, analog_threshold=1),
                        InputConfig(type=1, code=1),
                    )
                )
            )
        )
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination(
                    (
                        InputConfig(type=3, code=0, analog_threshold=1),
                        InputConfig(
                            type=1,
                            code=2,
                        ),
                    )
                )
            )
        )
        self.assertFalse(self.gui.get_sensitive())
        self.assertLess(self.gui.get_opacity(), 0.6)


@test_setup
class TestOutputAxisSelector(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.ComboBox()
        self.selection = OutputAxisSelector(
            self.message_broker, self.controller_mock, self.gui
        )
        absinfo = evdev.AbsInfo(0, -10, 10, 0, 0, 0)
        self.message_broker.publish(
            UInputsData(
                {
                    "mouse": {1: [1, 2, 3, 4], 2: [0, 1, 2, 3]},
                    "keyboard": {1: [1, 2, 3, 4]},
                    "gamepad": {
                        2: [0, 1, 2, 3],
                        3: [(0, absinfo), (1, absinfo), (2, absinfo), (3, absinfo)],
                    },
                }
            )
        )
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=1, code=1)]),
            )
        )

    def set_active_selection(self, selection: Tuple):
        self.gui.set_active_id(f"{selection[0]}, {selection[1]}")

    def get_active_selection(self) -> Tuple[int, int]:
        return tuple(int(i) for i in self.gui.get_active_id().split(","))  # type: ignore

    def test_updates_mapping(self):
        self.set_active_selection((2, 0))
        self.controller_mock.update_mapping.assert_called_once_with(
            output_type=2, output_code=0
        )

    def test_updates_mapping_with_none(self):
        self.set_active_selection((2, 0))
        self.controller_mock.reset_mock()
        self.set_active_selection((None, None))
        self.controller_mock.update_mapping.assert_called_once_with(
            output_type=None, output_code=None
        )

    def test_selects_correct_entry(self):
        self.assertEqual(self.gui.get_active_id(), "None, None")
        self.message_broker.publish(
            MappingData(target_uinput="mouse", output_type=2, output_code=3)
        )
        self.assertEqual(self.get_active_selection(), (2, 3))

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(
            MappingData(target_uinput="mouse", output_type=2, output_code=3)
        )
        self.controller_mock.update_mapping.assert_not_called()

    def test_updates_dropdown_model(self):
        self.assertEqual(len(self.gui.get_model()), 5)
        self.message_broker.publish(MappingData(target_uinput="keyboard"))
        self.assertEqual(len(self.gui.get_model()), 1)
        self.message_broker.publish(MappingData(target_uinput="gamepad"))
        self.assertEqual(len(self.gui.get_model()), 9)


@test_setup
class TestKeyAxisStackSwitcher(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.Box()
        self.gtk_stack = Gtk.Stack()
        self.analog_toggle = Gtk.ToggleButton()
        self.key_toggle = Gtk.ToggleButton()

        self.gui.add(self.gtk_stack)
        self.gui.add(self.analog_toggle)
        self.gui.add(self.key_toggle)
        self.gtk_stack.add_named(Gtk.Box(), "Analog Axis")
        self.gtk_stack.add_named(Gtk.Box(), "Key or Macro")

        self.stack = KeyAxisStackSwitcher(
            self.message_broker,
            self.controller_mock,
            self.gtk_stack,
            self.key_toggle,
            self.analog_toggle,
        )

        self.gui.show_all()
        self.gtk_stack.set_visible_child_name("Key or Macro")

    def assert_key_macro_active(self):
        self.assertEqual(self.gtk_stack.get_visible_child_name(), "Key or Macro")
        self.assertTrue(self.key_toggle.get_active())
        self.assertFalse(self.analog_toggle.get_active())

    def assert_analog_active(self):
        self.assertEqual(self.gtk_stack.get_visible_child_name(), "Analog Axis")
        self.assertFalse(self.key_toggle.get_active())
        self.assertTrue(self.analog_toggle.get_active())

    def test_switches_to_axis(self):
        self.message_broker.publish(MappingData(mapping_type="analog"))
        self.assert_analog_active()

    def test_switches_to_key_macro(self):
        self.message_broker.publish(MappingData(mapping_type="analog"))
        self.message_broker.publish(MappingData(mapping_type="key_macro"))
        self.assert_key_macro_active()

    def test_updates_mapping_type(self):
        self.key_toggle.set_active(True)
        self.controller_mock.update_mapping.assert_called_once_with(
            mapping_type="key_macro"
        )
        self.controller_mock.update_mapping.reset_mock()

        self.analog_toggle.set_active(True)
        self.controller_mock.update_mapping.assert_called_once_with(
            mapping_type="analog"
        )

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(MappingData(mapping_type="analog"))
        self.message_broker.publish(MappingData(mapping_type="key_macro"))
        self.controller_mock.update_mapping.assert_not_called()


@test_setup
class TestTransformationDrawArea(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.Window()
        self.draw_area = Gtk.DrawingArea()
        self.gui.add(self.draw_area)
        self.transform_draw_area = TransformationDrawArea(
            self.message_broker,
            self.controller_mock,
            self.draw_area,
        )

    def test_draws_transform(self):
        with spy(self.transform_draw_area, "_transformation") as mock:
            # show the window, it takes some time and iterations until it pops up
            self.gui.show_all()
            for _ in range(5):
                gtk_iteration()
                time.sleep(0.01)

            mock.assert_called()

    def test_updates_transform_when_mapping_updates(self):
        old_tf = self.transform_draw_area._transformation
        self.message_broker.publish(MappingData(gain=2))
        self.assertIsNot(old_tf, self.transform_draw_area._transformation)

    def test_redraws_when_mapping_updates(self):
        self.gui.show_all()
        gtk_iteration(20)
        mock = MagicMock()
        self.draw_area.connect("draw", mock)
        self.message_broker.publish(MappingData(gain=2))
        gtk_iteration(20)
        mock.assert_called()


@test_setup
class TestSliders(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.Box()
        self.gain = Gtk.Scale()
        self.deadzone = Gtk.Scale()
        self.expo = Gtk.Scale()

        # add everything to a box: it will be cleand up properly
        self.gui.add(self.gain)
        self.gui.add(self.deadzone)
        self.gui.add(self.expo)

        self.sliders = Sliders(
            self.message_broker,
            self.controller_mock,
            self.gain,
            self.deadzone,
            self.expo,
        )
        self.message_broker.publish(
            MappingData(
                input_combination=InputCombination([InputConfig(type=3, code=0)]),
                target_uinput="mouse",
            )
        )

    @staticmethod
    def get_range(range: Gtk.Range) -> Tuple[int, int]:
        """the Gtk.Range, has no get_range method. this is a workaround"""
        v = range.get_value()
        range.set_value(-(2**16))
        min_ = range.get_value()
        range.set_value(2**16)
        max_ = range.get_value()
        range.set_value(v)
        return min_, max_

    def test_slider_ranges(self):
        self.assertEqual(self.get_range(self.gain), (-2, 2))
        self.assertEqual(self.get_range(self.deadzone), (0, 0.9))
        self.assertEqual(self.get_range(self.expo), (-1, 1))

    def test_updates_value(self):
        self.message_broker.publish(
            MappingData(
                gain=0.5,
                deadzone=0.6,
                expo=0.3,
            )
        )
        self.assertEqual(self.gain.get_value(), 0.5)
        self.assertEqual(self.expo.get_value(), 0.3)
        self.assertEqual(self.deadzone.get_value(), 0.6)

    def test_gain_updates_mapping(self):
        self.gain.set_value(0.5)
        self.controller_mock.update_mapping.assert_called_once_with(gain=0.5)

    def test_expo_updates_mapping(self):
        self.expo.set_value(0.5)
        self.controller_mock.update_mapping.assert_called_once_with(expo=0.5)

    def test_deadzone_updates_mapping(self):
        self.deadzone.set_value(0.5)
        self.controller_mock.update_mapping.assert_called_once_with(deadzone=0.5)

    def test_avoids_recursion(self):
        self.message_broker.publish(MappingData(gain=0.5))
        self.controller_mock.update_mapping.assert_not_called()
        self.message_broker.publish(MappingData(expo=0.5))
        self.controller_mock.update_mapping.assert_not_called()
        self.message_broker.publish(MappingData(deadzone=0.5))
        self.controller_mock.update_mapping.assert_not_called()


@test_setup
class TestRelativeInputCutoffInput(ComponentBaseTest):
    def setUp(self) -> None:
        super().setUp()
        self.gui = Gtk.SpinButton()
        self.input = RelativeInputCutoffInput(
            self.message_broker, self.controller_mock, self.gui
        )
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=2, code=0)]),
                rel_to_abs_input_cutoff=1,
                output_type=3,
                output_code=0,
            )
        )

    def assert_active(self):
        self.assertTrue(self.gui.get_sensitive())
        self.assertEqual(self.gui.get_opacity(), 1)

    def assert_inactive(self):
        self.assertFalse(self.gui.get_sensitive())
        self.assertLess(self.gui.get_opacity(), 0.6)

    def test_avoids_infinite_recursion(self):
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=2, code=0)]),
                rel_to_abs_input_cutoff=3,
                output_type=3,
                output_code=0,
            )
        )
        self.controller_mock.update_mapping.assert_not_called()

    def test_updates_value(self):
        rel_to_abs_input_cutoff = 3
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=2, code=0)]),
                rel_to_abs_input_cutoff=rel_to_abs_input_cutoff,
                output_type=3,
                output_code=0,
            )
        )
        self.assertEqual(self.gui.get_value(), rel_to_abs_input_cutoff)

    def test_updates_mapping(self):
        self.gui.set_value(300)
        self.controller_mock.update_mapping.assert_called_once_with(rel_xy_cutoff=300)

    def test_disables_input_when_no_rel_axis_input(self):
        self.assert_active()
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=3, code=0)]),
                output_type=3,
                output_code=0,
            )
        )
        self.assert_inactive()

    def test_disables_input_when_no_abs_axis_output(self):
        self.assert_active()
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=2, code=0)]),
                rel_to_abs_input_cutoff=3,
                output_type=2,
                output_code=0,
            )
        )
        self.assert_inactive()

    def test_enables_input(self):
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=3, code=0)]),
                output_type=3,
                output_code=0,
            )
        )
        self.assert_inactive()
        self.message_broker.publish(
            MappingData(
                target_uinput="mouse",
                input_combination=InputCombination([InputConfig(type=2, code=0)]),
                rel_to_abs_input_cutoff=1,
                output_type=3,
                output_code=0,
            )
        )
        self.assert_active()


@test_setup
class TestRequireActiveMapping(ComponentBaseTest):
    def test_no_reqorded_input_required(self):
        self.box = Gtk.Box()
        RequireActiveMapping(
            self.message_broker,
            self.box,
            require_recorded_input=False,
        )
        combination = InputCombination([InputConfig(type=1, code=KEY_A)])

        self.message_broker.publish(MappingData())
        self.assert_inactive(self.box)

        self.message_broker.publish(PresetData(name="preset", mappings=()))
        self.assert_inactive(self.box)

        # a mapping is available, that is all the widget needs to be activated. one
        # mapping is always selected, so there is no need to check the mapping message
        self.message_broker.publish(PresetData(name="preset", mappings=(combination,)))
        self.assert_active(self.box)

        self.message_broker.publish(MappingData(input_combination=combination))
        self.assert_active(self.box)

        self.message_broker.publish(MappingData())
        self.assert_active(self.box)

    def test_recorded_input_required(self):
        self.box = Gtk.Box()
        RequireActiveMapping(
            self.message_broker,
            self.box,
            require_recorded_input=True,
        )
        combination = InputCombination([InputConfig(type=1, code=KEY_A)])

        self.message_broker.publish(MappingData())
        self.assert_inactive(self.box)

        self.message_broker.publish(PresetData(name="preset", mappings=()))
        self.assert_inactive(self.box)

        self.message_broker.publish(PresetData(name="preset", mappings=(combination,)))
        self.assert_inactive(self.box)

        # the widget will be enabled once a mapping with recorded input is selected
        self.message_broker.publish(MappingData(input_combination=combination))
        self.assert_active(self.box)

        # this mapping doesn't have input recorded, so the box is disabled
        self.message_broker.publish(MappingData())
        self.assert_inactive(self.box)

    def assert_inactive(self, widget: Gtk.Widget):
        self.assertFalse(widget.get_sensitive())
        self.assertLess(widget.get_opacity(), 0.6)
        self.assertGreater(widget.get_opacity(), 0.4)

    def assert_active(self, widget: Gtk.Widget):
        self.assertTrue(widget.get_sensitive())
        self.assertEqual(widget.get_opacity(), 1)


@test_setup
class TestStack(ComponentBaseTest):
    def test_switches_pages(self):
        self.stack = Gtk.Stack()
        self.stack.add_named(Gtk.Label(), "Devices")
        self.stack.add_named(Gtk.Label(), "Presets")
        self.stack.add_named(Gtk.Label(), "Editor")
        self.stack.show_all()
        stack_wrapper = Stack(self.message_broker, self.controller_mock, self.stack)

        self.message_broker.publish(DoStackSwitch(Stack.devices_page))
        self.assertEqual(self.stack.get_visible_child_name(), "Devices")

        self.message_broker.publish(DoStackSwitch(Stack.presets_page))
        self.assertEqual(self.stack.get_visible_child_name(), "Presets")

        self.message_broker.publish(DoStackSwitch(Stack.editor_page))
        self.assertEqual(self.stack.get_visible_child_name(), "Editor")


@test_setup
class TestBreadcrumbs(ComponentBaseTest):
    def test_breadcrumbs(self):
        self.label_1 = Gtk.Label()
        self.label_2 = Gtk.Label()
        self.label_3 = Gtk.Label()
        self.label_4 = Gtk.Label()
        self.label_5 = Gtk.Label()

        Breadcrumbs(
            self.message_broker,
            self.label_1,
            show_device_group=False,
            show_preset=False,
            show_mapping=False,
        )
        Breadcrumbs(
            self.message_broker,
            self.label_2,
            show_device_group=True,
            show_preset=False,
            show_mapping=False,
        )
        Breadcrumbs(
            self.message_broker,
            self.label_3,
            show_device_group=True,
            show_preset=True,
            show_mapping=False,
        )
        Breadcrumbs(
            self.message_broker,
            self.label_4,
            show_device_group=True,
            show_preset=True,
            show_mapping=True,
        )
        Breadcrumbs(
            self.message_broker,
            self.label_5,
            show_device_group=False,
            show_preset=False,
            show_mapping=True,
        )

        self.assertEqual(self.label_1.get_text(), "")
        self.assertEqual(self.label_2.get_text(), "?")
        self.assertEqual(self.label_3.get_text(), "?  /  ?")
        self.assertEqual(self.label_4.get_text(), "?  /  ?  /  ?")
        self.assertEqual(self.label_5.get_text(), "?")

        self.message_broker.publish(PresetData("preset", None))

        self.assertEqual(self.label_1.get_text(), "")
        self.assertEqual(self.label_2.get_text(), "?")
        self.assertEqual(self.label_3.get_text(), "?  /  preset")
        self.assertEqual(self.label_4.get_text(), "?  /  preset  /  ?")
        self.assertEqual(self.label_5.get_text(), "?")

        self.message_broker.publish(GroupData("group", ()))

        self.assertEqual(self.label_1.get_text(), "")
        self.assertEqual(self.label_2.get_text(), "group")
        self.assertEqual(self.label_3.get_text(), "group  /  preset")
        self.assertEqual(self.label_4.get_text(), "group  /  preset  /  ?")
        self.assertEqual(self.label_5.get_text(), "?")

        self.message_broker.publish(MappingData())

        self.assertEqual(self.label_1.get_text(), "")
        self.assertEqual(self.label_2.get_text(), "group")
        self.assertEqual(self.label_3.get_text(), "group  /  preset")
        self.assertEqual(self.label_4.get_text(), "group  /  preset  /  Empty Mapping")
        self.assertEqual(self.label_5.get_text(), "Empty Mapping")

        self.message_broker.publish(MappingData(name="mapping"))
        self.assertEqual(self.label_4.get_text(), "group  /  preset  /  mapping")
        self.assertEqual(self.label_5.get_text(), "mapping")

        combination = InputCombination(
            (
                InputConfig(type=1, code=KEY_A),
                InputConfig(type=1, code=KEY_B),
            )
        )
        self.message_broker.publish(MappingData(input_combination=combination))
        self.assertEqual(self.label_4.get_text(), "group  /  preset  /  a + b")
        self.assertEqual(self.label_5.get_text(), "a + b")

        combination = InputCombination([InputConfig(type=1, code=KEY_A)])
        self.message_broker.publish(
            MappingData(name="qux", input_combination=combination)
        )
        self.assertEqual(self.label_4.get_text(), "group  /  preset  /  qux")
        self.assertEqual(self.label_5.get_text(), "qux")
