//! 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 std::time::Duration; use zagan_bet::blackjack::BlackjackState; use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand, Key}; use zagan_bet::client::games::response::{ parse_blackjack_response, parse_crash_response, parse_spin_response, }; use zagan_bet::client::ui::command_names; use zagan_bet::crash::CrashState; 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::AnimateRoulette { final_lines, .. } => { if let Some(start) = self.region_start { self.lines.truncate(start); } else { self.region_start = Some(self.lines.len()); } self.lines.extend(final_lines.iter().cloned()); self.region_len = final_lines.len(); } 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"); } fn enter_roulette(&mut self) { self.line("play roulette"); self.reply("INFO alice 1 1000"); } fn enter_blackjack(&mut self) { self.line("play blackjack"); self.reply("INFO alice 1 1000"); } fn enter_crash(&mut self) { self.line("play crash"); self.reply("INFO alice 1 1000"); } fn tick(&mut self, elapsed: Duration) { let effects = self.core.tick_at(elapsed); self.apply(effects); } /// Deliver a server-initiated line the way the terminal loop does. fn push(&mut self, line: &str) { let effects = self .core .handle_async_line(line) .expect("a pushed crash line"); self.apply(effects); } } #[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 bet x symbol x shape")); assert!(h.board()[6].contains("Symbol C1 L2 O3 G4 D6 S10")); assert!(h.board()[6].contains("Shape 3:1 4:3 5:14")); 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 50 50 1000 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 50 PLN")); assert!(h.board()[8].contains("Winning lines: 1 (top)")); assert!(h.board()[1].contains("Balance: 1000 PLN")); assert!(h.board()[9].contains("[Q] Quit")); assert_eq!(h.screen.lines.len(), lines_before_spin); } #[test] fn equals_increases_the_bet_like_plus() { let mut h = Harness::new(); h.enter_slots("10"); h.key(Key::Char('=')); assert_eq!(h.board().len(), 10); assert!(h.board()[1].contains("Bet: 20 PLN")); } #[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 50 50 1000 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 700 10 1690 {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! 700 PLN")); assert!(h.board()[8] .contains("Winning lines: 1 (top), 2 (middle), 3 (bottom), 4 (V), 5 (inverted V)")); 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 50 50 1000 {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()); } #[test] fn roulette_split_bets_spin_and_show_the_result() { let mut h = Harness::new(); h.enter_roulette(); assert_eq!(h.board().len(), 8); assert_eq!(h.board()[0], "=== ROULETTE ==="); assert!(h.board()[1].contains("Balance: 1000 PLN")); assert!(h.board()[1].contains("Chip: 10 PLN")); assert!(h.board()[2].contains("Bets: (none)")); h.key(Key::Char('r')); h.key(Key::Char('e')); h.key(Key::Char('1')); h.key(Key::Char('7')); h.key(Key::Enter); assert!(h.board()[2].contains("RED 10")); assert!(h.board()[2].contains("EVEN 10")); assert!(h.board()[2].contains("#17 10")); assert!(h.board()[2].contains("Total: 30 PLN")); h.key(Key::Char(' ')); let request = h .sent .iter() .find(|line| line.starts_with("PLAY_START roulette")) .expect("roulette request"); assert_eq!( request, "PLAY_START roulette 30 {\"red\":10,\"black\":0,\"even\":10,\"odd\":0,\"numbers\":[{\"number\":17,\"amount\":10}]}" ); let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; h.reply(&format!( "ROULETTE_RESULT session-1 360 30 1330 17 black {bets}" )); assert_eq!(h.board().len(), 8); assert!(h.board()[1].contains("Balance: 1330 PLN")); assert!(h.board()[4].contains("Winning: 17 BLACK ODD")); assert!(h.board()[5].contains("Won 360 PLN")); } #[test] fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() { let mut h = Harness::new(); h.enter_roulette(); h.key(Key::Char(' ')); assert!(h.board()[4].contains("ERROR: Place at least one bet")); assert!(!h .sent .iter() .any(|line| line.starts_with("PLAY_START roulette"))); h.key(Key::Char('=')); assert!(h.board()[1].contains("Chip: 20 PLN")); h.key(Key::Char('r')); assert!(h.board()[2].contains("RED 20")); assert!(h.board()[2].contains("Total: 20 PLN")); } #[test] fn roulette_rejects_an_out_of_range_number() { let mut h = Harness::new(); h.enter_roulette(); h.key(Key::Char('9')); h.key(Key::Char('9')); h.key(Key::Enter); assert!(h.board()[4].contains("ERROR: Number must be 0-36")); assert!(h.board()[2].contains("Bets: (none)")); } fn bj_state( session: &str, phase: &str, player: &[&str], dealer: &[&str], player_total: u8, dealer_total: u8, bet: u64, base_bet: u64, balance: i64, actions: &[&str], result: &str, winnings: u64, ) -> BlackjackState { BlackjackState { session_id: session.to_string(), phase: phase.to_string(), player: player.iter().map(|card| card.to_string()).collect(), dealer: dealer.iter().map(|card| card.to_string()).collect(), player_total, dealer_total, bet, base_bet, balance, actions: actions.iter().map(|action| action.to_string()).collect(), result: result.to_string(), winnings, } } #[test] fn blackjack_deals_hits_and_settles() { let mut h = Harness::new(); h.enter_blackjack(); assert_eq!(h.board().len(), 6); assert!(h.board()[4].contains("Press [SPACE] to deal.")); h.key(Key::Char(' ')); assert!(h.sent.iter().any(|line| line == "PLAY_START blackjack 10")); let player = bj_state( "bj1", "player", &["10S", "6H"], &["9D", "?"], 16, 9, 10, 10, 990, &["hit", "stand", "double"], "playing", 0, ); h.reply(&format!( "BLACKJACK_STATE {}", serde_json::to_string(&player).unwrap() )); assert_eq!(h.board().len(), 6); assert!(h.board()[2].contains("Dealer: [9♦] [ ? ] 9")); assert!(h.board()[3].contains("You: [10♠] [6♥] 16")); assert!(h.board()[4].contains("Hit, stand or double.")); assert!(h.board()[5].contains("[D]ouble")); assert!(h.board()[5].contains("[Q] Forfeit")); h.key(Key::Char('h')); assert_eq!( h.sent.last().map(String::as_str), Some("BLACKJACK_ACTION bj1 hit") ); let done = bj_state( "bj1", "done", &["10S", "6H", "5D"], &["9D", "8C"], 21, 17, 10, 10, 1010, &[], "win", 20, ); h.reply(&format!( "BLACKJACK_STATE {}", serde_json::to_string(&done).unwrap() )); assert!(h.board()[4].contains("Win! Won 20 PLN")); assert!(h.board()[5].contains("[SPACE] New hand")); h.key(Key::Char('q')); assert!(h.board().is_empty()); assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); } #[test] fn blackjack_forfeit_is_sent_when_leaving_an_active_hand() { let mut h = Harness::new(); h.enter_blackjack(); h.key(Key::Char(' ')); let player = bj_state( "bj1", "player", &["10S", "6H"], &["9D", "?"], 16, 9, 10, 10, 990, &["hit", "stand", "double"], "playing", 0, ); h.reply(&format!( "BLACKJACK_STATE {}", serde_json::to_string(&player).unwrap() )); h.key(Key::Char('q')); assert_eq!( h.sent.last().map(String::as_str), Some("BLACKJACK_ACTION bj1 forfeit") ); let forfeited = bj_state( "bj1", "done", &["10S", "6H"], &["9D", "?"], 16, 9, 10, 10, 990, &[], "forfeit", 0, ); h.reply(&format!( "BLACKJACK_STATE {}", serde_json::to_string(&forfeited).unwrap() )); assert!(h.board().is_empty()); assert!(h .screen .lines .iter() .any(|line| line.contains("forfeited (10 PLN lost)"))); } #[test] fn blackjack_ready_board_can_change_the_next_bet() { let mut h = Harness::new(); h.enter_blackjack(); h.key(Key::Char('=')); assert!(h.board()[1].contains("Bet: 20 PLN")); h.key(Key::Char(' ')); assert!(h.sent.iter().any(|line| line == "PLAY_START blackjack 20")); } #[test] fn parses_a_blackjack_state() { let state = bj_state( "bj1", "player", &["10S", "6H"], &["9D", "?"], 16, 9, 10, 10, 990, &["hit", "stand"], "playing", 0, ); let response = format!("BLACKJACK_STATE {}", serde_json::to_string(&state).unwrap()); assert_eq!(parse_blackjack_response(&response).unwrap(), state); assert!(parse_blackjack_response("ERROR Insufficient balance").is_err()); } fn crash_state( phase: &str, multiplier: f64, crash_point: Option, payout: u64, balance: i64, ) -> CrashState { CrashState { session_id: "cr1".to_string(), phase: phase.to_string(), bet: 10, balance, multiplier, potential: (10.0 * multiplier) as u64, crash_point, payout, result: phase.to_string(), } } fn crash_line(prefix: &str, state: &CrashState) -> String { format!("{prefix} {}", serde_json::to_string(state).unwrap()) } #[test] fn crash_starts_a_round_and_draws_the_multiplier_locally() { let mut h = Harness::new(); h.enter_crash(); assert_eq!(h.board().len(), 6); assert!(h.board()[4].contains("Press [SPACE] to start.")); assert!(h.board()[5].contains("[SPACE] Play")); h.key(Key::Char(' ')); assert!(h.sent.iter().any(|line| line == "PLAY_START crash 10")); let running = crash_state("running", 1.0, None, 0, 990); h.reply(&crash_line("CRASH_START", &running)); assert_eq!(h.board().len(), 6); assert!(h.board()[2].contains("Multiplier: 1.00x")); assert!(h.board()[3].contains("Potential: 10 PLN")); assert!(h.board()[4].contains("Rising")); assert!(h.board()[5].contains("[C] Cash out")); // The multiplier is drawn from the local clock, with no server round trip. h.tick(Duration::from_secs(5)); assert!(h.board()[2].contains("Multiplier: 2.00x")); assert!(h.board()[3].contains("Potential: 20 PLN")); h.key(Key::Char('c')); assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_CASHOUT cr1")); let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); h.reply(&crash_line("CRASH_STATE", &cashed)); assert!(h.board()[4].contains("Cashed out at 2.00x Won 20 PLN")); assert!(h.board()[5].contains("[SPACE] Play")); assert_eq!(h.board()[1], "Balance: 1010 PLN Bet: 10 PLN"); } #[test] fn a_pushed_bust_ends_the_round_without_a_request() { let mut h = Harness::new(); h.enter_crash(); h.key(Key::Char(' ')); let running = crash_state("running", 1.0, None, 0, 990); h.reply(&crash_line("CRASH_START", &running)); h.tick(Duration::from_secs(3)); assert!(h.sent.iter().all(|line| line != "CRASH_CASHOUT cr1")); let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); h.push(&crash_line("CRASH_BUSTED", &busted)); assert!(h.board()[4].contains("CRASHED at 1.50x Bet lost")); assert!(h.board()[5].contains("[SPACE] Play")); // The local clock stops once the round is over. h.tick(Duration::from_secs(30)); assert!(h.board()[2].contains("Multiplier: 1.50x")); assert!(!h.core.expects_push()); } #[test] fn crash_q_forfeits_an_active_round() { let mut h = Harness::new(); h.enter_crash(); h.key(Key::Char(' ')); let running = crash_state("running", 1.0, None, 0, 990); h.reply(&crash_line("CRASH_START", &running)); h.key(Key::Char('q')); assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_FORFEIT cr1")); let forfeited = crash_state("forfeit", 1.2, Some(2.0), 0, 990); h.reply(&crash_line("CRASH_STATE", &forfeited)); assert!(h.board().is_empty()); assert!(h .screen .lines .iter() .any(|line| line.contains("Round forfeited (10 PLN lost)"))); } #[test] fn crash_ready_board_changes_the_next_bet() { let mut h = Harness::new(); h.enter_crash(); h.key(Key::Char('=')); assert!(h.board()[1].contains("Bet: 20 PLN")); assert!(h.board()[3].contains("Potential: 20 PLN")); h.key(Key::Char(' ')); assert!(h.sent.iter().any(|line| line == "PLAY_START crash 20")); } #[test] fn parses_crash_messages_and_errors() { let running = crash_state("running", 1.0, None, 0, 990); let cashed = crash_state("cashed", 2.0, Some(3.0), 20, 1010); assert_eq!( parse_crash_response(&crash_line("CRASH_START", &running)).unwrap(), running ); assert_eq!( parse_crash_response(&crash_line("CRASH_BUSTED", &cashed)).unwrap(), cashed ); assert_eq!( parse_crash_response("ERROR Round already ended").unwrap_err(), "Round already ended" ); assert!(parse_crash_response("GAME_RESULT nope").is_err()); } #[test] fn a_late_cashout_does_not_overwrite_a_pushed_bust() { let mut h = Harness::new(); h.enter_crash(); h.key(Key::Char(' ')); let running = crash_state("running", 1.0, None, 0, 990); h.reply(&crash_line("CRASH_START", &running)); h.key(Key::Char('c')); assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_CASHOUT cr1")); // The round crashes while the cashout is in flight, so the push lands // before the server's "session not found" reply. let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990); h.push(&crash_line("CRASH_BUSTED", &busted)); h.reply("ERROR Session not found"); assert!(h.board()[4].contains("CRASHED at 1.20x")); assert!(!h.board().iter().any(|line| line.contains("ERROR"))); assert!(h.board()[5].contains("[SPACE] Play")); } #[test] fn a_forfeit_that_arrives_after_the_bust_still_leaves_cleanly() { let mut h = Harness::new(); h.enter_crash(); h.key(Key::Char(' ')); let running = crash_state("running", 1.0, None, 0, 990); h.reply(&crash_line("CRASH_START", &running)); h.key(Key::Char('q')); assert_eq!(h.sent.last().map(String::as_str), Some("CRASH_FORFEIT cr1")); let busted = crash_state("crashed", 1.5, Some(1.5), 0, 990); h.push(&crash_line("CRASH_BUSTED", &busted)); h.reply("ERROR Session not found"); assert!(h.board().is_empty()); assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); assert!(!h.screen.lines.iter().any(|line| line.contains("PLN lost"))); }