feat: video/state_machine unit tests and on_video_completed logic fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-12 08:20:25 +08:00
parent 8ed9c93c8e
commit 45c0a8d54b
5 changed files with 551 additions and 17 deletions

View File

@@ -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;

View File

@@ -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(())

View File

@@ -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(())

View File

@@ -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::<Vec3b>(0, 0)
.expect("top pixel should exist") = Vec3b::from([10, 0, 0]);
*frame
.at_2d_mut::<Vec3b>(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::<Vec3b>(0, 0)
.expect("top-left pixel should exist"),
Vec3b::from([0, 0, 0])
);
assert_eq!(
*output
.at_2d::<Vec3b>(2, 0)
.expect("bottom-left pixel should exist"),
Vec3b::from([10, 0, 0])
);
assert_eq!(
*output
.at_2d::<Vec3b>(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), &current, 0.25)
.expect("fade should succeed");
assert_eq!(output.size().expect("size should exist"), Size::new(2, 2));
assert_eq!(
*output.at_2d::<Vec3b>(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), &current, 0.5)
.expect("cut should succeed");
assert_eq!(
*output.at_2d::<Vec3b>(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::<Vec3b>(540, 100)
.expect("left padding pixel should exist"),
Vec3b::from([0, 0, 0])
);
assert_eq!(
*output
.at_2d::<Vec3b>(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::<Vec3b>(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::<Vec3b>(540, 450)
.expect("shifted padding pixel should exist"),
Vec3b::from([0, 0, 0])
);
assert_eq!(
*output
.at_2d::<Vec3b>(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::<Vec3b>(540, 0)
.expect("left edge pixel should exist"),
Vec3b::from([0, 0, 0])
);
assert_eq!(
*output
.at_2d::<Vec3b>(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(),
}
}
}

View File

@@ -43,14 +43,11 @@ impl StateMachine {
pub fn on_video_completed(&mut self) -> Result<bool> {
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<bool> {
@@ -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<i32> {
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<const N: usize>(states: [StateConfig; N]) -> StateMachineConfig {
let states = Vec::from(states)
.into_iter()
.map(|state| {
let name = state.name.clone();
(name, state)
})
.collect::<HashMap<_, _>>();
StateMachineConfig {
initial_state: "idle".to_string(),
states,
}
}
fn state(
name: &str,
sequence: Vec<AnimationStep>,
transitions: Vec<StateTransition>,
) -> 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,
}
}
}