test+docs: 新增4个测试(66总计) + SDK API文档 + 员工soul更新
This commit is contained in:
@@ -9,4 +9,5 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
showen-plugin-sdk = { path = "../../plugin-sdk" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user