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

@@ -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,
}
}
}