feat: full implementation - switcher, device watcher, hotkeys, tray, settings UI

This commit is contained in:
Miłosz Matysiak
2026-04-09 10:09:01 +02:00
parent 40d1c23051
commit 83293c8a91
13 changed files with 1162 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import json
import pytest
from pathlib import Path
from config.config_manager import ConfigManager, DEFAULT_CONFIG
def test_load_defaults_when_no_file(tmp_path):
cm = ConfigManager(tmp_path / "config.json")
assert cm.get()["active_profile"] == "pc1_dp"
assert len(cm.get()["profiles"]) == 2
def test_saves_default_config_on_first_run(tmp_path):
path = tmp_path / "config.json"
ConfigManager(path)
assert path.exists()
data = json.loads(path.read_text())
assert data["active_profile"] == "pc1_dp"
def test_load_existing_config(tmp_path):
path = tmp_path / "config.json"
custom = {"active_profile": "custom", "profiles": [], "device_triggers": [], "general": {}}
path.write_text(json.dumps(custom))
cm = ConfigManager(path)
assert cm.get()["active_profile"] == "custom"
def test_fallback_on_corrupt_config(tmp_path):
path = tmp_path / "config.json"
path.write_text("not json {{{")
cm = ConfigManager(path)
assert cm.get()["active_profile"] == "pc1_dp"
def test_get_profile_returns_profile(tmp_path):
cm = ConfigManager(tmp_path / "config.json")
p = cm.get_profile("pc1_dp")
assert p is not None
assert p["name"] == "PC1 \u2014 DP"
def test_get_profile_returns_none_for_missing(tmp_path):
cm = ConfigManager(tmp_path / "config.json")
assert cm.get_profile("nonexistent") is None
def test_set_active_profile_persists(tmp_path):
path = tmp_path / "config.json"
cm = ConfigManager(path)
cm.set_active_profile("pc2_hdmi")
cm2 = ConfigManager(path)
assert cm2.get_active_profile_id() == "pc2_hdmi"
def test_on_change_callback_fires(tmp_path):
cm = ConfigManager(tmp_path / "config.json")
called_with = []
cm.on_change(lambda cfg: called_with.append(cfg["active_profile"]))
cfg = cm.get().copy()
cfg["active_profile"] = "pc2_hdmi"
cm.update(cfg)
assert called_with == ["pc2_hdmi"]

View File

@@ -0,0 +1,62 @@
import time
from unittest.mock import MagicMock
import pytest
from config.config_manager import ConfigManager
from core.device_watcher import DeviceWatcher
@pytest.fixture
def config(tmp_path):
return ConfigManager(tmp_path / "config.json")
@pytest.fixture
def switcher():
return MagicMock()
def test_handle_event_connect_triggers_correct_profile(config, switcher):
watcher = DeviceWatcher(config, switcher)
watcher._handle_event(
"HID\\{...}_DEV_VID&0203F0_PID&6343_REV&0413_AABBCCDD", "connect"
)
time.sleep(0.6)
switcher.apply_profile.assert_called_once_with("pc1_dp")
def test_handle_event_disconnect_triggers_correct_profile(config, switcher):
watcher = DeviceWatcher(config, switcher)
watcher._handle_event(
"HID\\{...}_DEV_VID&0203F0_PID&6343_REV&0413_AABBCCDD", "disconnect"
)
time.sleep(0.6)
switcher.apply_profile.assert_called_once_with("pc2_hdmi")
def test_handle_event_ignores_unknown_device(config, switcher):
watcher = DeviceWatcher(config, switcher)
watcher._handle_event("HID\\VID&DEAD_PID&BEEF", "connect")
time.sleep(0.6)
switcher.apply_profile.assert_not_called()
def test_handle_event_ignores_disabled_trigger(config, switcher):
cfg = config.get()
cfg["device_triggers"][0]["enabled"] = False
config.update(cfg)
watcher = DeviceWatcher(config, switcher)
watcher._handle_event(
"HID\\{...}_DEV_VID&0203F0_PID&6343_REV&0413", "connect"
)
time.sleep(0.6)
switcher.apply_profile.assert_not_called()
def test_debounce_collapses_rapid_events(config, switcher):
watcher = DeviceWatcher(config, switcher)
device_id = "HID\\{...}_DEV_VID&0203F0_PID&6343_REV&0413"
for _ in range(5):
watcher._handle_event(device_id, "connect")
time.sleep(0.8)
assert switcher.apply_profile.call_count == 1

View File

