Applies gradients derived from 103 bundled Philips Hue scenes to OpenRGB-controlled LEDs, with six mapping modes (sequence, per-device, per-zone, matrix, mirror, shuffle) and five animation modes (static, scroll, pingpong, pulse, wave). Typer/Rich CLI with dry-run preview, device/zone targeting, and a scenes-update command to refresh the bundled dataset from its source gist. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
4.0 KiB
Python
122 lines
4.0 KiB
Python
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)
|