//! Behaviour tests for the client core. //! //! These tests never touch a terminal or a socket. `Screen` applies the //! logical `Effect`s that `ClientCore` emits, so assertions are about what a //! player would see, not about ANSI sequences. use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; use zagan_bet::client::games::response::parse_spin_response; use zagan_bet::client::ui::command_names; const WIN3: &str = "CCCCLLCLODGSDGS"; const LOSS: &str = "CLOOGDSLCDCOGLS"; const ALL_C: &str = "CCCCCCCCCCCCCCC"; #[derive(Default)] struct Screen { lines: Vec, region_start: Option, region_len: usize, prompt: String, prompt_cursor: u16, } impl Screen { fn apply( &mut self, effects: &[Effect], sent: &mut Vec, interactive: &mut Vec, ) { for effect in effects { match effect { Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), Effect::ReplaceRegion(lines) => { if let Some(start) = self.region_start { self.lines.truncate(start); } else { self.region_start = Some(self.lines.len()); } self.lines.extend(lines.iter().cloned()); self.region_len = lines.len(); } Effect::ClearRegion => { if let Some(start) = self.region_start { self.lines.truncate(start); } self.region_start = None; self.region_len = 0; } 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 | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {} } } } fn board(&self) -> &[String] { match self.region_start { Some(start) => &self.lines[start..start + self.region_len], None => &[], } } } struct Harness { core: ClientCore, screen: Screen, sent: Vec, interactive: Vec, } impl Harness { fn new() -> Self { let mut core = ClientCore::new(); core.set_connected(true); core.set_logged_in(Some("alice".to_string())); Self { core, screen: Screen::default(), sent: Vec::new(), interactive: Vec::new(), } } fn apply(&mut self, effects: Vec) { self.screen .apply(&effects, &mut self.sent, &mut self.interactive); } fn key(&mut self, key: Key) { let effects = self.core.handle_key(key); self.apply(effects); if let Some(prompt) = self.core.prompt_effect() { self.apply(vec![prompt]); } } fn line(&mut self, line: &str) { for c in line.chars() { self.key(Key::Char(c)); } self.key(Key::Enter); } fn reply(&mut self, reply: &str) { let effects = self.core.handle_reply(reply); self.apply(effects); if let Some(prompt) = self.core.prompt_effect() { self.apply(vec![prompt]); } } fn board(&self) -> &[String] { self.screen.board() } fn enter_slots(&mut self, bet: &str) { self.line(&format!("play slots {bet}")); self.reply("INFO alice 1 1000"); } } #[test] fn play_shows_a_ten_line_board_and_fetches_the_balance() { let mut h = Harness::new(); h.line("play slots 50"); assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Bet: 50 PLN")); assert!(!h.board()[1].contains("Lines:")); assert!(h.board()[5].contains("Pays 3/4/5 x bet:")); assert!(h.board()[6].contains("G 4/12/40 D 6/18/70 S 9/30/150")); assert!(h.sent.contains(&"INFO".to_string())); h.reply("INFO alice 1 1000"); assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Balance: 1000 PLN")); } #[test] fn spin_replaces_the_board_and_keeps_the_controls_line() { let mut h = Harness::new(); h.enter_slots("50"); let lines_before_spin = h.screen.lines.len(); h.key(Key::Char(' ')); assert_eq!(h.board().len(), 10); assert!(h.board()[7].contains("Spinning...")); assert!(h.board()[9].contains("[Q] Quit")); assert!(h.sent.iter().any(|line| line == "PLAY_START slots 50")); assert_eq!(h.screen.lines.len(), lines_before_spin); h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); assert_eq!(h.board().len(), 10); assert!(h .board() .get(2) .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]"))); assert!(h.board()[7].contains("Won 100 PLN")); assert!(h.board()[8].contains("Winning lines: 1")); assert!(h.board()[1].contains("Balance: 1050 PLN")); assert!(h.board()[9].contains("[Q] Quit")); assert_eq!(h.screen.lines.len(), lines_before_spin); } #[test] fn changing_the_bet_after_a_spin_does_not_leave_stale_lines() { let mut h = Harness::new(); h.enter_slots("50"); h.key(Key::Char(' ')); h.reply("GAME_RESULT session-1 100 50 1050 CCCCLLCLODGSDGS win 1 0,3,6"); let lines_before_bet = h.screen.lines.len(); h.key(Key::Char('+')); assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Bet: 60 PLN")); assert!(h .board() .get(2) .is_some_and(|line| line.contains("{ C } { C } { C } [ D ] [ D ]"))); assert_eq!(h.screen.lines.len(), lines_before_bet); assert_eq!( h.screen .lines .iter() .filter(|line| line.contains("=== SLOTS ===")) .count(), 1 ); } #[test] fn a_losing_spin_shows_no_win_and_no_winning_lines() { let mut h = Harness::new(); h.enter_slots("10"); h.key(Key::Char(' ')); h.reply(&format!("GAME_RESULT session-1 0 10 990 {LOSS} none -")); assert_eq!(h.board().len(), 10); assert!(h.board()[7].contains("No win.")); assert_eq!(h.board()[8], ""); assert!(!h.board()[2].contains('{')); } #[test] fn a_mega_win_shows_all_winning_lines() { let mut h = Harness::new(); h.enter_slots("10"); h.key(Key::Char(' ')); h.reply(&format!("GAME_RESULT session-1 750 10 1740 {ALL_C} mega 1,2,3,4,5 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14")); assert_eq!(h.board().len(), 10); assert!(h.board()[7].contains("MEGA WIN! 750 PLN")); assert!(h.board()[8].contains("Winning lines: 1,2,3,4,5")); assert_eq!(h.board()[2].matches("{ C }").count(), 5); } #[test] fn quitting_clears_the_board_and_returns_the_prompt() { let mut h = Harness::new(); h.enter_slots("50"); h.key(Key::Char('q')); assert!(h.board().is_empty()); assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); assert_eq!(h.screen.prompt, ""); } #[test] fn a_server_error_replaces_the_status_line() { let mut h = Harness::new(); h.enter_slots("50"); h.key(Key::Char(' ')); h.reply("ERROR Insufficient balance"); assert_eq!(h.board().len(), 10); assert!(h.board()[7].contains("ERROR: Insufficient balance")); assert!(h.board()[2].contains("[ ? ]")); assert!(!h.board().iter().any(|line| line.contains("Spinning"))); } #[test] fn an_empty_reply_is_an_error_not_a_silent_spin() { let mut h = Harness::new(); h.enter_slots("50"); h.key(Key::Char(' ')); h.reply(""); assert_eq!(h.board().len(), 10); assert!(h.board()[7].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(), 10); 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); } #[test] fn info_shows_the_host_and_the_server_fields() { let mut h = Harness::new(); h.core.set_host_info( Some("127.0.0.1:11525".to_string()), Some("deadbeef".to_string()), ); h.line("info"); assert!(h .screen .lines .iter() .any(|line| line.contains("Connected to: 127.0.0.1:11525"))); assert!(h .screen .lines .iter() .any(|line| line.contains("Fingerprint: deadbeef"))); h.reply("INFO alice 42 1234"); assert!(h.screen.lines.iter().any(|line| line == "User: alice")); assert!(h.screen.lines.iter().any(|line| line == "Account: 42")); assert!(h .screen .lines .iter() .any(|line| line == "Balance: 1234 PLN")); } #[test] fn info_without_a_connection_does_not_send() { let mut h = Harness::new(); h.core.set_connected(false); h.line("info"); assert!(h .screen .lines .iter() .any(|line| line.contains("Not connected to any host"))); assert!(h.sent.is_empty()); } #[test] fn ping_prints_pong() { let mut h = Harness::new(); h.line("ping"); assert_eq!(h.sent.last().map(String::as_str), Some("PING")); h.reply("PONG"); assert!(h.screen.lines.iter().any(|line| line == "Pong")); } #[test] fn send_prints_the_echo() { let mut h = Harness::new(); h.line("send hello"); h.reply("hello"); assert!(h .screen .lines .iter() .any(|line| line == "Echo: hello")); } #[test] fn disconnect_clears_connection_state() { let mut h = Harness::new(); h.line("disconnect"); assert!(h .screen .lines .iter() .any(|line| line == "Disconnected")); h.line("play slots"); assert!(h .screen .lines .iter() .any(|line| line.contains("Not connected"))); } #[test] fn parses_a_five_by_three_result_with_winning_lines() { let response = format!("GAME_RESULT session-1 100 50 1050 {WIN3} win 1 0,3,6"); let result = parse_spin_response(&response).expect("valid 5x3 result"); assert_eq!(result.reels.chars().count(), 15); assert_eq!(result.win_type, "win"); assert_eq!(result.win_lines, vec![1]); assert_eq!(result.win_cells, vec![0, 3, 6]); } #[test] fn accepts_a_legacy_three_symbol_result() { let result = parse_spin_response("GAME_RESULT session-1 60 10 1050 OSS two") .expect("legacy result should still parse"); assert_eq!(result.reels, "OSS"); assert_eq!(result.win_type, "two"); assert!(result.win_lines.is_empty()); assert!(result.win_cells.is_empty()); } #[test] fn rejects_a_grid_with_the_wrong_size() { let response = "GAME_RESULT session-1 100 50 1050 CCCCCCCCCCCCCC win 1"; assert!(parse_spin_response(response).is_err()); }