Files
openfang/crates/openfang-kernel/src/presence.rs
iven 810e32077e
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
添加AOL路由和UI/UX增强组件
2026-03-01 17:59:03 +08:00

1379 lines
39 KiB
Rust

//! 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<PresenceCursor>,
/// Timestamp of the last activity.
pub last_activity: DateTime<Utc>,
/// 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<Utc>,
}
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<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
duration.as_secs().serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
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<ConnectionId, PresenceUser>,
/// Users indexed by session ID.
session_users: DashMap<CollabSessionId, Vec<ConnectionId>>,
/// Active collaboration sessions.
sessions: DashMap<CollabSessionId, CollabSession>,
/// Sessions indexed by agent session ID.
agent_sessions: DashMap<uuid::Uuid, CollabSessionId>,
}
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<CollabSession> {
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<CollabSession> {
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<CollabSession> {
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<PresenceUser, PresenceError> {
// 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<PresenceUser, PresenceError> {
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<PresenceUser> {
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<PresenceUser> {
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<CollabSession> {
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<ConnectionId> {
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<CollabSessionId> = 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<Uuid> = 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());
}
}