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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user