use std::sync::{Arc, Mutex};
use huesmith_core::light::command::{
LightCommand, DEFAULT_COLOR_TRANSITION_TENTHS, DEFAULT_LEVEL_TRANSITION_TENTHS,
};
use huesmith_core::light::state::{ColorMode, LightSnapshot, LightState, SceneState};
use huesmith_core::light::LightOutput;
#[derive(Debug, Clone, PartialEq)]
enum Event {
On(bool),
Brightness(u8),
ColorXy(u16, u16),
ColorTemp(u16),
Rgb(u8, u8, u8),
}
struct Recorder {
events: Arc<Mutex<Vec<Event>>>,
}
impl Recorder {
fn new(events: Arc<Mutex<Vec<Event>>>) -> Self {
Self { events }
}
}
impl LightOutput for Recorder {
fn set_on(&mut self, on: bool) {
self.events.lock().unwrap().push(Event::On(on));
}
fn set_brightness(&mut self, level: u8) {
self.events.lock().unwrap().push(Event::Brightness(level));
}
fn set_color_xy(&mut self, x: u16, y: u16) {
self.events.lock().unwrap().push(Event::ColorXy(x, y));
}
fn set_color_temp(&mut self, mireds: u16) {
self.events.lock().unwrap().push(Event::ColorTemp(mireds));
}
fn set_rgb(&mut self, r: u8, g: u8, b: u8) {
self.events.lock().unwrap().push(Event::Rgb(r, g, b));
}
}
fn new_state() -> (LightState, Arc<Mutex<Vec<Event>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let state = LightState::new(Box::new(Recorder::new(events.clone())));
(state, events)
}
fn new_powered_on_state() -> (LightState, Arc<Mutex<Vec<Event>>>) {
let events = Arc::new(Mutex::new(Vec::new()));
let state = LightState::new_powered_on(Box::new(Recorder::new(events.clone())));
(state, events)
}
#[test]
fn powered_on_boot_state_renders_without_waiting_for_on_command() {
let (mut state, events) = new_powered_on_state();
assert!(state.on);
assert_eq!(state.brightness, 254);
assert_eq!(state.color_mode, ColorMode::ColorTemperature);
assert!(events.lock().unwrap().contains(&Event::On(true)));
assert!(events.lock().unwrap().contains(&Event::Brightness(254)));
assert!(events.lock().unwrap().contains(&Event::ColorTemp(370)));
events.lock().unwrap().clear();
state.apply_command(&LightCommand::MoveToColorTemp {
mireds: 153,
transition_time: 0,
});
assert!(state.on);
assert_eq!(state.color_temp_mireds, 153);
assert!(events.lock().unwrap().contains(&Event::ColorTemp(153)));
}
#[test]
fn set_level_zero_stays_off_but_nonzero_level_turns_on() {
let (mut state, events) = new_state();
state.apply_command(&LightCommand::SetLevel {
level: 0,
transition_time: 0,
});
assert!(!state.on);
assert_eq!(state.brightness, 0);
state.apply_command(&LightCommand::SetLevel {
level: 42,
transition_time: 0,
});
assert!(state.on);
assert_eq!(state.brightness, 42);
assert!(events.lock().unwrap().contains(&Event::On(true)));
}
#[test]
fn brightness_transition_does_not_change_color_fields() {
let (mut state, _) = new_state();
state.apply_command(&LightCommand::MoveToColor {
x: Some(30_000),
y: Some(31_000),
transition_time: 0,
});
let original_color = (
state.color_mode,
state.color_x,
state.color_y,
state.color_temp_mireds,
state.hue,
state.saturation,
);
state.apply_command(&LightCommand::SetLevel {
level: 100,
transition_time: 10,
});
for _ in 0..5 {
assert!(state.step_transition());
assert_eq!(
original_color,
(
state.color_mode,
state.color_x,
state.color_y,
state.color_temp_mireds,
state.hue,
state.saturation,
)
);
}
while state.transition_active() {
let _ = state.step_transition();
}
assert_eq!(
original_color,
(
state.color_mode,
state.color_x,
state.color_y,
state.color_temp_mireds,
state.hue,
state.saturation,
)
);
}
#[test]
fn transition_progresses_linearly_without_initial_step() {
let (mut state, _) = new_state();
state.apply_command(&LightCommand::SetLevel {
level: 0,
transition_time: 0,
});
state.apply_command(&LightCommand::SetLevel {
level: 100,
transition_time: 10,
});
assert!(state.transition_active());
assert_eq!(state.brightness, 0);
for _ in 0..15 {
assert!(state.step_transition());
}
assert_eq!(state.brightness, 50);
for _ in 0..15 {
assert!(state.step_transition());
}
assert!(!state.step_transition());
assert_eq!(state.brightness, 100);
assert!(!state.transition_active());
}
#[test]
fn long_zcl_transition_time_is_not_clamped_to_u16_milliseconds() {
let (mut state, _) = new_state();
state.apply_command(&LightCommand::SetLevel {
level: 0,
transition_time: 0,
});
state.apply_command(&LightCommand::SetLevel {
level: 254,
transition_time: u16::MAX,
});
for _ in 0..700 {
assert!(state.step_transition());
}
assert!(state.transition_active());
assert!(state.brightness < 10, "brightness={}", state.brightness);
}
#[test]
fn stop_move_step_cancels_active_transition() {
let (mut state, _) = new_state();
state.apply_command(&LightCommand::SetLevel {
level: 0,
transition_time: 0,
});
state.apply_command(&LightCommand::SetLevel {
level: 200,
transition_time: 10,
});
assert!(state.transition_active());
state.apply_command(&LightCommand::StopMoveStep);
assert!(!state.transition_active());
assert!(!state.step_transition());
assert_eq!(state.brightness, 0);
}
#[test]
fn split_xy_updates_apply_only_after_both_coordinates_arrive() {
let (mut state, _) = new_state();
state.apply_command(&LightCommand::MoveToColor {
x: Some(30_000),
y: None,
transition_time: 0,
});
assert_eq!(state.color_mode, ColorMode::ColorTemperature);
assert_eq!(state.color_x, 20_495);
assert_eq!(state.color_y, 21_561);
state.apply_command(&LightCommand::MoveToColor {
x: None,
y: Some(31_000),
transition_time: 0,
});
assert_eq!(state.color_mode, ColorMode::XY);
assert_eq!(state.color_x, 30_000);
assert_eq!(state.color_y, 31_000);
}
const _: () = assert!(DEFAULT_LEVEL_TRANSITION_TENTHS > 0);
const _: () = assert!(DEFAULT_COLOR_TRANSITION_TENTHS > 0);
#[test]
fn on_off_commands_fade_without_repeating_power_toggles() {
let (mut state, events) = new_state();
state.apply_command(&LightCommand::On);
assert!(state.on);
assert!(state.transition_active());
while state.transition_active() {
let _ = state.step_transition();
}
assert!(state.on);
assert_eq!(
events
.lock()
.unwrap()
.iter()
.filter(|event| matches!(event, Event::On(true)))
.count(),
1
);
events.lock().unwrap().clear();
state.apply_command(&LightCommand::Off);
assert!(state.on);
assert!(state.transition_active());
assert!(!events
.lock()
.unwrap()
.iter()
.any(|event| matches!(event, Event::On(false))));
while state.transition_active() {
let _ = state.step_transition();
}
assert!(!state.on);
assert_eq!(state.brightness, 254);
assert_eq!(
events
.lock()
.unwrap()
.iter()
.filter(|event| matches!(event, Event::On(false)))
.count(),
1
);
}
#[test]
fn scene_recall_honors_transition_time_for_off_fade() {
let (mut state, events) = new_state();
state.apply_command(&LightCommand::SetLevel {
level: 100,
transition_time: 0,
});
events.lock().unwrap().clear();
state.apply_scene_state_with_transition(
&SceneState {
on: Some(false),
..SceneState::default()
},
4,
);
assert!(state.on);
assert_eq!(state.brightness, 100);
assert!(state.transition_active());
while state.transition_active() {
let _ = state.step_transition();
}
assert!(!state.on);
assert_eq!(state.brightness, 100);
assert_eq!(
events
.lock()
.unwrap()
.iter()
.filter(|event| matches!(event, Event::On(false)))
.count(),
1
);
}
#[test]
fn hue_zero_sets_red_not_ignored() {
let (mut state, _) = new_state();
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(100),
saturation: Some(200),
transition_time: 0,
});
assert_eq!(state.hue, 100);
assert_eq!(state.saturation, 200);
assert_eq!(state.color_mode, ColorMode::HueSaturation);
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(0),
saturation: None,
transition_time: 0,
});
assert_eq!(state.hue, 0);
assert_eq!(state.saturation, 200, "saturation unchanged when None");
}
#[test]
fn saturation_zero_desaturates_not_ignored() {
let (mut state, _) = new_state();
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(100),
saturation: Some(200),
transition_time: 0,
});
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: None,
saturation: Some(0),
transition_time: 0,
});
assert_eq!(state.saturation, 0);
assert_eq!(state.hue, 100, "hue unchanged when None");
}
#[test]
fn instant_scene_brightness_without_on_field_updates_rendered_level() {
let (mut state, events) = new_state();
state.apply_command(&LightCommand::SetLevel {
level: 10,
transition_time: 0,
});
events.lock().unwrap().clear();
state.apply_scene_state(&SceneState {
brightness: Some(60),
..SceneState::default()
});
assert!(state.on);
assert_eq!(state.brightness, 60);
assert!(events.lock().unwrap().contains(&Event::Brightness(60)));
}
#[test]
fn state_update_delivers_raw_values_to_custom_backends() {
struct RawObserver {
seen: Arc<Mutex<Vec<LightSnapshot>>>,
}
impl LightOutput for RawObserver {
fn set_on(&mut self, _on: bool) {}
fn set_brightness(&mut self, _level: u8) {}
fn set_rgb(&mut self, _r: u8, _g: u8, _b: u8) {}
fn state_update(&mut self, state: &LightSnapshot) {
self.seen.lock().unwrap().push(*state);
}
}
let seen = Arc::new(Mutex::new(Vec::new()));
let mut state = LightState::new_powered_on(Box::new(RawObserver { seen: seen.clone() }));
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(42),
saturation: Some(200),
transition_time: 0,
});
state.apply_command(&LightCommand::SetLevel {
level: 10,
transition_time: 10,
});
while state.step_transition() {}
{
let seen = seen.lock().unwrap();
let last = *seen.last().unwrap();
assert_eq!(last.brightness, 10, "final raw brightness must arrive");
assert_eq!(last.rendered_brightness, 10);
assert_eq!(last.hue, 42, "raw hue must arrive");
assert_eq!(last.color_temp_mireds, 370, "raw mireds must arrive");
assert!(
seen.len() > 5,
"expected per-frame updates, got {}",
seen.len()
);
}
seen.lock().unwrap().clear();
state.apply_command(&LightCommand::Off);
while state.step_transition() {}
let seen = seen.lock().unwrap();
assert!(seen.iter().all(|s| s.brightness == 10));
assert!(
seen.windows(2)
.all(|w| w[1].rendered_brightness <= w[0].rendered_brightness),
"rendered_brightness must ramp monotonically down during the off fade"
);
assert_eq!(seen.last().unwrap().rendered_brightness, 0);
}
#[test]
fn reserved_hue_255_is_normalized_and_snapshot_round_trips() {
let (mut state, _) = new_powered_on_state();
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(255),
saturation: Some(255),
transition_time: 0,
});
assert_eq!(state.hue, 254);
assert_eq!(state.saturation, 254);
let snapshot = state.scene_snapshot();
assert_eq!(snapshot.enhanced_hue, Some(65535));
state.apply_command(&LightCommand::SetLevel {
level: 255,
transition_time: 0,
});
assert_eq!(state.brightness, 254);
}
#[test]
fn scene_recall_with_matching_mode_renders_once() {
let (mut state, events) = new_powered_on_state();
events.lock().unwrap().clear();
state.apply_scene_state_with_transition(
&SceneState {
on: Some(true),
brightness: Some(200),
color_temp_mireds: Some(250),
color_mode: Some(ColorMode::ColorTemperature),
..SceneState::default()
},
10,
);
let color_renders = events
.lock()
.unwrap()
.iter()
.filter(|e| matches!(e, Event::ColorTemp(_) | Event::ColorXy(..) | Event::Rgb(..)))
.count();
assert_eq!(color_renders, 1, "matching-mode recall must render once");
}
#[test]
fn hue_transition_takes_shortest_path_through_wrap() {
let (mut state, _) = new_powered_on_state();
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(240),
saturation: Some(200),
transition_time: 0,
});
assert_eq!(state.hue, 240);
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(10),
saturation: None,
transition_time: 10, });
let mut steps = 0;
while state.step_transition() {
assert!(
state.hue >= 240 || state.hue <= 10,
"hue {} left the short 240→254→0→10 arc",
state.hue
);
steps += 1;
assert!(steps < 1000, "transition never completed");
}
assert_eq!(state.hue, 10);
}
#[test]
fn hue_transition_without_wrap_stays_linear() {
let (mut state, _) = new_powered_on_state();
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(20),
saturation: Some(200),
transition_time: 0,
});
state.apply_command(&LightCommand::MoveToHueAndSaturation {
hue: Some(120),
saturation: None,
transition_time: 10,
});
let mut previous = state.hue;
while state.step_transition() {
assert!(
state.hue >= previous && state.hue <= 120,
"hue {} regressed or overshot on the direct 20→120 arc",
state.hue
);
previous = state.hue;
}
assert_eq!(state.hue, 120);
}
#[test]
fn identify_max_duration_does_not_overflow() {
let (mut state, _) = new_powered_on_state();
state.apply_command(&LightCommand::Identify { duration: u16::MAX });
assert!(state.identify_active());
assert!(state.step_identify());
}
#[test]
fn scene_transition_explicit_color_mode_wins_from_first_frame() {
let (mut state, events) = new_powered_on_state();
events.lock().unwrap().clear();
state.apply_scene_state_with_transition(
&SceneState {
color_x: Some(30_000),
color_y: Some(31_000),
color_temp_mireds: Some(250),
color_mode: Some(ColorMode::ColorTemperature),
..SceneState::default()
},
10,
);
assert_eq!(state.color_mode, ColorMode::ColorTemperature);
let last_color_event = events
.lock()
.unwrap()
.iter()
.rev()
.find(|e| matches!(e, Event::ColorTemp(_) | Event::ColorXy(..) | Event::Rgb(..)))
.cloned();
assert!(
matches!(last_color_event, Some(Event::ColorTemp(_))),
"expected a ColorTemp render after mode override, got {last_color_event:?}"
);
while state.step_transition() {}
assert_eq!(state.color_mode, ColorMode::ColorTemperature);
assert_eq!(state.color_temp_mireds, 250);
}