feat(tui): add MLS epoch indicator, online/offline status, and 9 rendering tests

Enhance v2 TUI with connected/mls_epoch state fields, colored connection
indicator in status bar, MLS epoch display, and wildcard match for new
SDK event variants. Add 9 tests using ratatui TestBackend covering
rendering, navigation, scroll bounds, status bar content, and unread
count display. Also fix rand 0.8 compat issue in v2_repl.rs.
This commit is contained in:
2026-03-04 20:52:27 +01:00
parent 372dd67a3b
commit f667281831
2 changed files with 184 additions and 10 deletions

View File

@@ -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}"))?; let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
// Generate a random device ID (16 bytes). // Generate a random device ID (16 bytes).
use rand::Rng; use rand::RngCore;
let dev_id: Vec<u8> = rand::rng().random::<[u8; 16]>().to_vec(); let mut dev_id = vec![0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut dev_id);
let was_new = let was_new =
quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?; quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?;
if was_new { if was_new {

View File

@@ -84,6 +84,10 @@ pub struct TuiApp {
focus: Focus, focus: Focus,
/// Notification line (shown briefly, e.g. "Message sent", "Error: ..."). /// Notification line (shown briefly, e.g. "Message sent", "Error: ...").
notification: Option<String>, notification: Option<String>,
/// Whether the client is currently connected.
connected: bool,
/// Current MLS epoch for the active conversation (if available).
mls_epoch: Option<u64>,
} }
impl TuiApp { impl TuiApp {
@@ -101,6 +105,8 @@ impl TuiApp {
server_addr: server_addr.to_string(), server_addr: server_addr.to_string(),
focus: Focus::Input, focus: Focus::Input,
notification: None, notification: None,
connected: false,
mls_epoch: None,
} }
} }
@@ -140,13 +146,18 @@ impl TuiApp {
} }
fn update_status(&mut self) { fn update_status(&mut self) {
let conn_indicator = if self.connected { "Online" } else { "Offline" };
let user = self let user = self
.username .username
.as_deref() .as_deref()
.unwrap_or("not logged in"); .unwrap_or("not logged in");
let conv_count = self.conversations.len(); 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!( self.status_line = format!(
"Connected to {} | {} | {} conversation{}", "{conn_indicator} | {} | {} | {} conversation{} | MLS {epoch_str}",
self.server_addr, self.server_addr,
user, user,
conv_count, conv_count,
@@ -184,6 +195,7 @@ pub async fn run_v2_tui(client: &mut QpqClient) -> anyhow::Result<()> {
"disconnected" "disconnected"
}; };
let mut app = TuiApp::new(server_addr); let mut app = TuiApp::new(server_addr);
app.connected = client.is_connected();
// Populate initial state from client. // Populate initial state from client.
if let Some(name) = client.username() { 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) { fn handle_sdk_event(app: &mut TuiApp, event: ClientEvent) {
match event { match event {
ClientEvent::Connected => { ClientEvent::Connected => {
app.connected = true;
app.notification = Some("Connected to server".to_string()); app.notification = Some("Connected to server".to_string());
app.update_status(); app.update_status();
} }
ClientEvent::Disconnected { reason } => { ClientEvent::Disconnected { reason } => {
app.status_line = format!("Disconnected: {reason}"); app.connected = false;
app.notification = Some(format!("Disconnected: {reason}")); app.notification = Some(format!("Disconnected: {reason}"));
app.update_status();
} }
ClientEvent::Registered { username } => { ClientEvent::Registered { username } => {
app.notification = Some(format!("Registered as {username}")); app.notification = Some(format!("Registered as {username}"));
@@ -380,6 +394,8 @@ fn handle_sdk_event(app: &mut TuiApp, event: ClientEvent) {
ClientEvent::Error { message } => { ClientEvent::Error { message } => {
app.notification = Some(format!("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) { fn draw_status(frame: &mut Frame, app: &TuiApp, area: Rect) {
let status = Paragraph::new(Line::from(Span::styled( let conn_color = if app.connected {
format!(" {} ", app.status_line), Color::Green
Style::default() } else {
.fg(Color::Black) Color::Red
.bg(Color::DarkGray), };
))); 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); frame.render_widget(status, area);
} }
@@ -977,3 +1006,147 @@ fn now_ms() -> u64 {
.unwrap_or_default() .unwrap_or_default()
.as_millis() as u64 .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}"
);
}
}