## 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()
