diff --git a/Cargo.lock b/Cargo.lock index aad4f65..72cb945 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,6 +663,7 @@ dependencies = [ "log", "ndk-context", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tracing", @@ -727,6 +728,7 @@ dependencies = [ "futures", "reqwest 0.13.4", "serde", + "serde_json", "thiserror 2.0.18", "time", "tokio", diff --git a/core/chanora_core/src/events.rs b/core/chanora_core/src/events.rs index e32168e..56bdb2e 100644 --- a/core/chanora_core/src/events.rs +++ b/core/chanora_core/src/events.rs @@ -246,7 +246,7 @@ pub enum SessionEvent { } /// Bridge-safe mirror of channel-join projection sync state. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VoiceJoinSyncState { /// Reducer is ready to accept channel actions. Ready, @@ -257,7 +257,7 @@ pub enum VoiceJoinSyncState { } /// Bridge-safe mirror of stable channel-join error codes. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VoiceJoinErrorCode { /// Duplicate same-target join intent was coalesced. DuplicateSameTargetCoalesced, @@ -296,3 +296,506 @@ pub enum NetworkState { /// OS reports no networks available. Offline, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn network_state_equality() { + assert_eq!(NetworkState::Unknown, NetworkState::Unknown); + assert_eq!(NetworkState::Online, NetworkState::Online); + assert_eq!(NetworkState::Offline, NetworkState::Offline); + assert_ne!(NetworkState::Unknown, NetworkState::Online); + assert_ne!(NetworkState::Online, NetworkState::Offline); + } + + #[test] + fn persisted_ptt_binding_empty() { + let binding = PersistedPttBinding::empty(); + assert_eq!(binding.input_class, ""); + assert_eq!(binding.key_label, ""); + } + + #[test] + fn persisted_ptt_binding_equality() { + let a = PersistedPttBinding { + input_class: "keyboard".to_string(), + key_label: "Space".to_string(), + }; + let b = PersistedPttBinding { + input_class: "keyboard".to_string(), + key_label: "Space".to_string(), + }; + assert_eq!(a, b); + } + + #[test] + fn ptt_descriptor_snapshot_fields() { + let snap = PttDescriptorSnapshot { + level: "L0Focused".to_string(), + backend_id: "focused".to_string(), + bound_input_class: "keyboard".to_string(), + }; + assert_eq!(snap.level, "L0Focused"); + assert_eq!(snap.backend_id, "focused"); + assert_eq!(snap.bound_input_class, "keyboard"); + } + + #[test] + fn session_event_connected() { + let evt = SessionEvent::Connected { + server_name: "Test Server".to_string(), + }; + if let SessionEvent::Connected { server_name } = evt { + assert_eq!(server_name, "Test Server"); + } else { + panic!("expected Connected variant"); + } + } + + #[test] + fn session_event_lost() { + let evt = SessionEvent::Lost { + reason: "timeout".to_string(), + }; + if let SessionEvent::Lost { reason } = evt { + assert_eq!(reason, "timeout"); + } else { + panic!("expected Lost variant"); + } + } + + #[test] + fn session_event_reconnecting() { + let evt = SessionEvent::Reconnecting { + attempt: 3, + delay_secs: 30, + }; + if let SessionEvent::Reconnecting { + attempt, + delay_secs, + } = evt + { + assert_eq!(attempt, 3); + assert_eq!(delay_secs, 30); + } else { + panic!("expected Reconnecting variant"); + } + } + + #[test] + fn session_event_disconnected() { + let evt = SessionEvent::Disconnected { + reason: "user".to_string(), + }; + if let SessionEvent::Disconnected { reason } = evt { + assert_eq!(reason, "user"); + } else { + panic!("expected Disconnected variant"); + } + } + + #[test] + fn session_event_audio_started_stopped() { + let _ = SessionEvent::AudioStarted; + let _ = SessionEvent::AudioStopped; + } + + #[test] + fn session_event_ptt_capability() { + let evt = SessionEvent::PttCapability { + level: "L1GlobalShortcut".to_string(), + backend_id: "global".to_string(), + bound_input_class: "keyboard".to_string(), + }; + if let SessionEvent::PttCapability { + level, + backend_id, + bound_input_class, + } = evt + { + assert_eq!(level, "L1GlobalShortcut"); + assert_eq!(backend_id, "global"); + assert_eq!(bound_input_class, "keyboard"); + } else { + panic!("expected PttCapability variant"); + } + } + + #[test] + fn session_event_voice_state() { + let evt = SessionEvent::VoiceState { + in_channel: true, + transmit_mode: 1, + mute: false, + release_tail_ms: 200, + current_channel_id: Some(42), + pending_target_channel_id: None, + can_join: false, + can_leave: true, + join_sync_state: VoiceJoinSyncState::Ready, + join_error_code: None, + }; + if let SessionEvent::VoiceState { + in_channel, + transmit_mode, + mute, + release_tail_ms, + current_channel_id, + pending_target_channel_id, + can_join, + can_leave, + join_sync_state, + join_error_code, + } = evt + { + assert!(in_channel); + assert_eq!(transmit_mode, 1); + assert!(!mute); + assert_eq!(release_tail_ms, 200); + assert_eq!(current_channel_id, Some(42)); + assert_eq!(pending_target_channel_id, None); + assert!(!can_join); + assert!(can_leave); + assert_eq!(join_sync_state, VoiceJoinSyncState::Ready); + assert!(join_error_code.is_none()); + } else { + panic!("expected VoiceState variant"); + } + } + + #[test] + fn session_event_interruption_state() { + let evt = SessionEvent::InterruptionState { + began: true, + should_resume: false, + }; + if let SessionEvent::InterruptionState { + began, + should_resume, + } = evt + { + assert!(began); + assert!(!should_resume); + } else { + panic!("expected InterruptionState variant"); + } + } + + #[test] + fn session_event_chat_message() { + let evt = SessionEvent::ChatMessage { + sender_id: 5, + sender_name: "Alice".to_string(), + message: "Hello".to_string(), + target: chanora_protocol::MessageTarget::Channel, + poke_strength: None, + }; + if let SessionEvent::ChatMessage { + sender_id, + sender_name, + message, + target, + poke_strength, + } = evt + { + assert_eq!(sender_id, 5); + assert_eq!(sender_name, "Alice"); + assert_eq!(message, "Hello"); + assert_eq!(target, chanora_protocol::MessageTarget::Channel); + assert!(poke_strength.is_none()); + } else { + panic!("expected ChatMessage variant"); + } + } + + #[test] + fn session_event_chat_message_with_poke() { + let evt = SessionEvent::ChatMessage { + sender_id: 3, + sender_name: "Bob".to_string(), + message: "".to_string(), + target: chanora_protocol::MessageTarget::Poke(7), + poke_strength: Some(chanora_protocol::PokeStrength::Suppressed), + }; + if let SessionEvent::ChatMessage { + target, + poke_strength, + .. + } = evt + { + assert_eq!(target, chanora_protocol::MessageTarget::Poke(7)); + assert_eq!(poke_strength, Some(chanora_protocol::PokeStrength::Suppressed)); + } else { + panic!("expected ChatMessage variant"); + } + } + + #[test] + fn session_event_server_activity() { + let evt = SessionEvent::ServerActivity { + message: "User joined channel".to_string(), + }; + if let SessionEvent::ServerActivity { message } = &evt { + assert_eq!(message, "User joined channel"); + } else { + panic!("expected ServerActivity variant"); + } + } + + #[test] + fn session_event_audio_route_changed() { + let evt = SessionEvent::AudioRouteChanged { + route: chanora_audio::AudioRoute::Speaker, + }; + if let SessionEvent::AudioRouteChanged { route } = &evt { + assert_eq!(*route, chanora_audio::AudioRoute::Speaker); + } else { + panic!("expected AudioRouteChanged variant"); + } + } + + #[test] + fn session_event_client_moved() { + let evt = SessionEvent::ClientMoved { + client_id: 1, + new_channel_id: 2, + }; + if let SessionEvent::ClientMoved { + client_id, + new_channel_id, + } = evt + { + assert_eq!(client_id, 1); + assert_eq!(new_channel_id, 2); + } else { + panic!("expected ClientMoved variant"); + } + } + + #[test] + fn session_event_client_joined() { + let evt = SessionEvent::ClientJoined { + client_id: 10, + channel_id: 3, + name: "NewUser".to_string(), + input_muted: false, + output_muted: true, + is_server_query: false, + talk_power: 0, + talk_power_granted: false, + }; + if let SessionEvent::ClientJoined { + client_id, + channel_id, + name, + input_muted, + output_muted, + is_server_query, + talk_power, + talk_power_granted, + } = evt + { + assert_eq!(client_id, 10); + assert_eq!(channel_id, 3); + assert_eq!(name, "NewUser"); + assert!(!input_muted); + assert!(output_muted); + assert!(!is_server_query); + assert_eq!(talk_power, 0); + assert!(!talk_power_granted); + } else { + panic!("expected ClientJoined variant"); + } + } + + #[test] + fn session_event_client_left() { + let evt = SessionEvent::ClientLeft { + client_id: 10, + name: "Departing".to_string(), + }; + if let SessionEvent::ClientLeft { client_id, name } = evt { + assert_eq!(client_id, 10); + assert_eq!(name, "Departing"); + } else { + panic!("expected ClientLeft variant"); + } + } + + #[test] + fn session_event_client_updated() { + let evt = SessionEvent::ClientUpdated { + client_id: 5, + input_muted: true, + output_muted: false, + is_server_query: true, + talk_power: 75, + talk_power_granted: true, + }; + if let SessionEvent::ClientUpdated { + client_id, + input_muted, + output_muted, + is_server_query, + talk_power, + talk_power_granted, + } = evt + { + assert_eq!(client_id, 5); + assert!(input_muted); + assert!(!output_muted); + assert!(is_server_query); + assert_eq!(talk_power, 75); + assert!(talk_power_granted); + } else { + panic!("expected ClientUpdated variant"); + } + } + + #[test] + fn session_event_channel_added() { + let evt = SessionEvent::ChannelAdded { + id: 7, + parent: 1, + name: "Sub".to_string(), + order: 3, + has_password: true, + needed_talk_power: Some(50), + }; + if let SessionEvent::ChannelAdded { + id, + parent, + name, + order, + has_password, + needed_talk_power, + } = evt + { + assert_eq!(id, 7); + assert_eq!(parent, 1); + assert_eq!(name, "Sub"); + assert_eq!(order, 3); + assert!(has_password); + assert_eq!(needed_talk_power, Some(50)); + } else { + panic!("expected ChannelAdded variant"); + } + } + + #[test] + fn session_event_channel_removed() { + let evt = SessionEvent::ChannelRemoved { id: 7 }; + if let SessionEvent::ChannelRemoved { id } = evt { + assert_eq!(id, 7); + } else { + panic!("expected ChannelRemoved variant"); + } + } + + #[test] + fn session_event_channel_updated() { + let evt = SessionEvent::ChannelUpdated { + id: 7, + name: "Renamed".to_string(), + has_password: false, + needed_talk_power: None, + }; + if let SessionEvent::ChannelUpdated { + id, + name, + has_password, + needed_talk_power, + } = evt + { + assert_eq!(id, 7); + assert_eq!(name, "Renamed"); + assert!(!has_password); + assert!(needed_talk_power.is_none()); + } else { + panic!("expected ChannelUpdated variant"); + } + } + + #[test] + fn voice_join_sync_state_variants() { + let ready = VoiceJoinSyncState::Ready; + let init = VoiceJoinSyncState::SynchronizingInitialSnapshot; + let reconnect = VoiceJoinSyncState::SynchronizingReconnect; + assert_ne!( + std::mem::discriminant(&ready), + std::mem::discriminant(&init) + ); + assert_ne!( + std::mem::discriminant(&init), + std::mem::discriminant(&reconnect) + ); + } + + #[test] + fn voice_join_error_code_all_variants() { + let codes = [ + VoiceJoinErrorCode::DuplicateSameTargetCoalesced, + VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget, + VoiceJoinErrorCode::JoinDenied, + VoiceJoinErrorCode::JoinProtocolFailure, + VoiceJoinErrorCode::JoinNetworkFailure, + VoiceJoinErrorCode::JoinTimeout, + VoiceJoinErrorCode::JoinSupersededByLeave, + VoiceJoinErrorCode::JoinStaleOutcomeIgnored, + VoiceJoinErrorCode::JoinReconciledDifferentChannel, + VoiceJoinErrorCode::JoinCommandRejectedBeforeSend, + VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing, + ]; + for i in 0..codes.len() { + for j in 0..codes.len() { + if i == j { + assert_eq!( + std::mem::discriminant(&codes[i]), + std::mem::discriminant(&codes[j]) + ); + } else { + assert_ne!( + std::mem::discriminant(&codes[i]), + std::mem::discriminant(&codes[j]) + ); + } + } + } + } + + #[test] + fn session_event_clone_preserves_fields() { + let evt = SessionEvent::Connected { + server_name: "Cloneable".to_string(), + }; + let cloned = evt.clone(); + if let SessionEvent::Connected { server_name } = cloned { + assert_eq!(server_name, "Cloneable"); + } else { + panic!("expected Connected variant after clone"); + } + } + + #[test] + fn session_event_voice_state_with_join_error() { + let evt = SessionEvent::VoiceState { + in_channel: false, + transmit_mode: 0, + mute: false, + release_tail_ms: 200, + current_channel_id: None, + pending_target_channel_id: None, + can_join: true, + can_leave: false, + join_sync_state: VoiceJoinSyncState::Ready, + join_error_code: Some(VoiceJoinErrorCode::JoinDenied), + }; + if let SessionEvent::VoiceState { join_error_code, .. } = evt { + assert_eq!(join_error_code, Some(VoiceJoinErrorCode::JoinDenied)); + } else { + panic!("expected VoiceState variant"); + } + } +} diff --git a/crates/chanora_bridge/Cargo.toml b/crates/chanora_bridge/Cargo.toml index 2c3e72d..665c0d4 100644 --- a/crates/chanora_bridge/Cargo.toml +++ b/crates/chanora_bridge/Cargo.toml @@ -36,3 +36,6 @@ ndk-context = "0.1" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/chanora_bridge/src/lib.rs b/crates/chanora_bridge/src/lib.rs index f9e0059..986380a 100644 --- a/crates/chanora_bridge/src/lib.rs +++ b/crates/chanora_bridge/src/lib.rs @@ -51,7 +51,7 @@ use thiserror::Error; /// Errors raised at the bridge boundary. Production code must keep /// these user-safe โ€” no secrets, no protocol details, no path /// information beyond what the redaction policy permits. -#[derive(Debug, Error, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Error, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum BridgeError { /// The caller submitted a malformed command DTO. #[error("invalid command: {0}")] @@ -125,3 +125,308 @@ impl From for BridgeError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip_json< + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, + >( + value: &T, + ) { + let json = serde_json::to_string(value).expect("serialize"); + let back: T = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(&back, value, "roundtrip failed"); + } + + #[test] + fn bridge_error_invalid_command() { + let err = BridgeError::InvalidCommand("bad".to_string()); + assert_eq!(err.to_string(), "invalid command: bad"); + } + + #[test] + fn bridge_error_dns_failed() { + let err = BridgeError::DnsFailed { + host: "example.com".to_string(), + reason: "timeout".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("example.com")); + assert!(msg.contains("timeout")); + } + + #[test] + fn bridge_error_connection() { + let err = BridgeError::Connection("refused".to_string()); + assert_eq!(err.to_string(), "connection: refused"); + } + + #[test] + fn bridge_error_not_connected() { + let err = BridgeError::NotConnected; + assert_eq!(err.to_string(), "not connected"); + } + + #[test] + fn bridge_error_already_connected() { + let err = BridgeError::AlreadyConnected; + assert_eq!(err.to_string(), "already connected"); + } + + #[test] + fn bridge_error_server_rejected() { + let err = BridgeError::ServerRejected { + code: 2568, + message: "insufficient permissions".to_string(), + }; + let msg = err.to_string(); + assert!(msg.contains("2568")); + assert!(msg.contains("insufficient permissions")); + } + + #[test] + fn bridge_error_unmapped() { + let err = BridgeError::Unmapped("mystery".to_string()); + assert_eq!(err.to_string(), "unmapped: mystery"); + } + + #[test] + fn bridge_error_serde_roundtrip() { + roundtrip_json(&BridgeError::InvalidCommand("test".to_string())); + roundtrip_json(&BridgeError::NotConnected); + roundtrip_json(&BridgeError::AlreadyConnected); + roundtrip_json(&BridgeError::Connection("fail".to_string())); + roundtrip_json(&BridgeError::Unmapped("x".to_string())); + roundtrip_json(&BridgeError::DnsFailed { + host: "h".to_string(), + reason: "r".to_string(), + }); + roundtrip_json(&BridgeError::ServerRejected { + code: 42, + message: "nope".to_string(), + }); + } + + #[test] + fn bridge_error_clone_preserves() { + let err = BridgeError::InvalidCommand("orig".to_string()); + let cloned = err.clone(); + assert_eq!(cloned.to_string(), err.to_string()); + } + + #[test] + fn from_core_error_not_connected() { + let core_err = chanora_core::CoreError::NotConnected; + let bridge_err: BridgeError = core_err.into(); + assert!(matches!(bridge_err, BridgeError::NotConnected)); + } + + #[test] + fn from_core_error_already_connected() { + let core_err = chanora_core::CoreError::AlreadyConnected; + let bridge_err: BridgeError = core_err.into(); + assert!(matches!(bridge_err, BridgeError::AlreadyConnected)); + } + + #[test] + fn from_core_error_audio_not_started() { + let core_err = chanora_core::CoreError::AudioNotStarted; + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::InvalidCommand(msg) => { + assert!(msg.contains("audio not started")); + } + other => panic!("expected InvalidCommand, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_dns_failed() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::DnsFailed { + host: "bad.host".to_string(), + reason: "no address".to_string(), + }, + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::DnsFailed { host, reason } => { + assert_eq!(host, "bad.host"); + assert_eq!(reason, "no address"); + } + other => panic!("expected DnsFailed, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_server_rejected() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::ServerRejected { + code: 0x0501, + message: "channel password wrong".to_string(), + }, + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::ServerRejected { code, message } => { + assert_eq!(code, 0x0501); + assert_eq!(message, "channel password wrong"); + } + other => panic!("expected ServerRejected, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_file_transfer() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::FileTransfer("disk full".to_string()), + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("file transfer")); + assert!(msg.contains("disk full")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_generic() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::Connect("refused".to_string()), + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("refused")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_lost() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::Lost("timeout".to_string()), + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("timeout")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_invalid() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::Invalid("bad config".to_string()), + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("bad config")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_disconnected_early() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::DisconnectedEarly("premature".to_string()), + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("premature")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_identity() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::Identity("parse error".to_string()), + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("parse error")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_timeout() { + let core_err = + chanora_core::CoreError::Protocol(chanora_core::ProtocolError::Timeout); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("timeout")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_protocol_backend() { + let core_err = chanora_core::CoreError::Protocol( + chanora_core::ProtocolError::Backend("raw".to_string()), + ); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Connection(msg) => { + assert!(msg.contains("raw")); + } + other => panic!("expected Connection, got {other:?}"), + } + } + + #[test] + fn from_core_error_invariant() { + let core_err = chanora_core::CoreError::Invariant("broken"); + let bridge_err: BridgeError = core_err.into(); + match bridge_err { + BridgeError::Unmapped(msg) => { + assert!(msg.contains("broken")); + } + other => panic!("expected Unmapped, got {other:?}"), + } + } + + #[test] + fn error_surfaces_all_protocol_error_variants() { + let protocol_errors: Vec = vec![ + chanora_core::ProtocolError::Invalid("x".into()), + chanora_core::ProtocolError::DnsFailed { + host: "h".into(), + reason: "r".into(), + }, + chanora_core::ProtocolError::Connect("c".into()), + chanora_core::ProtocolError::DisconnectedEarly("d".into()), + chanora_core::ProtocolError::Lost("l".into()), + chanora_core::ProtocolError::Identity("i".into()), + chanora_core::ProtocolError::Timeout, + chanora_core::ProtocolError::ServerRejected { + code: 1, + message: "m".into(), + }, + chanora_core::ProtocolError::Backend("b".into()), + chanora_core::ProtocolError::FileTransfer("f".into()), + ]; + for p_err in protocol_errors { + let core_err = chanora_core::CoreError::Protocol(p_err); + let bridge_err: BridgeError = core_err.into(); + let msg = bridge_err.to_string(); + assert!(!msg.is_empty(), "BridgeError message must not be empty"); + } + } +} diff --git a/crates/chanora_protocol/Cargo.toml b/crates/chanora_protocol/Cargo.toml index adda0b6..6eddc59 100644 --- a/crates/chanora_protocol/Cargo.toml +++ b/crates/chanora_protocol/Cargo.toml @@ -42,3 +42,6 @@ reqwest = { version = "0.13", default-features = false, features = ["charset", " # Android cross-builds should not pull OpenSSL. Use rustls here while keeping # native-tls for Apple targets where aws-lc/rustls is problematic for iOS. reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index 142d1ff..84452b2 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -262,3 +262,436 @@ pub enum ProtocolDelta { needed_talk_power: Option, }, } + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip_json( + value: &T, + ) { + let json = serde_json::to_string(value).expect("serialize"); + let back: T = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(&back, value, "roundtrip failed"); + } + + #[test] + fn channel_id_serde_roundtrip() { + roundtrip_json(&ChannelId(0)); + roundtrip_json(&ChannelId(1)); + roundtrip_json(&ChannelId(u64::MAX)); + } + + #[test] + fn client_id_serde_roundtrip() { + roundtrip_json(&ClientId(0)); + roundtrip_json(&ClientId(42)); + roundtrip_json(&ClientId(u64::MAX)); + } + + #[test] + fn channel_id_root_is_zero() { + assert_eq!(ChannelId::ROOT, ChannelId(0)); + } + + #[test] + fn channel_info_serde_roundtrip() { + let info = ChannelInfo { + id: ChannelId(1), + parent: ChannelId(0), + name: "General".to_string(), + order: 0, + has_password: false, + needed_talk_power: None, + }; + roundtrip_json(&info); + } + + #[test] + fn channel_info_with_all_fields() { + let info = ChannelInfo { + id: ChannelId(99), + parent: ChannelId(5), + name: "AFK".to_string(), + order: -1, + has_password: true, + needed_talk_power: Some(75), + }; + roundtrip_json(&info); + } + + #[test] + fn channel_info_empty_name() { + let info = ChannelInfo { + id: ChannelId(1), + parent: ChannelId(0), + name: String::new(), + order: 0, + has_password: false, + needed_talk_power: None, + }; + roundtrip_json(&info); + } + + #[test] + fn channel_info_unicode_name() { + let info = ChannelInfo { + id: ChannelId(1), + parent: ChannelId(0), + name: "๐ŸŽฎ Spielsaal ๐ŸŽฎ".to_string(), + order: 0, + has_password: false, + needed_talk_power: None, + }; + roundtrip_json(&info); + } + + #[test] + fn message_target_serde_roundtrip() { + roundtrip_json(&MessageTarget::Server); + roundtrip_json(&MessageTarget::Channel); + roundtrip_json(&MessageTarget::Client(12345)); + roundtrip_json(&MessageTarget::Poke(67890)); + } + + #[test] + fn message_target_json_shape() { + let json = serde_json::to_string(&MessageTarget::Server).unwrap(); + assert_eq!(json, "\"Server\""); + + let json = serde_json::to_string(&MessageTarget::Client(42)).unwrap(); + assert!(json.contains("\"Client\"")); + assert!(json.contains("42")); + } + + #[test] + fn chat_message_serde_roundtrip() { + let msg = ChatMessage { + sender_id: ClientId(1), + sender_name: "Alice".to_string(), + message: "Hello world".to_string(), + target: MessageTarget::Channel, + poke_strength: None, + }; + roundtrip_json(&msg); + } + + #[test] + fn chat_message_with_poke_strength() { + let msg = ChatMessage { + sender_id: ClientId(5), + sender_name: "Bob".to_string(), + message: "".to_string(), + target: MessageTarget::Poke(99), + poke_strength: Some(PokeStrength::Strong), + }; + roundtrip_json(&msg); + } + + #[test] + fn chat_message_unicode_content() { + let msg = ChatMessage { + sender_id: ClientId(1), + sender_name: "ๆ—ฅๆœฌ่ชžใƒใƒผใƒ ".to_string(), + message: "๐ŸŽ‰ ใ“ใ‚“ใซใกใฏไธ–็•Œ ๐ŸŒ".to_string(), + target: MessageTarget::Server, + poke_strength: None, + }; + roundtrip_json(&msg); + } + + #[test] + fn server_activity_serde_roundtrip() { + let act = ServerActivity { + message: "User joined".to_string(), + }; + roundtrip_json(&act); + } + + #[test] + fn server_activity_empty_message() { + let act = ServerActivity { + message: String::new(), + }; + roundtrip_json(&act); + } + + #[test] + fn client_info_serde_roundtrip() { + let info = ClientInfo { + id: ClientId(1), + channel: ChannelId(2), + name: "Player".to_string(), + input_muted: false, + output_muted: true, + is_speaking: false, + is_server_query: false, + talk_power: 0, + talk_power_granted: false, + }; + roundtrip_json(&info); + } + + #[test] + fn client_info_server_query_with_talk_power() { + let info = ClientInfo { + id: ClientId(100), + channel: ChannelId(3), + name: "Bot".to_string(), + input_muted: true, + output_muted: true, + is_speaking: false, + is_server_query: true, + talk_power: 75, + talk_power_granted: true, + }; + roundtrip_json(&info); + } + + #[test] + fn server_snapshot_serde_roundtrip() { + let snap = ServerSnapshot { + server_name: "Test Server".to_string(), + welcome_message: "Welcome!".to_string(), + platform: "Linux".to_string(), + version: "3.13.7".to_string(), + channels: vec![ + ChannelInfo { + id: ChannelId(1), + parent: ChannelId(0), + name: "Root".to_string(), + order: 0, + has_password: false, + needed_talk_power: None, + }, + ], + clients: vec![ + ClientInfo { + id: ClientId(1), + channel: ChannelId(1), + name: "User1".to_string(), + input_muted: false, + output_muted: false, + is_speaking: false, + is_server_query: false, + talk_power: 0, + talk_power_granted: false, + }, + ], + own_client_id: 1, + }; + roundtrip_json(&snap); + } + + #[test] + fn server_snapshot_empty_channels_and_clients() { + let snap = ServerSnapshot { + server_name: String::new(), + welcome_message: String::new(), + platform: String::new(), + version: String::new(), + channels: vec![], + clients: vec![], + own_client_id: 0, + }; + roundtrip_json(&snap); + } + + #[test] + fn client_profile_serde_roundtrip() { + let profile = ClientProfile { + id: ClientId(1), + channel: ChannelId(2), + name: "Player".to_string(), + unique_id: "abc123".to_string(), + database_id: Some(42), + country_code: "DE".to_string(), + description: String::new(), + version: "3.5.0".to_string(), + platform: "Windows".to_string(), + created_unix_seconds: Some(1609459200), + last_connected_unix_seconds: Some(1700000000), + connections_total: Some(100), + online_seconds: Some(3600), + idle_milliseconds: Some(500), + ping_milliseconds: Some(42), + ping_deviation_milliseconds: Some(5), + client_address: String::new(), + server_groups: vec!["Admin".to_string(), "Mod".to_string()], + channel_group: "Channel Admin".to_string(), + avatar_path: String::new(), + bytes_downloaded_month: Some(1024), + bytes_uploaded_month: Some(512), + bytes_downloaded_total: Some(4096), + bytes_uploaded_total: Some(2048), + packet_loss_client_to_server_total: Some(0.01), + packet_loss_server_to_client_total: Some(0.02), + }; + roundtrip_json(&profile); + } + + #[test] + fn client_profile_minimal_fields() { + let profile = ClientProfile { + id: ClientId(1), + channel: ChannelId(0), + name: String::new(), + unique_id: String::new(), + database_id: None, + country_code: String::new(), + description: String::new(), + version: String::new(), + platform: String::new(), + created_unix_seconds: None, + last_connected_unix_seconds: None, + connections_total: None, + online_seconds: None, + idle_milliseconds: None, + ping_milliseconds: None, + ping_deviation_milliseconds: None, + client_address: String::new(), + server_groups: vec![], + channel_group: String::new(), + avatar_path: String::new(), + bytes_downloaded_month: None, + bytes_uploaded_month: None, + bytes_downloaded_total: None, + bytes_uploaded_total: None, + packet_loss_client_to_server_total: None, + packet_loss_server_to_client_total: None, + }; + roundtrip_json(&profile); + } + + #[test] + fn protocol_delta_client_moved() { + let delta = ProtocolDelta::ClientMoved { + client_id: 1, + new_channel_id: 2, + }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_client_joined() { + let delta = ProtocolDelta::ClientJoined { + client_id: 5, + channel_id: 3, + name: "NewUser".to_string(), + input_muted: false, + output_muted: false, + is_server_query: false, + talk_power: 0, + talk_power_granted: false, + }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_client_left() { + let delta = ProtocolDelta::ClientLeft { + client_id: 5, + name: "Departing".to_string(), + }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_client_updated() { + let delta = ProtocolDelta::ClientUpdated { + client_id: 10, + input_muted: true, + output_muted: false, + is_server_query: false, + talk_power: 50, + talk_power_granted: true, + }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_channel_added() { + let delta = ProtocolDelta::ChannelAdded { + id: 7, + parent: 1, + name: "New Channel".to_string(), + order: 5, + has_password: true, + needed_talk_power: Some(25), + }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_channel_removed() { + let delta = ProtocolDelta::ChannelRemoved { id: 7 }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_channel_updated() { + let delta = ProtocolDelta::ChannelUpdated { + id: 7, + name: "Renamed".to_string(), + has_password: false, + needed_talk_power: None, + }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_unicode_names() { + let delta = ProtocolDelta::ClientJoined { + client_id: 1, + channel_id: 1, + name: "ใƒฆใƒผใ‚ถใƒผ".to_string(), + input_muted: false, + output_muted: false, + is_server_query: false, + talk_power: 0, + talk_power_granted: false, + }; + roundtrip_json(&delta); + } + + #[test] + fn protocol_delta_boundary_values() { + let delta = ProtocolDelta::ChannelAdded { + id: u64::MAX, + parent: u64::MAX, + name: String::new(), + order: i64::MIN, + has_password: true, + needed_talk_power: Some(i32::MAX), + }; + roundtrip_json(&delta); + } + + #[test] + fn poke_strength_serde_roundtrip() { + roundtrip_json(&PokeStrength::Strong); + roundtrip_json(&PokeStrength::Suppressed); + roundtrip_json(&PokeStrength::SuppressedOverflow); + } + + #[test] + fn client_info_equality_and_clone() { + let a = ClientId(42); + let b = a; + assert_eq!(a, b); + let c = ClientId(42); + assert_eq!(a, c); + let d = ClientId(43); + assert_ne!(a, d); + } + + #[test] + fn channel_id_hash_consistency() { + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(ChannelId(1)); + set.insert(ChannelId(1)); + set.insert(ChannelId(2)); + assert_eq!(set.len(), 2); + } +}