From 45c0a8d54b8e5386f61a01526d213f63e22ddff2 Mon Sep 17 00:00:00 2001 From: showen Date: Thu, 12 Mar 2026 08:20:25 +0800 Subject: [PATCH] feat: video/state_machine unit tests and on_video_completed logic fix Co-Authored-By: Claude Opus 4.6 --- src/plugins/mod.rs | 4 +- src/plugins/screen/mod.rs | 16 +- src/plugins/video/mod.rs | 4 + src/plugins/video/processor.rs | 295 +++++++++++++++++++++++++++++ src/plugins/video/state_machine.rs | 249 ++++++++++++++++++++++-- 5 files changed, 551 insertions(+), 17 deletions(-) diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 9332cb1..e232cad 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -1,5 +1,5 @@ -pub mod video; -pub mod http; pub mod ble; +pub mod http; pub mod screen; +pub mod video; pub mod wifi; diff --git a/src/plugins/screen/mod.rs b/src/plugins/screen/mod.rs index 2b9d24a..22d25f0 100644 --- a/src/plugins/screen/mod.rs +++ b/src/plugins/screen/mod.rs @@ -28,7 +28,8 @@ impl ScreenPlugin { } match Command::new("systemd-inhibit") - .arg("--what=idle") + .arg("--what=idle:sleep") + .arg("--mode=block") .arg("--who=ShowenV2") .arg("--why=Prevent screen lock during playback") .arg("sleep") @@ -66,6 +67,13 @@ impl ScreenPlugin { } if hidden { + let _ = Command::new("pkill") + .args(["-f", "unclutter"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match Command::new("unclutter") .arg("-idle") .arg("0") @@ -80,7 +88,7 @@ impl ScreenPlugin { } } else { match Command::new("pkill") - .arg("unclutter") + .args(["-f", "unclutter"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -118,6 +126,10 @@ impl Plugin for ScreenPlugin { } } + fn dependencies(&self) -> Vec<&'static str> { + vec![] + } + fn init(&mut self, ctx: PluginContext) -> Result<()> { self.ctx = Some(ctx); Ok(()) diff --git a/src/plugins/video/mod.rs b/src/plugins/video/mod.rs index 51ca570..d03b386 100644 --- a/src/plugins/video/mod.rs +++ b/src/plugins/video/mod.rs @@ -102,6 +102,10 @@ impl Plugin for VideoPlugin { } } + fn dependencies(&self) -> Vec<&'static str> { + vec![] + } + fn init(&mut self, ctx: PluginContext) -> Result<()> { self.ctx = Some(ctx); Ok(()) diff --git a/src/plugins/video/processor.rs b/src/plugins/video/processor.rs index f6db5e0..922c812 100644 --- a/src/plugins/video/processor.rs +++ b/src/plugins/video/processor.rs @@ -1350,3 +1350,298 @@ impl Drop for VideoProcessor { Self::show_cursor(); } } + +#[cfg(test)] +mod tests { + use super::{resolve_video_loop_count, TransitionEffect, VideoProcessor, VideoTransformer}; + use crate::core::config::{ + AppConfig, BleConfig, DisplayConfig, PerspectiveCorrectionConfig, PlaybackConfig, + RemoteControlConfig, ScaleMode, ScenesConfig, TransitionConfig, TransitionType, VideoItem, + }; + use opencv::{ + core::{Scalar, Size, Vec3b, CV_8UC3}, + prelude::*, + }; + use std::path::PathBuf; + + #[test] + fn transformer_applies_rotation_flip_and_fit_padding() { + let mut display = sample_display(); + display.render_width = 4; + display.render_height = 4; + display.rotation = 90; + display.flip_horizontal = true; + + let transformer = VideoTransformer::new(&display); + let mut frame = Mat::new_rows_cols_with_default(2, 1, CV_8UC3, Scalar::all(0.0)) + .expect("frame should build"); + *frame + .at_2d_mut::(0, 0) + .expect("top pixel should exist") = Vec3b::from([10, 0, 0]); + *frame + .at_2d_mut::(1, 0) + .expect("bottom pixel should exist") = Vec3b::from([20, 0, 0]); + + let output = transformer + .transform(&frame) + .expect("transform should succeed"); + + assert_eq!(output.size().expect("size should exist"), Size::new(4, 4)); + assert_eq!( + *output + .at_2d::(0, 0) + .expect("top-left pixel should exist"), + Vec3b::from([0, 0, 0]) + ); + assert_eq!( + *output + .at_2d::(2, 0) + .expect("bottom-left pixel should exist"), + Vec3b::from([10, 0, 0]) + ); + assert_eq!( + *output + .at_2d::(2, 3) + .expect("bottom-right pixel should exist"), + Vec3b::from([20, 0, 0]) + ); + } + + #[test] + fn transition_effect_fades_between_frames_and_resizes_previous_frame() { + let effect = TransitionEffect::new(0.5, TransitionType::Fade); + let previous = Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(0.0)) + .expect("previous frame should build"); + let current = Mat::new_rows_cols_with_default(2, 2, CV_8UC3, Scalar::all(200.0)) + .expect("current frame should build"); + + let output = effect + .apply(Some(&previous), ¤t, 0.25) + .expect("fade should succeed"); + + assert_eq!(output.size().expect("size should exist"), Size::new(2, 2)); + assert_eq!( + *output.at_2d::(0, 0).expect("pixel should exist"), + Vec3b::from([50, 50, 50]) + ); + } + + #[test] + fn transition_effect_cut_returns_current_frame() { + let effect = TransitionEffect::new(0.5, TransitionType::Cut); + let previous = Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(0.0)) + .expect("previous frame should build"); + let current = Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(123.0)) + .expect("current frame should build"); + + let output = effect + .apply(Some(&previous), ¤t, 0.5) + .expect("cut should succeed"); + + assert_eq!( + *output.at_2d::(0, 0).expect("pixel should exist"), + Vec3b::from([123, 123, 123]) + ); + } + + #[test] + fn configured_output_size_requires_both_dimensions() { + let mut processor = sample_processor(); + processor.config.display.output_width = Some(1920); + processor.config.display.output_height = Some(1080); + assert_eq!( + processor.configured_output_size(), + Some(Size::new(1920, 1080)) + ); + + processor.config.display.output_height = None; + assert_eq!(processor.configured_output_size(), None); + } + + #[test] + fn prepare_output_frame_fit_preserves_aspect_ratio_with_padding() { + let mut processor = sample_processor(); + processor.output_size = Size::new(1920, 1080); + processor.config.display.scale_mode = ScaleMode::Fit; + + let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0)) + .expect("frame should build"); + let output = processor + .prepare_output_frame(frame) + .expect("fit output should build"); + + assert_eq!( + output.size().expect("output size should exist"), + Size::new(1920, 1080) + ); + assert_eq!( + *output + .at_2d::(540, 100) + .expect("left padding pixel should exist"), + Vec3b::from([0, 0, 0]) + ); + assert_eq!( + *output + .at_2d::(540, 960) + .expect("center pixel should exist"), + Vec3b::from([255, 255, 255]) + ); + } + + #[test] + fn prepare_output_frame_stretch_fills_target_without_padding() { + let mut processor = sample_processor(); + processor.output_size = Size::new(1920, 1080); + processor.config.display.scale_mode = ScaleMode::Stretch; + + let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0)) + .expect("frame should build"); + let output = processor + .prepare_output_frame(frame) + .expect("stretch output should build"); + + assert_eq!( + output.size().expect("output size should exist"), + Size::new(1920, 1080) + ); + assert_eq!( + *output + .at_2d::(540, 100) + .expect("edge pixel should exist"), + Vec3b::from([255, 255, 255]) + ); + } + + #[test] + fn prepare_output_frame_fit_applies_horizontal_offset_within_bounds() { + let mut processor = sample_processor(); + processor.output_size = Size::new(1920, 1080); + processor.config.display.scale_mode = ScaleMode::Fit; + processor.config.display.offset_x = 50; + + let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0)) + .expect("frame should build"); + let output = processor + .prepare_output_frame(frame) + .expect("fit output should build"); + + assert_eq!( + *output + .at_2d::(540, 450) + .expect("shifted padding pixel should exist"), + Vec3b::from([0, 0, 0]) + ); + assert_eq!( + *output + .at_2d::(540, 530) + .expect("shifted content pixel should exist"), + Vec3b::from([255, 255, 255]) + ); + } + + #[test] + fn prepare_output_frame_fit_clamps_offset_to_available_padding() { + let mut processor = sample_processor(); + processor.output_size = Size::new(1920, 1080); + processor.config.display.scale_mode = ScaleMode::Fit; + processor.config.display.offset_x = 1000; + + let frame = Mat::new_rows_cols_with_default(1024, 1024, CV_8UC3, Scalar::all(255.0)) + .expect("frame should build"); + let output = processor + .prepare_output_frame(frame) + .expect("fit output should build"); + + assert_eq!( + *output + .at_2d::(540, 0) + .expect("left edge pixel should exist"), + Vec3b::from([0, 0, 0]) + ); + assert_eq!( + *output + .at_2d::(540, 1919) + .expect("right edge pixel should exist"), + Vec3b::from([255, 255, 255]) + ); + } + + #[test] + fn resolve_video_loop_count_prefers_random_range() { + let item = VideoItem { + id: "clip".to_string(), + path: "clip.mp4".to_string(), + duration: None, + loop_count: 99, + random_loop_range: Some([2, 2]), + }; + + assert_eq!(resolve_video_loop_count(&item), 2); + } + + fn sample_processor() -> VideoProcessor { + VideoProcessor::new(sample_config()).expect("sample processor should build") + } + + fn sample_config() -> AppConfig { + AppConfig { + display: sample_display(), + playlist: vec![VideoItem { + id: "sample".to_string(), + path: "sample.mp4".to_string(), + duration: None, + loop_count: 1, + random_loop_range: None, + }], + transition: TransitionConfig { + enabled: false, + transition_type: TransitionType::None, + duration: 0.0, + }, + playback: PlaybackConfig { + loop_playlist: false, + auto_start: false, + }, + scenes: ScenesConfig { + rest: vec![], + active: vec![], + sleep: vec![], + interact: vec![], + state_machine: None, + }, + remote_control: RemoteControlConfig { + enabled: false, + host: "127.0.0.1".to_string(), + port: 8080, + }, + ble: BleConfig::default(), + source_path: PathBuf::from("/tmp/config.json"), + source_dir: PathBuf::from("/tmp"), + } + } + + fn sample_display() -> DisplayConfig { + DisplayConfig { + fullscreen: false, + window_title: "test".to_string(), + rotation: 0, + flip_horizontal: false, + flip_vertical: false, + offset_x: 0, + offset_y: 0, + prevent_screen_lock: false, + render_width: 1024, + render_height: 1024, + output_width: None, + output_height: None, + scale_mode: ScaleMode::Fit, + allow_upscale: true, + perspective_correction: PerspectiveCorrectionConfig { + enabled: false, + points: vec![], + }, + chroma_key: Default::default(), + brightness_adjust: Default::default(), + } + } +} diff --git a/src/plugins/video/state_machine.rs b/src/plugins/video/state_machine.rs index 0613636..e822d57 100644 --- a/src/plugins/video/state_machine.rs +++ b/src/plugins/video/state_machine.rs @@ -43,14 +43,11 @@ impl StateMachine { pub fn on_video_completed(&mut self) -> Result { self.ensure_current_state_valid()?; + let old_state = self.current_state.clone(); + if self.current_loop_remaining > 1 { self.current_loop_remaining -= 1; - return Ok(true); - } - - if let Some(target_state) = self.pending_trigger_target.take() { - self.transition_to_state(&target_state)?; - return Ok(true); + return Ok(false); } let sequence_len = self @@ -61,12 +58,17 @@ impl StateMachine { if self.current_sequence_index + 1 < sequence_len { self.current_sequence_index += 1; self.current_loop_remaining = self.resolve_current_loop_count()?; - return Ok(true); + return Ok(false); + } + + if let Some(target_state) = self.pending_trigger_target.take() { + self.transition_to_state(&target_state)?; + return Ok(self.current_state != old_state); } let next_state = self.select_next_state()?; self.transition_to_state(&next_state)?; - Ok(true) + Ok(self.current_state != old_state) } pub fn handle_trigger(&mut self, name: &str, value: &str) -> Result { @@ -78,6 +80,10 @@ impl StateMachine { return Ok(false); } + if self.pending_trigger_target.is_some() { + return Ok(false); + } + let target_state = self .matching_transition(&state.transitions, name, value) .map(|transition| transition.target_state.clone()); @@ -88,7 +94,10 @@ impl StateMachine { if state.defer_triggers { self.pending_trigger_target = Some(target_state); - return Ok(true); + if self.current_loop_remaining > 1 { + self.current_loop_remaining = 1; + } + return Ok(false); } self.transition_to_state(&target_state)?; @@ -178,6 +187,10 @@ impl StateMachine { } fn resolve_current_loop_count(&self) -> Result { + if self.pending_trigger_target.is_some() { + return Ok(1); + } + let state = self .current_state_config() .ok_or_else(|| anyhow::anyhow!("当前状态不存在: {}", self.current_state))?; @@ -238,14 +251,14 @@ impl StateMachine { } let mut cursor = rand::thread_rng().gen_range(0.0..total_weight); - for entry in valid_entries { + for entry in &valid_entries { cursor -= entry.weight; if cursor <= 0.0 { return Some(entry.state.clone()); } } - entries.last().map(|entry| entry.state.clone()) + valid_entries.last().map(|entry| entry.state.clone()) } fn matching_transition<'a>( @@ -262,10 +275,220 @@ impl StateMachine { fn trigger_matches(trigger: &TriggerType, name: &str, value: &str) -> bool { match trigger { - TriggerType::Button { name: expected } => expected == name, + TriggerType::Button { name: expected } => { + (name == "button" && value == expected) || expected == name + } TriggerType::Voice { keyword } => keyword == value || keyword == name, - TriggerType::Sensor { name: expected } => expected == name, + TriggerType::Sensor { name: expected } => { + (name == "sensor" && value == expected) || expected == name + } TriggerType::Timer { .. } | TriggerType::Random { .. } => false, } } } + +#[cfg(test)] +mod tests { + use super::StateMachine; + use crate::core::config::{ + AnimationStep, NextStateEntry, StateConfig, StateMachineConfig, StateMode, StateTransition, + TriggerType, + }; + use std::collections::HashMap; + + #[test] + fn button_and_sensor_triggers_match_legacy_message_shape() { + let mut machine = StateMachine::new(config_with_states([ + state( + "idle", + vec![step("idle")], + vec![ + transition( + TriggerType::Button { + name: "button1".to_string(), + }, + "button_state", + 10, + ), + transition( + TriggerType::Sensor { + name: "touch".to_string(), + }, + "sensor_state", + 10, + ), + ], + ), + state("button_state", vec![step("button")], vec![]), + state("sensor_state", vec![step("sensor")], vec![]), + ])); + machine.start().expect("state machine should start"); + + assert!(machine + .handle_trigger("button", "button1") + .expect("button trigger should work")); + assert_eq!(machine.current_state, "button_state"); + + machine.transition_to_state("idle").expect("reset to idle"); + assert!(machine + .handle_trigger("sensor", "touch") + .expect("sensor trigger should work")); + assert_eq!(machine.current_state, "sensor_state"); + } + + #[test] + fn deferred_trigger_waits_for_sequence_end_and_forces_single_remaining_loops() { + let mut machine = StateMachine::new(config_with_states([ + StateConfig { + name: "idle".to_string(), + mode: StateMode::FreeMode, + sequence: vec![ + AnimationStep { + video_id: "looping".to_string(), + loop_count: Some(3), + random_loop_range: None, + }, + AnimationStep { + video_id: "tail".to_string(), + loop_count: Some(5), + random_loop_range: None, + }, + ], + next_state: None, + next_states: None, + transitions: vec![transition( + TriggerType::Voice { + keyword: "name".to_string(), + }, + "called", + 10, + )], + weight: 1.0, + defer_triggers: true, + ignore_triggers: false, + }, + state("called", vec![step("called")], vec![]), + ])); + machine.start().expect("state machine should start"); + + assert_eq!(machine.current_loop_remaining, 3); + assert!(!machine + .handle_trigger("voice", "name") + .expect("deferred trigger should queue")); + assert!(machine.has_pending_trigger()); + assert_eq!(machine.current_state, "idle"); + assert_eq!(machine.current_loop_remaining, 1); + + assert!(!machine + .on_video_completed() + .expect("should advance within same state")); + assert_eq!(machine.current_sequence_index, 1); + assert_eq!(machine.current_loop_remaining, 1); + assert_eq!(machine.current_video_id().as_deref(), Some("tail")); + + assert!(machine + .on_video_completed() + .expect("pending trigger should fire at sequence end")); + assert_eq!(machine.current_state, "called"); + assert_eq!(machine.current_video_id().as_deref(), Some("called")); + assert!(!machine.has_pending_trigger()); + } + + #[test] + fn on_video_completed_returns_false_when_staying_in_same_state() { + let mut machine = StateMachine::new(config_with_states([StateConfig { + name: "idle".to_string(), + mode: StateMode::FreeMode, + sequence: vec![step("a"), step("b")], + next_state: Some("idle".to_string()), + next_states: None, + transitions: vec![], + weight: 1.0, + defer_triggers: false, + ignore_triggers: false, + }])); + machine.start().expect("state machine should start"); + + assert!(!machine + .on_video_completed() + .expect("step transition should stay in same state")); + assert_eq!(machine.current_video_id().as_deref(), Some("b")); + } + + #[test] + fn next_states_take_priority_over_next_state() { + let mut machine = StateMachine::new(config_with_states([ + StateConfig { + name: "idle".to_string(), + mode: StateMode::FreeMode, + sequence: vec![step("idle")], + next_state: Some("fallback".to_string()), + next_states: Some(vec![NextStateEntry { + state: "weighted".to_string(), + weight: 1.0, + }]), + transitions: vec![], + weight: 1.0, + defer_triggers: false, + ignore_triggers: false, + }, + state("weighted", vec![step("weighted")], vec![]), + state("fallback", vec![step("fallback")], vec![]), + ])); + machine.start().expect("state machine should start"); + + assert!(machine + .on_video_completed() + .expect("state should change via next_states")); + assert_eq!(machine.current_state, "weighted"); + } + + fn config_with_states(states: [StateConfig; N]) -> StateMachineConfig { + let states = Vec::from(states) + .into_iter() + .map(|state| { + let name = state.name.clone(); + (name, state) + }) + .collect::>(); + + StateMachineConfig { + initial_state: "idle".to_string(), + states, + } + } + + fn state( + name: &str, + sequence: Vec, + transitions: Vec, + ) -> StateConfig { + StateConfig { + name: name.to_string(), + mode: StateMode::FreeMode, + sequence, + next_state: None, + next_states: None, + transitions, + weight: 1.0, + defer_triggers: false, + ignore_triggers: false, + } + } + + fn step(video_id: &str) -> AnimationStep { + AnimationStep { + video_id: video_id.to_string(), + loop_count: Some(1), + random_loop_range: None, + } + } + + fn transition(trigger: TriggerType, target_state: &str, priority: i32) -> StateTransition { + StateTransition { + trigger, + target_state: target_state.to_string(), + priority, + } + } +}