diff --git a/crates/quicproquo-client/src/client/v2_repl.rs b/crates/quicproquo-client/src/client/v2_repl.rs index 7a1c740..5ea83ec 100644 --- a/crates/quicproquo-client/src/client/v2_repl.rs +++ b/crates/quicproquo-client/src/client/v2_repl.rs @@ -937,8 +937,9 @@ async fn do_devices(client: &mut QpqClient, args: &str) -> anyhow::Result<()> { } let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; // Generate a random device ID (16 bytes). - use rand::Rng; - let dev_id: Vec = rand::rng().random::<[u8; 16]>().to_vec(); + use rand::RngCore; + let mut dev_id = vec![0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut dev_id); let was_new = quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?; if was_new { diff --git a/crates/quicproquo-client/src/client/v2_tui.rs b/crates/quicproquo-client/src/client/v2_tui.rs index c5a4aca..5ae66e1 100644 --- a/crates/quicproquo-client/src/client/v2_tui.rs +++ b/crates/quicproquo-client/src/client/v2_tui.rs @@ -84,6 +84,10 @@ pub struct TuiApp { focus: Focus, /// Notification line (shown briefly, e.g. "Message sent", "Error: ..."). notification: Option, + /// Whether the client is currently connected. + connected: bool, + /// Current MLS epoch for the active conversation (if available). + mls_epoch: Option, } impl TuiApp { @@ -101,6 +105,8 @@ impl TuiApp { server_addr: server_addr.to_string(), focus: Focus::Input, notification: None, + connected: false, + mls_epoch: None, } } @@ -140,13 +146,18 @@ impl TuiApp { } fn update_status(&mut self) { + let conn_indicator = if self.connected { "Online" } else { "Offline" }; let user = self .username .as_deref() .unwrap_or("not logged in"); let conv_count = self.conversations.len(); + let epoch_str = match self.mls_epoch { + Some(e) => format!("epoch {e}"), + None => "epoch --".to_string(), + }; self.status_line = format!( - "Connected to {} | {} | {} conversation{}", + "{conn_indicator} | {} | {} | {} conversation{} | MLS {epoch_str}", self.server_addr, user, conv_count, @@ -184,6 +195,7 @@ pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> { "disconnected" }; let mut app = TuiApp::new(server_addr); + app.connected = client.is_connected(); // Populate initial state from client. if let Some(name) = client.username() { @@ -263,12 +275,14 @@ pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> { fn handle_sdk_event(app: &mut TuiApp, event: ClientEvent) { match event { ClientEvent::Connected => { + app.connected = true; app.notification = Some("Connected to server".to_string()); app.update_status(); } ClientEvent::Disconnected { reason } => { - app.status_line = format!("Disconnected: {reason}"); + app.connected = false; app.notification = Some(format!("Disconnected: {reason}")); + app.update_status(); } ClientEvent::Registered { username } => { app.notification = Some(format!("Registered as {username}")); @@ -380,6 +394,8 @@ fn handle_sdk_event(app: &mut TuiApp, event: ClientEvent) { ClientEvent::Error { message } => { app.notification = Some(format!("Error: {message}")); } + // Events that don't need TUI-specific handling. + _ => {} } } @@ -818,12 +834,25 @@ fn draw_input(frame: &mut Frame, app: &TuiApp, area: Rect) { } fn draw_status(frame: &mut Frame, app: &TuiApp, area: Rect) { - let status = Paragraph::new(Line::from(Span::styled( - format!(" {} ", app.status_line), - Style::default() - .fg(Color::Black) - .bg(Color::DarkGray), - ))); + let conn_color = if app.connected { + Color::Green + } else { + Color::Red + }; + let conn_indicator = if app.connected { " ON " } else { " OFF " }; + + let spans = vec![ + Span::styled( + conn_indicator, + Style::default().fg(Color::Black).bg(conn_color), + ), + Span::styled( + format!(" {} ", app.status_line), + Style::default().fg(Color::Black).bg(Color::DarkGray), + ), + ]; + + let status = Paragraph::new(Line::from(spans)); frame.render_widget(status, area); } @@ -977,3 +1006,147 @@ fn now_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{backend::TestBackend, Terminal}; + + fn make_app() -> TuiApp { + let mut app = TuiApp::new("127.0.0.1:7000"); + app.connected = true; + app.username = Some("alice".to_string()); + app.conversations.push(ConversationItem { + id: [1u8; 16], + name: "general".to_string(), + unread: 0, + last_message: None, + }); + app.conversations.push(ConversationItem { + id: [2u8; 16], + name: "random".to_string(), + unread: 3, + last_message: Some("bob: hello".to_string()), + }); + app.messages.push(DisplayMessage { + timestamp: "[12:34]".to_string(), + sender: "alice".to_string(), + body: "Hello world".to_string(), + is_outgoing: true, + }); + app.messages.push(DisplayMessage { + timestamp: "[12:35]".to_string(), + sender: "bob".to_string(), + body: "Hi alice!".to_string(), + is_outgoing: false, + }); + app.update_status(); + app + } + + #[test] + fn render_does_not_panic() { + let app = make_app(); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui(f, &app)).unwrap(); + } + + #[test] + fn render_help_overlay() { + let mut app = make_app(); + app.focus = Focus::Help; + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui(f, &app)).unwrap(); + } + + #[test] + fn status_bar_shows_online() { + let mut app = TuiApp::new("127.0.0.1:7000"); + app.connected = true; + app.username = Some("alice".to_string()); + app.update_status(); + assert!(app.status_line.contains("Online")); + assert!(app.status_line.contains("alice")); + assert!(app.status_line.contains("MLS epoch --")); + } + + #[test] + fn status_bar_shows_offline() { + let mut app = TuiApp::new("127.0.0.1:7000"); + app.connected = false; + app.update_status(); + assert!(app.status_line.contains("Offline")); + } + + #[test] + fn status_bar_shows_epoch() { + let mut app = TuiApp::new("127.0.0.1:7000"); + app.connected = true; + app.mls_epoch = Some(42); + app.update_status(); + assert!(app.status_line.contains("MLS epoch 42")); + } + + #[test] + fn conversation_navigation() { + let mut app = make_app(); + assert_eq!(app.selected_conversation, 0); + app.select_next_conversation(); + assert_eq!(app.selected_conversation, 1); + app.select_next_conversation(); + assert_eq!(app.selected_conversation, 0); // wraps around + app.select_prev_conversation(); + assert_eq!(app.selected_conversation, 1); // wraps back + } + + #[test] + fn scroll_bounds() { + let mut app = make_app(); + assert_eq!(app.scroll_offset, 0); + app.scroll_down(); // already at 0 + assert_eq!(app.scroll_offset, 0); + app.scroll_up(); + assert_eq!(app.scroll_offset, 1); + app.scroll_up(); + assert_eq!(app.scroll_offset, 2); + app.scroll_down(); + assert_eq!(app.scroll_offset, 1); + } + + #[test] + fn render_empty_state() { + let app = TuiApp::new("127.0.0.1:7000"); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui(f, &app)).unwrap(); + } + + #[test] + fn unread_count_display() { + let app = make_app(); + // The second conversation has 3 unread. + assert_eq!(app.conversations[1].unread, 3); + + // Render and check the sidebar contains the unread indicator. + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui(f, &app)).unwrap(); + + // Read the buffer and check the sidebar area contains "random (3)". + let buf = terminal.backend().buffer().clone(); + let mut sidebar_text = String::new(); + // Sidebar is roughly the left 20% = 16 columns. + for y in 0..buf.area.height { + for x in 0..16.min(buf.area.width) { + let cell = &buf[(x, y)]; + sidebar_text.push_str(cell.symbol()); + } + } + assert!( + sidebar_text.contains("random (3)"), + "sidebar should show unread count; got: {sidebar_text}" + ); + } +}