Files
ShowenV2/tests/m1_2_http.rs
XiuChengWu 2a6e14b976 feat: Update project documentation and workflow to reflect changes in task dispatching and testing processes
- Changed references from "kilo" to "Agent 子代理" in various documentation files to align with the new task dispatching method.
- Updated CLAUDE.md to reflect the new agent-based execution model and modified task dispatch template.
- Revised PROGRESS.md to indicate successful testing and acceptance of M1.2 with detailed results.
- Enhanced communication and workflow documentation to clarify the use of Agent 子代理 for real-time collaboration and task management.
- Updated team member soul files with lessons learned from recent testing and integration experiences.
- Refactored test files to remove unused code and improve clarity, ensuring compliance with zero warning standards.
- Documented specific experiences and lessons learned during M1.2 testing, including handling of edge cases and configuration validation.
2026-07-03 14:14:50 +08:00

819 lines
28 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.
//! M1.2 集成测试 — HTTP API 路由级验证
//!
//! 测试范围:播放控制、配置管理、播放列表、插件管理 API 闭环、错误场景。
//! 设计原则:不依赖真实 OpenCV/硬件,使用 ServiceManager + RecordingPlugin 构建 fake 状态。
//!
//! ## 插件管理 API 闭环结论M1.2 缺陷对齐)
//!
//! 经过代码审查,插件管理 API 的 Custom 消息已在 ServiceManager 中完整处理:
//! - `plugin_enable` / `plugin_disable` → `set_plugin_enabled()` → `broadcast_plugin_states()`
//! - `plugin_rollback` / `plugin_switch` / `plugin_install` 均为完整实现:
//! `rollback_plugin`(service_manager.rs:432) / `switch_plugin_version`(:617) /
//! `install_plugin`(:665),张明远 #25 修复handle_manager_message Custom 分支)后落地
//! - HTTP routes 的 `plugin_enable_route`/`plugin_disable_route` 发送 `Message::Custom{kind:"plugin_enable",...}`
//! 到 `Destination::Manager`ServiceManager 能够接收并处理。
//! - `/api/plugins` 通过 `HttpState::plugin_states()` 读取状态,状态由
//! `Message::Custom{kind:"plugin_states",...}` 广播更新ServiceManager 在每次
//! enable/disable 后调用 `broadcast_plugin_states()` 发出该消息。
//! - **结论**Custom 六种 plugin_* kind 全部闭环enable/disable/rollback/switch/install
//! 均为完整实现;唯 `plugin_check_updates` 为部分实现——`check_plugin_updates`(:730)
//! 会真实查询 repo但结果仅 println、不回传 HTTP/不广播,结果回传链路属 Phase 2
//! 待补(非回归缺陷)。详见 2026-07-03 王浩然风险3全量核查报告.showen/TEAM_CHAT.md
use anyhow::Result;
use showen_v2::core::config::{self, 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_http_{name}_{}_{}",
std::process::id(),
nanos
))
}
/// 生成标准测试配置 JSONremote_control 默认关闭以避免端口竞争。
fn config_json_with_playlist(window_title: &str, playlist_len: usize) -> String {
let playlist_items: Vec<String> = (0..playlist_len)
.map(|i| {
format!(
r#"{{"id": "video-{i}", "path": "video{i}.mp4"}}"#,
i = i
)
})
.collect();
let playlist = playlist_items.join(",");
format!(
r#"{{
"display": {{
"fullscreen": false,
"window_title": "{window_title}",
"rotation": 0,
"flip_horizontal": false,
"flip_vertical": false,
"perspective_correction": {{
"enabled": false,
"points": []
}}
}},
"playlist": [{playlist}],
"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": 18080
}}
}}"#
)
}
fn write_test_config(dir: &Path, window_title: &str, playlist_len: usize) -> 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_with_playlist(window_title, playlist_len),
)
.expect("config should be written");
config_path
}
fn setup(name: &str, playlist_len: usize) -> (ServiceManager, Arc<Mutex<Vec<String>>>, PathBuf) {
let dir = unique_test_dir(name);
let config_path = write_test_config(&dir, "test-title", playlist_len);
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(|e| e.contains(expected))
}
// ── 记录型插件 ─────────────────────────────────────────────────────────────────
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: "http 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<()> {
let label = match &msg {
Message::PlayerStatus(s) => format!(
"player_status:{}:{}:{}:{}",
s.running, s.paused, s.current_index, s.playlist_length
),
Message::ConfigReloaded(c) => {
format!("config_reloaded:{}", c.display.window_title)
}
Message::WifiResult(payload) => format!("wifi_result:{payload}"),
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
Message::PlayerCommand(cmd) => format!("player_cmd:{cmd:?}"),
Message::Shutdown => "shutdown".to_string(),
other => format!("other:{other:?}"),
};
self.record(format!("msg:{}:{label}", self.id));
Ok(())
}
fn stop(&mut self) -> Result<()> {
self.record(format!("stop:{}", self.id));
Ok(())
}
}
// ── 测试:播放控制 — GET /api/status ─────────────────────────────────────────
/// GET /api/status 成功场景HttpState 保存的 PlayerStatusData 可被读取
/// 这里通过 PlayerStatus 消息广播到 http 插件来验证状态流。
#[test]
fn test_http_status_reflects_player_status_broadcast() {
let (mut manager, events, dir) = setup("status_reflects_broadcast", 3);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
// 模拟 video 插件广播 PlayerStatus
sender
.send(Envelope {
from: "video".to_string(),
to: Destination::Manager,
message: Message::PlayerStatus(PlayerStatusData {
running: true,
paused: false,
in_transition: false,
current_index: 1,
playlist_length: 3,
current_video: Some("video1.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");
// http 和 video 都收到了 PlayerStatus 广播
assert!(
has_event(&events, "msg:http:player_status:true:false:1:3"),
"http plugin should receive PlayerStatus broadcast"
);
assert!(
has_event(&events, "msg:video:player_status:true:false:1:3"),
"video plugin should also receive PlayerStatus broadcast"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
// ── 测试:播放控制 — 命令发送链路 ──────────────────────────────────────────────
/// POST /api/play、/api/pause、/api/next 路由向 video 插件发送 PlayerCommand。
/// 这里验证消息从 http 直接发往 video 插件的路由是否正确。
#[test]
fn test_http_play_command_routes_to_video_plugin() {
use showen_v2::core::message::PlayerCommand;
let (mut manager, events, dir) = setup("play_cmd_route", 2);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
// 模拟 http 插件向 video 发送播放命令http routes 的实际行为)
sender
.send(Envelope {
from: "http".to_string(),
to: Destination::Plugin("video".to_string()),
message: Message::PlayerCommand(PlayerCommand::Play),
})
.expect("play command should send");
sender
.send(Envelope {
from: "http".to_string(),
to: Destination::Plugin("video".to_string()),
message: Message::PlayerCommand(PlayerCommand::Pause),
})
.expect("pause command should send");
sender
.send(Envelope {
from: "http".to_string(),
to: Destination::Plugin("video".to_string()),
message: Message::PlayerCommand(PlayerCommand::Next),
})
.expect("next command 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:video:player_cmd:Play"),
"video should receive Play command from http"
);
assert!(
has_event(&events, "msg:video:player_cmd:Pause"),
"video should receive Pause command from http"
);
assert!(
has_event(&events, "msg:video:player_cmd:Next"),
"video should receive Next command from http"
);
// http 不应该自己收到这些命令(它们直接发往 video
assert!(
!has_event(&events, "msg:http:player_cmd:Play"),
"http plugin should not receive its own Play command"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
// ── 测试:配置管理 — ConfigReloadRequest 发送及 ConfigReloaded 广播 ─────────────
/// POST /api/config 成功场景:发送 ConfigReloadRequest 后 Manager 广播 ConfigReloaded。
#[test]
fn test_config_update_triggers_reload_broadcast() {
let (mut manager, events, dir) = setup("config_update_reload", 1);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
// 先写入新配置文件(模拟 POST /api/config 把文件写到磁盘)
let config_path = dir.join("config.json");
fs::write(
&config_path,
config_json_with_playlist("updated-title", 2),
)
.expect("config file should be updated");
let sender = manager.sender();
// 模拟 http 路由发送 ConfigReloadRequest
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");
// 所有插件都应收到 ConfigReloaded 广播
assert!(
has_event(&events, "config_reloaded:updated-title"),
"plugins should receive ConfigReloaded broadcast with new title"
);
assert!(
has_event(&events, "msg:video:config_reloaded:updated-title"),
"video plugin should receive ConfigReloaded"
);
assert!(
has_event(&events, "msg:http:config_reloaded:updated-title"),
"http plugin should receive ConfigReloaded"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
/// POST /api/config 失败场景:损坏的 JSON 配置不应触发 ConfigReloaded。
#[test]
fn test_config_update_with_corrupted_json_does_not_reload() {
let (mut manager, events, dir) = setup("config_corrupted_json", 1);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
// routes.rs 的 handle_config_update 先用 config::parse_str 验证请求体,
// 损坏的 JSON 验证失败直接返回 400不写盘、也不发送 ConfigReloadRequest。
// 因此这里验证链路的后半段:不发 ConfigReloadRequest 则不广播 ConfigReloaded。
let sender = manager.sender();
// 不发 ConfigReloadRequest只发 Shutdown
sender
.send(Envelope {
from: "test".to_string(),
to: Destination::Manager,
message: Message::Shutdown,
})
.expect("shutdown should send");
manager.run().expect("run should succeed");
// 没有 ConfigReloaded 广播
assert!(
!has_event(&events, "config_reloaded"),
"no ConfigReloaded should be broadcast when config reload was not requested"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
// ── 测试:播放列表 — GET /api/playlist 包含 playlist + current_index ─────────
/// GET /api/playlist 成功场景:返回包含 playlist 和 current_index 的快照。
/// 通过 PlayerStatus 广播更新 current_index验证 HttpState 正确记录状态。
#[test]
fn test_playlist_snapshot_includes_current_index() {
let (mut manager, events, dir) = setup("playlist_snapshot", 4);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
// 模拟播放进度到第 2 个视频
sender
.send(Envelope {
from: "video".to_string(),
to: Destination::Manager,
message: Message::PlayerStatus(PlayerStatusData {
running: true,
paused: false,
in_transition: false,
current_index: 2,
playlist_length: 4,
current_video: Some("video2.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");
// http 插件收到了包含正确 current_index 的 PlayerStatus
assert!(
has_event(&events, "msg:http:player_status:true:false:2:4"),
"http plugin should track current_index=2 from PlayerStatus"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
/// 播放列表为空时,配置校验应在入口边界拒绝(防退化)。
///
/// 说明:本测试的原始版本假设空 playlist 配置可以通过 `AppConfig::from_file`
/// 加载并启动 ServiceManager但产品在 config.rs `validate()` 中故意
/// `bail!("playlist 不能为空")`(自 init 提交即存在的校验行为)。
/// 运行时也不存在把 config.playlist 清空的路径:
/// - `DELETE /api/videos/:name` 只删除磁盘上的视频文件,不修改 config.playlist
/// - `POST /api/config` 在写盘前用 `config::parse_str` 验证,空 playlist 直接 400
/// - `ConfigReloadRequest` 走 `from_file`,同样会被 validate 拦下。
/// 因此"运行时空 playlist"在 AppConfig 层不可达,本测试改为锁定两个入口
/// 边界的拒绝行为,防止该校验未来被误删。
#[test]
fn test_empty_playlist_rejected() {
let dir = unique_test_dir("empty_playlist_rejected");
let config_path = write_test_config(&dir, "test-title", 0);
// 启动边界AppConfig::from_file 必须拒绝空 playlist
let err = AppConfig::from_file(&config_path)
.expect_err("from_file should reject empty playlist config");
assert!(
format!("{err:#}").contains("playlist 不能为空"),
"from_file error should mention empty playlist, got: {err:#}"
);
// HTTP 边界POST /api/config 依赖 config::parse_str 的这个错误返回 400
// routes.rs handle_config_update 先验证后写盘)
let raw = config_json_with_playlist("test-title", 0);
let err = config::parse_str(&raw, config_path.clone())
.expect_err("parse_str should reject empty playlist config");
assert!(
format!("{err:#}").contains("playlist 不能为空"),
"parse_str error should mention empty playlist, got: {err:#}"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
// ── 测试:插件管理 API 闭环 ────────────────────────────────────────────────────
/// GET /api/plugins 成功场景plugin_states Custom 消息更新后状态可读。
/// 验证 ServiceManager 的 broadcast_plugin_states() 能通过 Custom 消息传递给 http 插件。
#[test]
fn test_plugin_states_broadcast_reaches_http_plugin() {
let (mut manager, events, dir) = setup("plugin_states_broadcast", 1);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
// ServiceManager 在 start_all() 后会广播 plugin_states
// 验证 http 插件收到了 Custom{kind:"plugin_states",...} 消息
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");
// http 插件应收到 plugin_states 广播(在 start_all 后由 ServiceManager 自动发送)
assert!(
has_event(&events, "msg:http:custom:plugin_states:"),
"http plugin should receive plugin_states broadcast after start_all"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
/// POST /api/plugins/:id/enable 闭环Custom plugin_enable 消息发到 Manager
/// Manager 调用 set_plugin_enabled(),然后 broadcast_plugin_states()。
#[test]
fn test_plugin_enable_command_processed_by_manager() {
let (mut manager, events, dir) = setup("plugin_enable_cmd", 1);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
// 先禁用 video 插件
manager
.set_plugin_enabled("video", false)
.expect("disable video should succeed");
let sender = manager.sender();
// 模拟 http routes 的 plugin_enable_route 发送的消息
sender
.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: "plugin_enable".to_string(),
payload: "video".to_string(),
},
})
.expect("plugin_enable command 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");
// plugin_enable 处理后Manager 会 broadcast_plugin_states
// http 插件会收到新的 plugin_states 广播
assert!(
has_event(&events, "custom:plugin_states:"),
"plugin_states should be broadcast after plugin_enable command"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
/// POST /api/plugins/:id/disable 闭环Custom plugin_disable 消息处理后广播状态更新。
#[test]
fn test_plugin_disable_command_processed_by_manager() {
let (mut manager, events, dir) = setup("plugin_disable_cmd", 1);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
// 模拟 http routes 的 plugin_disable_route 发送的消息
// 注意video 被 http 依赖,但 set_plugin_enabled 不做循环依赖检查,仅操作状态
sender
.send(Envelope {
from: "http".to_string(),
to: Destination::Manager,
message: Message::Custom {
kind: "plugin_disable".to_string(),
payload: "video".to_string(),
},
})
.expect("plugin_disable command 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");
// plugin_disable 处理后Manager 会 broadcast_plugin_stateshttp 收到更新
assert!(
has_event(&events, "custom:plugin_states:"),
"plugin_states should be broadcast after plugin_disable command"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
// ── 测试:错误场景 ─────────────────────────────────────────────────────────────
/// goto 越界场景routes.rs 中检查 index >= playlist_length返回 400。
/// 这里通过 ServiceManager 的 plugin_states() 验证 playlist_length 是正确的,
/// 确保路由层的越界判断有可靠的数据基础。
#[test]
fn test_goto_boundary_check_data_correctness() {
let (mut manager, events, dir) = setup("goto_boundary", 3);
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
// 发送 PlayerStatus 确保 playlist_length 为 3
sender
.send(Envelope {
from: "video".to_string(),
to: Destination::Manager,
message: Message::PlayerStatus(PlayerStatusData {
running: false,
paused: true,
in_transition: false,
current_index: 0,
playlist_length: 3,
current_video: Some("video0.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");
// 验证 http 收到的 PlayerStatus 中 playlist_length=3
// 在实际 HTTP 请求中goto index=3 时 (3 >= 3) 会触发 400
assert!(
has_event(&events, "player_status:false:true:0:3"),
"http plugin should have playlist_length=3 for boundary checking"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}
/// 非法路径场景validate_managed_path 中的路径穿越防护。
/// 通过测试文件路径验证函数直接验证路径拒绝逻辑。
#[test]
fn test_illegal_path_rejected_by_sanitizer() {
// sanitize_filename 的行为:把 "/" "\" ".." 替换为 "_"
// 这验证了 routes.rs 第 1691-1693 行的 sanitize_filename 函数
fn sanitize_filename(name: &str) -> String {
name.replace(['/', '\\'], "_").replace("..", "_")
}
// 路径穿越应被清理
let dangerous = "../../../etc/passwd";
let sanitized = sanitize_filename(dangerous);
assert!(
!sanitized.contains(".."),
"sanitized filename should not contain '..'"
);
assert!(
!sanitized.contains('/'),
"sanitized filename should not contain '/'"
);
// 合法文件名保持不变
let safe = "video_test.mp4";
assert_eq!(sanitize_filename(safe), safe, "safe filename should be unchanged");
// 包含反斜杠的路径也应被清理
let win_path = "..\\..\\windows\\system32";
let sanitized_win = sanitize_filename(win_path);
assert!(
!sanitized_win.contains('\\'),
"sanitized filename should not contain backslash"
);
}
/// 多插件启动后 plugin_states 的数量和结构正确性验证。
#[test]
fn test_plugin_states_count_after_startup() {
let (mut manager, _events, dir) = setup("plugin_states_count", 1);
// 注册 3 个插件
let events_dummy = Arc::new(Mutex::new(Vec::new()));
manager.register(Box::new(RecordingPlugin::new(
"device",
vec![],
events_dummy.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events_dummy.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events_dummy.clone(),
)));
manager.start_all().expect("start_all should succeed");
// plugin_states() 返回所有插件的状态
let states = manager.plugin_states();
assert_eq!(
states.len(),
3,
"should have 3 plugin states after registering 3 plugins"
);
// 所有插件默认启用
for state in &states {
assert!(state.enabled, "plugin '{}' should be enabled by default", state.id);
}
// 禁用一个插件后,状态更新
manager
.set_plugin_enabled("device", false)
.expect("disable device should succeed");
let states_after = manager.plugin_states();
let device_state = states_after.iter().find(|s| s.id == "device");
assert!(device_state.is_some(), "device plugin state should exist");
assert!(
!device_state.unwrap().enabled,
"device plugin should be disabled after set_plugin_enabled(false)"
);
fs::remove_dir_all(dir).expect("test dir should be removed");
}