Files
ShowenV2/plugins/example-plugin/src/lib.rs
showen d30c111c71 feat: M1.1 完成 + M1.2 启动 — 全量更新
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>
2026-03-14 18:12:42 +08:00

849 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 示例动态插件 — 展示如何使用 `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, Destination, DeviceCommand, DeviceResponse, 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";
const CAP_REQUEST_RESPONSE: &str = "request_response";
/// 示例插件配置。
///
/// 这里使用 `#[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,
/// DevicePlugin 的插件 ID用于演示请求/响应模式。
device_plugin: String,
/// 是否在 `start()` 时请求一次显示信息。
request_display_info_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,
device_plugin: "device".to_string(),
request_display_info_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.normalize();
config.validate()?;
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.normalize();
config.validate()?;
Ok(config)
}
fn normalize(&mut self) {
self.target_plugin = self.target_plugin.trim().to_string();
self.device_plugin = self.device_plugin.trim().to_string();
self.periodic_payload = self.periodic_payload.trim().to_string();
}
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.request_display_info_on_start && self.device_plugin.trim().is_empty() {
return Err(
"config field `device_plugin` must not be empty when `request_display_info_on_start` is enabled"
.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 {
/// `MessageSender` 会在 `init()` 时由宿主注入。
///
/// 用 `Arc` 包起来是为了后台线程可以安全共享同一个 sender。
sender: Option<Arc<MessageSender>>,
/// 当前生效配置。
config: ExamplePluginConfig,
/// 用于结束后台线程的停止信号。
worker_stop: Arc<AtomicBool>,
/// 后台线程句柄;在 `stop()` 和配置重载时负责回收。
worker: Option<JoinHandle<()>>,
/// 用于演示 `self_test()` 如何暴露可选失败项。
fail_optional_test: bool,
}
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(())
}
/// 演示典型的请求/响应模式:向 DevicePlugin 请求一次显示信息,随后在
/// `handle_message()` 里处理 `Message::DeviceResponse`。
fn request_display_info(&self) -> Result<(), String> {
let sender = self.sender()?;
sender.send_to(
PLUGIN_ID,
&self.config.device_plugin,
Message::DeviceCommand(DeviceCommand::GetDisplayInfo),
);
sender.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.request_sent".to_string(),
payload: format!(
"requested DeviceCommand::GetDisplayInfo from {}",
self.config.device_plugin
),
},
);
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()?;
}
"example.request_display_info" => {
self.request_display_info()?;
}
_ => {
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.trigger_ack".to_string(),
payload: format!("unknown trigger ignored: {name}={value}"),
},
);
}
}
Ok(())
}
/// 演示如何为自定义消息协议做显式解析和友好错误提示。
///
/// 这里把 `payload` 约定为 JSON模拟一个“订阅更新”场景真实业务可以换成
/// 自己的协议名称和字段。
fn handle_custom_message(&self, kind: String, payload: String) -> Result<(), String> {
eprintln!("[ExamplePlugin] custom message: kind={kind}, payload={payload}");
if kind != "example.subscription.update" {
return Ok(());
}
let parsed: serde_json::Value = serde_json::from_str(&payload).map_err(|error| {
format!(
"custom message `example.subscription.update` expects JSON payload, got parse error: {error}"
)
})?;
let topic = parsed
.get("topic")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|topic| !topic.is_empty())
.ok_or_else(|| {
"custom message `example.subscription.update` requires non-empty string field `topic`"
.to_string()
})?;
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.subscription.ack".to_string(),
payload: format!("subscription update processed for topic `{topic}`"),
},
);
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: 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![
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(),
CAP_REQUEST_RESPONSE.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: 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: 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 {
"optional self-test failure requested by config".to_string()
} else {
"self-test harness operational".to_string()
},
},
CapabilityTestResult {
capability: CAP_REQUEST_RESPONSE.to_string(),
passed: !self.config.request_display_info_on_start
|| !self.config.device_plugin.is_empty(),
message: if self.config.request_display_info_on_start {
format!(
"request/response demo targets DevicePlugin `{}`",
self.config.device_plugin
)
} else {
"request/response demo disabled by config".to_string()
},
},
]
}
fn init(&mut self, config_json: &str, sender: MessageSender) -> Result<(), String> {
eprintln!(
"[ExamplePlugin] init called, received config bytes: {}",
config_json.len()
);
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] start called");
if self.config.announce_on_start {
self.emit_demo_messages()?;
}
if self.config.request_display_info_on_start {
self.request_display_info()?;
}
self.start_worker()?;
Ok(())
}
fn handle_message(&mut self, message: Message) -> Result<(), String> {
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] shutdown requested");
self.stop()?;
}
Message::PluginReady(plugin_name) => {
eprintln!("[ExamplePlugin] observed peer readiness: {plugin_name}");
}
Message::DeviceCommand(command) => {
eprintln!("[ExamplePlugin] device command received (not handled): {command:?}");
}
Message::DeviceResponse(response) => {
eprintln!("[ExamplePlugin] device response received: {response:?}");
let summary = match response {
DeviceResponse::DisplayInfo {
width,
height,
format,
} => format!(
"display info received: width={width}, height={height}, format={format:?}"
),
DeviceResponse::BatteryLevel(level) => {
format!("battery level response received: {level}%")
}
DeviceResponse::SensorData { sensor, value } => {
format!("sensor response received: {sensor:?}={value}")
}
DeviceResponse::Ok => "device command completed successfully".to_string(),
DeviceResponse::Error(error) => format!("device command failed: {error}"),
DeviceResponse::Custom(value) => format!("custom device response: {value}"),
};
self.sender()?.send_to_manager(
PLUGIN_ID,
Message::Custom {
kind: "example.device_response".to_string(),
payload: summary,
},
);
}
Message::DeviceEvent(_event) => {
eprintln!("[ExamplePlugin] device event received (not handled)");
}
Message::Custom { kind, payload } => {
self.handle_custom_message(kind, payload)?;
}
}
Ok(())
}
fn stop(&mut self) -> Result<(), String> {
eprintln!("[ExamplePlugin] stop called");
self.stop_worker()?;
self.sender = None;
Ok(())
}
}
// 导出动态插件 FFI 接口。
//
// `export_plugin!` 会生成主程序约定的完整 ABI
// - `create` / `destroy`: 创建和销毁插件实例
// - `get_info`: 返回 `PluginInfo` 的 JSON
// - `init`: 接收配置 JSON 和宿主注入的消息发送回调
// - `start` / `stop`: 驱动插件生命周期
// - `handle_message`: 接收宿主转发的 JSON 消息并反序列化为 `Message`
// - `get_capabilities` / `self_test`: 向宿主暴露能力声明和自检结果
// - `free_string`: 释放由当前插件分配给宿主的 `FfiString`
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 config_rejects_missing_device_plugin_when_request_response_is_enabled() {
let error = ExamplePluginConfig::from_json(
r#"{"device_plugin":" ","request_display_info_on_start":true}"#,
)
.expect_err("config should be rejected");
assert!(error.contains("device_plugin"));
}
#[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",
"device_plugin": "device",
"announce_on_start": false,
"request_display_info_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");
}
#[test]
fn start_requests_display_info_when_enabled() {
let recorder = Recorder::new();
let mut plugin = ExamplePlugin::new();
plugin
.init(
r#"{
"announce_on_start": false,
"request_display_info_on_start": true,
"device_plugin": "device"
}"#,
recorder.sender(),
)
.expect("init should succeed");
plugin.start().expect("start should succeed");
plugin.stop().expect("stop should succeed");
let envelopes = recorder.snapshot();
assert!(
envelopes.iter().any(|env| {
matches!(
(&env.to, &env.message),
(
Destination::Plugin(plugin_id),
Message::DeviceCommand(DeviceCommand::GetDisplayInfo)
) if plugin_id == "device"
)
}),
"expected GetDisplayInfo request to device plugin"
);
}
#[test]
fn custom_subscription_update_requires_valid_payload() {
let recorder = Recorder::new();
let mut plugin = ExamplePlugin::new();
plugin
.init("{}", recorder.sender())
.expect("init should succeed");
let error = plugin
.handle_message(Message::Custom {
kind: "example.subscription.update".to_string(),
payload: "not-json".to_string(),
})
.expect_err("invalid payload should be rejected");
assert!(error.contains("expects JSON payload"));
}
}