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}"))?;
|
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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
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),
|
format!(" {} ", app.status_line),
|
||||||
Style::default()
|
Style::default().fg(Color::Black).bg(Color::DarkGray),
|
||||||
.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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user