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 --- tests/client_core.rs | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++- tests/client_ui.rs | 131 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/client_ui.rs (limited to 'tests') 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