## Copyright (C) Solaar contributors ## ## This program 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 2 of the License, or ## (at your option) any later version. """Tests for the display-state-aware color translation in rgb_power. These cover the pure math (`translate_color_for_display`) and the small manager hooks that PerKeyLighting calls into (`translate_color`, `notify_perkey_changed`) without requiring a GLib main loop. """ import pytest from logitech_receiver import rgb_power M = rgb_power.RGBPowerManager # --- translate_color_for_display (pure function) ---------------------------- @pytest.mark.parametrize( "color", [0x000000, 0x123456, 0xFF0000, 0xFFFFFF], ) def test_translate_active_is_identity(color): assert rgb_power.translate_color_for_display(color, M.ACTIVE, 50, 0, 25) == color def test_translate_idle_50pct(): # 255 * 50 // 100 == 127 assert rgb_power.translate_color_for_display(0xFFFFFF, M.IDLE, 50, 0, 25) == 0x7F7F7F def test_translate_idle_25pct_on_ff8800(): # 0xFF * 25 // 100 = 63 (0x3F); 0x88 * 25 // 100 = 34 (0x22); 0 stays 0 assert rgb_power.translate_color_for_display(0xFF8800, M.IDLE, 25, 0, 25) == 0x3F2200 def test_translate_dimming_start_is_saved_color(): # t = 0/25 = 0 → interpolation returns the start (saved) color assert rgb_power.translate_color_for_display(0xABCDEF, M.DIMMING, 50, 0, 25) == 0xABCDEF def test_translate_dimming_end_equals_idle(): # t = 25/25 = 1 → interpolation returns the target (fully dimmed) idle = rgb_power.translate_color_for_display(0xFFFFFF, M.IDLE, 50, 0, 25) dimming_end = rgb_power.translate_color_for_display(0xFFFFFF, M.DIMMING, 50, 25, 25) assert dimming_end == idle def test_translate_dimming_midramp_between_full_and_dim(): # At t=12/25 (≈0.48), white interpolates between 0xFF and 0x7F (50% target). # Expected r = 255 + (127 - 255) * 12/25 = 255 - 61.44 → int(193.56) = 193 (0xC1) assert rgb_power.translate_color_for_display(0xFFFFFF, M.DIMMING, 50, 12, 25) == 0xC1C1C1 def test_translate_sleeping_returns_none(): assert rgb_power.translate_color_for_display(0xFFFFFF, M.SLEEPING, 50, 0, 25) is None # --- translate_for_device (manager lookup) ---------------------------------- def test_translate_for_device_no_manager_is_identity(): # An arbitrary object with no manager registered → returns input unchanged. fake = object() assert rgb_power.translate_for_device(fake, 0xABCDEF) == 0xABCDEF def _dim(intensity): """Shortcut: build a Dim-mode LEDEffectSetting for tests that used to pass a bare dim-percent int as `_idle_effect`.""" from logitech_receiver import hidpp20 return hidpp20.LEDEffectSetting(ID=0x80, intensity=intensity) def _install_manager(monkeypatch, state, idle_effect=None, dim_step=0): """Build an RGBPowerManager with state injected and register it for a fake device id. Cleanup happens via monkeypatch's _managers swap. `idle_effect` defaults to Dim 50%. Pass a bare int (legacy) and it will be wrapped as a Dim-mode LEDEffectSetting; pass an LEDEffectSetting directly to use as-is. """ from logitech_receiver import hidpp20 fake_device = object() class _Dev: pass mgr = M.__new__(M) # bypass __init__ (no GLib needed) mgr._device = _Dev() mgr._state = state if idle_effect is None: mgr._idle_effect = _dim(50) elif isinstance(idle_effect, int): mgr._idle_effect = _dim(idle_effect) else: mgr._idle_effect = idle_effect mgr._dim_step = dim_step mgr._dim_perkey = None # Avoid circular import surprise — make sure the LEDEffectSetting is # imported here so type checks in helpers don't crash. _ = hidpp20.LEDEffectSetting saved_managers = dict(rgb_power._managers) monkeypatch.setattr(rgb_power, "_managers", {id(fake_device): mgr}) yield_data = (fake_device, mgr, saved_managers) return yield_data def test_translate_for_device_idle_routes_through_manager(monkeypatch): fake_device, mgr, _ = _install_manager(monkeypatch, M.IDLE, idle_effect=50) assert rgb_power.translate_for_device(fake_device, 0xFFFFFF) == 0x7F7F7F def test_translate_for_device_sleeping_returns_none(monkeypatch): fake_device, _, _ = _install_manager(monkeypatch, M.SLEEPING) assert rgb_power.translate_for_device(fake_device, 0xFFFFFF) is None def test_current_dim_pct_falls_back_to_100_for_non_dim_effects(): from logitech_receiver import hidpp20 mgr = M.__new__(M) # Dim is the only host-side idle effect — it's the only one Solaar can # render itself (by interpolating colors toward a dimmer target on a # Static zone color or in the per-key buffer). Disabled does nothing. # Breathe and Ripple hand off to the firmware effect engine, which # runs the animation at its own brightness; Solaar applies no dim # translation in those cases, so _current_dim_pct returns 100. for fw_or_disabled in (0x00, 0x0A, 0x0B): mgr._idle_effect = hidpp20.LEDEffectSetting(ID=fw_or_disabled) assert mgr._current_dim_pct() == 100 # Dim mode — intensity carries the dim percentage. for dim_pct in (25, 50, 75): mgr._idle_effect = hidpp20.LEDEffectSetting(ID=0x80, intensity=dim_pct) assert mgr._current_dim_pct() == dim_pct # --- notify_perkey_changed -------------------------------------------------- def test_notify_perkey_changed_updates_dim_map_during_dimming(): mgr = M.__new__(M) mgr._state = M.DIMMING mgr._idle_effect = _dim(50) mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F)} mgr.notify_perkey_changed(5, 0x00FF00) start, target = mgr._dim_perkey[5] assert start == 0x00FF00 # target = dim(0x00FF00, 50) = (0, 0xFF*50//100, 0) = (0, 0x7F, 0) assert target == 0x007F00 def test_notify_perkey_changed_noop_when_not_dimming(): mgr = M.__new__(M) mgr._state = M.IDLE mgr._idle_effect = _dim(50) mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F)} mgr.notify_perkey_changed(5, 0x00FF00) assert mgr._dim_perkey[5] == (0xFFFFFF, 0x7F7F7F) def test_notify_perkey_changed_noop_for_unknown_zone(): mgr = M.__new__(M) mgr._state = M.DIMMING mgr._idle_effect = _dim(50) mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F)} mgr.notify_perkey_changed(99, 0x00FF00) # not in _dim_perkey assert mgr._dim_perkey == {5: (0xFFFFFF, 0x7F7F7F)} def test_notify_perkey_bulk_changed_skips_no_change(): from logitech_receiver import special_keys no_change = special_keys.COLORSPLUS["No change"] mgr = M.__new__(M) mgr._state = M.DIMMING mgr._idle_effect = _dim(50) mgr._dim_perkey = {5: (0xFFFFFF, 0x7F7F7F), 7: (0x000000, 0x000000)} mgr.notify_perkey_bulk_changed({5: 0x00FF00, 7: no_change}) # Zone 5 updated, zone 7 (No change) left alone. assert mgr._dim_perkey[5][0] == 0x00FF00 assert mgr._dim_perkey[7] == (0x000000, 0x000000) # --- notify_zone_changed (zone-effect dim ramp) ----------------------------- class _FakeZone: """Minimal stand-in for hidpp20.LEDZoneInfo — only `.index` is consulted by RGBPowerManager.notify_zone_changed.""" def __init__(self, index): self.index = index def test_notify_zone_changed_updates_matching_cluster_during_dimming(): mgr = M.__new__(M) mgr._state = M.DIMMING mgr._idle_effect = _dim(50) zone_a = _FakeZone(0) zone_b = _FakeZone(1) mgr._dim_zones = [ (zone_a, 0xFFFFFF, 0x7F7F7F), (zone_b, 0xFF0000, 0x7F0000), ] mgr.notify_zone_changed(1, 0x00FF00) # zone_a unchanged assert mgr._dim_zones[0] == (zone_a, 0xFFFFFF, 0x7F7F7F) # zone_b updated: new start + recomputed target at 50% zone, start, target = mgr._dim_zones[1] assert zone is zone_b assert start == 0x00FF00 # _compute_dim_color(0x00FF00, 50) = (0, 0xFF*50//100, 0) = (0, 0x7F, 0) assert target == 0x007F00 def test_notify_zone_changed_noop_when_not_dimming(): mgr = M.__new__(M) mgr._state = M.IDLE mgr._idle_effect = _dim(50) zone = _FakeZone(0) mgr._dim_zones = [(zone, 0xFFFFFF, 0x7F7F7F)] mgr.notify_zone_changed(0, 0x00FF00) assert mgr._dim_zones[0] == (zone, 0xFFFFFF, 0x7F7F7F) def test_notify_zone_changed_noop_for_unknown_cluster(): mgr = M.__new__(M) mgr._state = M.DIMMING mgr._idle_effect = _dim(50) zone = _FakeZone(0) mgr._dim_zones = [(zone, 0xFFFFFF, 0x7F7F7F)] mgr.notify_zone_changed(99, 0x00FF00) # cluster 99 not in _dim_zones assert mgr._dim_zones[0] == (zone, 0xFFFFFF, 0x7F7F7F) def test_notify_zone_changed_noop_when_dim_zones_empty(): mgr = M.__new__(M) mgr._state = M.DIMMING mgr._idle_effect = _dim(50) mgr._dim_zones = [] # e.g. per-key active, zone path skipped mgr.notify_zone_changed(0, 0x00FF00) # should not raise assert mgr._dim_zones == [] # --- perkey_has_paint / zone_effect_is_static (module-level predicates) ----- class _FakePerKey: """Minimal stand-in for PerKeyLighting: holds a _value map and a _validator with .choices that perkey_has_paint inspects.""" name = "per-key-lighting" def __init__(self, value, choices=(1, 2, 3)): self._value = value class _V: pass self._validator = _V() self._validator.choices = list(choices) class _FakeZoneSetting: """Minimal stand-in for an RGBEffectSetting child instance: holds a name starting with "rgb_zone_" and a _value with an .ID attribute.""" def __init__(self, name, value): self.name = name self._value = value class _ValueWithID: """Stand-in for hidpp20.LEDEffectSetting — only .ID is consulted by zone_effect_is_static.""" def __init__(self, ID): self.ID = ID class _FakePersister: def __init__(self, sensitivities=None): self._s = sensitivities or {} def get_sensitivity(self, name): return self._s.get(name, False) class _FakeDevice: def __init__(self, settings_list, persister=None): self.settings = settings_list self.persister = persister def test_perkey_has_paint_with_real_colors(): pk = _FakePerKey({1: 0xFF0000, 2: -1, 3: -1}) # one real color, rest "No change" dev = _FakeDevice([pk], _FakePersister({"per-key-lighting": True})) found, has_paint = rgb_power.perkey_has_paint(dev) assert found is pk assert has_paint is True def test_perkey_has_paint_with_locked_sensitivity(): # False (locked) still counts as paint — only IGNORE opts out. pk = _FakePerKey({1: 0xFF0000}) dev = _FakeDevice([pk], _FakePersister()) # sensitivity defaults to False _, has_paint = rgb_power.perkey_has_paint(dev) assert has_paint is True def test_perkey_has_paint_only_no_change(): pk = _FakePerKey({1: -1, 2: -1, 3: -1}) # all "No change" dev = _FakeDevice([pk]) found, has_paint = rgb_power.perkey_has_paint(dev) assert found is pk assert has_paint is False def test_perkey_has_paint_no_perkey_setting(): dev = _FakeDevice([]) found, has_paint = rgb_power.perkey_has_paint(dev) assert found is None assert has_paint is False def test_perkey_has_paint_validator_choices_empty(): pk = _FakePerKey({1: 0xFF0000}, choices=()) dev = _FakeDevice([pk]) found, has_paint = rgb_power.perkey_has_paint(dev) assert found is pk assert has_paint is False def test_perkey_has_paint_user_ignores_perkey(): from logitech_receiver import settings as _settings pk = _FakePerKey({1: 0xFF0000}) dev = _FakeDevice([pk], _FakePersister({"per-key-lighting": _settings.SENSITIVITY_IGNORE})) found, has_paint = rgb_power.perkey_has_paint(dev) assert found is pk assert has_paint is False def test_perkey_has_paint_with_no_persister(): # No persister, no IGNORE flag — treat as paint present. pk = _FakePerKey({1: 0xFF0000}) dev = _FakeDevice([pk], persister=None) _, has_paint = rgb_power.perkey_has_paint(dev) assert has_paint is True def test_zone_effect_is_static_true_for_static(): z = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x01)) assert rgb_power.zone_effect_is_static(_FakeDevice([z])) is True def test_zone_effect_is_static_false_for_animated(): for eff_id in (0x02, 0x03, 0x0A, 0x0B, 0x0E, 0x15): z = _FakeZoneSetting("rgb_zone_1", _ValueWithID(eff_id)) assert rgb_power.zone_effect_is_static(_FakeDevice([z])) is False, eff_id def test_zone_effect_is_static_false_for_disabled(): # Disabled (0x00) means the user wants no zone effect — including # suppressing the per-key paint, since per-key push would re-light # the device. z = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x00)) assert rgb_power.zone_effect_is_static(_FakeDevice([z])) is False def test_zone_effect_is_static_true_when_no_zone_setting(): # Devices that only enumerate PER_KEY_LIGHTING_V2 (no RGB_EFFECTS) have # no zone-effect setting at all — per-key paint should be free to drive. assert rgb_power.zone_effect_is_static(_FakeDevice([])) is True def test_zone_effect_is_static_true_when_any_zone_is_static(): # Multi-zone device with one Static and one Cycle zone — at least one # Static slot is enough for per-key to overlay on. a = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x01)) # Static b = _FakeZoneSetting("rgb_zone_2", _ValueWithID(0x03)) # Cycle assert rgb_power.zone_effect_is_static(_FakeDevice([a, b])) is True # --- RGBEffectSetting.write divert / push -------------------------------- def _make_rgb_effect_setting(device, current_value): """Build a partial RGBEffectSetting bound to `device` without running the setup classmethod (which requires a real led_effects descriptor). Only the fields touched by RGBEffectSetting.write are populated.""" from unittest.mock import MagicMock from logitech_receiver import settings_templates s = settings_templates.RGBEffectSetting.__new__(settings_templates.RGBEffectSetting) s._device = device s._value = current_value s._rw = MagicMock() s._rw.prefix = b"\x01" s._validator = MagicMock() s._validator.needs_current_value = False # update() persists onto _value; mimic with a simple assignment. s.update = lambda v, save=True: setattr(s, "_value", v) return s def test_rgb_effect_write_static_to_static_repaints_unset_zones(monkeypatch): """User tweaks the Static base color while per-key is the visible layer: persist, repaint per-key's unset zones, send FrameEnd. No SetEffectByIndex (would reclaim the engine and overwrite per-key). Also notifies any in-flight dim ramp so unset cells' start_color tracks the new base.""" from unittest.mock import MagicMock from logitech_receiver import hidpp20 perkey = MagicMock() perkey._fill_unset_zones_with_base_color.return_value = True perkey._send_with_retry.return_value = True perkey._unset_zone_ids.return_value = [5, 7, 9] device = MagicMock() device.online = True mgr = MagicMock() monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True)) monkeypatch.setattr(rgb_power, "get_manager", lambda d: mgr) old = hidpp20.LEDEffectSetting(ID=1, color=0xFF0000) new = hidpp20.LEDEffectSetting(ID=1, color=0x00FF00) s = _make_rgb_effect_setting(device, old) result = s.write(new) assert result is new assert s._value is new perkey._fill_unset_zones_with_base_color.assert_called_once() perkey._send_with_retry.assert_called_once_with(0x70, b"\x00") s._rw.write.assert_not_called() # No follow-up per-key push — we were already in Static. perkey.write.assert_not_called() # In-flight dim ramp gets notified that unset cells now interpolate # from the new base (0x00FF00) rather than the old. mgr.notify_perkey_bulk_changed.assert_called_once_with({5: 0x00FF00, 7: 0x00FF00, 9: 0x00FF00}) def test_rgb_effect_write_static_to_static_no_notify_when_no_unset_zones(monkeypatch): """If every per-key cell is user-painted, _unset_zone_ids returns [] and the dim-ramp notification is skipped (nothing to update).""" from unittest.mock import MagicMock from logitech_receiver import hidpp20 perkey = MagicMock() perkey._fill_unset_zones_with_base_color.return_value = True perkey._send_with_retry.return_value = True perkey._unset_zone_ids.return_value = [] device = MagicMock() device.online = True mgr = MagicMock() monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True)) monkeypatch.setattr(rgb_power, "get_manager", lambda d: mgr) old = hidpp20.LEDEffectSetting(ID=1, color=0xFF0000) new = hidpp20.LEDEffectSetting(ID=1, color=0x00FF00) s = _make_rgb_effect_setting(device, old) s.write(new) mgr.notify_perkey_bulk_changed.assert_not_called() def test_rgb_effect_write_static_to_static_unchanged_is_noop(monkeypatch): """Same value persisted: no repaint, no FrameEnd, no wire write.""" from unittest.mock import MagicMock from logitech_receiver import hidpp20 perkey = MagicMock() device = MagicMock() device.online = True monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True)) same = hidpp20.LEDEffectSetting(ID=1, color=0xFF0000) s = _make_rgb_effect_setting(device, same) s.write(same) perkey._fill_unset_zones_with_base_color.assert_not_called() perkey._send_with_retry.assert_not_called() s._rw.write.assert_not_called() def test_rgb_effect_write_static_to_animation_pushes_wire(monkeypatch): """User switches the zone effect dropdown from Static to an animation while per-key has paint: push the new value to wire so the animation starts. Per-key is a sub-mode of Static — animations take over the visible layer when selected.""" from unittest.mock import MagicMock from logitech_receiver import hidpp20 perkey = MagicMock() perkey._value = {1: 0xFF0000} device = MagicMock() device.online = True monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True)) monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c) monkeypatch.setattr(rgb_power, "get_manager", lambda d: None) old = hidpp20.LEDEffectSetting(ID=0x01, color=0xFF0000) new = hidpp20.LEDEffectSetting(ID=0x0A, color=0x00FF00) s = _make_rgb_effect_setting(device, old) s._validator.prepare_write.return_value = b"prepared" s._rw.write.return_value = b"ack" s.write(new) s._rw.write.assert_called_once() perkey._fill_unset_zones_with_base_color.assert_not_called() def test_rgb_effect_write_repaints_perkey_unset_on_color_change(monkeypatch): """When per-key has paint and the zone's color changes, repaint the per-key unset cells with the new base so the visible result tracks the user's color choice.""" from unittest.mock import MagicMock from logitech_receiver import hidpp20 perkey = MagicMock() perkey._value = {1: 0xFF0000, 2: -1} perkey._fill_unset_zones_with_base_color.return_value = True perkey._unset_zone_ids.return_value = [2] device = MagicMock() device.online = True monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True)) monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c) monkeypatch.setattr(rgb_power, "get_manager", lambda d: None) old = hidpp20.LEDEffectSetting(ID=0x01, color=0xFF0000) new = hidpp20.LEDEffectSetting(ID=0x01, color=0x00FF00) s = _make_rgb_effect_setting(device, old) s._validator.prepare_write.return_value = b"prepared" s._rw.write.return_value = b"ack" s.write(new) s._rw.write.assert_not_called() perkey._fill_unset_zones_with_base_color.assert_called_once() perkey._send_with_retry.assert_called_once_with(0x70, b"\x00") def test_rgb_effect_write_apply_path_suppressed_when_perkey_has_paint(monkeypatch): """save=False is the apply_all_settings path. When per-key has paint and is opted in, per-key fully owns the visible layer — the zone wire push is suppressed here too, not just on the save path.""" from unittest.mock import MagicMock from logitech_receiver import hidpp20 perkey = MagicMock() device = MagicMock() device.online = True monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, True)) monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c) monkeypatch.setattr(rgb_power, "get_manager", lambda d: None) new = hidpp20.LEDEffectSetting(ID=0x01, color=0x00FF00) s = _make_rgb_effect_setting(device, None) s._validator.prepare_write.return_value = b"prepared" s._rw.write.return_value = b"ack" s.write(new, save=False) s._rw.write.assert_not_called() # Apply path doesn't repaint either — that's reserved for explicit user # color changes via save=True. perkey._fill_unset_zones_with_base_color.assert_not_called() def test_rgb_effect_write_inactive_perkey_falls_through(monkeypatch): """No per-key paint (or per-key feature absent): existing translate-through-power-state wire path runs unchanged.""" from unittest.mock import MagicMock from logitech_receiver import hidpp20 perkey = MagicMock() device = MagicMock() device.online = True monkeypatch.setattr(rgb_power, "perkey_has_paint", lambda d: (perkey, False)) monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c) monkeypatch.setattr(rgb_power, "get_manager", lambda d: None) new = hidpp20.LEDEffectSetting(ID=0x01, color=0x00FF00) s = _make_rgb_effect_setting(device, None) s._validator.prepare_write.return_value = b"prepared" s._rw.write.return_value = b"ack" s.write(new) s._rw.write.assert_called_once() perkey._fill_unset_zones_with_base_color.assert_not_called() perkey.write.assert_not_called() # --- PerKeyLighting.write defers to firmware animations ------------------- def test_perkey_write_skipped_when_zone_is_animation(monkeypatch): """Per-key is a sub-mode of Static. When the saved zone effect is an animation (Breathe etc.), the firmware engine owns the visible layer and per-key writes do not go to the wire.""" from unittest.mock import MagicMock from logitech_receiver import settings_templates breathe_zone = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x0A)) s = settings_templates.PerKeyLighting.__new__(settings_templates.PerKeyLighting) device = MagicMock() device.online = True device.settings = [breathe_zone] s._device = device s._value = {} s._has_rgb_effects = True s._send_with_retry = MagicMock(return_value=True) s._fill_unset_zones_with_base_color = MagicMock(return_value=True) monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c) monkeypatch.setattr(rgb_power, "get_manager", lambda d: None) s.update = lambda m, save=True: None s.write({1: 0xFF0000}) s._send_with_retry.assert_not_called() def test_perkey_write_key_value_skipped_when_zone_is_animation(monkeypatch): """Same: write_key_value defers to firmware animations.""" from unittest.mock import MagicMock from logitech_receiver import settings_templates breathe_zone = _FakeZoneSetting("rgb_zone_1", _ValueWithID(0x0A)) s = settings_templates.PerKeyLighting.__new__(settings_templates.PerKeyLighting) device = MagicMock() device.online = True device.settings = [breathe_zone] s._device = device s._value = {} s._has_rgb_effects = True s._send_with_retry = MagicMock(return_value=True) s._send_zone_color = MagicMock(return_value=True) s._fill_unset_zones_with_base_color = MagicMock(return_value=True) monkeypatch.setattr(rgb_power, "translate_for_device", lambda d, c: c) monkeypatch.setattr(rgb_power, "get_manager", lambda d: None) s.update_key_value = lambda k, v, save=True: None s.write_key_value(7, 0xFF0000) s._send_zone_color.assert_not_called()