@@ -0,0 +1,67 @@
from unittest.mock import MagicMock, patch
import pytest
from config.config_manager import ConfigManager
from core.hotkey_manager import HotkeyManager
@pytest.fixture
def config(tmp_path):
return ConfigManager(tmp_path / "config.json")
@pytest.fixture
def switcher():
return MagicMock()
def test_register_all_registers_profile_hotkeys(config, switcher):
with patch("core.hotkey_manager.keyboard") as mock_kb:
hm = HotkeyManager(config, switcher)
hm.register_all()
assert mock_kb.add_hotkey.call_count == 2
calls = [c.args[0] for c in mock_kb.add_hotkey.call_args_list]
assert "ctrl+alt+1" in calls
assert "ctrl+alt+2" in calls
def test_register_all_skips_empty_hotkeys(config, switcher):
cfg = config.get()
cfg["profiles"][0]["hotkey"] = ""
config.update(cfg)
with patch("core.hotkey_manager.keyboard") as mock_kb:
hm = HotkeyManager(config, switcher)
hm.register_all()
assert mock_kb.add_hotkey.call_count == 1
def test_unregister_all_removes_hotkeys(config, switcher):
with patch("core.hotkey_manager.keyboard") as mock_kb:
hm = HotkeyManager(config, switcher)
hm.register_all()
hm.unregister_all()
assert mock_kb.remove_hotkey.call_count == 2
def test_register_all_clears_old_before_registering(config, switcher):
with patch("core.hotkey_manager.keyboard") as mock_kb:
hm = HotkeyManager(config, switcher)
hm.register_all()
hm.register_all()
assert mock_kb.remove_hotkey.call_count == 2
def test_hotkey_calls_apply_profile(config, switcher):
captured_callbacks = {}
def fake_add_hotkey(hotkey, fn, args):
captured_callbacks[hotkey] = (fn, args)
with patch("core.hotkey_manager.keyboard") as mock_kb:
mock_kb.add_hotkey.side_effect = fake_add_hotkey
hm = HotkeyManager(config, switcher)
hm.register_all()
fn, args = captured_callbacks["ctrl+alt+1"]
fn(*args)
switcher.apply_profile.assert_called_once_with("pc1_dp")

93
tests/test_switcher.py Normal file
View File

@@ -0,0 +1,93 @@
from unittest.mock import MagicMock, patch
import pytest
from config.config_manager import ConfigManager
from core.switcher import MonitorSwitcher
@pytest.fixture
def config(tmp_path):
return ConfigManager(tmp_path / "config.json")
def _make_mock_monitor():
m = MagicMock()
m.__enter__ = MagicMock(return_value=m)
m.__exit__ = MagicMock(return_value=False)
return m
def test_apply_profile_sends_vcp_to_both_monitors(config):
mon0 = _make_mock_monitor()
mon1 = _make_mock_monitor()
with patch("core.switcher.get_monitors", return_value=[mon0, mon1]):
sw = MonitorSwitcher(config)
sw.apply_profile("pc1_dp")
mon0.set_vcp_feature.assert_called_once_with(0x60, 15)
mon1.set_vcp_feature.assert_called_once_with(0x60, 15)
def test_apply_profile_hdmi(config):
mon0 = _make_mock_monitor()
mon1 = _make_mock_monitor()
with patch("core.switcher.get_monitors", return_value=[mon0, mon1]):
sw = MonitorSwitcher(config)
sw.apply_profile("pc2_hdmi")
mon0.set_vcp_feature.assert_called_once_with(0x60, 17)
mon1.set_vcp_feature.assert_called_once_with(0x60, 17)
def test_apply_profile_skips_missing_monitor(config):
mon0 = _make_mock_monitor()
with patch("core.switcher.get_monitors", return_value=[mon0]):
sw = MonitorSwitcher(config)
sw.apply_profile("pc1_dp")
mon0.set_vcp_feature.assert_called_once_with(0x60, 15)
def test_apply_profile_continues_on_monitor_error(config):
mon0 = _make_mock_monitor()
mon1 = _make_mock_monitor()
mon0.set_vcp_feature.side_effect = Exception("DDC/CI error")
with patch("core.switcher.get_monitors", return_value=[mon0, mon1]):
sw = MonitorSwitcher(config)
sw.apply_profile("pc1_dp")
mon1.set_vcp_feature.assert_called_once_with(0x60, 15)
def test_apply_profile_unknown_profile_does_nothing(config):
with patch("core.switcher.get_monitors", return_value=[]) as mock_gm:
sw = MonitorSwitcher(config)
sw.apply_profile("nonexistent")
mock_gm.assert_not_called()
def test_on_switch_callback_fires(config):
mon0 = _make_mock_monitor()
mon1 = _make_mock_monitor()
received = []
with patch("core.switcher.get_monitors", return_value=[mon0, mon1]):
sw = MonitorSwitcher(config)
sw.on_switch(lambda pid: received.append(pid))
sw.apply_profile("pc2_hdmi")
assert received == ["pc2_hdmi"]
def test_apply_profile_updates_active_profile(config):
mon0 = _make_mock_monitor()
mon1 = _make_mock_monitor()
with patch("core.switcher.get_monitors", return_value=[mon0, mon1]):
sw = MonitorSwitcher(config)
sw.apply_profile("pc2_hdmi")
assert config.get_active_profile_id() == "pc2_hdmi"