//! 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.chat_border, Color::Cyan); assert_eq!(theme.text, Color::White); assert_eq!(theme.self_name, Color::Green); 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.text, "white"); assert_eq!(ui.self_name, "green"); 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::app_logic::AppCommand; use p2p_chat::config::{Theme, UiConfig}; use p2p_chat::tui::{App, InputMode}; 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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::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, AppCommand::ToggleVoice)); } #[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, AppCommand::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, AppCommand::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, AppCommand::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 { AppCommand::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); } }