test+docs: 新增4个测试(66总计) + SDK API文档 + 员工soul更新

This commit is contained in:
showen
2026-03-13 05:52:26 +08:00
parent f764f27d77
commit 086b4600eb
9 changed files with 1220 additions and 55 deletions

View File

@@ -9,4 +9,5 @@ crate-type = ["cdylib"]
[dependencies]
showen-plugin-sdk = { path = "../../plugin-sdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -1,14 +1,128 @@
//! 示例动态插件 — 展示如何使用 showen-plugin-sdk 编写插件
//! 示例动态插件 — 展示如何使用 `showen-plugin-sdk` 编写较完整的插件
//!
//! 此插件演示动态加载流程及自测机制。
//! 这个示例特意覆盖几个第三方开发者最常见的需求:
//! 1. 使用 `MessageSender` 发送点对点、广播、管理层以及原始 `Envelope` 消息。
//! 2. 在 `handle_message` 中处理多种 `Message` 变体。
//! 3. 用 `serde` 解析配置,并在解析后做显式校验。
//! 4. 用 `thread + sleep` 模拟一个简单的定时后台任务。
//! 5. 提供完整的 `capabilities` 和 `self_test` 实现。
//! 6. 用注释解释每个阶段应该做什么,作为插件作者的参考模板。
use serde::{Deserialize, Serialize};
use showen_plugin_sdk::{
export_plugin, CapabilityTestResult, Message, MessageSender, PluginInfo, ShowenPlugin,
export_plugin, CapabilityTestResult, Destination, Envelope, Message, MessageSender, PluginInfo,
ShowenPlugin,
};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread::{self, JoinHandle};
use std::time::Duration;
const PLUGIN_ID: &str = "example-plugin";
const CAP_MESSAGE_SENDER: &str = "message_sender";
const CAP_MESSAGE_ROUTING: &str = "message_routing";
const CAP_CONFIG_PARSING: &str = "config_parsing";
const CAP_BACKGROUND_TASK: &str = "background_task";
const CAP_SELF_TEST: &str = "self_test";
/// 示例插件配置。
///
/// 这里使用 `#[serde(default)]` 而不是依赖调用方传完整配置,目的是让示例更健壮:
/// - 新增字段后,旧配置仍然可以工作。
/// - 每个字段都有清晰的默认值。
/// - `validate()` 负责做业务约束校验,把解析错误和业务错误分开。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
struct ExamplePluginConfig {
/// 后台任务的心跳间隔,单位毫秒。
heartbeat_interval_ms: u64,
/// 启动后示例消息要发给哪个插件。
target_plugin: String,
/// 是否在 `start()` 时发送一组教学用途的示例消息。
announce_on_start: bool,
/// 是否启用后台定时任务。
enable_periodic_task: bool,
/// 周期性上报里携带的示例文本。
periodic_payload: String,
/// 用来演示可配置的自测失败项。
optional_test_should_fail: bool,
}
impl Default for ExamplePluginConfig {
fn default() -> Self {
Self {
heartbeat_interval_ms: 5_000,
target_plugin: PLUGIN_ID.to_string(),
announce_on_start: true,
enable_periodic_task: true,
periodic_payload: "heartbeat-from-example-plugin".to_string(),
optional_test_should_fail: false,
}
}
}
impl ExamplePluginConfig {
/// 解析 JSON 配置,并在成功后执行额外校验。
fn from_json(config_json: &str) -> Result<Self, String> {
let trimmed = config_json.trim();
let mut config = if trimmed.is_empty() || trimmed == "null" {
Self::default()
} else {
serde_json::from_str::<Self>(trimmed)
.map_err(|error| format!("failed to parse example plugin config: {error}"))?
};
config.validate()?;
config.target_plugin = config.target_plugin.trim().to_string();
config.periodic_payload = config.periodic_payload.trim().to_string();
Ok(config)
}
/// 处理 `ConfigReloaded` 的场景,调用方式与初始化时保持一致。
fn from_value(value: serde_json::Value) -> Result<Self, String> {
let mut config = serde_json::from_value::<Self>(value)
.map_err(|error| format!("failed to decode reloaded config: {error}"))?;
config.validate()?;
config.target_plugin = config.target_plugin.trim().to_string();
config.periodic_payload = config.periodic_payload.trim().to_string();
Ok(config)
}
fn validate(&self) -> Result<(), String> {
if self.target_plugin.trim().is_empty() {
return Err("config field `target_plugin` must not be empty".to_string());
}
if self.periodic_payload.trim().is_empty() {
return Err("config field `periodic_payload` must not be empty".to_string());
}
if self.enable_periodic_task && self.heartbeat_interval_ms < 100 {
return Err(
"config field `heartbeat_interval_ms` must be at least 100 when the periodic task is enabled"
.to_string(),
);
}
Ok(())
}
}
/// 一个更完整的示例插件实现。
pub struct ExamplePlugin {
sender: Option<MessageSender>,
/// 用于演示可配置的自测失败
/// `MessageSender` 会在 `init()` 时由宿主注入。
///
/// 用 `Arc` 包起来是为了后台线程可以安全共享同一个 sender。
sender: Option<Arc<MessageSender>>,
/// 当前生效配置。
config: ExamplePluginConfig,
/// 用于结束后台线程的停止信号。
worker_stop: Arc<AtomicBool>,
/// 后台线程句柄;在 `stop()` 和配置重载时负责回收。
worker: Option<JoinHandle<()>>,
/// 用于演示 `self_test()` 如何暴露可选失败项。
fail_optional_test: bool,
}
@@ -16,85 +130,507 @@ impl ExamplePlugin {
pub fn new() -> Self {
Self {
sender: None,
config: ExamplePluginConfig::default(),
worker_stop: Arc::new(AtomicBool::new(false)),
worker: None,
fail_optional_test: false,
}
}
fn sender(&self) -> Result<&Arc<MessageSender>, String> {
self.sender
.as_ref()
.ok_or_else(|| "plugin sender is not initialized; call init() first".to_string())
}
/// 发送一个“插件已准备好”的管理消息。
///
/// 这是插件初始化阶段最常见的通知类型之一。
fn notify_ready(&self) -> Result<(), String> {
self.sender()?
.send_to_manager(PLUGIN_ID, Message::PluginReady(PLUGIN_ID.to_string()));
Ok(())
}
/// 演示 `MessageSender` 的四种常见用法:
/// - `send_to_manager()`
/// - `broadcast()`
/// - `send_to()`
/// - `send()` + 原始 `Envelope`
fn emit_demo_messages(&self) -> Result<(), String> {
let sender = self.sender()?;
sender.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.lifecycle".to_string(),
payload: "start() completed".to_string(),
},
);
sender.broadcast(
PLUGIN_ID,
Message::StateChanged {
old_state: "initialized".to_string(),
new_state: "running".to_string(),
},
);
sender.send_to(
PLUGIN_ID,
&self.config.target_plugin,
Message::Trigger {
name: "example.handshake".to_string(),
value: "hello-from-example-plugin".to_string(),
},
);
let config_payload = serde_json::to_string(&self.config)
.map_err(|error| format!("failed to serialize config snapshot: {error}"))?;
sender.send(&Envelope {
from: PLUGIN_ID.to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: "example.config_snapshot".to_string(),
payload: config_payload,
},
});
Ok(())
}
/// 启动一个简单的后台线程,周期性向管理层发送心跳消息。
///
/// 这里故意使用 `thread::sleep`,因为这是第三方插件开发者最容易理解的最小示例。
fn start_worker(&mut self) -> Result<(), String> {
self.stop_worker()?;
if !self.config.enable_periodic_task {
eprintln!("[ExamplePlugin] periodic task disabled by config");
return Ok(());
}
let sender = Arc::clone(self.sender()?);
let stop_flag = Arc::clone(&self.worker_stop);
let interval = Duration::from_millis(self.config.heartbeat_interval_ms);
let payload = self.config.periodic_payload.clone();
stop_flag.store(false, Ordering::SeqCst);
self.worker = Some(thread::spawn(move || {
let mut tick = 0_u64;
loop {
thread::sleep(interval);
if stop_flag.load(Ordering::SeqCst) {
break;
}
tick += 1;
sender.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.heartbeat".to_string(),
payload: format!("{{\"tick\":{tick},\"payload\":{payload:?}}}"),
},
);
}
}));
Ok(())
}
/// 停止后台线程并回收资源。
fn stop_worker(&mut self) -> Result<(), String> {
self.worker_stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.worker.take() {
handle
.join()
.map_err(|_| "example background worker panicked".to_string())?;
}
Ok(())
}
/// 统一处理触发器消息,让 `handle_message()` 保持可读性。
fn handle_trigger(&mut self, name: String, value: String) -> Result<(), String> {
eprintln!("[ExamplePlugin] trigger received: {name}={value}");
match name.as_str() {
"example.report_self_test" => {
let payload = serde_json::to_string(&self.self_test())
.map_err(|error| format!("failed to serialize self_test results: {error}"))?;
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.self_test_report".to_string(),
payload,
},
);
}
"example.send_demo_messages" => {
self.emit_demo_messages()?;
}
_ => {
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.trigger_ack".to_string(),
payload: format!("unknown trigger ignored: {name}={value}"),
},
);
}
}
Ok(())
}
/// 处理配置热重载。
///
/// 最佳实践:
/// 1. 先解析并校验新配置。
/// 2. 仅在成功后替换运行时状态。
/// 3. 如果配置会影响后台任务,重启对应任务。
fn reload_config(&mut self, value: serde_json::Value) -> Result<(), String> {
let next_config = ExamplePluginConfig::from_value(value)?;
self.config = next_config;
self.fail_optional_test = self.config.optional_test_should_fail;
self.start_worker()?;
let payload = serde_json::to_string(&self.config)
.map_err(|error| format!("failed to serialize reloaded config: {error}"))?;
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.config_reloaded".to_string(),
payload,
},
);
Ok(())
}
}
impl ShowenPlugin for ExamplePlugin {
fn info(&self) -> PluginInfo {
PluginInfo {
name: "example-plugin".to_string(),
version: "0.1.0".to_string(),
description: "示例动态插件".to_string(),
name: PLUGIN_ID.to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
description: "Feature-complete example plugin for third-party developers".to_string(),
platform: "Any".to_string(),
}
}
fn capabilities(&self) -> Vec<String> {
vec!["logging".to_string(), "metrics".to_string()]
vec![
CAP_MESSAGE_SENDER.to_string(),
CAP_MESSAGE_ROUTING.to_string(),
CAP_CONFIG_PARSING.to_string(),
CAP_BACKGROUND_TASK.to_string(),
CAP_SELF_TEST.to_string(),
]
}
fn self_test(&mut self) -> Vec<CapabilityTestResult> {
let config_ok = self.config.validate().is_ok();
let worker_ok = !self.config.enable_periodic_task || self.worker.is_some();
let sender_ready = self.sender.is_some();
vec![
CapabilityTestResult {
capability: "logging".to_string(),
passed: true,
message: "log output verified".to_string(),
capability: CAP_MESSAGE_SENDER.to_string(),
passed: sender_ready,
message: if sender_ready {
"MessageSender injected during init()".to_string()
} else {
"MessageSender not available yet".to_string()
},
},
CapabilityTestResult {
capability: "metrics".to_string(),
capability: CAP_MESSAGE_ROUTING.to_string(),
passed: sender_ready,
message: "send_to_manager / broadcast / send_to / send are implemented".to_string(),
},
CapabilityTestResult {
capability: CAP_CONFIG_PARSING.to_string(),
passed: config_ok,
message: if config_ok {
"config parsed with serde defaults + validation".to_string()
} else {
"current config failed validation".to_string()
},
},
CapabilityTestResult {
capability: CAP_BACKGROUND_TASK.to_string(),
passed: worker_ok,
message: if self.config.enable_periodic_task {
if worker_ok {
"periodic worker thread is running".to_string()
} else {
"periodic worker thread is not running".to_string()
}
} else {
"periodic task disabled by config".to_string()
},
},
CapabilityTestResult {
capability: CAP_SELF_TEST.to_string(),
passed: !self.fail_optional_test,
message: if self.fail_optional_test {
"metrics backend unreachable".to_string()
"optional self-test failure requested by config".to_string()
} else {
"metrics endpoint ok".to_string()
"self-test harness operational".to_string()
},
},
]
}
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> {
eprintln!("[ExamplePlugin] init called, config length: {}", config_json.len());
self.sender = Some(sender);
eprintln!(
"[ExamplePlugin] init called, received config bytes: {}",
config_json.len()
);
// 通知主程序就绪
if let Some(sender) = &self.sender {
sender.send_to_manager(
"example-plugin",
Message::PluginReady("example-plugin".to_string()),
);
}
let config = ExamplePluginConfig::from_json(config_json)?;
self.config = config;
self.fail_optional_test = self.config.optional_test_should_fail;
self.sender = Some(Arc::new(sender));
self.notify_ready()?;
Ok(())
}
fn start(&mut self) -> Result<(), String> {
eprintln!("[ExamplePlugin] started");
eprintln!("[ExamplePlugin] start called");
if self.config.announce_on_start {
self.emit_demo_messages()?;
}
self.start_worker()?;
Ok(())
}
fn handle_message(&mut self, message: Message) -> Result<(), String> {
match &message {
match message {
Message::PlayerCommand(payload) => {
eprintln!("[ExamplePlugin] player command: {payload}");
}
Message::PlayerStatus(payload) => {
eprintln!("[ExamplePlugin] player status: {payload}");
}
Message::Trigger { name, value } => {
self.handle_trigger(name, value)?;
}
Message::StateChanged {
old_state,
new_state,
} => {
eprintln!("[ExamplePlugin] state changed: {old_state} -> {new_state}");
}
Message::ScreenLockRequest(locked) => {
eprintln!("[ExamplePlugin] screen lock requested: {locked}");
}
Message::CursorVisibility(visible) => {
eprintln!("[ExamplePlugin] cursor visibility requested: {visible}");
}
Message::WifiCommand(payload) => {
eprintln!("[ExamplePlugin] wifi command payload: {payload}");
}
Message::WifiResult(result) => {
eprintln!("[ExamplePlugin] wifi result: {result}");
}
Message::WifiProvisioned { ssid, ip } => {
eprintln!("[ExamplePlugin] wifi provisioned: ssid={ssid}, ip={ip}");
self.sender()?.broadcast(
PLUGIN_ID,
Message::Custom {
kind: "example.wifi_provisioned".to_string(),
payload: format!("ssid={ssid}, ip={ip}"),
},
);
}
Message::ConfigReloaded(config) => {
self.reload_config(config)?;
}
Message::ConfigReloadRequest => {
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.reload_request".to_string(),
payload: "host should respond with ConfigReloaded(JSON)".to_string(),
},
);
}
Message::Shutdown => {
eprintln!("[ExamplePlugin] received shutdown");
eprintln!("[ExamplePlugin] shutdown requested");
self.stop()?;
}
Message::PluginReady(plugin_name) => {
eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}");
}
Message::Custom { kind, payload } => {
eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}");
}
_ => {
eprintln!("[ExamplePlugin] received message: {:?}", message);
}
}
Ok(())
}
fn stop(&mut self) -> Result<(), String> {
eprintln!("[ExamplePlugin] stopped");
eprintln!("[ExamplePlugin] stop called");
self.stop_worker()?;
self.sender = None;
Ok(())
}
}
// 导出 FFI 接口
// 导出动态插件 FFI 接口
export_plugin!(ExamplePlugin, ExamplePlugin::new());
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::{c_char, c_void, CStr};
use std::sync::Mutex;
use std::thread;
struct Recorder {
envelopes: Box<Mutex<Vec<Envelope>>>,
}
impl Recorder {
fn new() -> Self {
Self {
envelopes: Box::new(Mutex::new(Vec::new())),
}
}
fn sender(&self) -> MessageSender {
let ctx = (&*self.envelopes as *const Mutex<Vec<Envelope>>) as *mut c_void;
MessageSender::new(ctx, record_envelope)
}
fn snapshot(&self) -> Vec<Envelope> {
self.envelopes
.lock()
.expect("recorder mutex poisoned")
.clone()
}
}
unsafe extern "C" fn record_envelope(ctx: *mut c_void, envelope_json: *const c_char) {
let storage = unsafe { &*(ctx as *const Mutex<Vec<Envelope>>) };
let raw = unsafe { CStr::from_ptr(envelope_json) }
.to_str()
.expect("callback JSON should be valid UTF-8");
let envelope =
serde_json::from_str::<Envelope>(raw).expect("callback JSON should decode to Envelope");
storage
.lock()
.expect("recorder mutex poisoned")
.push(envelope);
}
#[test]
fn config_defaults_when_json_is_empty() {
assert_eq!(
ExamplePluginConfig::from_json(" ").expect("empty config should use defaults"),
ExamplePluginConfig::default()
);
}
#[test]
fn config_rejects_too_small_heartbeat_interval() {
let error = ExamplePluginConfig::from_json(
r#"{"heartbeat_interval_ms":99,"enable_periodic_task":true}"#,
)
.expect_err("config should be rejected");
assert!(error.contains("heartbeat_interval_ms"));
}
#[test]
fn start_sends_demo_messages_and_heartbeat() {
let recorder = Recorder::new();
let mut plugin = ExamplePlugin::new();
plugin
.init(
r#"{
"heartbeat_interval_ms": 100,
"target_plugin": "example-plugin",
"announce_on_start": true,
"enable_periodic_task": true,
"periodic_payload": "test-heartbeat"
}"#,
recorder.sender(),
)
.expect("init should succeed");
plugin.start().expect("start should succeed");
thread::sleep(Duration::from_millis(130));
plugin.stop().expect("stop should succeed");
let envelopes = recorder.snapshot();
assert!(
envelopes
.iter()
.any(|env| matches!(env.message, Message::PluginReady(_))),
"expected PluginReady message"
);
assert!(
envelopes
.iter()
.any(|env| { matches!(env.message, Message::StateChanged { .. }) }),
"expected StateChanged broadcast"
);
assert!(
envelopes
.iter()
.any(|env| matches!(env.message, Message::Trigger { .. })),
"expected Trigger direct message"
);
assert!(
envelopes.iter().any(|env| match &env.message {
Message::Custom { kind, .. } => kind == "example.heartbeat",
_ => false,
}),
"expected heartbeat custom message"
);
}
#[test]
fn config_reloaded_updates_runtime_state() {
let recorder = Recorder::new();
let mut plugin = ExamplePlugin::new();
plugin
.init(
r#"{
"announce_on_start": false,
"enable_periodic_task": false
}"#,
recorder.sender(),
)
.expect("init should succeed");
plugin.start().expect("start should succeed");
plugin
.handle_message(Message::ConfigReloaded(serde_json::json!({
"heartbeat_interval_ms": 100,
"target_plugin": "example-plugin",
"announce_on_start": false,
"enable_periodic_task": true,
"periodic_payload": "reloaded-heartbeat",
"optional_test_should_fail": true
})))
.expect("config reload should succeed");
assert!(plugin.config.enable_periodic_task);
assert_eq!(plugin.config.periodic_payload, "reloaded-heartbeat");
assert!(plugin.fail_optional_test);
assert!(plugin.worker.is_some());
plugin.stop().expect("stop should succeed");
}
}