Add openrgb-hue: Hue scene gradients for OpenRGB lights
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>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openrgb_hue.client import LedRef
|
||||
from openrgb_hue.color import Color
|
||||
from openrgb_hue.scenes import Scene, SceneLight
|
||||
from openrgb_hue.targets import synthetic_targets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_scene() -> Scene:
|
||||
"""A small hand-built scene with an intentional consecutive duplicate
|
||||
(light 2 and 3 share a color) so dedupe behavior is exercised."""
|
||||
return Scene(
|
||||
name="Test Scene",
|
||||
lights=[
|
||||
SceneLight("light.hue_1", Color(255, 0, 0), 255),
|
||||
SceneLight("light.hue_2", Color(0, 255, 0), 255),
|
||||
SceneLight("light.hue_3", Color(0, 255, 0), 255),
|
||||
SceneLight("light.hue_4", Color(0, 0, 255), 128),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def synthetic_leds() -> list[LedRef]:
|
||||
return synthetic_targets(12)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def matrix_leds() -> list[LedRef]:
|
||||
return [led for led in synthetic_targets() if led.matrix_row is not None]
|
||||
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from openrgb_hue.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_scenes_list_shows_scene_count():
|
||||
result = runner.invoke(app, ["scenes", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "103" in result.output
|
||||
|
||||
|
||||
def test_scenes_list_filter():
|
||||
result = runner.invoke(app, ["scenes", "list", "--filter", "tropical"])
|
||||
assert result.exit_code == 0
|
||||
assert "Tropical twilight" in result.output
|
||||
|
||||
|
||||
def test_scenes_show_known_scene():
|
||||
result = runner.invoke(app, ["scenes", "show", "Tropical twilight"])
|
||||
assert result.exit_code == 0
|
||||
assert "Tropical twilight" in result.output
|
||||
assert "Derived gradient" in result.output
|
||||
|
||||
|
||||
def test_scenes_show_unknown_scene_errors():
|
||||
result = runner.invoke(app, ["scenes", "show", "Definitely Not A Scene"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mapping_mode", ["sequence", "per-device", "per-zone", "mirror", "shuffle"])
|
||||
def test_apply_dry_run_across_mapping_modes(mapping_mode):
|
||||
result = runner.invoke(app, ["apply", "Tropical twilight", "--dry-run", "--mapping", mapping_mode])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "synthetic LEDs" in result.output
|
||||
|
||||
|
||||
def test_apply_dry_run_matrix_radial():
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["apply", "Tropical twilight", "--dry-run", "--mapping", "matrix", "--direction", "radial"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["static", "scroll", "pingpong", "pulse", "wave"])
|
||||
def test_animate_dry_run_across_animation_modes(mode):
|
||||
result = runner.invoke(
|
||||
app, ["animate", "Tropical twilight", "--dry-run", "--mode", mode, "--dry-run-frames", "3"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "t=0.00s" in result.output
|
||||
|
||||
|
||||
def test_apply_unknown_scene_errors():
|
||||
result = runner.invoke(app, ["apply", "Not A Real Scene", "--dry-run"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
def test_apply_unknown_mapping_mode_errors():
|
||||
result = runner.invoke(app, ["apply", "Tropical twilight", "--dry-run", "--mapping", "nonsense"])
|
||||
assert result.exit_code == 1
|
||||
@@ -0,0 +1,47 @@
|
||||
from openrgb_hue.color import Color, hsv_lerp, rgb_lerp, rgb_to_hsv
|
||||
|
||||
|
||||
def test_color_clamps_out_of_range():
|
||||
assert Color(300, -10, 128) == Color(255, 0, 128)
|
||||
|
||||
|
||||
def test_color_hex_roundtrip():
|
||||
c = Color(18, 52, 86)
|
||||
assert c.to_hex() == "123456"
|
||||
assert Color.from_hex("#123456") == c
|
||||
|
||||
|
||||
def test_rgb_lerp_endpoints_and_midpoint():
|
||||
a, b = Color(0, 0, 0), Color(100, 200, 50)
|
||||
assert rgb_lerp(a, b, 0.0) == a
|
||||
assert rgb_lerp(a, b, 1.0) == b
|
||||
mid = rgb_lerp(a, b, 0.5)
|
||||
assert mid == Color(50, 100, 25)
|
||||
|
||||
|
||||
def test_hsv_lerp_endpoints():
|
||||
a, b = Color(255, 0, 0), Color(0, 0, 255)
|
||||
assert hsv_lerp(a, b, 0.0) == a
|
||||
assert hsv_lerp(a, b, 1.0) == b
|
||||
|
||||
|
||||
def test_hsv_lerp_takes_shortest_hue_path():
|
||||
# Hue 350deg -> 10deg should pass through 0deg/360deg, not through 180deg.
|
||||
a = Color.from_hex("#ff0022") # hue ~350deg
|
||||
b = Color.from_hex("#ff2200") # hue ~10deg
|
||||
mid = hsv_lerp(a, b, 0.5)
|
||||
mid_h, _, _ = rgb_to_hsv(mid)
|
||||
# Near 0/360deg, not anywhere near 180deg.
|
||||
assert mid_h < 0.05 or mid_h > 0.95
|
||||
|
||||
|
||||
def test_scale_brightness():
|
||||
c = Color(200, 100, 50)
|
||||
assert c.scale_brightness(0.5) == Color(100, 50, 25)
|
||||
assert c.scale_brightness(0.0) == Color(0, 0, 0)
|
||||
|
||||
|
||||
def test_to_openrgb_conversion():
|
||||
c = Color(1, 2, 3)
|
||||
rgb = c.to_openrgb()
|
||||
assert (rgb.red, rgb.green, rgb.blue) == (1, 2, 3)
|
||||
@@ -0,0 +1,58 @@
|
||||
from openrgb_hue.color import Color
|
||||
from openrgb_hue.gradient import Gradient, Stop
|
||||
|
||||
|
||||
def test_from_colors_even_spacing_single():
|
||||
gradient = Gradient.from_colors([Color(10, 20, 30)])
|
||||
assert [s.position for s in gradient.stops] == [0.0]
|
||||
|
||||
|
||||
def test_from_colors_even_spacing_multiple():
|
||||
colors = [Color(0, 0, 0), Color(50, 50, 50), Color(255, 255, 255)]
|
||||
gradient = Gradient.from_colors(colors)
|
||||
assert [s.position for s in gradient.stops] == [0.0, 0.5, 1.0]
|
||||
|
||||
|
||||
def test_sample_at_exact_stop_positions():
|
||||
colors = [Color(255, 0, 0), Color(0, 255, 0), Color(0, 0, 255)]
|
||||
gradient = Gradient.from_colors(colors, interpolation="rgb")
|
||||
assert gradient.sample(0.0) == colors[0]
|
||||
assert gradient.sample(0.5) == colors[1]
|
||||
assert gradient.sample(1.0) == colors[0] # cyclic: wraps back to the first stop
|
||||
|
||||
|
||||
def test_sample_wraps_cyclically():
|
||||
colors = [Color(255, 0, 0), Color(0, 0, 255)]
|
||||
gradient = Gradient.from_colors(colors, interpolation="rgb")
|
||||
# t=0.75 is halfway between the last stop (1.0 -> wraps to 0.0) and back
|
||||
# to the first stop; verify negative/over-1 t wrap to the same result.
|
||||
assert gradient.sample(-0.25) == gradient.sample(0.75)
|
||||
assert gradient.sample(1.25) == gradient.sample(0.25)
|
||||
|
||||
|
||||
def test_single_stop_gradient_is_constant():
|
||||
gradient = Gradient([Stop(0.3, Color(1, 2, 3))])
|
||||
assert gradient.sample(0.0) == Color(1, 2, 3)
|
||||
assert gradient.sample(0.9) == Color(1, 2, 3)
|
||||
|
||||
|
||||
def test_from_scene_dedupe_collapses_consecutive_duplicates(sample_scene):
|
||||
with_dedupe = Gradient.from_scene(sample_scene, dedupe=True, use_brightness=False)
|
||||
without_dedupe = Gradient.from_scene(sample_scene, dedupe=False, use_brightness=False)
|
||||
# sample_scene has 4 lights but lights 2 and 3 share a color.
|
||||
assert len(with_dedupe.stops) == 3
|
||||
assert len(without_dedupe.stops) == 4
|
||||
|
||||
|
||||
def test_from_scene_use_brightness_scales_colors(sample_scene):
|
||||
gradient = Gradient.from_scene(sample_scene, dedupe=False, use_brightness=True)
|
||||
# Fourth light: Color(0, 0, 255) at brightness 128/255.
|
||||
last_stop_color = gradient.stops[-1].color
|
||||
expected = Color(0, 0, 255).scale_brightness(128 / 255)
|
||||
assert last_stop_color == expected
|
||||
|
||||
|
||||
def test_preview_stops_length():
|
||||
gradient = Gradient.from_colors([Color(255, 0, 0), Color(0, 255, 0)])
|
||||
assert len(gradient.preview_stops(10)) == 10
|
||||
assert gradient.preview_stops(0) == []
|
||||
@@ -0,0 +1,139 @@
|
||||
import pytest
|
||||
|
||||
from openrgb_hue.color import Color
|
||||
from openrgb_hue.gradient import Gradient
|
||||
from openrgb_hue.mapping import (
|
||||
FrameParams,
|
||||
MatrixMapping,
|
||||
MirrorMapping,
|
||||
PerDeviceMapping,
|
||||
PerZoneMapping,
|
||||
SequenceMapping,
|
||||
ShuffleMapping,
|
||||
UnknownMappingModeError,
|
||||
base_positions,
|
||||
build_mapping,
|
||||
render_frame,
|
||||
)
|
||||
from openrgb_hue.targets import synthetic_targets
|
||||
|
||||
|
||||
def test_sequence_mapping_linear_spacing(synthetic_leds):
|
||||
positions = SequenceMapping().positions(synthetic_leds)
|
||||
ordered = [positions[led] for led in synthetic_leds]
|
||||
assert ordered[0] == 0.0
|
||||
assert ordered[-1] == 1.0
|
||||
assert ordered == sorted(ordered)
|
||||
|
||||
|
||||
def test_sequence_mapping_single_target_is_zero():
|
||||
leds = synthetic_targets(1)
|
||||
positions = SequenceMapping().positions(leds)
|
||||
assert positions[leds[0]] == 0.0
|
||||
|
||||
|
||||
def test_per_device_mapping_each_device_spans_full_range():
|
||||
leds = synthetic_targets() # two linear devices + one matrix device
|
||||
positions = PerDeviceMapping().positions(leds)
|
||||
for device_index in {led.device_index for led in leds}:
|
||||
device_leds = [led for led in leds if led.device_index == device_index]
|
||||
values = [positions[led] for led in device_leds]
|
||||
assert min(values) == 0.0
|
||||
assert max(values) == 1.0
|
||||
|
||||
|
||||
def test_per_zone_mapping_groups_by_zone():
|
||||
leds = synthetic_targets()
|
||||
positions = PerZoneMapping().positions(leds)
|
||||
for key in {(led.device_index, led.zone_index) for led in leds}:
|
||||
zone_leds = [led for led in leds if (led.device_index, led.zone_index) == key]
|
||||
values = [positions[led] for led in zone_leds]
|
||||
assert min(values) == 0.0
|
||||
assert max(values) == 1.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def matrix_leds():
|
||||
return [led for led in synthetic_targets() if led.matrix_row is not None]
|
||||
|
||||
|
||||
def test_matrix_mapping_left_right(matrix_leds):
|
||||
positions = MatrixMapping(direction="left-right").positions(matrix_leds)
|
||||
top_left = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
|
||||
top_right = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 3)
|
||||
assert positions[top_left] == 0.0
|
||||
assert positions[top_right] == 1.0
|
||||
|
||||
|
||||
def test_matrix_mapping_top_bottom(matrix_leds):
|
||||
positions = MatrixMapping(direction="top-bottom").positions(matrix_leds)
|
||||
top = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
|
||||
bottom = next(led for led in matrix_leds if led.matrix_row == 3 and led.matrix_col == 0)
|
||||
assert positions[top] == 0.0
|
||||
assert positions[bottom] == 1.0
|
||||
|
||||
|
||||
def test_matrix_mapping_diagonal(matrix_leds):
|
||||
positions = MatrixMapping(direction="diagonal").positions(matrix_leds)
|
||||
corner_near = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
|
||||
corner_far = next(led for led in matrix_leds if led.matrix_row == 3 and led.matrix_col == 3)
|
||||
assert positions[corner_near] == 0.0
|
||||
assert positions[corner_far] == 1.0
|
||||
|
||||
|
||||
def test_matrix_mapping_radial_center_is_minimum(matrix_leds):
|
||||
positions = MatrixMapping(direction="radial").positions(matrix_leds)
|
||||
# On a 4x4 grid the center falls between cells; corners should be
|
||||
# further from center than the near-center cells.
|
||||
corner = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
|
||||
near_center = next(led for led in matrix_leds if led.matrix_row == 1 and led.matrix_col == 1)
|
||||
assert positions[near_center] < positions[corner]
|
||||
|
||||
|
||||
def test_matrix_mapping_invalid_direction_raises():
|
||||
with pytest.raises(ValueError):
|
||||
MatrixMapping(direction="sideways")
|
||||
|
||||
|
||||
def test_matrix_mapping_warns_on_non_matrix_leds():
|
||||
leds = synthetic_targets(4) # flat LEDs, no matrix coordinates
|
||||
warnings = []
|
||||
MatrixMapping(warn=warnings.append).positions(leds)
|
||||
assert len(warnings) == 1
|
||||
assert all(led.matrix_row is None for led in leds)
|
||||
|
||||
|
||||
def test_mirror_mapping_is_symmetric(synthetic_leds):
|
||||
positions = MirrorMapping().positions(synthetic_leds)
|
||||
values = [positions[led] for led in synthetic_leds]
|
||||
n = len(values)
|
||||
for i in range(n):
|
||||
assert values[i] == pytest.approx(values[n - 1 - i])
|
||||
|
||||
|
||||
def test_shuffle_mapping_deterministic_and_seed_sensitive(synthetic_leds):
|
||||
a = ShuffleMapping(seed=1).positions(synthetic_leds)
|
||||
b = ShuffleMapping(seed=1).positions(synthetic_leds)
|
||||
c = ShuffleMapping(seed=2).positions(synthetic_leds)
|
||||
assert a == b
|
||||
assert a != c
|
||||
|
||||
|
||||
def test_build_mapping_unknown_name_raises():
|
||||
with pytest.raises(UnknownMappingModeError):
|
||||
build_mapping("not-a-real-mode")
|
||||
|
||||
|
||||
def test_render_frame_uses_gradient_and_offset(synthetic_leds):
|
||||
gradient = Gradient.from_colors([Color(255, 0, 0), Color(0, 0, 255)], interpolation="rgb")
|
||||
base = base_positions(SequenceMapping(), synthetic_leds)
|
||||
colors = render_frame(base, gradient, FrameParams(t_offset=0.0, brightness=1.0))
|
||||
assert colors[synthetic_leds[0]] == gradient.sample(0.0)
|
||||
|
||||
|
||||
def test_render_frame_brightness_fn_overrides_scalar_brightness(synthetic_leds):
|
||||
gradient = Gradient.from_colors([Color(255, 255, 255)], interpolation="rgb")
|
||||
base = base_positions(SequenceMapping(), synthetic_leds)
|
||||
params = FrameParams(brightness=1.0, brightness_fn=lambda t: 0.0)
|
||||
colors = render_frame(base, gradient, params)
|
||||
assert all(c == Color(0, 0, 0) for c in colors.values())
|
||||
@@ -0,0 +1,92 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openrgb_hue.scenes import (
|
||||
BUNDLED_CSV_PATH,
|
||||
SceneNotFoundError,
|
||||
get_scene,
|
||||
list_scene_names,
|
||||
load_scenes,
|
||||
refresh_bundled_csv,
|
||||
)
|
||||
|
||||
SAMPLE_CSV = (
|
||||
"Scene,Light,Red,Green,Blue,Brightness\n"
|
||||
"Alpha,light.hue_1,255,0,0,255\n"
|
||||
"Alpha,light.hue_2,0,255,0,200\n"
|
||||
"Beta,light.hue_1,0,0,255,100\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_csv_path(tmp_path):
|
||||
path = tmp_path / "scenes.csv"
|
||||
path.write_text(SAMPLE_CSV, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_load_scenes_groups_by_name_preserving_order(sample_csv_path):
|
||||
scenes = load_scenes(sample_csv_path)
|
||||
assert list(scenes.keys()) == ["Alpha", "Beta"]
|
||||
assert len(scenes["Alpha"].lights) == 2
|
||||
assert len(scenes["Beta"].lights) == 1
|
||||
|
||||
|
||||
def test_list_scene_names(sample_csv_path):
|
||||
assert list_scene_names(sample_csv_path) == ["Alpha", "Beta"]
|
||||
|
||||
|
||||
def test_get_scene_case_insensitive(sample_csv_path):
|
||||
scene = get_scene("alpha", sample_csv_path)
|
||||
assert scene.name == "Alpha"
|
||||
|
||||
|
||||
def test_get_scene_missing_raises_with_suggestion(sample_csv_path):
|
||||
with pytest.raises(SceneNotFoundError, match="Alpha"):
|
||||
get_scene("Alphaa", sample_csv_path)
|
||||
|
||||
|
||||
def test_bundled_csv_has_103_scenes_of_10_lights_each():
|
||||
scenes = load_scenes(BUNDLED_CSV_PATH)
|
||||
assert len(scenes) == 103
|
||||
assert all(len(scene.lights) == 10 for scene in scenes.values())
|
||||
|
||||
|
||||
def test_refresh_bundled_csv_writes_validated_data(tmp_path):
|
||||
dest = tmp_path / "scenes.csv"
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return SAMPLE_CSV.encode("utf-8")
|
||||
|
||||
with patch("openrgb_hue.scenes.urllib.request.urlopen", return_value=FakeResponse()):
|
||||
result = refresh_bundled_csv(dest=dest, url="https://example.invalid/scenes.csv")
|
||||
|
||||
assert result == dest
|
||||
assert dest.read_text(encoding="utf-8") == SAMPLE_CSV
|
||||
|
||||
|
||||
def test_refresh_bundled_csv_rejects_invalid_data(tmp_path):
|
||||
dest = tmp_path / "scenes.csv"
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b"not,a,valid,csv\n"
|
||||
|
||||
with patch("openrgb_hue.scenes.urllib.request.urlopen", return_value=FakeResponse()):
|
||||
with pytest.raises(ValueError):
|
||||
refresh_bundled_csv(dest=dest, url="https://example.invalid/scenes.csv")
|
||||
assert not dest.exists()
|
||||
Reference in New Issue
Block a user