summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 22:31:18 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 22:31:18 +0100
commit828470137cb4e806c31a26fa741d5d67c64f98c2 (patch)
tree45dbe92af0f61166559d73c21fabd7759eca4b11
parentv0.2.3: add ClientCore and fix the game board rendering (diff)
downloadzagan-bet-828470137cb4e806c31a26fa741d5d67c64f98c2.tar.gz
zagan-bet-828470137cb4e806c31a26fa741d5d67c64f98c2.zip
v0.2.4: fix command UX, completion wrapping and auth prompt cancellation
-rw-r--r--Cargo.toml2
-rw-r--r--src/bin/client/repl.rs44
-rw-r--r--src/client/core.rs7
-rw-r--r--src/client/ui.rs27
-rw-r--r--tests/client_core.rs174
-rw-r--r--tests/client_ui.rs131
6 files changed, 371 insertions, 14 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 33f7700..f61b963 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "0.2.3" 3version = "0.2.4"
4edition = "2024" 4edition = "2024"
5 5
6[dependencies] 6[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 {
290 290
291 fn do_login(&mut self) { 291 fn do_login(&mut self) {
292 loop { 292 loop {
293 let username = read_line("Username: "); 293 let username = read_line("Username: ", &self.cleanup_done);
294 if username.is_empty() { 294 if username.is_empty() {
295 break; 295 break;
296 } 296 }
297 let password = read_password("Password: "); 297 let password = read_password("Password: ", &self.cleanup_done);
298 if password.is_empty() { 298 if password.is_empty() {
299 break; 299 break;
300 } 300 }
@@ -329,11 +329,11 @@ impl ClientState {
329 } 329 }
330 330
331 fn do_register(&mut self) { 331 fn do_register(&mut self) {
332 let username = read_line("Username: "); 332 let username = read_line("Username: ", &self.cleanup_done);
333 if username.is_empty() { 333 if username.is_empty() {
334 return; 334 return;
335 } 335 }
336 let password = read_password("Password: "); 336 let password = read_password("Password: ", &self.cleanup_done);
337 if password.is_empty() { 337 if password.is_empty() {
338 return; 338 return;
339 } 339 }
@@ -357,11 +357,11 @@ impl ClientState {
357 println!("No host connected. Use 'connect <ip>:<port>' first."); 357 println!("No host connected. Use 'connect <ip>:<port>' first.");
358 return; 358 return;
359 } 359 }
360 let username = read_line("Username: "); 360 let username = read_line("Username: ", &self.cleanup_done);
361 if username.is_empty() { 361 if username.is_empty() {
362 return; 362 return;
363 } 363 }
364 let password = read_password("Password: "); 364 let password = read_password("Password: ", &self.cleanup_done);
365 if password.is_empty() { 365 if password.is_empty() {
366 return; 366 return;
367 } 367 }
@@ -419,7 +419,7 @@ fn restore_terminal(cleanup_done: &Arc<Mutex<bool>>) {
419 } 419 }
420} 420}
421 421
422fn read_line(prompt: &str) -> String { 422fn read_line(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String {
423 execute!(stdout(), Show).unwrap(); 423 execute!(stdout(), Show).unwrap();
424 printsame!("{}", prompt); 424 printsame!("{}", prompt);
425 stdout().flush().unwrap(); 425 stdout().flush().unwrap();
@@ -451,6 +451,20 @@ fn read_line(prompt: &str) -> String {
451 stdout().flush().unwrap(); 451 stdout().flush().unwrap();
452 } 452 }
453 } 453 }
454 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
455 println!();
456 restore_terminal(cleanup_done);
457 std::process::exit(0);
458 }
459 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
460 println!();
461 restore_terminal(cleanup_done);
462 std::process::exit(0);
463 }
464 KeyCode::Esc => {
465 println!();
466 return String::new();
467 }
454 KeyCode::Char(c) => { 468 KeyCode::Char(c) => {
455 input.push(c); 469 input.push(c);
456 print!("{}", c); 470 print!("{}", c);
@@ -466,7 +480,7 @@ fn read_line(prompt: &str) -> String {
466 input.trim().to_string() 480 input.trim().to_string()
467} 481}
468 482
469fn read_password(prompt: &str) -> String { 483fn read_password(prompt: &str, cleanup_done: &Arc<Mutex<bool>>) -> String {
470 execute!(stdout(), Show).unwrap(); 484 execute!(stdout(), Show).unwrap();
471 print!("{}", prompt); 485 print!("{}", prompt);
472 stdout().flush().unwrap(); 486 stdout().flush().unwrap();
@@ -498,6 +512,20 @@ fn read_password(prompt: &str) -> String {
498 stdout().flush().unwrap(); 512 stdout().flush().unwrap();
499 } 513 }
500 } 514 }
515 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
516 println!();
517 restore_terminal(cleanup_done);
518 std::process::exit(0);
519 }
520 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
521 println!();
522 restore_terminal(cleanup_done);
523 std::process::exit(0);
524 }
525 KeyCode::Esc => {
526 println!();
527 return String::new();
528 }
501 KeyCode::Char(c) => { 529 KeyCode::Char(c) => {
502 input.push(c); 530 input.push(c);
503 print!("*"); 531 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 @@
1use crate::client::games::manager::GameManager; 1use crate::client::games::manager::GameManager;
2use crate::client::games::response::parse_spin_response; 2use crate::client::games::response::parse_spin_response;
3use crate::client::ui::{ 3use crate::client::ui::{
4 command_names, help_lines, input_cursor_column, parse_bet_arg, record_history, 4 command_names, completion_lines, help_lines, input_cursor_column, parse_bet_arg,
5 record_history,
5}; 6};
6 7
7#[derive(Debug, Clone, Copy, PartialEq, Eq)] 8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -439,9 +440,7 @@ impl ClientCore {
439 if matches.len() == 1 { 440 if matches.len() == 1 {
440 self.input = matches[0].to_string(); 441 self.input = matches[0].to_string();
441 } else if matches.len() > 1 { 442 } else if matches.len() > 1 {
442 let mut lines = vec!["Possible completions:".to_string()]; 443 effects.push(Effect::Print(completion_lines(&matches, 76)));
443 lines.push(matches.join(" "));
444 effects.push(Effect::Print(lines));
445 } 444 }
446 if let Some(prompt) = self.prompt_effect() { 445 if let Some(prompt) = self.prompt_effect() {
447 effects.push(prompt); 446 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<String> {
102 lines 102 lines
103} 103}
104 104
105/// Wrap a completion list to `width` columns, one space between names.
106pub fn completion_lines(commands: &[&str], width: usize) -> Vec<String> {
107 let mut lines = vec!["Possible completions:".to_string()];
108 let mut current = String::new();
109
110 for command in commands {
111 let needed = if current.is_empty() {
112 command.len()
113 } else {
114 current.len() + 1 + command.len()
115 };
116 if !current.is_empty() && needed > width {
117 lines.push(current.clone());
118 current.clear();
119 }
120 if !current.is_empty() {
121 current.push(' ');
122 }
123 current.push_str(command);
124 }
125
126 if !current.is_empty() {
127 lines.push(current);
128 }
129 lines
130}
131
105/// Add a command to the history, skipping blanks and consecutive duplicates. 132/// Add a command to the history, skipping blanks and consecutive duplicates.
106pub fn record_history(history: &mut Vec<String>, line: &str) { 133pub fn record_history(history: &mut Vec<String>, line: &str) {
107 let line = line.trim(); 134 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 @@
5//! player would see, not about ANSI sequences. 5//! player would see, not about ANSI sequences.
6 6
7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; 7use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key};
8use zagan_bet::client::ui::command_names;
8 9
9#[derive(Default)] 10#[derive(Default)]
10struct Screen { 11struct Screen {
@@ -12,6 +13,7 @@ struct Screen {
12 region_start: Option<usize>, 13 region_start: Option<usize>,
13 region_len: usize, 14 region_len: usize,
14 prompt: String, 15 prompt: String,
16 prompt_cursor: u16,
15} 17}
16 18
17impl Screen { 19impl Screen {
@@ -40,7 +42,10 @@ impl Screen {
40 self.region_start = None; 42 self.region_start = None;
41 self.region_len = 0; 43 self.region_len = 0;
42 } 44 }
43 Effect::SetPrompt { text, .. } => self.prompt = text.clone(), 45 Effect::SetPrompt { text, cursor_col } => {
46 self.prompt = text.clone();
47 self.prompt_cursor = *cursor_col;
48 }
44 Effect::SendLine(line) => sent.push(line.clone()), 49 Effect::SendLine(line) => sent.push(line.clone()),
45 Effect::Interactive(command) => interactive.push(command.clone()), 50 Effect::Interactive(command) => interactive.push(command.clone()),
46 Effect::Quit 51 Effect::Quit
@@ -214,3 +219,170 @@ fn an_empty_reply_is_an_error_not_a_silent_spin() {
214 assert_eq!(h.board().len(), 7); 219 assert_eq!(h.board().len(), 7);
215 assert!(h.board()[4].contains("No response from server")); 220 assert!(h.board()[4].contains("No response from server"));
216} 221}
222
223#[test]
224fn empty_enter_does_not_print_help() {
225 let mut h = Harness::new();
226 h.line("games");
227 let lines_before = h.screen.lines.clone();
228
229 h.key(Key::Enter);
230
231 assert_eq!(h.screen.lines, lines_before);
232 assert_eq!(h.screen.prompt, "");
233}
234
235#[test]
236fn history_does_not_duplicate_consecutive_commands() {
237 let mut h = Harness::new();
238 h.line("games");
239 h.line("games");
240
241 h.key(Key::Up);
242 assert_eq!(h.screen.prompt, "games");
243 h.key(Key::Up);
244 assert_eq!(h.screen.prompt, "games");
245}
246
247#[test]
248fn history_recalls_previous_commands() {
249 let mut h = Harness::new();
250 h.line("games");
251 h.line("help");
252
253 h.key(Key::Up);
254 assert_eq!(h.screen.prompt, "help");
255 h.key(Key::Up);
256 assert_eq!(h.screen.prompt, "games");
257}
258
259#[test]
260fn tab_completes_a_unique_command() {
261 let mut h = Harness::new();
262 for c in "con".chars() {
263 h.key(Key::Char(c));
264 }
265
266 h.key(Key::Tab);
267
268 assert_eq!(h.screen.prompt, "connect");
269 assert_eq!(h.screen.prompt_cursor, 9);
270}
271
272#[test]
273fn tab_completion_list_wraps() {
274 let mut h = Harness::new();
275
276 h.key(Key::Tab);
277
278 assert!(h
279 .screen
280 .lines
281 .iter()
282 .any(|line| line == "Possible completions:"));
283 for line in h
284 .screen
285 .lines
286 .iter()
287 .skip_while(|line| line.as_str() != "Possible completions:")
288 .skip(1)
289 {
290 assert!(
291 line.len() <= 76,
292 "completion line is too wide ({}): {line:?}",
293 line.len()
294 );
295 }
296 assert_eq!(h.screen.prompt, "");
297}
298
299#[test]
300fn play_without_a_bet_uses_the_default() {
301 let mut h = Harness::new();
302 h.line("play slots");
303
304 assert_eq!(h.board().len(), 7);
305 assert!(h.board()[1].contains("Bet: 10 PLN"));
306}
307
308#[test]
309fn an_invalid_bet_does_not_start_a_game() {
310 let mut h = Harness::new();
311 h.line("play slots 5");
312
313 assert!(h.board().is_empty());
314 assert!(h
315 .screen
316 .lines
317 .iter()
318 .any(|line| line.contains("Bet must be at least 10 PLN")));
319}
320
321#[test]
322fn help_lists_every_command() {
323 let mut h = Harness::new();
324 h.line("help");
325
326 let transcript = h.screen.lines.join("\n");
327 for command in command_names() {
328 assert!(
329 transcript.contains(command),
330 "help is missing '{command}'"
331 );
332 }
333}
334
335#[test]
336fn send_keeps_the_whole_message() {
337 let mut h = Harness::new();
338 h.line("send hello world");
339
340 assert_eq!(h.sent.last().map(String::as_str), Some("hello world"));
341}
342
343#[test]
344fn play_requires_a_connection_and_a_login() {
345 let mut h = Harness::new();
346 h.core.set_connected(false);
347 h.line("play slots");
348 assert!(h
349 .screen
350 .lines
351 .iter()
352 .any(|line| line.contains("Not connected")));
353
354 let mut h = Harness::new();
355 h.core.set_logged_in(None);
356 h.line("play slots");
357 assert!(h
358 .screen
359 .lines
360 .iter()
361 .any(|line| line.contains("Not logged in")));
362}
363
364#[test]
365fn ctrl_c_quits_from_the_prompt() {
366 let mut core = ClientCore::new();
367 assert_eq!(core.handle_key(Key::CtrlC), vec![Effect::Quit]);
368}
369
370#[test]
371fn ctrl_c_quits_from_a_game() {
372 let mut h = Harness::new();
373 h.enter_slots("50");
374
375 assert_eq!(h.core.handle_key(Key::CtrlC), vec![Effect::Quit]);
376}
377
378#[test]
379fn prompt_cursor_tracks_the_input() {
380 let mut h = Harness::new();
381 h.key(Key::Char('a'));
382 assert_eq!(h.screen.prompt, "a");
383 assert_eq!(h.screen.prompt_cursor, 3);
384
385 h.key(Key::Backspace);
386 assert_eq!(h.screen.prompt, "");
387 assert_eq!(h.screen.prompt_cursor, 2);
388}
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 @@
1use zagan_bet::client::ui::{
2 command_names, completion_lines, help_lines, input_cursor_column, lines_to_move_up,
3 parse_bet_arg, record_history,
4};
5
6#[test]
7fn help_has_no_source_indentation() {
8 for line in help_lines() {
9 assert!(
10 !line.starts_with(" "),
11 "help line kept its source indentation: {line:?}"
12 );
13 }
14}
15
16#[test]
17fn help_documents_every_command() {
18 let help = help_lines().join("\n");
19 for command in command_names() {
20 assert!(help.contains(command), "help text is missing '{command}'");
21 }
22}
23
24#[test]
25fn completion_knows_every_command() {
26 let commands = command_names();
27 for expected in [
28 "connect",
29 "games",
30 "play",
31 "hosts",
32 "send",
33 "logout",
34 "quit",
35 ] {
36 assert!(
37 commands.contains(&expected),
38 "tab completion is missing '{expected}'"
39 );
40 }
41}
42
43#[test]
44fn history_records_a_command_once() {
45 let mut history = Vec::new();
46 record_history(&mut history, "info");
47 record_history(&mut history, "info");
48
49 assert_eq!(history, vec!["info"]);
50}
51
52#[test]
53fn history_keeps_distinct_commands_in_order() {
54 let mut history = Vec::new();
55 record_history(&mut history, "info");
56 record_history(&mut history, "ping");
57 record_history(&mut history, "info");
58
59 assert_eq!(history, vec!["info", "ping", "info"]);
60}
61
62#[test]
63fn blank_commands_are_not_recorded() {
64 let mut history = Vec::new();
65 record_history(&mut history, "");
66 record_history(&mut history, " ");
67
68 assert!(history.is_empty());
69}
70
71#[test]
72fn bet_accepts_ten_or_more() {
73 assert_eq!(parse_bet_arg("10"), Ok(10));
74 assert_eq!(parse_bet_arg("50"), Ok(50));
75 assert_eq!(parse_bet_arg(" 250 "), Ok(250));
76}
77
78#[test]
79fn bet_rejects_less_than_ten() {
80 assert!(parse_bet_arg("9").is_err());
81 assert!(parse_bet_arg("0").is_err());
82}
83
84#[test]
85fn bet_rejects_non_numbers() {
86 assert!(parse_bet_arg("abc").is_err());
87 assert!(parse_bet_arg("-50").is_err());
88 assert!(parse_bet_arg("").is_err());
89}
90
91#[test]
92fn redraw_moves_up_by_line_count_minus_one() {
93 assert_eq!(lines_to_move_up(0), 0);
94 assert_eq!(lines_to_move_up(1), 0);
95 assert_eq!(lines_to_move_up(7), 6);
96 assert_eq!(lines_to_move_up(70_000), u16::MAX);
97}
98
99#[test]
100fn input_cursor_is_after_the_removed_character() {
101 assert_eq!(input_cursor_column(10, 8), 18);
102 assert_eq!(input_cursor_column(10, 0), 10);
103 assert_eq!(input_cursor_column(2, 5), 7);
104 assert_eq!(input_cursor_column(70_000, 70_000), u16::MAX);
105}
106
107#[test]
108fn completions_wrap_within_the_requested_width() {
109 let commands = command_names();
110 let lines = completion_lines(&commands, 40);
111
112 assert_eq!(lines.first().map(String::as_str), Some("Possible completions:"));
113 for line in &lines[1..] {
114 assert!(
115 line.len() <= 40,
116 "completion line is too wide ({} > 40): {line:?}",
117 line.len()
118 );
119 }
120
121 let listed: Vec<&str> = lines[1..]
122 .iter()
123 .flat_map(|line| line.split_whitespace())
124 .collect();
125 for command in commands {
126 assert!(
127 listed.contains(&command),
128 "completion list is missing '{command}'"
129 );
130 }
131}