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>
This commit is contained in:
showen
2026-03-14 18:12:42 +08:00
parent 8ed9cb2d9d
commit d30c111c71
68 changed files with 8115 additions and 1201 deletions

View File

@@ -0,0 +1,565 @@
use anyhow::Result;
use showen_v2::core::config::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_service_manager_{name}_{}_{}",
std::process::id(),
nanos
))
}
fn config_json(window_title: &str) -> String {
format!(
r#"{{
"display": {{
"fullscreen": false,
"window_title": "{window_title}",
"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
}}
}}"#
)
}
fn write_test_config(dir: &Path, window_title: &str) -> 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(window_title)).expect("config should be written");
config_path
}
fn test_manager(name: &str) -> (ServiceManager, Arc<Mutex<Vec<String>>>, PathBuf) {
let dir = unique_test_dir(name);
let config_path = write_test_config(&dir, "initial-title");
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(|event| event == expected)
}
fn event_position(events: &Arc<Mutex<Vec<String>>>, expected: &str) -> usize {
lock_events(events)
.iter()
.position(|event| event == expected)
.unwrap_or_else(|| panic!("missing expected event: {}", expected))
}
fn message_label(message: &Message) -> String {
match message {
Message::Shutdown => "shutdown".to_string(),
Message::ConfigReloadRequest => "config_reload_request".to_string(),
Message::ConfigReloaded(config) => {
format!("config_reloaded:{}", config.display.window_title)
}
Message::PlayerStatus(status) => format!(
"player_status:{}:{}:{}:{}:{}:{}",
status.running,
status.paused,
status.in_transition,
status.current_index,
status.playlist_length,
status.current_video.as_deref().unwrap_or("none")
),
Message::WifiResult(payload) => format!("wifi_result:{payload}"),
Message::StateChanged {
old_state,
new_state,
} => format!("state_changed:{old_state}->{new_state}"),
Message::PluginReady(id) => format!("plugin_ready:{id}"),
Message::Custom { kind, payload } => format!("custom:{kind}:{payload}"),
other => format!("other:{other:?}"),
}
}
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: "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<()> {
self.record(format!("msg:{}:{}", self.id, message_label(&msg)));
Ok(())
}
fn stop(&mut self) -> Result<()> {
self.record(format!("stop:{}", self.id));
Ok(())
}
}
#[test]
fn test_startup_order_matches_dependency_sort() {
let (mut manager, events, dir) = test_manager("startup_order");
manager.register(Box::new(RecordingPlugin::new(
"dashboard",
vec!["http", "screen"],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"screen",
vec!["device"],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"http",
vec!["video"],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"wifi",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"video",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"device",
vec![],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
assert!(event_position(&events, "init:device") < event_position(&events, "init:screen"));
assert!(event_position(&events, "init:video") < event_position(&events, "init:http"));
assert!(event_position(&events, "init:screen") < event_position(&events, "init:dashboard"));
assert!(event_position(&events, "init:http") < event_position(&events, "init:dashboard"));
assert!(event_position(&events, "start:device") < event_position(&events, "start:screen"));
assert!(event_position(&events, "start:video") < event_position(&events, "start:http"));
assert!(event_position(&events, "start:screen") < event_position(&events, "start:dashboard"));
assert!(event_position(&events, "start:http") < event_position(&events, "start:dashboard"));
fs::remove_dir_all(dir).expect("test dir should be removed");
}
#[test]
fn test_shutdown_stops_all_enabled_plugins() {
let (mut manager, events, dir) = test_manager("shutdown_stop_all");
manager.register(Box::new(RecordingPlugin::new(
"alpha",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"beta",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"gamma",
vec![],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
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");
assert!(has_event(&events, "msg:alpha:shutdown"));
assert!(has_event(&events, "msg:beta:shutdown"));
assert!(has_event(&events, "msg:gamma:shutdown"));
assert!(event_position(&events, "stop:gamma") < event_position(&events, "stop:beta"));
assert!(event_position(&events, "stop:beta") < event_position(&events, "stop:alpha"));
fs::remove_dir_all(dir).expect("test dir should be removed");
}
#[test]
fn test_config_reload_broadcasts_new_config() {
let (mut manager, events, dir) = test_manager("config_reload");
manager.register(Box::new(RecordingPlugin::new(
"alpha",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"beta",
vec![],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let config_path = dir.join("config.json");
fs::write(&config_path, config_json("reloaded-title")).expect("config should be updated");
let sender = manager.sender();
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");
assert!(has_event(
&events,
"msg:alpha:config_reloaded:reloaded-title"
));
assert!(has_event(
&events,
"msg:beta:config_reloaded:reloaded-title"
));
fs::remove_dir_all(dir).expect("test dir should be removed");
}
#[test]
fn test_player_status_broadcast() {
let (mut manager, events, dir) = test_manager("player_status");
manager.register(Box::new(RecordingPlugin::new(
"alpha",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"beta",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"gamma",
vec![],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
sender
.send(Envelope {
from: "video".to_string(),
to: Destination::Manager,
message: Message::PlayerStatus(PlayerStatusData {
running: true,
paused: false,
in_transition: true,
current_index: 2,
playlist_length: 5,
current_video: Some("intro.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");
let expected = "player_status:true:false:true:2:5:intro.mp4";
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
assert!(has_event(&events, &format!("msg:beta:{expected}")));
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
fs::remove_dir_all(dir).expect("test dir should be removed");
}
#[test]
fn test_wifi_result_broadcast() {
let (mut manager, events, dir) = test_manager("wifi_result");
manager.register(Box::new(RecordingPlugin::new(
"alpha",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"beta",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"gamma",
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:ssid=showen-lab".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");
let expected = "wifi_result:connected:ssid=showen-lab";
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
assert!(has_event(&events, &format!("msg:beta:{expected}")));
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
fs::remove_dir_all(dir).expect("test dir should be removed");
}
#[test]
fn test_state_changed_broadcast() {
let (mut manager, events, dir) = test_manager("state_changed");
manager.register(Box::new(RecordingPlugin::new(
"alpha",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"beta",
vec![],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
sender
.send(Envelope {
from: "video".to_string(),
to: Destination::Manager,
message: Message::StateChanged {
old_state: "idle".to_string(),
new_state: "playing".to_string(),
},
})
.expect("state changed 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:state_changed:idle->playing"));
assert!(has_event(&events, "msg:beta:state_changed:idle->playing"));
fs::remove_dir_all(dir).expect("test dir should be removed");
}
#[test]
fn test_plugin_ready_broadcast() {
let (mut manager, events, dir) = test_manager("plugin_ready");
manager.register(Box::new(RecordingPlugin::new(
"alpha",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"beta",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"gamma",
vec![],
events.clone(),
)));
manager.start_all().expect("start_all should succeed");
let sender = manager.sender();
sender
.send(Envelope {
from: "video".to_string(),
to: Destination::Manager,
message: Message::PluginReady("video".to_string()),
})
.expect("plugin ready 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:plugin_ready:video"));
assert!(has_event(&events, "msg:beta:plugin_ready:video"));
assert!(has_event(&events, "msg:gamma:plugin_ready:video"));
fs::remove_dir_all(dir).expect("test dir should be removed");
}
#[test]
fn test_disabled_plugin_skipped_in_message_routing() {
let (mut manager, events, dir) = test_manager("disabled_plugin_skip");
manager.register(Box::new(RecordingPlugin::new(
"alpha",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"beta",
vec![],
events.clone(),
)));
manager.register(Box::new(RecordingPlugin::new(
"gamma",
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: "video".to_string(),
to: Destination::Manager,
message: Message::PlayerStatus(PlayerStatusData {
running: true,
paused: true,
in_transition: false,
current_index: 1,
playlist_length: 3,
current_video: Some("paused.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");
let expected = "player_status:true:true:false:1:3:paused.mp4";
assert!(has_event(&events, &format!("msg:alpha:{expected}")));
assert!(has_event(&events, &format!("msg:gamma:{expected}")));
assert!(!has_event(&events, &format!("msg:beta:{expected}")));
assert!(!manager.plugin_states()[1].enabled);
fs::remove_dir_all(dir).expect("test dir should be removed");
}