use crate::effect::{
advance_position, validate_buffer, validate_num_leds, validate_speed, Direction, Effect,
EffectError,
};
use crate::hsv::hsv_to_rgb;
use rgb::RGB8;
#[derive(Debug, Clone, PartialEq)]
pub struct RainbowCometEffect {
num_leds: usize,
hue: u8,
saturation: u8,
brightness: u8,
hue_step: u8,
tail_length: u8,
speed: u8,
direction: Direction,
decay: u8,
position: u8,
}
impl RainbowCometEffect {
pub fn new(num_leds: usize) -> Result<Self, EffectError> {
validate_num_leds(num_leds)?;
let max_tail = num_leds.saturating_sub(1).min(u8::MAX as usize) as u8;
Ok(Self {
num_leds,
hue: 0,
saturation: 255,
brightness: 255,
hue_step: 16,
tail_length: 6_u8.min(max_tail),
speed: 1,
direction: Direction::Clockwise,
decay: 192,
position: 0,
})
}
pub fn with_hue(mut self, hue: u8) -> Self {
self.hue = hue;
self
}
pub fn with_saturation(mut self, saturation: u8) -> Self {
self.saturation = saturation;
self
}
pub fn with_brightness(mut self, brightness: u8) -> Self {
self.brightness = brightness;
self
}
pub fn with_hue_step(mut self, hue_step: u8) -> Self {
self.hue_step = hue_step;
self
}
pub fn with_tail_length(mut self, tail_length: u8) -> Self {
let max_tail = self.num_leds.saturating_sub(1).min(u8::MAX as usize) as u8;
self.tail_length = tail_length.min(max_tail);
self
}
pub fn with_speed(mut self, speed: u8) -> Result<Self, EffectError> {
validate_speed(speed)?;
self.speed = speed;
Ok(self)
}
pub fn with_direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
pub fn with_decay(mut self, decay: u8) -> Self {
self.decay = decay;
self
}
pub fn num_leds(&self) -> usize {
self.num_leds
}
pub fn set_hue(&mut self, hue: u8) {
self.hue = hue;
}
pub fn current(&self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
validate_buffer(buffer, self.num_leds)?;
let n = self.num_leds;
let head = self.position as usize % n;
buffer[..n].fill(RGB8::default());
buffer[head] = hsv_to_rgb(self.hue, self.saturation, self.brightness);
let effective_tail = self.tail_length as usize;
let mut brightness_val: u16 = 255;
for i in 1..=effective_tail {
brightness_val = brightness_val * self.decay as u16 / 255;
if brightness_val == 0 {
break;
}
let tail_idx = match self.direction {
Direction::Clockwise => (head + n - i) % n,
Direction::CounterClockwise => (head + i) % n,
};
let tail_hue = self.hue.wrapping_add((i as u8).wrapping_mul(self.hue_step));
let tail_brightness = (brightness_val * self.brightness as u16 / 255) as u8;
buffer[tail_idx] = hsv_to_rgb(tail_hue, self.saturation, tail_brightness);
}
Ok(())
}
pub fn update(&mut self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
self.current(buffer)?;
self.position = advance_position(self.position, self.speed, self.num_leds, self.direction);
Ok(())
}
pub fn reset(&mut self) {
self.position = 0;
}
}
impl Effect for RainbowCometEffect {
fn update(&mut self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
RainbowCometEffect::update(self, buffer)
}
fn current(&self, buffer: &mut [RGB8]) -> Result<(), EffectError> {
RainbowCometEffect::current(self, buffer)
}
fn reset(&mut self) {
RainbowCometEffect::reset(self);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::effect::MAX_LEDS;
#[test]
fn test_new_with_zero_leds_returns_error() {
assert_eq!(
RainbowCometEffect::new(0).unwrap_err(),
EffectError::ZeroLeds
);
}
#[test]
fn test_new_with_valid_leds_succeeds() {
let effect = RainbowCometEffect::new(12).unwrap();
assert_eq!(effect.num_leds, 12);
}
#[test]
fn test_new_with_too_many_leds_returns_error() {
assert!(matches!(
RainbowCometEffect::new(MAX_LEDS + 1).unwrap_err(),
EffectError::TooManyLeds { .. }
));
}
#[test]
fn test_with_speed_zero_returns_error() {
assert_eq!(
RainbowCometEffect::new(12)
.unwrap()
.with_speed(0)
.unwrap_err(),
EffectError::ZeroStep
);
}
#[test]
fn test_buffer_too_small_returns_error() {
let effect = RainbowCometEffect::new(12).unwrap();
let mut buffer = [RGB8::default(); 8];
assert_eq!(
effect.current(&mut buffer).unwrap_err(),
EffectError::BufferTooSmall {
required: 12,
actual: 8
}
);
}
#[test]
fn test_head_at_full_brightness_initial() {
let effect = RainbowCometEffect::new(12).unwrap();
let mut buffer = [RGB8::default(); 12];
effect.current(&mut buffer).unwrap();
let expected = hsv_to_rgb(0, 255, 255);
assert_eq!(
buffer[0], expected,
"head LED must equal hsv_to_rgb(0,255,255)"
);
}
#[test]
fn test_tail_hue_increases_with_distance() {
let mut effect = RainbowCometEffect::new(12)
.unwrap()
.with_hue(0)
.with_saturation(255)
.with_brightness(255)
.with_hue_step(16)
.with_tail_length(4)
.with_decay(255);
let mut buf = [RGB8::default(); 12];
for _ in 0..6 {
effect.update(&mut buf).unwrap();
}
effect.current(&mut buf).unwrap();
let head = buf[6];
let tail1 = buf[5];
let tail2 = buf[4];
let tail3 = buf[3];
let tail4 = buf[2];
assert_ne!(head, tail1, "head and tail[1] must differ in hue");
assert_ne!(tail1, tail2, "tail[1] and tail[2] must differ in hue");
assert_ne!(tail2, tail3, "tail[2] and tail[3] must differ in hue");
assert_ne!(tail3, tail4, "tail[3] and tail[4] must differ in hue");
}
#[test]
fn test_tail_brightness_decreases_with_distance() {
let effect = RainbowCometEffect::new(12)
.unwrap()
.with_hue(0)
.with_saturation(0)
.with_brightness(255)
.with_tail_length(4)
.with_decay(192);
let mut buffer = [RGB8::default(); 12];
effect.current(&mut buffer).unwrap();
let head_v = buffer[0].r;
let tail1_v = buffer[11].r;
let tail2_v = buffer[10].r;
let tail3_v = buffer[9].r;
let tail4_v = buffer[8].r;
assert_eq!(head_v, 255, "head must be at peak brightness");
assert!(tail1_v > tail2_v, "tail[1] must be brighter than tail[2]");
assert!(tail2_v > tail3_v, "tail[2] must be brighter than tail[3]");
assert!(tail3_v > tail4_v, "tail[3] must be brighter than tail[4]");
assert!(tail4_v > 0, "tail[4] must be non-zero with decay=192");
}
#[test]
fn test_clockwise_advances_position() {
let mut effect = RainbowCometEffect::new(8)
.unwrap()
.with_hue(0)
.with_tail_length(0)
.with_speed(1)
.unwrap();
let mut buffer = [RGB8::default(); 8];
effect.update(&mut buffer).unwrap();
effect.current(&mut buffer).unwrap();
let expected = hsv_to_rgb(0, 255, 255);
assert_eq!(
buffer[1], expected,
"head should be at index 1 after one update"
);
assert_eq!(buffer[0], RGB8::default(), "index 0 should be off");
}
#[test]
fn test_counter_clockwise_direction() {
let mut effect = RainbowCometEffect::new(8)
.unwrap()
.with_hue(0)
.with_tail_length(0)
.with_direction(Direction::CounterClockwise)
.with_speed(1)
.unwrap();
let mut buffer = [RGB8::default(); 8];
effect.update(&mut buffer).unwrap();
effect.current(&mut buffer).unwrap();
let expected = hsv_to_rgb(0, 255, 255);
assert_eq!(
buffer[7], expected,
"head should be at index 7 after CCW wrap"
);
assert_eq!(buffer[0], RGB8::default(), "index 0 should be off");
}
#[test]
fn test_wrapping_around_ring() {
let mut effect = RainbowCometEffect::new(8)
.unwrap()
.with_hue(0)
.with_tail_length(0)
.with_speed(1)
.unwrap();
let mut buffer = [RGB8::default(); 8];
for _ in 0..8 {
effect.update(&mut buffer).unwrap();
}
effect.current(&mut buffer).unwrap();
let expected = hsv_to_rgb(0, 255, 255);
assert_eq!(buffer[0], expected, "head should wrap back to index 0");
}
#[test]
fn test_large_speed_no_panic() {
let mut effect = RainbowCometEffect::new(4).unwrap().with_speed(200).unwrap();
let mut buffer = [RGB8::default(); 4];
for _ in 0..20 {
effect.update(&mut buffer).unwrap();
}
}
#[test]
fn test_current_does_not_advance() {
let effect = RainbowCometEffect::new(8).unwrap();
let mut buf1 = [RGB8::default(); 8];
let mut buf2 = [RGB8::default(); 8];
effect.current(&mut buf1).unwrap();
effect.current(&mut buf2).unwrap();
assert_eq!(buf1, buf2, "current() must not advance position");
}
#[test]
fn test_reset_restores_initial_state() {
let mut effect = RainbowCometEffect::new(8).unwrap().with_speed(3).unwrap();
let mut initial = [RGB8::default(); 8];
effect.current(&mut initial).unwrap();
let mut temp = [RGB8::default(); 8];
for _ in 0..20 {
effect.update(&mut temp).unwrap();
}
effect.reset();
let mut after_reset = [RGB8::default(); 8];
effect.current(&mut after_reset).unwrap();
assert_eq!(
initial, after_reset,
"state after reset must match initial state"
);
}
#[test]
fn test_set_hue_does_not_reset_position() {
let mut effect = RainbowCometEffect::new(8)
.unwrap()
.with_hue(0)
.with_tail_length(0)
.with_speed(1)
.unwrap();
let mut buffer = [RGB8::default(); 8];
for _ in 0..5 {
effect.update(&mut buffer).unwrap();
}
effect.current(&mut buffer).unwrap();
let pos_before = buffer
.iter()
.position(|led| *led != RGB8::default())
.unwrap();
effect.set_hue(128);
effect.current(&mut buffer).unwrap();
let pos_after = buffer
.iter()
.position(|led| *led != RGB8::default())
.unwrap();
assert_eq!(
pos_before, pos_after,
"position must not change after set_hue"
);
}
#[test]
fn test_oversized_buffer_accepted() {
let effect = RainbowCometEffect::new(4).unwrap();
let mut buffer = [RGB8::new(100, 100, 100); 12];
effect.current(&mut buffer).unwrap();
for i in 4..12 {
assert_eq!(
buffer[i],
RGB8::new(100, 100, 100),
"LED {} beyond num_leds must not be modified",
i
);
}
}
#[test]
fn test_tail_zero_decay_only_head_lit() {
let effect = RainbowCometEffect::new(12)
.unwrap()
.with_tail_length(6)
.with_decay(0);
let mut buffer = [RGB8::default(); 12];
effect.current(&mut buffer).unwrap();
let expected_head = hsv_to_rgb(0, 255, 255);
assert_eq!(buffer[0], expected_head, "head must be lit");
for i in 1..12 {
assert_eq!(
buffer[i],
RGB8::new(0, 0, 0),
"LED {} should be black with decay=0",
i
);
}
}
#[test]
fn test_trait_object_update() {
let mut effect = RainbowCometEffect::new(8).unwrap().with_speed(2).unwrap();
let effect_ref: &mut dyn Effect = &mut effect;
let mut buf1 = [RGB8::default(); 8];
let mut buf2 = [RGB8::default(); 8];
effect_ref.update(&mut buf1).unwrap();
effect_ref.update(&mut buf2).unwrap();
assert_ne!(buf1, buf2, "comet should advance between updates");
}
#[test]
fn test_trait_reset_path() {
let mut effect = RainbowCometEffect::new(8).unwrap();
let mut initial = [RGB8::default(); 8];
effect.current(&mut initial).unwrap();
let mut temp = [RGB8::default(); 8];
for _ in 0..5 {
effect.update(&mut temp).unwrap();
}
let effect_ref: &mut dyn Effect = &mut effect;
effect_ref.reset();
let mut after_reset = [RGB8::default(); 8];
effect_ref.update(&mut after_reset).unwrap();
assert_eq!(
initial, after_reset,
"first update after reset must replay the initial frame"
);
}
#[test]
fn test_new_with_max_leds_succeeds() {
assert!(RainbowCometEffect::new(MAX_LEDS).is_ok());
}
#[test]
fn test_counter_clockwise_tail_trails_at_higher_indices() {
let mut effect = RainbowCometEffect::new(8)
.unwrap()
.with_saturation(0) .with_tail_length(3)
.with_decay(255)
.with_direction(Direction::CounterClockwise)
.with_speed(1)
.unwrap();
let mut buffer = [RGB8::default(); 8];
for _ in 0..4 {
effect.update(&mut buffer).unwrap();
}
effect.current(&mut buffer).unwrap();
assert!(buffer[4].r > 0, "head at 4 should be lit");
assert!(buffer[5].r > 0, "tail at 5 should be lit (behind CCW head)");
assert!(buffer[6].r > 0, "tail at 6 should be lit");
assert!(buffer[7].r > 0, "tail at 7 should be lit");
assert_eq!(
buffer[3],
RGB8::default(),
"index 3 should be off (ahead of head in CCW)"
);
}
#[test]
fn test_with_brightness_dims_head() {
let full_brightness = {
let e = RainbowCometEffect::new(8).unwrap().with_saturation(0);
let mut buf = [RGB8::default(); 8];
e.current(&mut buf).unwrap();
buf[0].r
};
let half_brightness = {
let e = RainbowCometEffect::new(8)
.unwrap()
.with_saturation(0)
.with_brightness(128);
let mut buf = [RGB8::default(); 8];
e.current(&mut buf).unwrap();
buf[0].r
};
assert_eq!(full_brightness, 255, "default brightness must be full");
assert!(
half_brightness < full_brightness,
"with_brightness(128) must be dimmer than default"
);
assert!(half_brightness > 0, "with_brightness(128) must not be zero");
}
#[test]
fn test_tail_length_clamped_by_builder() {
let mut effect = RainbowCometEffect::new(4)
.unwrap()
.with_saturation(0)
.with_tail_length(255)
.with_decay(255)
.with_speed(1)
.unwrap();
let mut buffer = [RGB8::default(); 4];
for _ in 0..3 {
effect.update(&mut buffer).unwrap();
}
effect.current(&mut buffer).unwrap();
assert!(buffer[3].r > 0, "head at 3");
assert!(buffer[2].r > 0, "tail at 2");
assert!(buffer[1].r > 0, "tail at 1");
assert!(buffer[0].r > 0, "tail at 0");
}
}