From 828470137cb4e806c31a26fa741d5d67c64f98c2 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:31:18 +0100 Subject: v0.2.4: fix command UX, completion wrapping and auth prompt cancellation --- Cargo.toml | 2 +- src/bin/client/repl.rs | 44 ++++++++++--- src/client/core.rs | 7 +- src/client/ui.rs | 27 ++++++++ tests/client_core.rs | 174 ++++++++++++++++++++++++++++++++++++++++++++++++- tests/client_ui.rs | 131 +++++++++++++++++++++++++++++++++++++ 6 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 tests/client_ui.rs diff --git a/Cargo.toml b/Cargo.toml index 33f7700..f61b963 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index e9d32a0..3fa49b8 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -290,11 +290,11 @@ impl ClientState { fn do_login(&mut self) { loop { - let username = read_line("Username: "); + let username = read_line("Username: ", &self.cleanup_done); if username.is_empty() { break; } - let password = read_password("Password: "); + let password = read_password("Password: ", &self.cleanup_done); if password.is_empty() { break; } @@ -329,11 +329,11 @@ impl ClientState { } fn do_register(&mut self) { - let username = read_line("Username: "); + let username = read_line("Username: ", &self.cleanup_done); if username.is_empty() { return; } - let password = read_password("Password: "); + let password = read_password("Password: ", &self.cleanup_done); if password.is_empty() { return; } @@ -357,11 +357,11 @@ impl ClientState { println!("No host connected. Use 'connect :' first."); return; } - let username = read_line("Username: "); + let username = read_line("Username: ", &self.cleanup_done); if username.is_empty() { return; } - let password = read_password("Password: "); + let password = read_password("Password: ", &self.cleanup_done); if password.is_empty() { return; } @@ -419,7 +419,7 @@ fn restore_terminal(cleanup_done: &Arc>) { } } -fn read_line(prompt: &str) -> String { +fn read_line(prompt: &str, cleanup_done: &Arc>) -> String { execute!(stdout(), Show).unwrap(); printsame!("{}", prompt); stdout().flush().unwrap(); @@ -451,6 +451,20 @@ fn read_line(prompt: &str) -> String { stdout().flush().unwrap(); } } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + println!(); + restore_terminal(cleanup_done); + std::process::exit(0); + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + println!(); + restore_terminal(cleanup_done); + std::process::exit(0); + } + KeyCode::Esc => { + println!(); + return String::new(); + } KeyCode::Char(c) => { input.push(c); print!("{}", c); @@ -466,7 +480,7 @@ fn read_line(prompt: &str) -> String { input.trim().to_string() } -fn read_password(prompt: &str) -> String { +fn read_password(prompt: &str, cleanup_done: &Arc>) -> String { execute!(stdout(), Show).unwrap(); print!("{}", prompt); stdout().flush().unwrap(); @@ -498,6 +512,20 @@ fn read_password(prompt: &str) -> String { stdout().flush().unwrap(); } } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + println!(); + restore_terminal(cleanup_done); + std::process::exit(0); + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + println!(); + restore_terminal(cleanup_done); + std::process::exit(0); + } + KeyCode::Esc => { + println!(); + return String::new(); + } KeyCode::Char(c) => { input.push(c); print!("*"); diff --git a/src/client/core.rs b/src/client/core.rs index 0e54fba..a910a68 100644 --- a/src/client/core.rs +++ b/src/client/core.rs @@ -1,7 +1,8 @@ use crate::client::games::manager::GameManager; use crate::client::games::response::parse_spin_response; use crate::client::ui::{ - command_names, help_lines, input_cursor_column, parse_bet_arg, record_history, + command_names, completion_lines, help_lines, input_cursor_column, parse_bet_arg, + record_history, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -439,9 +440,7 @@ impl ClientCore { if matches.len() == 1 { self.input = matches[0].to_string(); } else if matches.len() > 1 { - let mut lines = vec!["Possible completions:".to_string()]; - lines.push(matches.join(" ")); - effects.push(Effect::Print(lines)); + effects.push(Effect::Print(completion_lines(&matches, 76))); } if let Some(prompt) = self.prompt_effect() { effects.push(prompt); diff --git a/src/client/ui.rs b/src/client/ui.rs index 036c36a..6fc2f62 100644 --- a/src/client/ui.rs +++ b/src/client/ui.rs @@ -102,6 +102,33 @@ pub fn help_lines() -> Vec { lines } +/// Wrap a completion list to `width` columns, one space between names. +pub fn completion_lines(commands: &[&str], width: usize) -> Vec { + let mut lines = vec!["Possible completions:".to_string()]; + let mut current = String::new(); + + for command in commands { + let needed = if current.is_empty() { + command.len() + } else { + current.len() + 1 + command.len() + }; + if !current.is_empty() && needed > width { + lines.push(current.clone()); + current.clear(); + } + if !current.is_empty() { + current.push(' '); + } + current.push_str(command); + } + + if !current.is_empty() { + lines.push(current); + } + lines +} + /// Add a command to the history, skipping blanks and consecutive duplicates. pub fn record_history(history: &mut Vec, line: &str) { let line = line.trim(); diff --git a/tests/client_core.rs b/tests/client_core.rs index 3dbbfe5..79b3dd8 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -5,6 +5,7 @@ //! player would see, not about ANSI sequences. use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; +use zagan_bet::client::ui::command_names; #[derive(Default)] struct Screen { @@ -12,6 +13,7 @@ struct Screen { region_start: Option, region_len: usize, prompt: String, + prompt_cursor: u16, } impl Screen { @@ -40,7 +42,10 @@ impl Screen { self.region_start = None; self.region_len = 0; } - Effect::SetPrompt { text, .. } => self.prompt = text.clone(), + Effect::SetPrompt { text, cursor_col } => { + self.prompt = text.clone(); + self.prompt_cursor = *cursor_col; + } Effect::SendLine(line) => sent.push(line.clone()), Effect::Interactive(command) => interactive.push(command.clone()), Effect::Quit @@ -214,3 +219,170 @@ fn an_empty_reply_is_an_error_not_a_silent_spin() { assert_eq!(h.board().len(), 7); assert!(h.board()[4].contains("No response from server")); } + +#[test] +fn empty_enter_does_not_print_help() { + let mut h = Harness::new(); + h.line("games"); + let lines_before = h.screen.lines.clone(); + + h.key(Key::Enter); + + assert_eq!(h.screen.lines, lines_before); + assert_eq!(h.screen.prompt, ""); +} + +#[test] +fn history_does_not_duplicate_consecutive_commands() { + let mut h = Harness::new(); + h.line("games"); + h.line("games"); + + h.key(Key::Up); + assert_eq!(h.screen.prompt, "games"); + h.key(Key::Up); + assert_eq!(h.screen.prompt, "games"); +} + +#[test] +fn history_recalls_previous_commands() { + let mut h = Harness::new(); + h.line("games"); + h.line("help"); + + h.key(Key::Up); + assert_eq!(h.screen.prompt, "help"); + h.key(Key::Up); + assert_eq!(h.screen.prompt, "games"); +} + +#[test] +fn tab_completes_a_unique_command() { + let mut h = Harness::new(); + for c in "con".chars() { + h.key(Key::Char(c)); + } + + h.key(Key::Tab); + + assert_eq!(h.screen.prompt, "connect"); + assert_eq!(h.screen.prompt_cursor, 9); +} + +#[test] +fn tab_completion_list_wraps() { + let mut h = Harness::new(); + + h.key(Key::Tab); + + assert!(h + .screen + .lines + .iter() + .any(|line| line == "Possible completions:")); + for line in h + .screen + .lines + .iter() + .skip_while(|line| line.as_str() != "Possible completions:") + .skip(1) + { + assert!( + line.len() <= 76, + "completion line is too wide ({}): {line:?}", + line.len() + ); + } + assert_eq!(h.screen.prompt, ""); +} + +#[test] +fn play_without_a_bet_uses_the_default() { + let mut h = Harness::new(); + h.line("play slots"); + + assert_eq!(h.board().len(), 7); + assert!(h.board()[1].contains("Bet: 10 PLN")); +} + +#[test] +fn an_invalid_bet_does_not_start_a_game() { + let mut h = Harness::new(); + h.line("play slots 5"); + + assert!(h.board().is_empty()); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Bet must be at least 10 PLN"))); +} + +#[test] +fn help_lists_every_command() { + let mut h = Harness::new(); + h.line("help"); + + let transcript = h.screen.lines.join("\n"); + for command in command_names() { + assert!( + transcript.contains(command), + "help is missing '{command}'" + ); + } +} + +#[test] +fn send_keeps_the_whole_message() { + let mut h = Harness::new(); + h.line("send hello world"); + + assert_eq!(h.sent.last().map(String::as_str), Some("hello world")); +} + +#[test] +fn play_requires_a_connection_and_a_login() { + let mut h = Harness::new(); + h.core.set_connected(false); + h.line("play slots"); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Not connected"))); + + let mut h = Harness::new(); + h.core.set_logged_in(None); + h.line("play slots"); + assert!(h + .screen + .lines + .iter() + .any(|line| line.contains("Not logged in"))); +} + +#[test] +fn ctrl_c_quits_from_the_prompt() { + let mut core = ClientCore::new(); + assert_eq!(core.handle_key(Key::CtrlC), vec![Effect::Quit]); +} + +#[test] +fn ctrl_c_quits_from_a_game() { + let mut h = Harness::new(); + h.enter_slots("50"); + + assert_eq!(h.core.handle_key(Key::CtrlC), vec![Effect::Quit]); +} + +#[test] +fn prompt_cursor_tracks_the_input() { + let mut h = Harness::new(); + h.key(Key::Char('a')); + assert_eq!(h.screen.prompt, "a"); + assert_eq!(h.screen.prompt_cursor, 3); + + h.key(Key::Backspace); + assert_eq!(h.screen.prompt, ""); + assert_eq!(h.screen.prompt_cursor, 2); +} diff --git a/tests/client_ui.rs b/tests/client_ui.rs new file mode 100644 index 0000000..78faed0 --- /dev/null +++ b/tests/client_ui.rs @@ -0,0 +1,131 @@ +use zagan_bet::client::ui::{ + command_names, completion_lines, help_lines, input_cursor_column, lines_to_move_up, + parse_bet_arg, record_history, +}; + +#[test] +fn help_has_no_source_indentation() { + for line in help_lines() { + assert!( + !line.starts_with(" "), + "help line kept its source indentation: {line:?}" + ); + } +} + +#[test] +fn help_documents_every_command() { + let help = help_lines().join("\n"); + for command in command_names() { + assert!(help.contains(command), "help text is missing '{command}'"); + } +} + +#[test] +fn completion_knows_every_command() { + let commands = command_names(); + for expected in [ + "connect", + "games", + "play", + "hosts", + "send", + "logout", + "quit", + ] { + assert!( + commands.contains(&expected), + "tab completion is missing '{expected}'" + ); + } +} + +#[test] +fn history_records_a_command_once() { + let mut history = Vec::new(); + record_history(&mut history, "info"); + record_history(&mut history, "info"); + + assert_eq!(history, vec!["info"]); +} + +#[test] +fn history_keeps_distinct_commands_in_order() { + let mut history = Vec::new(); + record_history(&mut history, "info"); + record_history(&mut history, "ping"); + record_history(&mut history, "info"); + + assert_eq!(history, vec!["info", "ping", "info"]); +} + +#[test] +fn blank_commands_are_not_recorded() { + let mut history = Vec::new(); + record_history(&mut history, ""); + record_history(&mut history, " "); + + assert!(history.is_empty()); +} + +#[test] +fn bet_accepts_ten_or_more() { + assert_eq!(parse_bet_arg("10"), Ok(10)); + assert_eq!(parse_bet_arg("50"), Ok(50)); + assert_eq!(parse_bet_arg(" 250 "), Ok(250)); +} + +#[test] +fn bet_rejects_less_than_ten() { + assert!(parse_bet_arg("9").is_err()); + assert!(parse_bet_arg("0").is_err()); +} + +#[test] +fn bet_rejects_non_numbers() { + assert!(parse_bet_arg("abc").is_err()); + assert!(parse_bet_arg("-50").is_err()); + assert!(parse_bet_arg("").is_err()); +} + +#[test] +fn redraw_moves_up_by_line_count_minus_one() { + assert_eq!(lines_to_move_up(0), 0); + assert_eq!(lines_to_move_up(1), 0); + assert_eq!(lines_to_move_up(7), 6); + assert_eq!(lines_to_move_up(70_000), u16::MAX); +} + +#[test] +fn input_cursor_is_after_the_removed_character() { + assert_eq!(input_cursor_column(10, 8), 18); + assert_eq!(input_cursor_column(10, 0), 10); + assert_eq!(input_cursor_column(2, 5), 7); + assert_eq!(input_cursor_column(70_000, 70_000), u16::MAX); +} + +#[test] +fn completions_wrap_within_the_requested_width() { + let commands = command_names(); + let lines = completion_lines(&commands, 40); + + assert_eq!(lines.first().map(String::as_str), Some("Possible completions:")); + for line in &lines[1..] { + assert!( + line.len() <= 40, + "completion line is too wide ({} > 40): {line:?}", + line.len() + ); + } + + let listed: Vec<&str> = lines[1..] + .iter() + .flat_map(|line| line.split_whitespace()) + .collect(); + for command in commands { + assert!( + listed.contains(&command), + "completion list is missing '{command}'" + ); + } +} -- cgit v1.2.3