M1.1 收尾: - 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests) - Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB - 示例插件完善: manifest.json + 请求/响应示范 + 7个测试 - API 文档重写 (以 routes.rs 为唯一权威) - MILESTONES.md 更新至 100% M1.2 启动: - P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states) - ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs) - M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景) - 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护 总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
566 lines
17 KiB
Rust
566 lines
17 KiB
Rust
use anyhow::Result;
|
|
use showen_v2::core::config::AppConfig;
|
|
use showen_v2::core::message::{Destination, Envelope, Message, PlayerStatusData};
|
|
use showen_v2::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
|
use showen_v2::core::service_manager::ServiceManager;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
fn unique_test_dir(name: &str) -> PathBuf {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("system time should be after unix epoch")
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!(
|
|
"showen_m1_2_service_manager_{name}_{}_{}",
|
|
std::process::id(),
|
|
nanos
|
|
))
|
|
}
|
|
|
|
fn config_json(window_title: &str) -> String {
|
|
format!(
|
|
r#"{{
|
|
"display": {{
|
|
"fullscreen": false,
|
|
"window_title": "{window_title}",
|
|
"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
|
|
}}
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
fn write_test_config(dir: &Path, window_title: &str) -> PathBuf {
|
|
fs::create_dir_all(dir).expect("test dir should be created");
|
|
let config_path = dir.join("config.json");
|
|
fs::write(&config_path, config_json(window_title)).expect("config should be written");
|
|
config_path
|
|
}
|
|
|
|
fn test_manager(name: &str) -> (ServiceManager, Arc<Mutex<Vec<String>>>, PathBuf) {
|
|
let dir = unique_test_dir(name);
|
|
let config_path = write_test_config(&dir, "initial-title");
|
|
let config = AppConfig::from_file(&config_path).expect("test config should load");
|
|
(
|
|
ServiceManager::new(config),
|
|
Arc::new(Mutex::new(Vec::new())),
|
|
dir,
|
|
)
|
|
}
|
|
|
|
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 event_position(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> usize {
|
|
lock_events(events)
|
|
.iter()
|
|
.position(|event| event == expected)
|
|
.unwrap_or_else(|| panic!("missing expected event: {}", expected))
|
|
}
|
|
|
|
fn message_label(message: &Message) -> String {
|
|
match message {
|
|
Message::Shutdown => "shutdown".to_string(),
|
|
Message::ConfigReloadRequest => "config_reload_request".to_string(),
|
|
Message::ConfigReloaded(config) => {
|
|
format!("config_reloaded:{}", config.display.window_title)
|
|
}
|
|
Message::PlayerStatus(status) => format!(
|
|
"player_status:{}:{}:{}:{}:{}:{}",
|
|
status.running,
|
|
status.paused,
|
|
status.in_transition,
|
|
status.current_index,
|
|
status.playlist_length,
|
|
status.current_video.as_deref().unwrap_or("none")
|
|
),
|
|
Message::WifiResult(payload) => format!("wifi_result:{payload}"),
|
|
Message::StateChanged {
|
|
old_state,
|
|
new_state,
|
|
} => format!("state_changed:{old_state}->{new_state}"),
|
|
Message::PluginReady(id) => format!("plugin_ready:{id}"),
|
|
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
|
|
other => format!("other:{other:?}"),
|
|
}
|
|
}
|
|
|
|
struct RecordingPlugin {
|
|
id: String,
|
|
deps: Vec<String>,
|
|
events: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl RecordingPlugin {
|
|
fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
|
Self {
|
|
id: id.to_string(),
|
|
deps: deps.into_iter().map(str::to_string).collect(),
|
|
events,
|
|
}
|
|
}
|
|
|
|
fn record(&self, entry: impl Into<String>) {
|
|
lock_events(&self.events).push(entry.into());
|
|
}
|
|
}
|
|
|
|
impl Plugin for RecordingPlugin {
|
|
fn id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
|
|
fn info(&self) -> PluginInfo {
|
|
PluginInfo {
|
|
name: self.id.clone(),
|
|
version: "test".to_string(),
|
|
description: "integration test plugin".to_string(),
|
|
platform: Platform::Any,
|
|
}
|
|
}
|
|
|
|
fn dependencies(&self) -> Vec<String> {
|
|
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 test_startup_order_matches_dependency_sort() {
|
|
let (mut manager, events, dir) = test_manager("startup_order");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"dashboard",
|
|
vec!["http", "screen"],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"screen",
|
|
vec!["device"],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"http",
|
|
vec!["video"],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"wifi",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"video",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"device",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
assert!(event_position(&events, "init:device") < event_position(&events, "init:screen"));
|
|
assert!(event_position(&events, "init:video") < event_position(&events, "init:http"));
|
|
assert!(event_position(&events, "init:screen") < event_position(&events, "init:dashboard"));
|
|
assert!(event_position(&events, "init:http") < event_position(&events, "init:dashboard"));
|
|
assert!(event_position(&events, "start:device") < event_position(&events, "start:screen"));
|
|
assert!(event_position(&events, "start:video") < event_position(&events, "start:http"));
|
|
assert!(event_position(&events, "start:screen") < event_position(&events, "start:dashboard"));
|
|
assert!(event_position(&events, "start:http") < event_position(&events, "start:dashboard"));
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_shutdown_stops_all_enabled_plugins() {
|
|
let (mut manager, events, dir) = test_manager("shutdown_stop_all");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"alpha",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"beta",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"gamma",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::Shutdown,
|
|
})
|
|
.expect("shutdown should send");
|
|
|
|
manager.run().expect("run should succeed");
|
|
|
|
assert!(has_event(&events, "msg:alpha:shutdown"));
|
|
assert!(has_event(&events, "msg:beta:shutdown"));
|
|
assert!(has_event(&events, "msg:gamma:shutdown"));
|
|
assert!(event_position(&events, "stop:gamma") < event_position(&events, "stop:beta"));
|
|
assert!(event_position(&events, "stop:beta") < event_position(&events, "stop:alpha"));
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_reload_broadcasts_new_config() {
|
|
let (mut manager, events, dir) = test_manager("config_reload");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"alpha",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"beta",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let config_path = dir.join("config.json");
|
|
fs::write(&config_path, config_json("reloaded-title")).expect("config should be updated");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "http".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::ConfigReloadRequest,
|
|
})
|
|
.expect("config reload request should send");
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::Shutdown,
|
|
})
|
|
.expect("shutdown should send");
|
|
|
|
manager.run().expect("run should succeed");
|
|
|
|
assert!(has_event(
|
|
&events,
|
|
"msg:alpha:config_reloaded:reloaded-title"
|
|
));
|
|
assert!(has_event(
|
|
&events,
|
|
"msg:beta:config_reloaded:reloaded-title"
|
|
));
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_player_status_broadcast() {
|
|
let (mut manager, events, dir) = test_manager("player_status");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"alpha",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"beta",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"gamma",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "video".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::PlayerStatus(PlayerStatusData {
|
|
running: true,
|
|
paused: false,
|
|
in_transition: true,
|
|
current_index: 2,
|
|
playlist_length: 5,
|
|
current_video: Some("intro.mp4".to_string()),
|
|
}),
|
|
})
|
|
.expect("player status should send");
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::Shutdown,
|
|
})
|
|
.expect("shutdown should send");
|
|
|
|
manager.run().expect("run should succeed");
|
|
|
|
let expected = "player_status:true:false:true:2:5:intro.mp4";
|
|
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
|
|
assert!(has_event(&events, &format!("msg:beta:{expected}")));
|
|
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_wifi_result_broadcast() {
|
|
let (mut manager, events, dir) = test_manager("wifi_result");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"alpha",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"beta",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"gamma",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "wifi".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::WifiResult("connected:ssid=showen-lab".to_string()),
|
|
})
|
|
.expect("wifi result should send");
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::Shutdown,
|
|
})
|
|
.expect("shutdown should send");
|
|
|
|
manager.run().expect("run should succeed");
|
|
|
|
let expected = "wifi_result:connected:ssid=showen-lab";
|
|
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
|
|
assert!(has_event(&events, &format!("msg:beta:{expected}")));
|
|
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_changed_broadcast() {
|
|
let (mut manager, events, dir) = test_manager("state_changed");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"alpha",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"beta",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "video".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::StateChanged {
|
|
old_state: "idle".to_string(),
|
|
new_state: "playing".to_string(),
|
|
},
|
|
})
|
|
.expect("state changed should send");
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::Shutdown,
|
|
})
|
|
.expect("shutdown should send");
|
|
|
|
manager.run().expect("run should succeed");
|
|
|
|
assert!(has_event(&events, "msg:alpha:state_changed:idle->playing"));
|
|
assert!(has_event(&events, "msg:beta:state_changed:idle->playing"));
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_plugin_ready_broadcast() {
|
|
let (mut manager, events, dir) = test_manager("plugin_ready");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"alpha",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"beta",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"gamma",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "video".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::PluginReady("video".to_string()),
|
|
})
|
|
.expect("plugin ready should send");
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::Shutdown,
|
|
})
|
|
.expect("shutdown should send");
|
|
|
|
manager.run().expect("run should succeed");
|
|
|
|
assert!(has_event(&events, "msg:alpha:plugin_ready:video"));
|
|
assert!(has_event(&events, "msg:beta:plugin_ready:video"));
|
|
assert!(has_event(&events, "msg:gamma:plugin_ready:video"));
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_disabled_plugin_skipped_in_message_routing() {
|
|
let (mut manager, events, dir) = test_manager("disabled_plugin_skip");
|
|
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"alpha",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"beta",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(RecordingPlugin::new(
|
|
"gamma",
|
|
vec![],
|
|
events.clone(),
|
|
)));
|
|
manager.start_all().expect("start_all should succeed");
|
|
manager
|
|
.set_plugin_enabled("beta", false)
|
|
.expect("beta should be disabled");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "video".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::PlayerStatus(PlayerStatusData {
|
|
running: true,
|
|
paused: true,
|
|
in_transition: false,
|
|
current_index: 1,
|
|
playlist_length: 3,
|
|
current_video: Some("paused.mp4".to_string()),
|
|
}),
|
|
})
|
|
.expect("player status should send");
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Manager,
|
|
message: Message::Shutdown,
|
|
})
|
|
.expect("shutdown should send");
|
|
|
|
manager.run().expect("run should succeed");
|
|
|
|
let expected = "player_status:true:true:false:1:3:paused.mp4";
|
|
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
|
|
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
|
|
assert!(!has_event(&events, &format!("msg:beta:{expected}")));
|
|
assert!(!manager.plugin_states()[1].enabled);
|
|
|
|
fs::remove_dir_all(dir).expect("test dir should be removed");
|
|
}
|