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>>, 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>>) -> std::sync::MutexGuard<'_, Vec> { events.lock().expect("events mutex poisoned") } fn has_event(events: &Arc>>, expected: &str) -> bool { lock_events(events).iter().any(|event| event == expected) } fn event_position(events: &Arc>>, 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, events: Arc>>, } impl RecordingPlugin { fn new(id: &str, deps: Vec<&str>, events: Arc>>) -> Self { Self { id: id.to_string(), deps: deps.into_iter().map(str::to_string).collect(), events, } } fn record(&self, entry: impl Into) { 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 { 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"); }