diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-28 22:36:07 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-28 22:36:07 +0100 |
| commit | 25f9abeb4c69024f8d9dceb472ba33c03e00daa0 (patch) | |
| tree | a02a78f04b5afa2e3fbebffb524f1568df8f00d8 | |
| parent | v0.2.4: fix command UX, completion wrapping and auth prompt cancellation (diff) | |
| download | zagan-bet-25f9abeb4c69024f8d9dceb472ba33c03e00daa0.tar.gz zagan-bet-25f9abeb4c69024f8d9dceb472ba33c03e00daa0.zip | |
v0.2.5: fix terminal width, ping timing and output formatting
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/bin/client/connection.rs | 14 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 107 | ||||
| -rw-r--r-- | src/client/core.rs | 6 | ||||
| -rw-r--r-- | src/client/ui.rs | 93 | ||||
| -rw-r--r-- | tests/client_core.rs | 89 | ||||
| -rw-r--r-- | tests/client_ui.rs | 65 |
7 files changed, 333 insertions, 43 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "0.2.4" | 3 | version = "0.2.5" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [dependencies] |
diff --git a/src/bin/client/connection.rs b/src/bin/client/connection.rs index e54b646..93a7829 100644 --- a/src/bin/client/connection.rs +++ b/src/bin/client/connection.rs | |||
| @@ -1,4 +1,5 @@ | |||
| 1 | use zagan_bet::client::database; | 1 | use zagan_bet::client::database; |
| 2 | use zagan_bet::client::ui::first_contact_lines; | ||
| 2 | use crate::tls::TlsConnector; | 3 | use crate::tls::TlsConnector; |
| 3 | use rusqlite::Connection; | 4 | use rusqlite::Connection; |
| 4 | use std::io::{Read, Write}; | 5 | use std::io::{Read, Write}; |
| @@ -57,10 +58,9 @@ pub fn connect( | |||
| 57 | if let Some(fp) = observed { | 58 | if let Some(fp) = observed { |
| 58 | if database::get_fingerprint(conn, addr).is_none() { | 59 | if database::get_fingerprint(conn, addr).is_none() { |
| 59 | database::save_fingerprint(conn, addr, &fp)?; | 60 | database::save_fingerprint(conn, addr, &fp)?; |
| 60 | println!( | 61 | for line in first_contact_lines(addr, &fp) { |
| 61 | "First contact with {addr}: trusting fingerprint {fp}\n\ | 62 | println!("{}", line); |
| 62 | Future connections must present the same certificate key." | 63 | } |
| 63 | ); | ||
| 64 | } | 64 | } |
| 65 | } | 65 | } |
| 66 | } | 66 | } |
| @@ -72,7 +72,11 @@ pub fn connect( | |||
| 72 | let mut response = String::new(); | 72 | let mut response = String::new(); |
| 73 | tls.read_line(&mut response)?; | 73 | tls.read_line(&mut response)?; |
| 74 | let elapsed = start.elapsed(); | 74 | let elapsed = start.elapsed(); |
| 75 | println!("\n\rConnected to {} with ping delay: {:?} (TLS)", addr, elapsed); | 75 | println!( |
| 76 | "\n\rConnected to {} with ping delay: {:.1}ms (TLS)", | ||
| 77 | addr, | ||
| 78 | elapsed.as_secs_f64() * 1000.0 | ||
| 79 | ); | ||
| 76 | 80 | ||
| 77 | if let Some((username, password)) = database::get_credentials(conn, addr) { | 81 | if let Some((username, password)) = database::get_credentials(conn, addr) { |
| 78 | let auth_msg = format!("AUTH {} {}", username, password); | 82 | let auth_msg = format!("AUTH {} {}", username, password); |
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index 3fa49b8..76b149b 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs | |||
| @@ -1,11 +1,13 @@ | |||
| 1 | use rusqlite::Connection; | 1 | use rusqlite::Connection; |
| 2 | use std::io::{stdout, Write}; | 2 | use std::io::{stdout, Write}; |
| 3 | use std::sync::{Arc, Mutex}; | 3 | use std::sync::{Arc, Mutex}; |
| 4 | use std::time::Instant; | ||
| 4 | 5 | ||
| 5 | use crossterm::{ | 6 | use crossterm::{ |
| 6 | cursor::{MoveToColumn, MoveUp, Show}, | 7 | cursor::{MoveToColumn, MoveUp, Show}, |
| 7 | event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, | 8 | event::{read, Event, KeyCode, KeyEventKind, KeyModifiers}, |
| 8 | execute, | 9 | execute, |
| 10 | style::Stylize, | ||
| 9 | terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, | 11 | terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, |
| 10 | }; | 12 | }; |
| 11 | 13 | ||
| @@ -13,7 +15,9 @@ use sha2::{Digest, Sha256}; | |||
| 13 | 15 | ||
| 14 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; | 16 | use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; |
| 15 | use zagan_bet::client::database; | 17 | use zagan_bet::client::database; |
| 16 | use zagan_bet::client::ui::{input_cursor_column, lines_to_move_up}; | 18 | use zagan_bet::client::ui::{ |
| 19 | display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, | ||
| 20 | }; | ||
| 17 | 21 | ||
| 18 | use crate::connection::TlsStream; | 22 | use crate::connection::TlsStream; |
| 19 | 23 | ||
| @@ -96,18 +100,22 @@ impl ClientState { | |||
| 96 | 100 | ||
| 97 | loop { | 101 | loop { |
| 98 | let event = read().unwrap(); | 102 | let event = read().unwrap(); |
| 99 | if let Event::Key(key) = event { | 103 | match event { |
| 100 | if key.kind != KeyEventKind::Press { | 104 | Event::Key(key) => { |
| 101 | continue; | 105 | if key.kind != KeyEventKind::Press { |
| 102 | } | 106 | continue; |
| 103 | let Some(key) = map_key(key) else { | 107 | } |
| 104 | continue; | 108 | let Some(key) = map_key(key) else { |
| 105 | }; | 109 | continue; |
| 106 | let effects = self.core.handle_key(key); | 110 | }; |
| 107 | if self.apply_effects(effects) { | 111 | let effects = self.core.handle_key(key); |
| 108 | break; | 112 | if self.apply_effects(effects) { |
| 113 | break; | ||
| 114 | } | ||
| 115 | self.apply_prompt(); | ||
| 109 | } | 116 | } |
| 110 | self.apply_prompt(); | 117 | Event::Resize(_, _) => self.apply_prompt(), |
| 118 | _ => {} | ||
| 111 | } | 119 | } |
| 112 | } | 120 | } |
| 113 | 121 | ||
| @@ -120,15 +128,45 @@ impl ClientState { | |||
| 120 | Effect::Print(lines) => { | 128 | Effect::Print(lines) => { |
| 121 | self.commit_prompt_line(); | 129 | self.commit_prompt_line(); |
| 122 | for line in lines { | 130 | for line in lines { |
| 123 | println!("{}", line); | 131 | let rendered = if line.starts_with("ERROR") |
| 132 | || line.contains("failed") | ||
| 133 | { | ||
| 134 | line.as_str().red().to_string() | ||
| 135 | } else if line.starts_with("Logged in") | ||
| 136 | || line.starts_with("Successfully") | ||
| 137 | { | ||
| 138 | line.as_str().green().to_string() | ||
| 139 | } else if line.starts_with("Available commands:") | ||
| 140 | || line.starts_with("Available games:") | ||
| 141 | || line.starts_with("Remembered hosts:") | ||
| 142 | { | ||
| 143 | line.as_str().bold().cyan().to_string() | ||
| 144 | } else { | ||
| 145 | line | ||
| 146 | }; | ||
| 147 | println!("{}", rendered); | ||
| 124 | } | 148 | } |
| 125 | } | 149 | } |
| 126 | Effect::ReplaceRegion(lines) => self.draw_region(&lines), | 150 | Effect::ReplaceRegion(lines) => self.draw_region(&lines), |
| 127 | Effect::ClearRegion => self.clear_region(), | 151 | Effect::ClearRegion => self.clear_region(), |
| 128 | Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), | 152 | Effect::SetPrompt { text, cursor_col } => self.draw_prompt(&text, cursor_col), |
| 129 | Effect::SendLine(line) => { | 153 | Effect::SendLine(line) => { |
| 154 | let is_ping = line == "PING"; | ||
| 155 | let started = Instant::now(); | ||
| 130 | let reply = self.send_and_read(&line); | 156 | let reply = self.send_and_read(&line); |
| 131 | let more = self.core.handle_reply(&reply); | 157 | let elapsed = started.elapsed(); |
| 158 | let mut more = self.core.handle_reply(&reply); | ||
| 159 | if is_ping && reply.trim() == "PONG" { | ||
| 160 | for effect in &mut more { | ||
| 161 | if let Effect::Print(lines) = effect { | ||
| 162 | for text in lines { | ||
| 163 | if text == "Pong" { | ||
| 164 | *text = format_pong(elapsed); | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | } | ||
| 169 | } | ||
| 132 | if self.stream.is_none() { | 170 | if self.stream.is_none() { |
| 133 | self.core.set_connected(false); | 171 | self.core.set_connected(false); |
| 134 | } | 172 | } |
| @@ -196,7 +234,21 @@ impl ClientState { | |||
| 196 | execute!(stdout(), MoveUp(lines_to_move_up(self.game_lines))).unwrap(); | 234 | execute!(stdout(), MoveUp(lines_to_move_up(self.game_lines))).unwrap(); |
| 197 | } | 235 | } |
| 198 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::FromCursorDown)).unwrap(); | 236 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::FromCursorDown)).unwrap(); |
| 199 | print!("{}", lines.join("\r\n")); | 237 | let rendered: Vec<String> = lines |
| 238 | .iter() | ||
| 239 | .map(|line| { | ||
| 240 | if line.starts_with("===") { | ||
| 241 | line.as_str().bold().cyan().to_string() | ||
| 242 | } else if line.contains("JACKPOT") || line.contains("Two of a kind") { | ||
| 243 | line.as_str().bold().green().to_string() | ||
| 244 | } else if line.starts_with("ERROR:") { | ||
| 245 | line.as_str().red().to_string() | ||
| 246 | } else { | ||
| 247 | line.clone() | ||
| 248 | } | ||
| 249 | }) | ||
| 250 | .collect(); | ||
| 251 | print!("{}", rendered.join("\r\n")); | ||
| 200 | stdout().flush().unwrap(); | 252 | stdout().flush().unwrap(); |
| 201 | self.game_lines = lines.len().max(1); | 253 | self.game_lines = lines.len().max(1); |
| 202 | } | 254 | } |
| @@ -217,7 +269,8 @@ impl ClientState { | |||
| 217 | 269 | ||
| 218 | fn draw_prompt(&mut self, text: &str, cursor_col: u16) { | 270 | fn draw_prompt(&mut self, text: &str, cursor_col: u16) { |
| 219 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); | 271 | execute!(stdout(), MoveToColumn(0), Clear(ClearType::CurrentLine)).unwrap(); |
| 220 | print!("> {}", text); | 272 | print!("{}", "> ".cyan()); |
| 273 | print!("{}", text); | ||
| 221 | stdout().flush().unwrap(); | 274 | stdout().flush().unwrap(); |
| 222 | execute!(stdout(), MoveToColumn(cursor_col)).unwrap(); | 275 | execute!(stdout(), MoveToColumn(cursor_col)).unwrap(); |
| 223 | self.prompt_text = text.to_string(); | 276 | self.prompt_text = text.to_string(); |
| @@ -376,16 +429,8 @@ impl ClientState { | |||
| 376 | 429 | ||
| 377 | fn list_hosts(&mut self) { | 430 | fn list_hosts(&mut self) { |
| 378 | let hosts = database::get_all_hosts(&self.conn); | 431 | let hosts = database::get_all_hosts(&self.conn); |
| 379 | if hosts.is_empty() { | 432 | for line in host_lines(&hosts) { |
| 380 | println!("No remembered hosts."); | 433 | println!("{}", line); |
| 381 | return; | ||
| 382 | } | ||
| 383 | println!("Remembered hosts:"); | ||
| 384 | for (host, fingerprint) in hosts { | ||
| 385 | match fingerprint { | ||
| 386 | Some(fingerprint) => println!(" {} - {}", host, fingerprint), | ||
| 387 | None => println!(" {} - (no fingerprint)", host), | ||
| 388 | } | ||
| 389 | } | 434 | } |
| 390 | } | 435 | } |
| 391 | } | 436 | } |
| @@ -425,7 +470,6 @@ fn read_line(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 425 | stdout().flush().unwrap(); | 470 | stdout().flush().unwrap(); |
| 426 | 471 | ||
| 427 | let mut input = String::new(); | 472 | let mut input = String::new(); |
| 428 | let prompt_len = prompt.len(); | ||
| 429 | 473 | ||
| 430 | loop { | 474 | loop { |
| 431 | match read().unwrap() { | 475 | match read().unwrap() { |
| @@ -442,8 +486,8 @@ fn read_line(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 442 | execute!( | 486 | execute!( |
| 443 | stdout(), | 487 | stdout(), |
| 444 | MoveToColumn(input_cursor_column( | 488 | MoveToColumn(input_cursor_column( |
| 445 | prompt_len, | 489 | display_width(prompt), |
| 446 | input.len() | 490 | display_width(&input) |
| 447 | )), | 491 | )), |
| 448 | Clear(ClearType::UntilNewLine) | 492 | Clear(ClearType::UntilNewLine) |
| 449 | ) | 493 | ) |
| @@ -486,7 +530,6 @@ fn read_password(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 486 | stdout().flush().unwrap(); | 530 | stdout().flush().unwrap(); |
| 487 | 531 | ||
| 488 | let mut input = String::new(); | 532 | let mut input = String::new(); |
| 489 | let prompt_len = prompt.len(); | ||
| 490 | 533 | ||
| 491 | loop { | 534 | loop { |
| 492 | match read().unwrap() { | 535 | match read().unwrap() { |
| @@ -503,8 +546,8 @@ fn read_password(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String { | |||
| 503 | execute!( | 546 | execute!( |
| 504 | stdout(), | 547 | stdout(), |
| 505 | MoveToColumn(input_cursor_column( | 548 | MoveToColumn(input_cursor_column( |
| 506 | prompt_len, | 549 | display_width(prompt), |
| 507 | input.len() | 550 | display_width(&input) |
| 508 | )), | 551 | )), |
| 509 | Clear(ClearType::UntilNewLine) | 552 | Clear(ClearType::UntilNewLine) |
| 510 | ) | 553 | ) |
diff --git a/src/client/core.rs b/src/client/core.rs index a910a68..c204cb2 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -1,8 +1,8 @@ | |||
| 1 | use crate::client::games::manager::GameManager; | 1 | use crate::client::games::manager::GameManager; |
| 2 | use crate::client::games::response::parse_spin_response; | 2 | use crate::client::games::response::parse_spin_response; |
| 3 | use crate::client::ui::{ | 3 | use crate::client::ui::{ |
| 4 | command_names, completion_lines, help_lines, input_cursor_column, parse_bet_arg, | 4 | command_names, completion_lines, display_width, help_lines, input_cursor_column, |
| 5 | record_history, | 5 | parse_bet_arg, record_history, |
| 6 | }; | 6 | }; |
| 7 | 7 | ||
| 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| @@ -120,7 +120,7 @@ impl ClientCore { | |||
| 120 | } | 120 | } |
| 121 | Some(Effect::SetPrompt { | 121 | Some(Effect::SetPrompt { |
| 122 | text: self.input.clone(), | 122 | text: self.input.clone(), |
| 123 | cursor_col: input_cursor_column(2, self.input.chars().count()), | 123 | cursor_col: input_cursor_column(2, display_width(&self.input)), |
| 124 | }) | 124 | }) |
| 125 | } | 125 | } |
| 126 | 126 | ||
diff --git a/src/client/ui.rs b/src/client/ui.rs index 6fc2f62..493f660 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs | |||
| @@ -153,6 +153,94 @@ pub fn parse_bet_arg(raw: &str) -> Result<u64, String> { | |||
| 153 | Ok(bet) | 153 | Ok(bet) |
| 154 | } | 154 | } |
| 155 | 155 | ||
| 156 | /// Number of terminal columns used by `text`. | ||
| 157 | /// | ||
| 158 | /// This is a deliberately small approximation of `unicode-width`: it handles | ||
| 159 | /// ASCII, Latin diacritics, combining marks, CJK, and the common emoji ranges | ||
| 160 | /// without adding a dependency. | ||
| 161 | pub fn display_width(text: &str) -> usize { | ||
| 162 | text.chars().map(char_width).sum() | ||
| 163 | } | ||
| 164 | |||
| 165 | fn char_width(c: char) -> usize { | ||
| 166 | let cp = c as u32; | ||
| 167 | if cp < 0x20 || cp == 0x7F { | ||
| 168 | return 0; | ||
| 169 | } | ||
| 170 | if is_zero_width(cp) || is_combining(cp) { | ||
| 171 | return 0; | ||
| 172 | } | ||
| 173 | if is_wide(cp) { | ||
| 174 | 2 | ||
| 175 | } else { | ||
| 176 | 1 | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | fn is_zero_width(cp: u32) -> bool { | ||
| 181 | matches!( | ||
| 182 | cp, | ||
| 183 | 0x200B..=0x200F | 0xFE00..=0xFE0F | 0xFEFF | 0x1F3FB..=0x1F3FF | ||
| 184 | ) | ||
| 185 | } | ||
| 186 | |||
| 187 | fn is_combining(cp: u32) -> bool { | ||
| 188 | matches!( | ||
| 189 | cp, | ||
| 190 | 0x0300..=0x036F | ||
| 191 | | 0x1AB0..=0x1AFF | ||
| 192 | | 0x1DC0..=0x1DFF | ||
| 193 | | 0x20D0..=0x20FF | ||
| 194 | | 0xFE20..=0xFE2F | ||
| 195 | ) | ||
| 196 | } | ||
| 197 | |||
| 198 | fn is_wide(cp: u32) -> bool { | ||
| 199 | matches!( | ||
| 200 | cp, | ||
| 201 | 0x1100..=0x115F | ||
| 202 | | 0x2329..=0x232A | ||
| 203 | | 0x2E80..=0xA4CF | ||
| 204 | | 0xAC00..=0xD7A3 | ||
| 205 | | 0xF900..=0xFAFF | ||
| 206 | | 0xFE10..=0xFE19 | ||
| 207 | | 0xFE30..=0xFE6F | ||
| 208 | | 0xFF00..=0xFF60 | ||
| 209 | | 0xFFE0..=0xFFE6 | ||
| 210 | | 0x1F300..=0x1F64F | ||
| 211 | | 0x1F680..=0x1F6FF | ||
| 212 | | 0x1F900..=0x1F9FF | ||
| 213 | | 0x20000..=0x3FFFD | ||
| 214 | ) | ||
| 215 | } | ||
| 216 | |||
| 217 | /// The three lines printed on first contact with a host. | ||
| 218 | pub fn first_contact_lines(addr: &str, fingerprint: &str) -> Vec<String> { | ||
| 219 | vec![ | ||
| 220 | format!("First contact with {addr}: trusting a new certificate."), | ||
| 221 | format!("Fingerprint: {fingerprint}"), | ||
| 222 | "Future connections must present the same certificate key.".to_string(), | ||
| 223 | ] | ||
| 224 | } | ||
| 225 | |||
| 226 | /// Format remembered hosts so a 64-character fingerprint does not wrap on an | ||
| 227 | /// 80-column terminal. | ||
| 228 | pub fn host_lines(hosts: &[(String, Option<String>)]) -> Vec<String> { | ||
| 229 | if hosts.is_empty() { | ||
| 230 | return vec!["No remembered hosts.".to_string()]; | ||
| 231 | } | ||
| 232 | |||
| 233 | let mut lines = vec!["Remembered hosts:".to_string()]; | ||
| 234 | for (host, fingerprint) in hosts { | ||
| 235 | lines.push(format!(" {host}")); | ||
| 236 | match fingerprint { | ||
| 237 | Some(fingerprint) => lines.push(format!(" fingerprint: {fingerprint}")), | ||
| 238 | None => lines.push(" (no fingerprint)".to_string()), | ||
| 239 | } | ||
| 240 | } | ||
| 241 | lines | ||
| 242 | } | ||
| 243 | |||
| 156 | /// Lines to move the cursor up to reach the first line of a region that ends | 244 | /// Lines to move the cursor up to reach the first line of a region that ends |
| 157 | /// on the current line. `render()` has no trailing newline, so a 7-line board | 245 | /// on the current line. `render()` has no trailing newline, so a 7-line board |
| 158 | /// needs `MoveUp(6)`. | 246 | /// needs `MoveUp(6)`. |
| @@ -168,3 +256,8 @@ pub fn input_cursor_column(prompt_len: usize, input_len: usize) -> u16 { | |||
| 168 | .saturating_add(input_len) | 256 | .saturating_add(input_len) |
| 169 | .min(u16::MAX as usize) as u16 | 257 | .min(u16::MAX as usize) as u16 |
| 170 | } | 258 | } |
| 259 | |||
| 260 | /// Format a ping round-trip time with one decimal place. | ||
| 261 | pub fn format_pong(elapsed: std::time::Duration) -> String { | ||
| 262 | format!("Pong ({:.1}ms)", elapsed.as_secs_f64() * 1000.0) | ||
| 263 | } | ||
diff --git a/tests/client_core.rs b/tests/client_core.rs index 79b3dd8..c298ab7 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -386,3 +386,92 @@ fn prompt_cursor_tracks_the_input() { | |||
| 386 | assert_eq!(h.screen.prompt, ""); | 386 | assert_eq!(h.screen.prompt, ""); |
| 387 | assert_eq!(h.screen.prompt_cursor, 2); | 387 | assert_eq!(h.screen.prompt_cursor, 2); |
| 388 | } | 388 | } |
| 389 | |||
| 390 | #[test] | ||
| 391 | fn info_shows_the_host_and_the_server_fields() { | ||
| 392 | let mut h = Harness::new(); | ||
| 393 | h.core.set_host_info( | ||
| 394 | Some("127.0.0.1:11525".to_string()), | ||
| 395 | Some("deadbeef".to_string()), | ||
| 396 | ); | ||
| 397 | |||
| 398 | h.line("info"); | ||
| 399 | assert!(h | ||
| 400 | .screen | ||
| 401 | .lines | ||
| 402 | .iter() | ||
| 403 | .any(|line| line.contains("Connected to: 127.0.0.1:11525"))); | ||
| 404 | assert!(h | ||
| 405 | .screen | ||
| 406 | .lines | ||
| 407 | .iter() | ||
| 408 | .any(|line| line.contains("Fingerprint: deadbeef"))); | ||
| 409 | |||
| 410 | h.reply("INFO alice 42 1234"); | ||
| 411 | |||
| 412 | assert!(h.screen.lines.iter().any(|line| line == "User: alice")); | ||
| 413 | assert!(h.screen.lines.iter().any(|line| line == "Account: 42")); | ||
| 414 | assert!(h | ||
| 415 | .screen | ||
| 416 | .lines | ||
| 417 | .iter() | ||
| 418 | .any(|line| line == "Balance: 1234 PLN")); | ||
| 419 | } | ||
| 420 | |||
| 421 | #[test] | ||
| 422 | fn info_without_a_connection_does_not_send() { | ||
| 423 | let mut h = Harness::new(); | ||
| 424 | h.core.set_connected(false); | ||
| 425 | |||
| 426 | h.line("info"); | ||
| 427 | |||
| 428 | assert!(h | ||
| 429 | .screen | ||
| 430 | .lines | ||
| 431 | .iter() | ||
| 432 | .any(|line| line.contains("Not connected to any host"))); | ||
| 433 | assert!(h.sent.is_empty()); | ||
| 434 | } | ||
| 435 | |||
| 436 | #[test] | ||
| 437 | fn ping_prints_pong() { | ||
| 438 | let mut h = Harness::new(); | ||
| 439 | h.line("ping"); | ||
| 440 | assert_eq!(h.sent.last().map(String::as_str), Some("PING")); | ||
| 441 | |||
| 442 | h.reply("PONG"); | ||
| 443 | |||
| 444 | assert!(h.screen.lines.iter().any(|line| line == "Pong")); | ||
| 445 | } | ||
| 446 | |||
| 447 | #[test] | ||
| 448 | fn send_prints_the_echo() { | ||
| 449 | let mut h = Harness::new(); | ||
| 450 | h.line("send hello"); | ||
| 451 | h.reply("hello"); | ||
| 452 | |||
| 453 | assert!(h | ||
| 454 | .screen | ||
| 455 | .lines | ||
| 456 | .iter() | ||
| 457 | .any(|line| line == "Echo: hello")); | ||
| 458 | } | ||
| 459 | |||
| 460 | #[test] | ||
| 461 | fn disconnect_clears_connection_state() { | ||
| 462 | let mut h = Harness::new(); | ||
| 463 | h.line("disconnect"); | ||
| 464 | |||
| 465 | assert!(h | ||
| 466 | .screen | ||
| 467 | .lines | ||
| 468 | .iter() | ||
| 469 | .any(|line| line == "Disconnected")); | ||
| 470 | |||
| 471 | h.line("play slots"); | ||
| 472 | assert!(h | ||
| 473 | .screen | ||
| 474 | .lines | ||
| 475 | .iter() | ||
| 476 | .any(|line| line.contains("Not connected"))); | ||
| 477 | } | ||
diff --git a/tests/client_ui.rs b/tests/client_ui.rs index 78faed0..a22fbe7 100644 --- a/tests/client_ui.rs +++ b/tests/client_ui.rs | |||
| @@ -1,7 +1,8 @@ | |||
| 1 | use zagan_bet::client::ui::{ | 1 | use zagan_bet::client::ui::{ |
| 2 | command_names, completion_lines, help_lines, input_cursor_column, lines_to_move_up, | 2 | command_names, completion_lines, display_width, first_contact_lines, format_pong, |
| 3 | parse_bet_arg, record_history, | 3 | help_lines, host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, |
| 4 | }; | 4 | }; |
| 5 | use std::time::Duration; | ||
| 5 | 6 | ||
| 6 | #[test] | 7 | #[test] |
| 7 | fn help_has_no_source_indentation() { | 8 | fn help_has_no_source_indentation() { |
| @@ -129,3 +130,63 @@ fn completions_wrap_within_the_requested_width() { | |||
| 129 | ); | 130 | ); |
| 130 | } | 131 | } |
| 131 | } | 132 | } |
| 133 | |||
| 134 | #[test] | ||
| 135 | fn display_width_counts_terminal_columns_not_bytes() { | ||
| 136 | assert_eq!(display_width("abc"), 3); | ||
| 137 | assert_eq!(display_width("ąćęłńóśźż"), 9); | ||
| 138 | assert_eq!(display_width("中"), 2); | ||
| 139 | assert_eq!(display_width("🎉"), 2); | ||
| 140 | assert_eq!(display_width("e\u{0301}"), 1); | ||
| 141 | } | ||
| 142 | |||
| 143 | #[test] | ||
| 144 | fn cursor_column_uses_display_width() { | ||
| 145 | assert_eq!(input_cursor_column(2, display_width("ąć")), 4); | ||
| 146 | assert_eq!(input_cursor_column(2, display_width("中")), 4); | ||
| 147 | } | ||
| 148 | |||
| 149 | #[test] | ||
| 150 | fn first_contact_message_stays_within_80_columns() { | ||
| 151 | let fingerprint = "a4d6f80784154c2cb739a1df61ab3e9038ed731ee2e05c7611457ac741b3834c"; | ||
| 152 | let lines = first_contact_lines("127.0.0.1:11525", fingerprint); | ||
| 153 | |||
| 154 | assert!(lines.iter().any(|line| line.contains("127.0.0.1:11525"))); | ||
| 155 | assert!(lines.iter().any(|line| line.contains(fingerprint))); | ||
| 156 | for line in lines { | ||
| 157 | assert!( | ||
| 158 | line.len() <= 80, | ||
| 159 | "first-contact line is too wide ({}): {line:?}", | ||
| 160 | line.len() | ||
| 161 | ); | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | #[test] | ||
| 166 | fn pong_is_formatted_to_one_decimal() { | ||
| 167 | assert_eq!(format_pong(Duration::from_micros(144)), "Pong (0.1ms)"); | ||
| 168 | assert_eq!(format_pong(Duration::from_millis(41)), "Pong (41.0ms)"); | ||
| 169 | } | ||
| 170 | |||
| 171 | #[test] | ||
| 172 | fn host_lines_fit_an_80_column_terminal() { | ||
| 173 | let fingerprint = "a4d6f80784154c2cb739a1df61ab3e9038ed731ee2e05c7611457ac741b3834c"; | ||
| 174 | let lines = host_lines(&[ | ||
| 175 | ( | ||
| 176 | "127.0.0.1:11525".to_string(), | ||
| 177 | Some(fingerprint.to_string()), | ||
| 178 | ), | ||
| 179 | ("unknown.example:443".to_string(), None), | ||
| 180 | ]); | ||
| 181 | |||
| 182 | assert!(lines.iter().any(|line| line.contains("127.0.0.1:11525"))); | ||
| 183 | assert!(lines.iter().any(|line| line.contains(fingerprint))); | ||
| 184 | assert!(lines.iter().any(|line| line.contains("no fingerprint"))); | ||
| 185 | for line in lines { | ||
| 186 | assert!( | ||
| 187 | line.len() <= 80, | ||
| 188 | "host line is too wide ({}): {line:?}", | ||
| 189 | line.len() | ||
| 190 | ); | ||
| 191 | } | ||
| 192 | } | ||
