from dataclasses import dataclass import pytest from openrgb_hue.animation import ( ANIMATION_NAMES, PingpongAnimation, PulseAnimation, ScrollAnimation, StaticAnimation, UnknownAnimationModeError, WaveAnimation, build_animation, run_animation, ) from openrgb_hue.gradient import Gradient from openrgb_hue.color import Color from openrgb_hue.mapping import SequenceMapping, base_positions from openrgb_hue.targets import synthetic_targets def test_static_animation_never_moves(): anim = StaticAnimation() params = anim.frame_params(elapsed=100.0) assert params.t_offset == 0.0 assert params.brightness == 1.0 assert params.brightness_fn is None def test_scroll_animation_offset_formula(): anim = ScrollAnimation(speed=0.5) # half a cycle per second assert anim.frame_params(0.0).t_offset == pytest.approx(0.0) assert anim.frame_params(1.0).t_offset == pytest.approx(0.5) assert anim.frame_params(2.0).t_offset == pytest.approx(0.0) # wraps def test_pingpong_animation_reverses_at_boundary(): anim = PingpongAnimation(speed=1.0) assert anim.frame_params(0.0).t_offset == pytest.approx(0.0) assert anim.frame_params(1.0).t_offset == pytest.approx(1.0) # peak assert anim.frame_params(1.5).t_offset == pytest.approx(0.5) # reversing assert anim.frame_params(2.0).t_offset == pytest.approx(0.0) # trough def test_pulse_animation_brightness_bounds(): anim = PulseAnimation(speed=1.0, min_brightness=0.1) values = [anim.frame_params(t / 8).brightness for t in range(9)] assert max(values) == pytest.approx(1.0, abs=1e-6) assert min(values) == pytest.approx(0.1, abs=1e-6) def test_wave_animation_has_per_led_brightness_fn(): anim = WaveAnimation(speed=0.0, min_brightness=0.0, wavelength=1.0) params = anim.frame_params(elapsed=0.0) assert params.brightness_fn is not None # At elapsed=0, brightness_fn(t) is a sine over t itself. assert params.brightness_fn(0.0) == pytest.approx(0.5, abs=1e-6) assert params.brightness_fn(0.25) == pytest.approx(1.0, abs=1e-6) def test_build_animation_unknown_name_raises(): with pytest.raises(UnknownAnimationModeError): build_animation("not-a-real-mode") def test_build_animation_covers_all_names(): for name in ANIMATION_NAMES: assert build_animation(name).name == name @dataclass class FakeColor: red: int green: int blue: int class FakeDevice: def __init__(self, n_leds: int): self.colors = [FakeColor(0, 0, 0) for _ in range(n_leds)] self.calls: list[tuple[list, bool]] = [] def set_colors(self, colors, fast=False): self.calls.append((list(colors), fast)) self.colors = colors class FakeClient: def __init__(self, n_leds: int = 4): self.ee_devices = [FakeDevice(n_leds)] def test_run_animation_terminates_and_restores_on_duration(): leds = synthetic_targets(4) client = FakeClient(n_leds=4) gradient = Gradient.from_colors([Color(255, 0, 0), Color(0, 0, 255)], interpolation="rgb") base = base_positions(SequenceMapping(), leds) anim = ScrollAnimation(speed=1.0) run_animation(client, base, gradient, anim, fps=50, duration=0.1, restore=True) device = client.ee_devices[0] assert len(device.calls) > 0 # Final write should be the restore call, putting LEDs back to black. final_colors, final_fast = device.calls[-1] assert final_fast is True assert all((c.red, c.green, c.blue) == (0, 0, 0) for c in final_colors) def test_run_animation_no_restore_leaves_last_frame(): leds = synthetic_targets(4) client = FakeClient(n_leds=4) gradient = Gradient.from_colors([Color(255, 0, 0)], interpolation="rgb") base = base_positions(SequenceMapping(), leds) anim = StaticAnimation() run_animation(client, base, gradient, anim, fps=50, duration=0.05, restore=False) device = client.ee_devices[0] final_colors, _ = device.calls[-1] assert all((c.red, c.green, c.blue) == (255, 0, 0) for c in final_colors)