Files
p2p-chat/tests/tests.rs

632 lines
20 KiB
Rust

//! Integration tests for p2p-chat
//!
//! Covers:
//! - Protocol message serialization roundtrips
//! - Config defaults, TOML parsing, and color parsing
//! - TUI App command dispatch & key handling
//! - ChatState history management
// ============================================================================
// Protocol tests
// ============================================================================
mod protocol_tests {
use p2p_chat::protocol::*;
#[test]
fn chat_message_roundtrip() {
let msg = GossipMessage::Chat(ChatMessage {
sender_name: "alice".into(),
timestamp: 1234567890,
text: "Hello, world!".into(),
});
let bytes = postcard::to_allocvec(&msg).unwrap();
let decoded: GossipMessage = postcard::from_bytes(&bytes).unwrap();
match decoded {
GossipMessage::Chat(m) => {
assert_eq!(m.sender_name, "alice");
assert_eq!(m.timestamp, 1234567890);
assert_eq!(m.text, "Hello, world!");
}
_ => panic!("Expected Chat variant"),
}
}
#[test]
fn disconnect_message_roundtrip() {
let msg = GossipMessage::Disconnect {
sender_name: "bob".into(),
};
let bytes = postcard::to_allocvec(&msg).unwrap();
let decoded: GossipMessage = postcard::from_bytes(&bytes).unwrap();
match decoded {
GossipMessage::Disconnect { sender_name } => {
assert_eq!(sender_name, "bob");
}
_ => panic!("Expected Disconnect variant"),
}
}
#[test]
fn name_change_roundtrip() {
let msg = GossipMessage::NameChange(NameChange {
old_name: "anon".into(),
new_name: "alice".into(),
});
let bytes = postcard::to_allocvec(&msg).unwrap();
let decoded: GossipMessage = postcard::from_bytes(&bytes).unwrap();
match decoded {
GossipMessage::NameChange(nc) => {
assert_eq!(nc.old_name, "anon");
assert_eq!(nc.new_name, "alice");
}
_ => panic!("Expected NameChange variant"),
}
}
#[test]
fn peer_announce_roundtrip() {
let msg = GossipMessage::PeerAnnounce(PeerAnnounce {
sender_name: "charlie".into(),
});
let bytes = postcard::to_allocvec(&msg).unwrap();
let decoded: GossipMessage = postcard::from_bytes(&bytes).unwrap();
match decoded {
GossipMessage::PeerAnnounce(pa) => {
assert_eq!(pa.sender_name, "charlie");
}
_ => panic!("Expected PeerAnnounce variant"),
}
}
#[test]
fn capabilities_default() {
let caps = CapabilitiesMessage::default();
assert!(caps.chat);
assert!(caps.files);
assert!(!caps.audio);
assert!(!caps.camera);
assert!(!caps.screen);
assert_eq!(caps.sender_name, "");
}
#[test]
fn file_offer_broadcast_roundtrip() {
let fid = new_file_id();
let msg = GossipMessage::FileOfferBroadcast(FileOfferBroadcast {
sender_name: "dave".into(),
file_id: fid,
file_name: "test.txt".into(),
file_size: 42,
timeout: 60,
});
let bytes = postcard::to_allocvec(&msg).unwrap();
let decoded: GossipMessage = postcard::from_bytes(&bytes).unwrap();
match decoded {
GossipMessage::FileOfferBroadcast(f) => {
assert_eq!(f.sender_name, "dave");
assert_eq!(f.file_id, fid);
assert_eq!(f.file_name, "test.txt");
assert_eq!(f.file_size, 42);
}
_ => panic!("Expected FileOfferBroadcast variant"),
}
}
#[test]
fn encode_framed_produces_valid_length_prefix() {
let msg = ChatMessage {
sender_name: "test".into(),
timestamp: 0,
text: "hi".into(),
};
let framed = encode_framed(&msg).unwrap();
assert!(framed.len() > 4);
let len = u32::from_be_bytes([framed[0], framed[1], framed[2], framed[3]]) as usize;
assert_eq!(len, framed.len() - 4);
// Verify the payload can be deserialized
let decoded: ChatMessage = postcard::from_bytes(&framed[4..]).unwrap();
assert_eq!(decoded.sender_name, "test");
}
#[test]
fn file_id_is_unique() {
let id1 = new_file_id();
let id2 = new_file_id();
assert_ne!(id1, id2);
}
#[test]
fn media_kind_serialization() {
for kind in [MediaKind::Voice, MediaKind::Camera, MediaKind::Screen] {
let bytes = postcard::to_allocvec(&kind).unwrap();
let decoded: MediaKind = postcard::from_bytes(&bytes).unwrap();
assert_eq!(decoded, kind);
}
}
#[test]
fn media_stream_message_roundtrip() {
let msgs = vec![
MediaStreamMessage::AudioStart {
sample_rate: 48000,
channels: 1,
frame_size_ms: 20,
},
MediaStreamMessage::AudioData {
sequence: 42,
opus_data: vec![0xDE, 0xAD],
},
MediaStreamMessage::AudioStop,
MediaStreamMessage::VideoStart {
kind: MediaKind::Screen,
width: 1920,
height: 1080,
fps: 30,
},
MediaStreamMessage::VideoFrame {
sequence: 1,
timestamp_ms: 1000,
data: vec![0x01, 0x02, 0x03],
},
MediaStreamMessage::VideoStop {
kind: MediaKind::Camera,
},
];
for msg in msgs {
let bytes = postcard::to_allocvec(&msg).unwrap();
let _decoded: MediaStreamMessage = postcard::from_bytes(&bytes).unwrap();
}
}
}
// ============================================================================
// Config tests
// ============================================================================
mod config_tests {
use p2p_chat::config::*;
use ratatui::style::Color;
#[test]
fn default_config_values() {
let config = AppConfig::default();
assert_eq!(config.media.screen_resolution, "1280x720");
assert!(config.media.mic_name.is_none());
assert!(config.network.topic.is_none());
}
#[test]
fn config_toml_roundtrip() {
let config = AppConfig::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: AppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
parsed.media.screen_resolution,
config.media.screen_resolution
);
assert_eq!(parsed.media.mic_name, config.media.mic_name);
assert_eq!(parsed.network.topic, config.network.topic);
}
#[test]
fn config_partial_toml_uses_defaults() {
let toml_str = r#"
[media]
screen_resolution = "1920x1080"
"#;
let config: AppConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.media.screen_resolution, "1920x1080");
// network should use default
assert!(config.network.topic.is_none());
}
#[test]
fn parse_color_named_colors() {
assert_eq!(parse_color("red"), Color::Red);
assert_eq!(parse_color("green"), Color::Green);
assert_eq!(parse_color("blue"), Color::Blue);
assert_eq!(parse_color("cyan"), Color::Cyan);
assert_eq!(parse_color("magenta"), Color::Magenta);
assert_eq!(parse_color("yellow"), Color::Yellow);
assert_eq!(parse_color("white"), Color::White);
assert_eq!(parse_color("black"), Color::Black);
assert_eq!(parse_color("gray"), Color::Gray);
assert_eq!(parse_color("dark_gray"), Color::DarkGray);
assert_eq!(parse_color("darkgray"), Color::DarkGray);
}
#[test]
fn parse_color_case_insensitive() {
assert_eq!(parse_color("RED"), Color::Red);
assert_eq!(parse_color("Green"), Color::Green);
assert_eq!(parse_color("BLUE"), Color::Blue);
}
#[test]
fn parse_color_hex_6digit() {
assert_eq!(parse_color("#ff0000"), Color::Rgb(255, 0, 0));
assert_eq!(parse_color("#00ff00"), Color::Rgb(0, 255, 0));
assert_eq!(parse_color("#0000ff"), Color::Rgb(0, 0, 255));
assert_eq!(parse_color("#ffffff"), Color::Rgb(255, 255, 255));
assert_eq!(parse_color("#000000"), Color::Rgb(0, 0, 0));
}
#[test]
fn parse_color_hex_3digit() {
assert_eq!(parse_color("#f00"), Color::Rgb(255, 0, 0));
assert_eq!(parse_color("#0f0"), Color::Rgb(0, 255, 0));
assert_eq!(parse_color("#00f"), Color::Rgb(0, 0, 255));
assert_eq!(parse_color("#fff"), Color::Rgb(255, 255, 255));
}
#[test]
fn parse_color_unknown_falls_back_to_white() {
assert_eq!(parse_color("nonexistent"), Color::White);
assert_eq!(parse_color(""), Color::White);
}
#[test]
fn theme_from_ui_config() {
let ui = UiConfig::default();
let theme: Theme = ui.into();
assert_eq!(theme.border, Color::Cyan);
assert_eq!(theme.text, Color::White);
assert_eq!(theme.self_name, Color::Green);
assert_eq!(theme.peer_name, Color::Magenta);
assert_eq!(theme.system_msg, Color::Yellow);
assert_eq!(theme.time, Color::DarkGray);
}
#[test]
fn ui_config_defaults() {
let ui = UiConfig::default();
assert_eq!(ui.border, "cyan");
assert_eq!(ui.text, "white");
assert_eq!(ui.self_name, "green");
assert_eq!(ui.peer_name, "magenta");
assert_eq!(ui.system_msg, "yellow");
assert_eq!(ui.time, "dark_gray");
}
}
// ============================================================================
// TUI key handling tests
// ============================================================================
mod tui_tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use p2p_chat::config::{Theme, UiConfig};
use p2p_chat::tui::{App, InputMode, TuiCommand};
fn make_app() -> App {
let theme: Theme = UiConfig::default().into();
App::new(theme)
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl_key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn initial_state() {
let app = make_app();
assert_eq!(app.input_mode, InputMode::Editing);
assert_eq!(app.input, "");
assert_eq!(app.cursor_position, 0);
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn esc_switches_to_normal_mode() {
let mut app = make_app();
assert_eq!(app.input_mode, InputMode::Editing);
let cmd = app.handle_key(key(KeyCode::Esc));
assert!(matches!(cmd, TuiCommand::None));
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn i_switches_to_editing_mode() {
let mut app = make_app();
app.input_mode = InputMode::Normal;
let cmd = app.handle_key(key(KeyCode::Char('i')));
assert!(matches!(cmd, TuiCommand::None));
assert_eq!(app.input_mode, InputMode::Editing);
}
#[test]
fn enter_in_normal_switches_to_editing() {
let mut app = make_app();
app.input_mode = InputMode::Normal;
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::None));
assert_eq!(app.input_mode, InputMode::Editing);
}
#[test]
fn slash_in_normal_prefills_slash() {
let mut app = make_app();
app.input_mode = InputMode::Normal;
let cmd = app.handle_key(key(KeyCode::Char('/')));
assert!(matches!(cmd, TuiCommand::None));
assert_eq!(app.input_mode, InputMode::Editing);
assert_eq!(app.input, "/");
assert_eq!(app.cursor_position, 1);
}
#[test]
fn q_in_normal_quits() {
let mut app = make_app();
app.input_mode = InputMode::Normal;
let cmd = app.handle_key(key(KeyCode::Char('q')));
assert!(matches!(cmd, TuiCommand::Quit));
}
#[test]
fn typing_characters_in_editing() {
let mut app = make_app();
app.handle_key(key(KeyCode::Char('h')));
app.handle_key(key(KeyCode::Char('i')));
assert_eq!(app.input, "hi");
assert_eq!(app.cursor_position, 2);
}
#[test]
fn backspace_deletes_character() {
let mut app = make_app();
app.handle_key(key(KeyCode::Char('a')));
app.handle_key(key(KeyCode::Char('b')));
app.handle_key(key(KeyCode::Char('c')));
assert_eq!(app.input, "abc");
app.handle_key(key(KeyCode::Backspace));
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_position, 2);
}
#[test]
fn enter_sends_message() {
let mut app = make_app();
app.handle_key(key(KeyCode::Char('h')));
app.handle_key(key(KeyCode::Char('i')));
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::SendMessage(ref s) if s == "hi"));
assert_eq!(app.input, "");
assert_eq!(app.cursor_position, 0);
}
#[test]
fn enter_on_empty_input_does_nothing() {
let mut app = make_app();
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::None));
}
#[test]
fn quit_command() {
let mut app = make_app();
for c in "/quit".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::Quit));
}
#[test]
fn help_command_is_system_message() {
let mut app = make_app();
for c in "/help".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::SystemMessage(_)));
}
#[test]
fn nick_command_without_name_is_system_message() {
let mut app = make_app();
for c in "/nick".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::SystemMessage(_)));
}
#[test]
fn nick_command_with_name() {
let mut app = make_app();
for c in "/nick alice".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::ChangeNick(ref s) if s == "alice"));
}
#[test]
fn connect_command_without_id_is_system_message() {
let mut app = make_app();
for c in "/connect".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::SystemMessage(_)));
}
#[test]
fn connect_command_with_id() {
let mut app = make_app();
for c in "/connect abc123".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::Connect(ref s) if s == "abc123"));
}
#[test]
fn voice_command() {
let mut app = make_app();
for c in "/voice".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::ToggleVoice));
}
#[test]
fn camera_command() {
let mut app = make_app();
for c in "/camera".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::ToggleCamera));
}
#[test]
fn screen_command() {
let mut app = make_app();
for c in "/screen".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::ToggleScreen));
}
#[test]
fn leave_command() {
let mut app = make_app();
for c in "/leave".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::Leave));
}
#[test]
fn unknown_command_is_system_message() {
let mut app = make_app();
for c in "/foobar".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
assert!(matches!(cmd, TuiCommand::SystemMessage(_)));
}
#[test]
fn file_command_with_path() {
let mut app = make_app();
for c in "/file /tmp/test.txt".chars() {
app.handle_key(key(KeyCode::Char(c)));
}
let cmd = app.handle_key(key(KeyCode::Enter));
match cmd {
TuiCommand::SendFile(path) => {
assert_eq!(path.to_str().unwrap(), "/tmp/test.txt");
}
_ => panic!("Expected SendFile, got {:?}", cmd),
}
}
#[test]
fn scroll_up_and_down_in_normal_mode() {
let mut app = make_app();
app.input_mode = InputMode::Normal;
app.handle_key(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 1);
app.handle_key(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 2);
app.handle_key(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 1);
app.handle_key(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 0);
// Should not go below 0
app.handle_key(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn ctrl_c_not_handled_in_tui_layer() {
// Ctrl+C is handled at the signal level (tokio::signal::ctrl_c),
// not in TUI key handling. Crossterm delivers Char('c') with CONTROL modifier,
// which the TUI treats like any other char input.
let mut app = make_app();
app.handle_key(key(KeyCode::Char('h')));
app.handle_key(key(KeyCode::Char('i')));
// Ctrl+C in editing mode inserts 'c' (crossterm behavior)
app.handle_key(ctrl_key(KeyCode::Char('c')));
assert_eq!(app.input, "hic");
}
}
// ============================================================================
// Chat state tests
// ============================================================================
mod chat_tests {
use p2p_chat::chat::ChatState;
use p2p_chat::protocol::ChatMessage;
#[test]
fn new_chat_state() {
let chat = ChatState::new("alice".into());
assert_eq!(chat.our_name, "alice");
assert!(chat.history.is_empty());
}
#[test]
fn add_system_message() {
let mut chat = ChatState::new("alice".into());
chat.add_system_message("test message".into());
assert_eq!(chat.history.len(), 1);
assert!(chat.history[0].is_system);
assert_eq!(chat.history[0].text, "test message");
assert_eq!(chat.history[0].sender_name, "SYSTEM");
}
#[test]
fn receive_message() {
let mut chat = ChatState::new("alice".into());
chat.receive_message(ChatMessage {
sender_name: "bob".into(),
timestamp: 1234567890,
text: "hello".into(),
});
assert_eq!(chat.history.len(), 1);
assert!(!chat.history[0].is_self);
assert!(!chat.history[0].is_system);
assert_eq!(chat.history[0].sender_name, "bob");
assert_eq!(chat.history[0].text, "hello");
}
#[test]
fn history_trimming() {
let mut chat = ChatState::new("alice".into());
chat.max_history = 5;
for i in 0..10 {
chat.add_system_message(format!("msg {}", i));
}
assert_eq!(chat.history.len(), 5);
// Should keep the last 5 messages
assert_eq!(chat.history[0].text, "msg 5");
assert_eq!(chat.history[4].text, "msg 9");
}
#[test]
fn multiple_message_types() {
let mut chat = ChatState::new("alice".into());
chat.add_system_message("welcome".into());
chat.receive_message(ChatMessage {
sender_name: "bob".into(),
timestamp: 100,
text: "hi alice".into(),
});
chat.add_system_message("bob left".into());
assert_eq!(chat.history.len(), 3);
assert!(chat.history[0].is_system);
assert!(!chat.history[1].is_system);
assert_eq!(chat.history[1].sender_name, "bob");
assert!(chat.history[2].is_system);
}
}