feat: core tests, bug fixes, API docs rewrite, HTTP compat routes

- Fix state_machine reset_state_progress: reset sequence index before
  validation to prevent out-of-bounds error on state transitions
- Fix video transformer test: use ±1 tolerance for OpenCV interpolation
- Add core integration tests (service_manager, dependencies, messages)
- Add HTTP compat routes (/index.html, POST /api/wifi/scan, hotspot aliases)
- Rewrite clients/docs/API.md to match actual implementation
- Fix BLE unused imports warning
- CEO task planning for next round (ConfigReload, playlist snapshot)

cargo check: 0 warnings, cargo test: 22/22 passed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-12 12:40:17 +08:00
parent 60488311d3
commit 5af7fc18a5
10 changed files with 924 additions and 306 deletions

View File

@@ -2,3 +2,6 @@ pub mod config;
pub mod message;
pub mod plugin;
pub mod service_manager;
#[cfg(test)]
mod tests;

321
src/core/tests.rs Normal file
View File

@@ -0,0 +1,321 @@
use super::config::{parse_str, AppConfig};
use super::message::{Destination, Envelope, Message};
use super::plugin::{Platform, Plugin, PluginContext, PluginInfo};
use super::service_manager::ServiceManager;
use anyhow::Result;
use std::sync::{Arc, Mutex};
fn test_config() -> AppConfig {
parse_str(
r#"{
"display": {
"fullscreen": false,
"window_title": "test",
"rotation": 0,
"flip_horizontal": false,
"flip_vertical": false,
"perspective_correction": {
"enabled": false,
"points": []
}
},
"playlist": [
{
"id": "video-1",
"path": "video.mp4"
}
],
"transition": {
"enabled": false,
"type": "none",
"duration": 0.0
},
"playback": {
"loop_playlist": true,
"auto_start": false
},
"scenes": {},
"remote_control": {
"enabled": false,
"host": "127.0.0.1",
"port": 8080
}
}"#,
"tests/core-config.json",
)
.expect("test config should be valid")
}
fn lock_events(events: &Arc<Mutex<Vec<String>>>) -> std::sync::MutexGuard<'_, Vec<String>> {
events.lock().expect("events mutex poisoned")
}
fn has_event(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> bool {
lock_events(events).iter().any(|event| event == expected)
}
fn message_label(message: &Message) -> String {
match message {
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
Message::PluginReady(id) => format!("plugin_ready:{id}"),
Message::WifiResult(payload) => format!("wifi_result:{payload}"),
Message::Shutdown => "shutdown".to_string(),
Message::PlayerStatus(_) => "player_status".to_string(),
Message::StateChanged {
old_state,
new_state,
} => format!("state_changed:{old_state}->{new_state}"),
Message::WifiProvisioned { ssid, ip } => format!("wifi_provisioned:{ssid}:{ip}"),
Message::ConfigReloadRequest => "config_reload_request".to_string(),
Message::ConfigReloaded(_) => "config_reloaded".to_string(),
Message::PlayerCommand(_) => "player_command".to_string(),
Message::Trigger { name, value } => format!("trigger:{name}:{value}"),
Message::ScreenLockRequest(value) => format!("screen_lock:{value}"),
Message::CursorVisibility(value) => format!("cursor_visibility:{value}"),
Message::WifiCommand(_) => "wifi_command".to_string(),
}
}
struct TestPlugin {
id: &'static str,
deps: Vec<&'static str>,
events: Arc<Mutex<Vec<String>>>,
}
impl TestPlugin {
fn new(id: &'static str, deps: Vec<&'static str>, events: Arc<Mutex<Vec<String>>>) -> Self {
Self { id, deps, events }
}
fn record(&self, entry: impl Into<String>) {
lock_events(&self.events).push(entry.into());
}
}
impl Plugin for TestPlugin {
fn id(&self) -> &'static str {
self.id
}
fn info(&self) -> PluginInfo {
PluginInfo {
name: self.id,
version: "test",
description: "test plugin",
platform: Platform::Any,
}
}
fn dependencies(&self) -> Vec<&'static str> {
self.deps.clone()
}
fn init(&mut self, _ctx: PluginContext) -> Result<()> {
self.record(format!("init:{}", self.id));
Ok(())
}
fn start(&mut self) -> Result<()> {
self.record(format!("start:{}", self.id));
Ok(())
}
fn handle_message(&mut self, msg: Message) -> Result<()> {
self.record(format!("msg:{}:{}", self.id, message_label(&msg)));
Ok(())
}
fn stop(&mut self) -> Result<()> {
self.record(format!("stop:{}", self.id));
Ok(())
}
}
#[test]
fn service_manager_register_start_and_stop_flow() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
manager.register(Box::new(TestPlugin::new("beta", vec![], events.clone())));
manager.start_all().expect("start_all should succeed");
manager.stop_all().expect("stop_all should succeed");
assert_eq!(
lock_events(&events).clone(),
vec![
"init:alpha",
"init:beta",
"start:alpha",
"start:beta",
"stop:beta",
"stop:alpha",
]
);
}
#[test]
fn routes_plugin_broadcast_and_manager_messages() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
manager.register(Box::new(TestPlugin::new("beta", vec![], events.clone())));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
sender
.send(Envelope {
from: "alpha",
to: Destination::Plugin("beta"),
message: Message::Custom {
kind: "direct".to_string(),
payload: "hello".to_string(),
},
})
.expect("direct message should send");
sender
.send(Envelope {
from: "alpha",
to: Destination::Broadcast,
message: Message::Custom {
kind: "broadcast".to_string(),
payload: "everyone".to_string(),
},
})
.expect("broadcast message should send");
sender
.send(Envelope {
from: "alpha",
to: Destination::Manager,
message: Message::PluginReady("alpha"),
})
.expect("manager message should send");
sender
.send(Envelope {
from: "test",
to: Destination::Manager,
message: Message::Shutdown,
})
.expect("shutdown should send");
manager.run().expect("run should succeed");
assert!(!has_event(&events, "msg:alpha:custom:direct:hello"));
assert!(has_event(&events, "msg:beta:custom:direct:hello"));
assert!(has_event(&events, "msg:alpha:custom:broadcast:everyone"));
assert!(has_event(&events, "msg:beta:custom:broadcast:everyone"));
assert!(has_event(&events, "msg:alpha:plugin_ready:alpha"));
assert!(has_event(&events, "msg:beta:plugin_ready:alpha"));
}
#[test]
fn start_all_rejects_missing_dependencies() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
manager.register(Box::new(TestPlugin::new(
"dependent",
vec!["missing"],
events,
)));
let error = manager
.start_all()
.expect_err("missing dependency should fail");
assert!(error
.to_string()
.contains("plugin 'dependent' depends on missing plugin 'missing'"));
}
#[test]
fn start_all_rejects_dependency_cycles() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
manager.register(Box::new(TestPlugin::new(
"alpha",
vec!["beta"],
events.clone(),
)));
manager.register(Box::new(TestPlugin::new("beta", vec!["alpha"], events)));
let error = manager
.start_all()
.expect_err("dependency cycle should fail");
assert!(error
.to_string()
.contains("plugin dependency cycle detected among"));
}
#[test]
fn start_all_sorts_plugins_topologically() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
manager.register(Box::new(TestPlugin::new(
"gamma",
vec!["beta"],
events.clone(),
)));
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
manager.register(Box::new(TestPlugin::new(
"beta",
vec!["alpha"],
events.clone(),
)));
manager
.start_all()
.expect("start_all should sort dependencies");
manager.stop_all().expect("stop_all should succeed");
assert_eq!(
lock_events(&events).clone(),
vec![
"init:alpha",
"init:beta",
"init:gamma",
"start:alpha",
"start:beta",
"start:gamma",
"stop:gamma",
"stop:beta",
"stop:alpha",
]
);
}
#[test]
fn wifi_result_sent_to_manager_is_broadcast_to_plugins() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut manager = ServiceManager::new(test_config());
manager.register(Box::new(TestPlugin::new("alpha", vec![], events.clone())));
manager.register(Box::new(TestPlugin::new("beta", vec![], events.clone())));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
sender
.send(Envelope {
from: "wifi",
to: Destination::Manager,
message: Message::WifiResult("connected".to_string()),
})
.expect("wifi result should send");
sender
.send(Envelope {
from: "test",
to: Destination::Manager,
message: Message::Shutdown,
})
.expect("shutdown should send");
manager.run().expect("run should succeed");
assert!(has_event(&events, "msg:alpha:wifi_result:connected"));
assert!(has_event(&events, "msg:beta:wifi_result:connected"));
}

