//! Presence manager for real-time collaboration layer. //! //! This module manages user online status, cursor positions, and activity tracking //! for collaborative sessions. It uses DashMap for high-concurrency support. use chrono::{DateTime, Utc}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; use tracing::debug; use uuid::Uuid; /// Unique identifier for a WebSocket connection. pub type ConnectionId = Uuid; /// Unique identifier for a collaborative session. pub type CollabSessionId = Uuid; /// User presence status. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum PresenceStatus { /// User is actively interacting. Active, /// User has been idle for a short period. Idle, /// User is away (idle for an extended period). Away, } impl Default for PresenceStatus { fn default() -> Self { Self::Active } } /// Cursor position in a message/conversation context. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct PresenceCursor { /// Index of the message in the conversation. pub message_index: usize, /// Start character position within the message. pub char_start: usize, /// End character position within the message (for selections). pub char_end: usize, } impl Default for PresenceCursor { fn default() -> Self { Self { message_index: 0, char_start: 0, char_end: 0, } } } impl PresenceCursor { /// Create a new cursor at a specific position. pub fn new(message_index: usize, char_start: usize, char_end: usize) -> Self { Self { message_index, char_start, char_end, } } /// Create a cursor at the beginning of a message. pub fn at_message(message_index: usize) -> Self { Self { message_index, char_start: 0, char_end: 0, } } /// Check if the cursor represents a selection (range). pub fn is_selection(&self) -> bool { self.char_start != self.char_end } /// Get the length of the selection (0 if not a selection). pub fn selection_length(&self) -> usize { if self.char_start < self.char_end { self.char_end - self.char_start } else { 0 } } } /// User presence information. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PresenceUser { /// Unique connection identifier. pub connection_id: ConnectionId, /// Display name of the user. pub display_name: String, /// Current presence status. pub status: PresenceStatus, /// Current cursor position (if any). pub cursor: Option, /// Timestamp of the last activity. pub last_activity: DateTime, /// User's color for UI display (hex code). pub color: String, /// Session ID the user is currently in. pub session_id: CollabSessionId, } impl PresenceUser { /// Create a new presence user. pub fn new(connection_id: ConnectionId, display_name: String, session_id: CollabSessionId) -> Self { Self { connection_id, display_name, status: PresenceStatus::Active, cursor: None, last_activity: Utc::now(), color: generate_user_color(connection_id), session_id, } } /// Update the user's last activity timestamp. pub fn touch(&mut self) { self.last_activity = Utc::now(); } /// Update the user's cursor position. pub fn set_cursor(&mut self, cursor: PresenceCursor) { self.cursor = Some(cursor); self.touch(); } /// Clear the user's cursor. pub fn clear_cursor(&mut self) { self.cursor = None; self.touch(); } /// Update the user's status. pub fn set_status(&mut self, status: PresenceStatus) { self.status = status; self.touch(); } /// Check if the user has been idle for longer than the given duration. pub fn is_idle_for(&self, duration: Duration) -> bool { let now = Utc::now(); let elapsed = now.signed_duration_since(self.last_activity); elapsed.num_seconds() as u64 > duration.as_secs() } } /// Session information for collaboration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollabSession { /// Unique session identifier. pub id: CollabSessionId, /// The agent/session this collaboration is for. pub agent_session_id: uuid::Uuid, /// Owner's connection ID. pub owner_connection_id: ConnectionId, /// Share mode (e.g., "read", "write", "admin"). pub share_mode: String, /// Maximum number of participants. pub max_participants: usize, /// When the session was created. pub created_at: DateTime, } impl CollabSession { /// Create a new collaboration session. pub fn new( agent_session_id: uuid::Uuid, owner_connection_id: ConnectionId, share_mode: String, max_participants: usize, ) -> Self { Self { id: Uuid::new_v4(), agent_session_id, owner_connection_id, share_mode, max_participants, created_at: Utc::now(), } } } /// Configuration for presence management. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PresenceConfig { /// Duration after which a user is considered idle. #[serde(with = "duration_serde")] pub idle_timeout: Duration, /// Duration after which a user is considered away. #[serde(with = "duration_serde")] pub away_timeout: Duration, /// Duration after which a user is automatically removed. #[serde(with = "duration_serde")] pub cleanup_timeout: Duration, /// Interval for running cleanup tasks. #[serde(with = "duration_serde")] pub cleanup_interval: Duration, } impl Default for PresenceConfig { fn default() -> Self { Self { idle_timeout: Duration::from_secs(30), // 30 seconds away_timeout: Duration::from_secs(120), // 2 minutes cleanup_timeout: Duration::from_secs(300), // 5 minutes cleanup_interval: Duration::from_secs(60), // 1 minute } } } /// Custom serialization for Duration (as seconds). mod duration_serde { use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::time::Duration; pub fn serialize(duration: &Duration, serializer: S) -> Result where S: Serializer, { duration.as_secs().serialize(serializer) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let secs = u64::deserialize(deserializer)?; Ok(Duration::from_secs(secs)) } } /// Presence manager for tracking users in collaborative sessions. #[derive(Debug)] pub struct PresenceManager { /// Configuration. config: PresenceConfig, /// All active users by connection ID. users: DashMap, /// Users indexed by session ID. session_users: DashMap>, /// Active collaboration sessions. sessions: DashMap, /// Sessions indexed by agent session ID. agent_sessions: DashMap, } impl PresenceManager { /// Create a new presence manager with default configuration. pub fn new() -> Self { Self::with_config(PresenceConfig::default()) } /// Create a new presence manager with custom configuration. pub fn with_config(config: PresenceConfig) -> Self { Self { config, users: DashMap::new(), session_users: DashMap::new(), sessions: DashMap::new(), agent_sessions: DashMap::new(), } } /// Create a collaboration session for an agent. pub fn create_session( &self, agent_session_id: uuid::Uuid, owner_connection_id: ConnectionId, share_mode: String, max_participants: usize, ) -> CollabSession { // Check if a session already exists for this agent if let Some(existing_id) = self.agent_sessions.get(&agent_session_id) { if let Some(session) = self.sessions.get(existing_id.value()) { return session.clone(); } } let session = CollabSession::new( agent_session_id, owner_connection_id, share_mode, max_participants, ); // Index by session ID self.sessions.insert(session.id, session.clone()); // Index by agent session ID self.agent_sessions.insert(agent_session_id, session.id); // Initialize empty user list self.session_users.insert(session.id, Vec::new()); debug!( session_id = %session.id, agent_session_id = %agent_session_id, owner = %owner_connection_id, "Created collaboration session" ); session } /// Get a collaboration session by ID. pub fn get_session(&self, session_id: CollabSessionId) -> Option { self.sessions.get(&session_id).map(|s| s.clone()) } /// Get a collaboration session by agent session ID. pub fn get_session_by_agent(&self, agent_session_id: uuid::Uuid) -> Option { self.agent_sessions .get(&agent_session_id) .and_then(|id| self.sessions.get(id.value()).map(|s| s.clone())) } /// Remove a collaboration session. pub fn remove_session(&self, session_id: CollabSessionId) -> Option { let session = self.sessions.remove(&session_id).map(|(_, s)| s); if let Some(ref session) = session { // Remove all users from the session if let Some((_, user_ids)) = self.session_users.remove(&session_id) { for user_id in user_ids { self.users.remove(&user_id); } } // Remove from agent session index self.agent_sessions.remove(&session.agent_session_id); debug!( session_id = %session_id, "Removed collaboration session" ); } session } /// User joins a session. pub fn join_session( &self, session_id: CollabSessionId, connection_id: ConnectionId, display_name: String, ) -> Result { // Check if session exists let session = self .sessions .get(&session_id) .ok_or(PresenceError::SessionNotFound(session_id))? .clone(); // Check if user is already in another session if let Some(existing_user) = self.users.get(&connection_id) { if existing_user.session_id != session_id { // Leave the previous session first self.leave_session(connection_id)?; } else { // Already in this session return Ok(existing_user.clone()); } } // Check max participants if let Some(users) = self.session_users.get(&session_id) { if users.len() >= session.max_participants { return Err(PresenceError::SessionFull(session_id)); } } // Create the user let user = PresenceUser::new(connection_id, display_name, session_id); // Add to users map self.users.insert(connection_id, user.clone()); // Add to session users index self.session_users .entry(session_id) .or_default() .push(connection_id); debug!( session_id = %session_id, connection_id = %connection_id, display_name = %user.display_name, "User joined session" ); Ok(user) } /// User leaves a session. pub fn leave_session(&self, connection_id: ConnectionId) -> Result { let user = self .users .remove(&connection_id) .ok_or(PresenceError::UserNotFound(connection_id))? .1; // Remove from session users index if let Some(mut users) = self.session_users.get_mut(&user.session_id) { users.retain(|id| *id != connection_id); } debug!( session_id = %user.session_id, connection_id = %connection_id, "User left session" ); Ok(user) } /// Update user's cursor position. pub fn update_cursor( &self, connection_id: ConnectionId, cursor: PresenceCursor, ) -> Result<(), PresenceError> { let mut user = self .users .get_mut(&connection_id) .ok_or(PresenceError::UserNotFound(connection_id))?; user.set_cursor(cursor); user.status = PresenceStatus::Active; debug!( connection_id = %connection_id, message_index = cursor.message_index, char_start = cursor.char_start, char_end = cursor.char_end, "Updated cursor" ); Ok(()) } /// Clear user's cursor. pub fn clear_cursor(&self, connection_id: ConnectionId) -> Result<(), PresenceError> { let mut user = self .users .get_mut(&connection_id) .ok_or(PresenceError::UserNotFound(connection_id))?; user.clear_cursor(); Ok(()) } /// Update user's status. pub fn update_status( &self, connection_id: ConnectionId, status: PresenceStatus, ) -> Result<(), PresenceError> { let mut user = self .users .get_mut(&connection_id) .ok_or(PresenceError::UserNotFound(connection_id))?; user.set_status(status); debug!( connection_id = %connection_id, status = ?status, "Updated status" ); Ok(()) } /// Record user activity (heartbeat). pub fn heartbeat(&self, connection_id: ConnectionId) -> Result<(), PresenceError> { let mut user = self .users .get_mut(&connection_id) .ok_or(PresenceError::UserNotFound(connection_id))?; user.touch(); user.status = PresenceStatus::Active; debug!( connection_id = %connection_id, "Heartbeat received" ); Ok(()) } /// Get all users in a session. pub fn get_session_users(&self, session_id: CollabSessionId) -> Vec { self.session_users .get(&session_id) .map(|ids| { ids.iter() .filter_map(|id| self.users.get(id).map(|u| u.clone())) .collect() }) .unwrap_or_default() } /// Get a specific user by connection ID. pub fn get_user(&self, connection_id: ConnectionId) -> Option { self.users.get(&connection_id).map(|u| u.clone()) } /// Get the count of users in a session. pub fn get_session_user_count(&self, session_id: CollabSessionId) -> usize { self.session_users .get(&session_id) .map(|ids| ids.len()) .unwrap_or(0) } /// Get all active sessions. pub fn list_sessions(&self) -> Vec { self.sessions.iter().map(|s| s.clone()).collect() } /// Update user statuses based on activity timeouts. pub fn update_idle_statuses(&self) { let now = Utc::now(); for mut entry in self.users.iter_mut() { let user = entry.value_mut(); let elapsed = now.signed_duration_since(user.last_activity); let elapsed_secs = elapsed.num_seconds() as u64; let new_status = if elapsed_secs > self.config.away_timeout.as_secs() { PresenceStatus::Away } else if elapsed_secs > self.config.idle_timeout.as_secs() { PresenceStatus::Idle } else { PresenceStatus::Active }; if user.status != new_status { user.status = new_status; debug!( connection_id = %user.connection_id, status = ?new_status, "Auto-updated user status" ); } } } /// Clean up users that have been inactive for too long. /// Returns the list of removed connection IDs. pub fn cleanup_inactive_users(&self) -> Vec { let mut removed = Vec::new(); let now = Utc::now(); // Find users to remove for entry in self.users.iter() { let user = entry.value(); let elapsed = now.signed_duration_since(user.last_activity); if elapsed.num_seconds() as u64 > self.config.cleanup_timeout.as_secs() { removed.push(user.connection_id); } } // Remove the users for connection_id in &removed { if let Some((_, user)) = self.users.remove(connection_id) { // Remove from session index if let Some(mut users) = self.session_users.get_mut(&user.session_id) { users.retain(|id| *id != *connection_id); } debug!( connection_id = %connection_id, "Cleaned up inactive user" ); } } // Also clean up empty sessions let empty_sessions: Vec = self .session_users .iter() .filter(|entry| entry.value().is_empty()) .map(|entry| *entry.key()) .collect(); for session_id in empty_sessions { self.remove_session(session_id); } removed } /// Get presence statistics. pub fn stats(&self) -> PresenceStats { let total_users = self.users.len(); let total_sessions = self.sessions.len(); let mut status_counts = HashMap::new(); for entry in self.users.iter() { let status = entry.value().status; *status_counts.entry(status).or_insert(0) += 1; } PresenceStats { total_users, total_sessions, active_users: *status_counts.get(&PresenceStatus::Active).unwrap_or(&0), idle_users: *status_counts.get(&PresenceStatus::Idle).unwrap_or(&0), away_users: *status_counts.get(&PresenceStatus::Away).unwrap_or(&0), } } /// Get the configuration. pub fn config(&self) -> &PresenceConfig { &self.config } /// Check if a user is in a specific session. pub fn is_user_in_session(&self, connection_id: ConnectionId, session_id: CollabSessionId) -> bool { self.users .get(&connection_id) .map(|u| u.session_id == session_id) .unwrap_or(false) } /// Check if a connection is the owner of a session. pub fn is_session_owner(&self, connection_id: ConnectionId, session_id: CollabSessionId) -> bool { self.sessions .get(&session_id) .map(|s| s.owner_connection_id == connection_id) .unwrap_or(false) } } impl Default for PresenceManager { fn default() -> Self { Self::new() } } /// Presence statistics. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PresenceStats { /// Total number of users. pub total_users: usize, /// Total number of sessions. pub total_sessions: usize, /// Number of active users. pub active_users: usize, /// Number of idle users. pub idle_users: usize, /// Number of away users. pub away_users: usize, } /// Errors for presence operations. #[derive(Debug, thiserror::Error)] pub enum PresenceError { /// Session not found. #[error("Session not found: {0}")] SessionNotFound(CollabSessionId), /// User not found. #[error("User not found: {0}")] UserNotFound(ConnectionId), /// Session is full. #[error("Session is full: {0}")] SessionFull(CollabSessionId), /// User already in session. #[error("User {0} is already in session {1}")] AlreadyInSession(ConnectionId, CollabSessionId), /// Invalid cursor position. #[error("Invalid cursor position")] InvalidCursor, } /// Generate a consistent color for a user based on their connection ID. fn generate_user_color(connection_id: ConnectionId) -> String { // Use the connection ID bytes to generate a color let bytes = connection_id.as_bytes(); // Create a hue from the first few bytes let hue = (bytes[0] as u16 * 256 + bytes[1] as u16) % 360; // Convert HSL to hex (saturation: 70%, lightness: 50%) hsl_to_hex(hue, 70, 50) } /// Convert HSL color to hex string. fn hsl_to_hex(h: u16, s: u8, l: u8) -> String { let s = s as f64 / 100.0; let l = l as f64 / 100.0; let c = (1.0 - (2.0 * l - 1.0).abs()) * s; let x = c * (1.0 - ((h as f64 / 60.0) % 2.0 - 1.0).abs()); let m = l - c / 2.0; let (r, g, b) = if h < 60 { (c, x, 0.0) } else if h < 120 { (x, c, 0.0) } else if h < 180 { (0.0, c, x) } else if h < 240 { (0.0, x, c) } else if h < 300 { (x, 0.0, c) } else { (c, 0.0, x) }; let r = ((r + m) * 255.0).round() as u8; let g = ((g + m) * 255.0).round() as u8; let b = ((b + m) * 255.0).round() as u8; format!("#{:02X}{:02X}{:02X}", r, g, b) } #[cfg(test)] mod tests { use super::*; use std::thread; use std::time::Duration; fn setup() -> PresenceManager { PresenceManager::new() } #[test] fn test_create_session() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); assert!(manager.get_session(session.id).is_some()); assert!(manager.get_session_by_agent(agent_session_id).is_some()); } #[test] fn test_join_session() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); let user = manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); assert_eq!(user.connection_id, user_id); assert_eq!(user.display_name, "Alice"); assert_eq!(user.status, PresenceStatus::Active); let users = manager.get_session_users(session.id); assert_eq!(users.len(), 1); } #[test] fn test_leave_session() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); let left_user = manager.leave_session(user_id).unwrap(); assert_eq!(left_user.connection_id, user_id); let users = manager.get_session_users(session.id); assert_eq!(users.len(), 0); } #[test] fn test_update_cursor() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); let cursor = PresenceCursor::new(5, 10, 20); manager.update_cursor(user_id, cursor).unwrap(); let user = manager.get_user(user_id).unwrap(); assert!(user.cursor.is_some()); let c = user.cursor.unwrap(); assert_eq!(c.message_index, 5); assert_eq!(c.char_start, 10); assert_eq!(c.char_end, 20); assert!(c.is_selection()); assert_eq!(c.selection_length(), 10); } #[test] fn test_clear_cursor() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); let cursor = PresenceCursor::new(5, 10, 20); manager.update_cursor(user_id, cursor).unwrap(); manager.clear_cursor(user_id).unwrap(); let user = manager.get_user(user_id).unwrap(); assert!(user.cursor.is_none()); } #[test] fn test_update_status() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); manager.update_status(user_id, PresenceStatus::Idle).unwrap(); let user = manager.get_user(user_id).unwrap(); assert_eq!(user.status, PresenceStatus::Idle); } #[test] fn test_heartbeat() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); // Set to idle manager.update_status(user_id, PresenceStatus::Idle).unwrap(); // Heartbeat should set back to active manager.heartbeat(user_id).unwrap(); let user = manager.get_user(user_id).unwrap(); assert_eq!(user.status, PresenceStatus::Active); } #[test] fn test_session_full() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 2, // max 2 participants ); // First user let user1 = Uuid::new_v4(); manager .join_session(session.id, user1, "Alice".to_string()) .unwrap(); // Second user let user2 = Uuid::new_v4(); manager .join_session(session.id, user2, "Bob".to_string()) .unwrap(); // Third user should fail let user3 = Uuid::new_v4(); let result = manager.join_session(session.id, user3, "Charlie".to_string()); assert!(matches!(result, Err(PresenceError::SessionFull(_)))); } #[test] fn test_user_not_found() { let manager = setup(); let user_id = Uuid::new_v4(); let result = manager.update_status(user_id, PresenceStatus::Idle); assert!(matches!(result, Err(PresenceError::UserNotFound(_)))); let result = manager.update_cursor(user_id, PresenceCursor::default()); assert!(matches!(result, Err(PresenceError::UserNotFound(_)))); let result = manager.leave_session(user_id); assert!(matches!(result, Err(PresenceError::UserNotFound(_)))); } #[test] fn test_session_not_found() { let manager = setup(); let session_id = Uuid::new_v4(); let user_id = Uuid::new_v4(); let result = manager.join_session(session_id, user_id, "Alice".to_string()); assert!(matches!(result, Err(PresenceError::SessionNotFound(_)))); } #[test] fn test_update_idle_statuses() { let config = PresenceConfig { idle_timeout: Duration::from_millis(50), away_timeout: Duration::from_millis(100), cleanup_timeout: Duration::from_secs(300), cleanup_interval: Duration::from_secs(60), }; let manager = PresenceManager::with_config(config); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); // Initially active let user = manager.get_user(user_id).unwrap(); assert_eq!(user.status, PresenceStatus::Active); // Wait for idle timeout thread::sleep(Duration::from_millis(60)); manager.update_idle_statuses(); let user = manager.get_user(user_id).unwrap(); assert_eq!(user.status, PresenceStatus::Idle); // Wait for away timeout thread::sleep(Duration::from_millis(60)); manager.update_idle_statuses(); let user = manager.get_user(user_id).unwrap(); assert_eq!(user.status, PresenceStatus::Away); } #[test] fn test_cleanup_inactive_users() { let config = PresenceConfig { idle_timeout: Duration::from_secs(30), away_timeout: Duration::from_secs(60), cleanup_timeout: Duration::from_millis(100), cleanup_interval: Duration::from_secs(60), }; let manager = PresenceManager::with_config(config); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); // User should be present assert!(manager.get_user(user_id).is_some()); // Wait for cleanup timeout thread::sleep(Duration::from_millis(150)); let removed = manager.cleanup_inactive_users(); assert_eq!(removed.len(), 1); assert_eq!(removed[0], user_id); // User should be removed assert!(manager.get_user(user_id).is_none()); } #[test] fn test_remove_session() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); // Add users let user1 = Uuid::new_v4(); let user2 = Uuid::new_v4(); manager .join_session(session.id, user1, "Alice".to_string()) .unwrap(); manager .join_session(session.id, user2, "Bob".to_string()) .unwrap(); // Remove session let removed = manager.remove_session(session.id).unwrap(); assert_eq!(removed.id, session.id); // Session should be gone assert!(manager.get_session(session.id).is_none()); assert!(manager.get_session_by_agent(agent_session_id).is_none()); // Users should be removed assert!(manager.get_user(user1).is_none()); assert!(manager.get_user(user2).is_none()); } #[test] fn test_stats() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user1 = Uuid::new_v4(); let user2 = Uuid::new_v4(); let user3 = Uuid::new_v4(); manager .join_session(session.id, user1, "Alice".to_string()) .unwrap(); manager .join_session(session.id, user2, "Bob".to_string()) .unwrap(); manager .join_session(session.id, user3, "Charlie".to_string()) .unwrap(); manager.update_status(user2, PresenceStatus::Idle).unwrap(); manager.update_status(user3, PresenceStatus::Away).unwrap(); let stats = manager.stats(); assert_eq!(stats.total_users, 3); assert_eq!(stats.total_sessions, 1); assert_eq!(stats.active_users, 1); assert_eq!(stats.idle_users, 1); assert_eq!(stats.away_users, 1); } #[test] fn test_is_user_in_session() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); assert!(manager.is_user_in_session(user_id, session.id)); assert!(!manager.is_user_in_session(Uuid::new_v4(), session.id)); } #[test] fn test_is_session_owner() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); assert!(manager.is_session_owner(owner, session.id)); assert!(!manager.is_session_owner(user_id, session.id)); } #[test] fn test_cursor_default() { let cursor = PresenceCursor::default(); assert_eq!(cursor.message_index, 0); assert_eq!(cursor.char_start, 0); assert_eq!(cursor.char_end, 0); assert!(!cursor.is_selection()); } #[test] fn test_cursor_at_message() { let cursor = PresenceCursor::at_message(5); assert_eq!(cursor.message_index, 5); assert_eq!(cursor.char_start, 0); assert_eq!(cursor.char_end, 0); } #[test] fn test_user_touch() { let mut user = PresenceUser::new( Uuid::new_v4(), "Alice".to_string(), Uuid::new_v4(), ); let before = user.last_activity; thread::sleep(Duration::from_millis(10)); user.touch(); assert!(user.last_activity > before); } #[test] fn test_user_is_idle_for() { let mut user = PresenceUser::new( Uuid::new_v4(), "Alice".to_string(), Uuid::new_v4(), ); // Manually set last activity to the past user.last_activity = Utc::now() - chrono::Duration::seconds(10); assert!(user.is_idle_for(Duration::from_secs(5))); assert!(!user.is_idle_for(Duration::from_secs(20))); } #[test] fn test_generate_user_color() { let id1 = Uuid::new_v4(); let id2 = Uuid::new_v4(); let color1 = generate_user_color(id1); let color2 = generate_user_color(id2); // Colors should be valid hex codes assert!(color1.starts_with('#')); assert_eq!(color1.len(), 7); assert!(color2.starts_with('#')); assert_eq!(color2.len(), 7); // Same ID should produce same color let color1_again = generate_user_color(id1); assert_eq!(color1, color1_again); } #[test] fn test_hsl_to_hex() { // Test red let red = hsl_to_hex(0, 70, 50); assert!(red.starts_with('#')); // Test green let green = hsl_to_hex(120, 70, 50); assert!(green.starts_with('#')); // Test blue let blue = hsl_to_hex(240, 70, 50); assert!(blue.starts_with('#')); } #[test] fn test_join_same_session_twice() { let manager = setup(); let agent_session_id = Uuid::new_v4(); let owner = Uuid::new_v4(); let session = manager.create_session( agent_session_id, owner, "write".to_string(), 10, ); let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); // Joining the same session again should return the existing user let user2 = manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); assert_eq!(user2.connection_id, user_id); // Should still be only 1 user let users = manager.get_session_users(session.id); assert_eq!(users.len(), 1); } #[test] fn test_rejoin_different_session() { let manager = setup(); // Create two sessions let session1 = manager.create_session( Uuid::new_v4(), Uuid::new_v4(), "write".to_string(), 10, ); let session2 = manager.create_session( Uuid::new_v4(), Uuid::new_v4(), "write".to_string(), 10, ); let user_id = Uuid::new_v4(); // Join first session manager .join_session(session1.id, user_id, "Alice".to_string()) .unwrap(); assert_eq!(manager.get_session_user_count(session1.id), 1); // Join second session (should auto-leave first) manager .join_session(session2.id, user_id, "Alice".to_string()) .unwrap(); assert_eq!(manager.get_session_user_count(session1.id), 0); assert_eq!(manager.get_session_user_count(session2.id), 1); let user = manager.get_user(user_id).unwrap(); assert_eq!(user.session_id, session2.id); } #[test] fn test_list_sessions() { let manager = setup(); let session1 = manager.create_session( Uuid::new_v4(), Uuid::new_v4(), "write".to_string(), 10, ); let session2 = manager.create_session( Uuid::new_v4(), Uuid::new_v4(), "read".to_string(), 5, ); let sessions = manager.list_sessions(); assert_eq!(sessions.len(), 2); let ids: Vec = sessions.iter().map(|s| s.id).collect(); assert!(ids.contains(&session1.id)); assert!(ids.contains(&session2.id)); } #[test] fn test_empty_session_cleanup() { let manager = setup(); let session = manager.create_session( Uuid::new_v4(), Uuid::new_v4(), "write".to_string(), 10, ); // Add and remove a user let user_id = Uuid::new_v4(); manager .join_session(session.id, user_id, "Alice".to_string()) .unwrap(); manager.leave_session(user_id).unwrap(); // Session should still exist assert!(manager.get_session(session.id).is_some()); // Cleanup should remove empty session manager.cleanup_inactive_users(); assert!(manager.get_session(session.id).is_none()); } }