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:
@@ -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<u8> = 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 {
|
||||
|
||||
@@ -84,6 +84,10 @@ pub struct TuiApp {
|
||||
focus: Focus,
|
||||
/// Notification line (shown briefly, e.g. "Message sent", "Error: ...").
|
||||
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 {
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user