View File

@@ -5,7 +5,7 @@
mod gatt;
use crate::core::message::{Destination, Envelope, Message};
use crate::core::message::Message;
use crate::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
use anyhow::{anyhow, Context, Result};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -71,11 +71,6 @@ impl Plugin for BlePlugin {
self.stop.store(false, Ordering::SeqCst);
if !ctx.config.ble.enabled {
ctx.tx.send(Envelope {
from: "ble",
to: Destination::Manager,
message: Message::PluginReady("ble"),
})?;
return Ok(());
}

View File

@@ -38,6 +38,7 @@ struct PendingWifiResponse {
pub(crate) struct HttpState {
wifi_response: Mutex<PendingWifiResponse>,
wifi_response_cv: Condvar,
last_wifi_result: Mutex<Option<String>>,
config: Mutex<Arc<AppConfig>>,
player_status: Mutex<crate::core::message::PlayerStatusData>,
ble_ready: AtomicBool,
@@ -62,6 +63,7 @@ impl HttpState {
payload: None,
}),
wifi_response_cv: Condvar::new(),
last_wifi_result: Mutex::new(None),
config: Mutex::new(config),
player_status: Mutex::new(player_status),
ble_ready: AtomicBool::new(false),
@@ -72,9 +74,22 @@ impl HttpState {
fn publish_wifi_result(&self, payload: String) {
if let Ok(mut state) = self.wifi_response.lock() {
state.version += 1;
state.payload = Some(payload);
state.payload = Some(payload.clone());
self.wifi_response_cv.notify_all();
}
if let Ok(mut last_wifi_result) = self.last_wifi_result.lock() {
*last_wifi_result = Some(payload.clone());
}
let ws_payload = match serde_json::from_str::<serde_json::Value>(&payload) {
Ok(value) => encode_ws_event("wifi_update", value),
Err(_) => encode_ws_event("wifi_update", serde_json::json!({ "raw": payload })),
};
if let Some(ws_payload) = ws_payload {
self.publish_ws(ws_payload);
}
}
pub(crate) fn config(&self) -> Arc<AppConfig> {
@@ -137,6 +152,19 @@ impl HttpState {
snapshots.push(payload);
}
if let Ok(last_wifi_result) = self.last_wifi_result.lock() {
if let Some(raw) = last_wifi_result.as_ref() {
let payload = match serde_json::from_str::<serde_json::Value>(raw) {
Ok(value) => encode_ws_event("wifi_update", value),
Err(_) => encode_ws_event("wifi_update", serde_json::json!({ "raw": raw })),
};
if let Some(payload) = payload {
snapshots.push(payload);
}
}
}
snapshots
}

View File

@@ -97,16 +97,24 @@ fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> +
warp::path::end()
.and(warp::get())
.map(|| warp::reply::html(WEB_UI_HTML))
.or(
warp::path("index.html")
.and(warp::path::end())
.and(warp::get())
.map(|| warp::reply::html(WEB_UI_HTML)),
)
}
fn ws_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path("ws").and(warp::ws()).and(with_state(state)).map(
|ws: warp::ws::Ws, state: Arc<HttpState>| {
warp::path("ws")
.and(warp::path::end())
.and(warp::ws())
.and(with_state(state))
.map(|ws: warp::ws::Ws, state: Arc<HttpState>| {
ws.on_upgrade(move |socket| websocket_session(socket, state))
},
)
})
}
fn status_route(
@@ -343,11 +351,21 @@ fn wifi_scan_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
let tx_for_post = tx.clone();
let state_for_post = Arc::clone(&state);
warp::path!("api" / "wifi" / "scan")
.and(warp::get())
.and(with_tx(tx))
.and(with_state(state))
.and(with_state(Arc::clone(&state)))
.and_then(handle_wifi_scan)
.or(
warp::path!("api" / "wifi" / "scan")
.and(warp::post())
.and(with_tx(tx_for_post))
.and(with_state(state_for_post))
.and_then(handle_wifi_scan),
)
}
fn wifi_connect_route(
@@ -383,11 +401,14 @@ fn wifi_ap_start_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
let tx_for_hotspot = tx.clone();
let state_for_hotspot = Arc::clone(&state);
warp::path!("api" / "wifi" / "ap" / "start")
.and(warp::post())
.and(warp::body::bytes())
.and(with_tx(tx))
.and(with_state(state))
.and(with_state(Arc::clone(&state)))
.and_then(|body: bytes::Bytes, tx, state| async move {
let req: WifiApStartRequest = match parse_optional_json(&body) {
Ok(req) => req,
@@ -404,22 +425,60 @@ fn wifi_ap_start_route(
)
.await
})
.or(
warp::path!("api" / "wifi" / "hotspot" / "start")
.and(warp::post())
.and(warp::body::bytes())
.and(with_tx(tx_for_hotspot))
.and(with_state(state_for_hotspot))
.and_then(|body: bytes::Bytes, tx, state| async move {
let req: WifiApStartRequest = match parse_optional_json(&body) {
Ok(req) => req,
Err(reply) => return Ok::<_, Infallible>(*reply),
};
let ssid = req.ssid.unwrap_or_else(|| "showen".to_string());
let password = req.password.unwrap_or_else(|| "12345678".to_string());
let success_ssid = ssid.clone();
wifi_action_reply(
tx,
state,
WifiCommand::ApStart { ssid, password },
move |_| format!("AP 热点已启动: SSID={success_ssid}"),
)
.await
}),
)
}
fn wifi_ap_stop_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
let tx_for_hotspot = tx.clone();
let state_for_hotspot = Arc::clone(&state);
warp::path!("api" / "wifi" / "ap" / "stop")
.and(warp::post())
.and(with_tx(tx))
.and(with_state(state))
.and(with_state(Arc::clone(&state)))
.and_then(|tx, state| async move {
wifi_action_reply(tx, state, WifiCommand::ApStop, |_| {
"AP 热点已关闭".to_string()
})
.await
})
.or(
warp::path!("api" / "wifi" / "hotspot" / "stop")
.and(warp::post())
.and(with_tx(tx_for_hotspot))
.and(with_state(state_for_hotspot))
.and_then(|tx, state| async move {
wifi_action_reply(tx, state, WifiCommand::ApStop, |_| {
"AP 热点已关闭".to_string()
})
.await
}),
)
}
fn ble_start_route(
@@ -784,6 +843,7 @@ async fn wifi_request(
async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
let (mut sender, mut receiver) = ws.split();
let mut events = state.ws_subscribe();
for payload in state.ws_snapshots() {
if sender.send(warp::ws::Message::text(payload)).await.is_err() {
@@ -791,8 +851,6 @@ async fn websocket_session(ws: warp::ws::WebSocket, state: Arc<HttpState>) {
}
}
let mut events = state.ws_subscribe();
loop {
tokio::select! {
event = events.recv() => {

View File

@@ -1355,13 +1355,16 @@ impl Drop for VideoProcessor {
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,
AnimationStep, AppConfig, BleConfig, DisplayConfig, PerspectiveCorrectionConfig,
PlaybackConfig, RemoteControlConfig, ScaleMode, ScenesConfig, StateConfig,
StateMachineConfig, StateMode, StateTransition, TransitionConfig, TransitionType,
TriggerType, VideoItem,
};
use opencv::{
core::{Scalar, Size, Vec3b, CV_8UC3},
prelude::*,
};
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
@@ -1393,17 +1396,22 @@ mod tests {
.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])
// Allow ±1 tolerance for OpenCV interpolation rounding
let pixel_2_0 = *output
.at_2d::<Vec3b>(2, 0)
.expect("bottom-left pixel should exist");
assert!(
(pixel_2_0[0] as i16 - 10).abs() <= 1,
"expected ~10, got {}",
pixel_2_0[0]
);
assert_eq!(
*output
.at_2d::<Vec3b>(2, 3)
.expect("bottom-right pixel should exist"),
Vec3b::from([20, 0, 0])
let pixel_2_3 = *output
.at_2d::<Vec3b>(2, 3)
.expect("bottom-right pixel should exist");
assert!(
(pixel_2_3[0] as i16 - 20).abs() <= 1,
"expected ~20, got {}",
pixel_2_3[0]
);
}
@@ -1579,10 +1587,133 @@ mod tests {
assert_eq!(resolve_video_loop_count(&item), 2);
}
#[test]
fn advance_playlist_runs_random_trigger_only_when_state_does_not_change() {
let mut processor = sample_processor_with_state_machine(StateMachineConfig {
initial_state: "idle".to_string(),
states: HashMap::from([
(
"idle".to_string(),
StateConfig {
name: "idle".to_string(),
mode: StateMode::FreeMode,
sequence: vec![animation_step("idle")],
next_state: Some("next".to_string()),
next_states: None,
transitions: vec![StateTransition {
trigger: TriggerType::Random { probability: 1.0 },
target_state: "random".to_string(),
priority: 10,
}],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
},
),
(
"next".to_string(),
basic_state("next", vec![animation_step("next")]),
),
(
"random".to_string(),
basic_state("random", vec![animation_step("random")]),
),
]),
});
processor.advance_playlist();
assert_eq!(processor.current_state(), Some("next"));
assert_eq!(processor.current_video_id().as_deref(), Some("next"));
assert_eq!(processor.current_index, 1);
}
#[test]
fn advance_playlist_allows_random_trigger_after_same_state_completion() {
let mut processor = sample_processor_with_state_machine(StateMachineConfig {
initial_state: "idle".to_string(),
states: HashMap::from([
(
"idle".to_string(),
StateConfig {
name: "idle".to_string(),
mode: StateMode::FreeMode,
sequence: vec![animation_step("idle")],
next_state: Some("idle".to_string()),
next_states: None,
transitions: vec![StateTransition {
trigger: TriggerType::Random { probability: 1.0 },
target_state: "random".to_string(),
priority: 10,
}],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
},
),
(
"random".to_string(),
basic_state("random", vec![animation_step("random")]),
),
]),
});
processor.advance_playlist();
assert_eq!(processor.current_state(), Some("random"));
assert_eq!(processor.current_video_id().as_deref(), Some("random"));
assert_eq!(processor.current_index, 2);
}
#[test]
fn advance_playlist_skips_transition_when_video_id_is_unchanged() {
let mut processor = sample_processor_with_state_machine(StateMachineConfig {
initial_state: "idle".to_string(),
states: HashMap::from([(
"idle".to_string(),
StateConfig {
name: "idle".to_string(),
mode: StateMode::FreeMode,
sequence: vec![animation_step("idle")],
next_state: Some("idle".to_string()),
next_states: None,
transitions: vec![],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
},
)]),
});
processor.transition_enabled = true;
processor.last_frame = Some(
Mat::new_rows_cols_with_default(1, 1, CV_8UC3, Scalar::all(0.0))
.expect("frame should build"),
);
processor.advance_playlist();
assert_eq!(processor.current_state(), Some("idle"));
assert_eq!(processor.current_video_id().as_deref(), Some("idle"));
assert_eq!(processor.current_index, 0);
assert!(!processor.in_transition);
assert!(processor.transition_start.is_none());
}
fn sample_processor() -> VideoProcessor {
VideoProcessor::new(sample_config()).expect("sample processor should build")
}
fn sample_processor_with_state_machine(state_machine: StateMachineConfig) -> VideoProcessor {
let mut config = sample_config();
config.playlist = vec![
sample_video("idle"),
sample_video("next"),
sample_video("random"),
];
config.scenes.state_machine = Some(state_machine);
VideoProcessor::new(config).expect("state machine processor should build")
}
fn sample_config() -> AppConfig {
AppConfig {
display: sample_display(),
@@ -1644,4 +1775,36 @@ mod tests {
brightness_adjust: Default::default(),
}
}
fn sample_video(id: &str) -> VideoItem {
VideoItem {
id: id.to_string(),
path: format!("{id}.mp4"),
duration: None,
loop_count: 1,
random_loop_range: None,
}
}
fn animation_step(video_id: &str) -> AnimationStep {
AnimationStep {
video_id: video_id.to_string(),
loop_count: Some(1),
random_loop_range: None,
}
}
fn basic_state(name: &str, sequence: Vec<AnimationStep>) -> StateConfig {
StateConfig {
name: name.to_string(),
mode: StateMode::FreeMode,
sequence,
next_state: None,
next_states: None,
transitions: vec![],
weight: 1.0,
defer_triggers: false,
ignore_triggers: false,
}
}
}

View File

@@ -149,8 +149,8 @@ impl StateMachine {
}
fn reset_state_progress(&mut self) -> Result<()> {
self.ensure_current_state_valid()?;
self.current_sequence_index = 0;
self.ensure_current_state_valid()?;
self.current_loop_remaining = self.resolve_current_loop_count()?;
Ok(())
}