979 lines
30 KiB
Rust
979 lines
30 KiB
Rust
use super::config::{parse_str, AppConfig};
|
|
use super::message::{Destination, Envelope, Message};
|
|
use super::plugin::{CapabilityTestResult, Platform, Plugin, PluginContext, PluginInfo};
|
|
use super::plugin_loader::{ErrorPolicy, PluginLoader, PluginRegistry, PluginRegistryEntry};
|
|
use super::service_manager::ServiceManager;
|
|
use super::version_manager::VersionManager;
|
|
use anyhow::Result;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
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: String,
|
|
deps: Vec<String>,
|
|
events: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl TestPlugin {
|
|
fn new(id: &str, deps: Vec<&str>, events: Arc<Mutex<Vec<String>>>) -> Self {
|
|
Self {
|
|
id: id.to_string(),
|
|
deps: deps.into_iter().map(|s| s.to_string()).collect(),
|
|
events,
|
|
}
|
|
}
|
|
|
|
fn record(&self, entry: impl Into<String>) {
|
|
lock_events(&self.events).push(entry.into());
|
|
}
|
|
}
|
|
|
|
impl Plugin for TestPlugin {
|
|
fn id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
|
|
fn info(&self) -> PluginInfo {
|
|
PluginInfo {
|
|
name: self.id.clone(),
|
|
version: "test".to_string(),
|
|
description: "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 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_string(),
|
|
to: Destination::Plugin("beta".to_string()),
|
|
message: Message::Custom {
|
|
kind: "direct".to_string(),
|
|
payload: "hello".to_string(),
|
|
},
|
|
})
|
|
.expect("direct message should send");
|
|
sender
|
|
.send(Envelope {
|
|
from: "alpha".to_string(),
|
|
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_string(),
|
|
to: Destination::Manager,
|
|
message: Message::PluginReady("alpha".to_string()),
|
|
})
|
|
.expect("manager message 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: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_string(),
|
|
to: Destination::Manager,
|
|
message: Message::WifiResult("connected".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");
|
|
|
|
assert!(has_event(&events, "msg:alpha:wifi_result:connected"));
|
|
assert!(has_event(&events, "msg:beta:wifi_result:connected"));
|
|
}
|
|
|
|
#[test]
|
|
fn http_plugin_must_depend_on_video() {
|
|
use crate::plugins::http::HttpPlugin;
|
|
let plugin = HttpPlugin::new();
|
|
let deps = plugin.dependencies();
|
|
assert_eq!(deps, vec!["video"], "http plugin must depend on video");
|
|
}
|
|
|
|
#[test]
|
|
fn ble_plugin_must_have_no_dependencies() {
|
|
use crate::plugins::ble::BlePlugin;
|
|
let plugin = BlePlugin::new();
|
|
let deps = plugin.dependencies();
|
|
assert!(deps.is_empty(), "ble plugin must have no dependencies");
|
|
}
|
|
|
|
#[test]
|
|
fn wifi_plugin_must_have_no_dependencies() {
|
|
use crate::plugins::wifi::WifiPlugin;
|
|
let plugin = WifiPlugin::new();
|
|
let deps = plugin.dependencies();
|
|
assert!(deps.is_empty(), "wifi plugin must have no dependencies");
|
|
}
|
|
|
|
#[test]
|
|
fn video_plugin_must_have_no_dependencies() {
|
|
use crate::plugins::video::VideoPlugin;
|
|
let plugin = VideoPlugin::new();
|
|
let deps = plugin.dependencies();
|
|
assert!(deps.is_empty(), "video plugin must have no dependencies");
|
|
}
|
|
|
|
#[test]
|
|
fn screen_plugin_must_have_no_dependencies() {
|
|
use crate::plugins::screen::ScreenPlugin;
|
|
let plugin = ScreenPlugin::new();
|
|
let deps = plugin.dependencies();
|
|
assert!(deps.is_empty(), "screen plugin must have no dependencies");
|
|
}
|
|
|
|
#[test]
|
|
fn all_plugin_ids_must_be_unique() {
|
|
use crate::plugins::ble::BlePlugin;
|
|
use crate::plugins::http::HttpPlugin;
|
|
use crate::plugins::screen::ScreenPlugin;
|
|
use crate::plugins::video::VideoPlugin;
|
|
use crate::plugins::wifi::WifiPlugin;
|
|
use std::collections::HashSet;
|
|
|
|
let plugins: Vec<Box<dyn Plugin>> = vec![
|
|
Box::new(BlePlugin::new()),
|
|
Box::new(HttpPlugin::new()),
|
|
Box::new(ScreenPlugin::new()),
|
|
Box::new(VideoPlugin::new()),
|
|
Box::new(WifiPlugin::new()),
|
|
];
|
|
|
|
let mut ids = HashSet::new();
|
|
for plugin in plugins {
|
|
let id = plugin.id().to_string();
|
|
assert!(
|
|
ids.insert(id.clone()),
|
|
"duplicate plugin id detected: '{}'",
|
|
id
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn topological_sort_places_http_after_video() {
|
|
let events = Arc::new(Mutex::new(Vec::new()));
|
|
let mut manager = ServiceManager::new(test_config());
|
|
|
|
manager.register(Box::new(TestPlugin::new(
|
|
"http",
|
|
vec!["video"],
|
|
events.clone(),
|
|
)));
|
|
manager.register(Box::new(TestPlugin::new("video", vec![], events.clone())));
|
|
|
|
manager
|
|
.start_all()
|
|
.expect("start_all should succeed with http depending on video");
|
|
|
|
let event_log = lock_events(&events).clone();
|
|
let http_init_pos = event_log
|
|
.iter()
|
|
.position(|e| e == "init:http")
|
|
.expect("http should be initialized");
|
|
let video_init_pos = event_log
|
|
.iter()
|
|
.position(|e| e == "init:video")
|
|
.expect("video should be initialized");
|
|
|
|
assert!(
|
|
video_init_pos < http_init_pos,
|
|
"video must be initialized before http (video at {}, http at {})",
|
|
video_init_pos,
|
|
http_init_pos
|
|
);
|
|
}
|
|
|
|
// ── 自测相关测试 ──
|
|
|
|
/// 支持自测的 TestPlugin 变体
|
|
struct TestPluginWithSelfTest {
|
|
id: String,
|
|
events: Arc<Mutex<Vec<String>>>,
|
|
caps: Vec<String>,
|
|
test_results: Vec<CapabilityTestResult>,
|
|
}
|
|
|
|
impl TestPluginWithSelfTest {
|
|
fn new(
|
|
id: &str,
|
|
events: Arc<Mutex<Vec<String>>>,
|
|
caps: Vec<String>,
|
|
test_results: Vec<CapabilityTestResult>,
|
|
) -> Self {
|
|
Self {
|
|
id: id.to_string(),
|
|
events,
|
|
caps,
|
|
test_results,
|
|
}
|
|
}
|
|
|
|
fn record(&self, entry: impl Into<String>) {
|
|
lock_events(&self.events).push(entry.into());
|
|
}
|
|
}
|
|
|
|
impl Plugin for TestPluginWithSelfTest {
|
|
fn id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
|
|
fn info(&self) -> PluginInfo {
|
|
PluginInfo {
|
|
name: self.id.clone(),
|
|
version: "test".to_string(),
|
|
description: "test plugin with self_test".to_string(),
|
|
platform: Platform::Any,
|
|
}
|
|
}
|
|
|
|
fn capabilities(&self) -> Vec<String> {
|
|
self.caps.clone()
|
|
}
|
|
|
|
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
|
|
self.record(format!("self_test:{}", self.id));
|
|
self.test_results.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<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn stop(&mut self) -> Result<()> {
|
|
self.record(format!("stop:{}", self.id));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct FailingPlugin {
|
|
id: String,
|
|
events: Arc<Mutex<Vec<String>>>,
|
|
}
|
|
|
|
impl FailingPlugin {
|
|
fn new(id: &str, events: Arc<Mutex<Vec<String>>>) -> Self {
|
|
Self {
|
|
id: id.to_string(),
|
|
events,
|
|
}
|
|
}
|
|
|
|
fn record(&self, entry: impl Into<String>) {
|
|
lock_events(&self.events).push(entry.into());
|
|
}
|
|
}
|
|
|
|
impl Plugin for FailingPlugin {
|
|
fn id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
|
|
fn info(&self) -> PluginInfo {
|
|
PluginInfo {
|
|
name: self.id.clone(),
|
|
version: "test".to_string(),
|
|
description: "failing test plugin".to_string(),
|
|
platform: Platform::Any,
|
|
}
|
|
}
|
|
|
|
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!("error:{}", self.id));
|
|
Err(anyhow::anyhow!("simulated failure"))
|
|
}
|
|
|
|
fn stop(&mut self) -> Result<()> {
|
|
self.record(format!("stop:{}", self.id));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn setup_rollback_store(base: &Path, plugin_id: &str) -> VersionManager {
|
|
let _ = fs::remove_dir_all(base);
|
|
fs::create_dir_all(base.join(plugin_id).join("1.0.0")).unwrap();
|
|
fs::create_dir_all(base.join(plugin_id).join("2.0.0")).unwrap();
|
|
|
|
let loader = PluginLoader::new(base);
|
|
let mut registry = PluginRegistry::default();
|
|
registry.plugins.insert(
|
|
plugin_id.to_string(),
|
|
PluginRegistryEntry {
|
|
active_version: "2.0.0".to_string(),
|
|
last_stable_version: Some("1.0.0".to_string()),
|
|
enabled: true,
|
|
error_policy: ErrorPolicy::AutoRollback,
|
|
max_errors: 1,
|
|
},
|
|
);
|
|
loader.save_registry(®istry).unwrap();
|
|
|
|
VersionManager::new(loader)
|
|
}
|
|
|
|
#[test]
|
|
fn self_test_all_pass_allows_normal_start() {
|
|
let events = Arc::new(Mutex::new(Vec::new()));
|
|
let mut manager = ServiceManager::new(test_config());
|
|
|
|
let plugin = TestPluginWithSelfTest::new(
|
|
"sensor",
|
|
events.clone(),
|
|
vec!["temperature".into()],
|
|
vec![CapabilityTestResult {
|
|
capability: "temperature".into(),
|
|
passed: true,
|
|
message: "ok".into(),
|
|
}],
|
|
);
|
|
|
|
manager.register_dynamic_with_manifest(
|
|
Box::new(plugin),
|
|
ErrorPolicy::DisableAndLog,
|
|
5,
|
|
vec!["temperature".into()],
|
|
vec!["temperature".into()],
|
|
true,
|
|
);
|
|
|
|
manager
|
|
.start_all()
|
|
.expect("start_all should succeed when all tests pass");
|
|
|
|
let log = lock_events(&events);
|
|
assert!(log.contains(&"init:sensor".to_string()));
|
|
assert!(log.contains(&"self_test:sensor".to_string()));
|
|
assert!(log.contains(&"start:sensor".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn self_test_required_capability_fails_disables_dynamic_plugin() {
|
|
let events = Arc::new(Mutex::new(Vec::new()));
|
|
let mut manager = ServiceManager::new(test_config());
|
|
|
|
let plugin = TestPluginWithSelfTest::new(
|
|
"sensor",
|
|
events.clone(),
|
|
vec!["temperature".into()],
|
|
vec![CapabilityTestResult {
|
|
capability: "temperature".into(),
|
|
passed: false,
|
|
message: "sensor not connected".into(),
|
|
}],
|
|
);
|
|
|
|
manager.register_dynamic_with_manifest(
|
|
Box::new(plugin),
|
|
ErrorPolicy::DisableAndLog,
|
|
5,
|
|
vec!["temperature".into()],
|
|
vec!["temperature".into()],
|
|
true,
|
|
);
|
|
|
|
// Should succeed (dynamic plugin failure doesn't abort)
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let log = lock_events(&events);
|
|
assert!(log.contains(&"init:sensor".to_string()));
|
|
assert!(log.contains(&"self_test:sensor".to_string()));
|
|
// start should NOT have been called
|
|
assert!(!log.contains(&"start:sensor".to_string()));
|
|
|
|
// Plugin should be disabled
|
|
let states = manager.plugin_states();
|
|
assert!(
|
|
!states[0].enabled,
|
|
"plugin should be disabled after required capability failure"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn self_test_optional_capability_fails_still_starts() {
|
|
let events = Arc::new(Mutex::new(Vec::new()));
|
|
let mut manager = ServiceManager::new(test_config());
|
|
|
|
let plugin = TestPluginWithSelfTest::new(
|
|
"sensor",
|
|
events.clone(),
|
|
vec!["temperature".into(), "humidity".into()],
|
|
vec![
|
|
CapabilityTestResult {
|
|
capability: "temperature".into(),
|
|
passed: true,
|
|
message: "ok".into(),
|
|
},
|
|
CapabilityTestResult {
|
|
capability: "humidity".into(),
|
|
passed: false,
|
|
message: "sensor not calibrated".into(),
|
|
},
|
|
],
|
|
);
|
|
|
|
// Only temperature is required; humidity is optional
|
|
manager.register_dynamic_with_manifest(
|
|
Box::new(plugin),
|
|
ErrorPolicy::DisableAndLog,
|
|
5,
|
|
vec!["temperature".into()],
|
|
vec!["temperature".into(), "humidity".into()],
|
|
true,
|
|
);
|
|
|
|
manager.start_all().expect("start_all should succeed");
|
|
|
|
let log = lock_events(&events);
|
|
assert!(log.contains(&"self_test:sensor".to_string()));
|
|
assert!(
|
|
log.contains(&"start:sensor".to_string()),
|
|
"plugin should start despite optional failure"
|
|
);
|
|
|
|
// Test results should be recorded
|
|
let states = manager.plugin_states();
|
|
assert_eq!(states[0].test_results.len(), 2);
|
|
assert!(states[0].test_results[0].passed);
|
|
assert!(!states[0].test_results[1].passed);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_rollback_updates_registry_and_marks_pending_when_reload_fails() {
|
|
let tmp = std::env::temp_dir().join("showen_test_service_manager_autorollback");
|
|
let events = Arc::new(Mutex::new(Vec::new()));
|
|
let mut manager = ServiceManager::new(test_config());
|
|
manager.set_version_manager(setup_rollback_store(&tmp, "sensor"));
|
|
|
|
manager.register_dynamic(
|
|
Box::new(FailingPlugin::new("sensor", events.clone())),
|
|
ErrorPolicy::AutoRollback,
|
|
1,
|
|
);
|
|
|
|
manager.start_all().expect("start_all should succeed");
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "test".to_string(),
|
|
to: Destination::Plugin("sensor".to_string()),
|
|
message: Message::Custom {
|
|
kind: "tick".to_string(),
|
|
payload: "1".to_string(),
|
|
},
|
|
})
|
|
.expect("failing message 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 registry = PluginLoader::new(&tmp).load_registry().unwrap();
|
|
assert_eq!(registry.plugins["sensor"].active_version, "1.0.0");
|
|
|
|
let states = manager.plugin_states();
|
|
assert!(
|
|
!states[0].enabled,
|
|
"plugin should be disabled after rollback failure"
|
|
);
|
|
assert!(
|
|
states[0].needs_rollback,
|
|
"plugin should be marked for restart-time reload"
|
|
);
|
|
assert!(has_event(&events, "stop:sensor"));
|
|
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn message_config_reload_request_round_trips_through_json() {
|
|
let json = serde_json::to_string(&Message::ConfigReloadRequest)
|
|
.expect("ConfigReloadRequest should serialize");
|
|
let message: Message =
|
|
serde_json::from_str(&json).expect("ConfigReloadRequest should deserialize");
|
|
|
|
assert!(matches!(message, Message::ConfigReloadRequest));
|
|
}
|
|
|
|
#[test]
|
|
fn message_config_reloaded_round_trips_through_json() {
|
|
let config = test_config();
|
|
let json = serde_json::to_string(&Message::ConfigReloaded(config.clone()))
|
|
.expect("ConfigReloaded should serialize");
|
|
let message: Message = serde_json::from_str(&json).expect("ConfigReloaded should deserialize");
|
|
|
|
match message {
|
|
Message::ConfigReloaded(decoded) => {
|
|
assert_eq!(decoded.display.window_title, config.display.window_title);
|
|
assert_eq!(decoded.playlist.len(), config.playlist.len());
|
|
assert_eq!(decoded.remote_control.port, config.remote_control.port);
|
|
}
|
|
other => panic!("unexpected message after round trip: {:?}", other),
|
|
}
|
|
}
|
|
|
|
fn unique_test_dir(name: &str) -> std::path::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_{name}_{}_{}", std::process::id(), nanos))
|
|
}
|
|
|
|
fn assert_message_json_round_trip(message: Message) {
|
|
let expected = serde_json::to_value(&message).expect("message should serialize to value");
|
|
let decoded: Message =
|
|
serde_json::from_value(expected.clone()).expect("message should deserialize from value");
|
|
let actual = serde_json::to_value(decoded).expect("decoded message should serialize");
|
|
assert_eq!(actual, expected);
|
|
}
|
|
|
|
// NOTE: 动态插件 null vtable 测试已移除 —— 在测试环境中编译动态 .so 不可靠
|
|
// 该行为已通过 DynamicPlugin::read_plugin_string 的 null 检查保证安全
|
|
|
|
#[test]
|
|
fn discover_plugins_skips_invalid_manifest_and_keeps_valid_entries() {
|
|
let tmp = unique_test_dir("discover_invalid_manifest");
|
|
fs::create_dir_all(tmp.join("valid-plugin").join("1.0.0"))
|
|
.expect("valid plugin dir should be created");
|
|
fs::create_dir_all(tmp.join("broken-plugin").join("1.0.0"))
|
|
.expect("broken plugin dir should be created");
|
|
|
|
fs::write(
|
|
tmp.join("valid-plugin").join("1.0.0").join("manifest.json"),
|
|
r#"{
|
|
"id": "valid-plugin",
|
|
"version": "1.0.0",
|
|
"sdk_version": "0.2.0",
|
|
"so_filename": "libvalid_plugin.so"
|
|
}"#,
|
|
)
|
|
.expect("valid manifest should be written");
|
|
fs::write(
|
|
tmp.join("broken-plugin")
|
|
.join("1.0.0")
|
|
.join("manifest.json"),
|
|
r#"{"id": "broken-plugin", "version": }"#,
|
|
)
|
|
.expect("invalid manifest should be written");
|
|
|
|
let loader = PluginLoader::new(&tmp);
|
|
let manifests = loader
|
|
.discover_plugins()
|
|
.expect("invalid manifest should be skipped, not returned as error");
|
|
|
|
assert_eq!(manifests.len(), 1);
|
|
assert_eq!(manifests[0].id, "valid-plugin");
|
|
assert_eq!(manifests[0].version, "1.0.0");
|
|
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn handle_message_skips_disabled_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");
|
|
manager
|
|
.set_plugin_enabled("beta", false)
|
|
.expect("beta should be disabled");
|
|
|
|
let sender = manager.sender();
|
|
sender
|
|
.send(Envelope {
|
|
from: "alpha".to_string(),
|
|
to: Destination::Plugin("beta".to_string()),
|
|
message: Message::Custom {
|
|
kind: "direct".to_string(),
|
|
payload: "ignored".to_string(),
|
|
},
|
|
})
|
|
.expect("direct message 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:beta:custom:direct:ignored"));
|
|
assert!(!manager.plugin_states()[1].enabled);
|
|
}
|
|
|
|
#[test]
|
|
fn rollback_without_stable_version_returns_error_and_keeps_active_version() {
|
|
let tmp = unique_test_dir("rollback_without_stable");
|
|
fs::create_dir_all(tmp.join("sensor").join("2.0.0")).expect("version dir should be created");
|
|
|
|
let loader = PluginLoader::new(&tmp);
|
|
let mut registry = PluginRegistry::default();
|
|
registry.plugins.insert(
|
|
"sensor".to_string(),
|
|
PluginRegistryEntry {
|
|
active_version: "2.0.0".to_string(),
|
|
last_stable_version: None,
|
|
enabled: true,
|
|
error_policy: ErrorPolicy::AutoRollback,
|
|
max_errors: 1,
|
|
},
|
|
);
|
|
loader
|
|
.save_registry(®istry)
|
|
.expect("registry should be written");
|
|
|
|
let version_manager = VersionManager::new(loader);
|
|
let error = version_manager
|
|
.rollback("sensor")
|
|
.expect_err("missing stable version should fail");
|
|
assert!(error
|
|
.to_string()
|
|
.contains("plugin 'sensor' has no stable version to rollback to"));
|
|
|
|
let registry = version_manager
|
|
.loader()
|
|
.load_registry()
|
|
.expect("registry should still load");
|
|
assert_eq!(registry.plugins["sensor"].active_version, "2.0.0");
|
|
assert!(registry.plugins["sensor"].last_stable_version.is_none());
|
|
|
|
let _ = fs::remove_dir_all(&tmp);
|
|
}
|
|
|
|
#[test]
|
|
fn all_message_variants_round_trip_through_json() {
|
|
let config = test_config();
|
|
let messages = vec![
|
|
Message::PlayerCommand(super::message::PlayerCommand::Play),
|
|
Message::PlayerCommand(super::message::PlayerCommand::Pause),
|
|
Message::PlayerCommand(super::message::PlayerCommand::Next),
|
|
Message::PlayerCommand(super::message::PlayerCommand::Previous),
|
|
Message::PlayerCommand(super::message::PlayerCommand::Goto(3)),
|
|
Message::PlayerCommand(super::message::PlayerCommand::ChangeScene(
|
|
"intro".to_string(),
|
|
)),
|
|
Message::PlayerStatus(super::message::PlayerStatusData {
|
|
running: true,
|
|
paused: false,
|
|
in_transition: true,
|
|
current_index: 2,
|
|
playlist_length: 5,
|
|
current_video: Some("video.mp4".to_string()),
|
|
}),
|
|
Message::Trigger {
|
|
name: "motion".to_string(),
|
|
value: "detected".to_string(),
|
|
},
|
|
Message::StateChanged {
|
|
old_state: "idle".to_string(),
|
|
new_state: "playing".to_string(),
|
|
},
|
|
Message::ScreenLockRequest(true),
|
|
Message::CursorVisibility(false),
|
|
Message::WifiCommand(super::message::WifiCommand::Scan),
|
|
Message::WifiCommand(super::message::WifiCommand::Connect {
|
|
ssid: "lab".to_string(),
|
|
password: "secret".to_string(),
|
|
}),
|
|
Message::WifiCommand(super::message::WifiCommand::Status),
|
|
Message::WifiCommand(super::message::WifiCommand::ApStart {
|
|
ssid: "showen-ap".to_string(),
|
|
password: "password".to_string(),
|
|
}),
|
|
Message::WifiCommand(super::message::WifiCommand::ApStop),
|
|
Message::WifiResult("connected".to_string()),
|
|
Message::WifiProvisioned {
|
|
ssid: "lab".to_string(),
|
|
ip: "192.168.1.10".to_string(),
|
|
},
|
|
Message::ConfigReloaded(config),
|
|
Message::ConfigReloadRequest,
|
|
Message::Shutdown,
|
|
Message::PluginReady("sensor".to_string()),
|
|
Message::Custom {
|
|
kind: "health".to_string(),
|
|
payload: "ok".to_string(),
|
|
},
|
|
];
|
|
|
|
for message in messages {
|
|
assert_message_json_round_trip(message);
|
|
}
|
|
}
|