//! 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::games::roulette::{Cursor, Direction}; use zagan_bet::client::ui::command_names; use zagan_bet::client::view::{Command, RoulettePhase, View}; use zagan_bet::roulette::BetTarget; 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::Render(view) => { let lines = zagan_bet::client::tui::render_view(view); 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 { view, .. } => { let lines = zagan_bet::client::tui::render_view(view); 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::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 Kr")); 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 Kr")); } #[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 Kr")); assert!(h.board()[8].contains("Winning lines: 1 (top)")); assert!(h.board()[1].contains("Balance: 1000 Kr")); 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 Kr")); } #[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 Kr")); 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 Kr")); 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 Kr")); } #[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 Kr")) ); } #[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 Kr")); } #[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_cursor_places_bets_and_spins() { let mut h = Harness::new(); h.enter_roulette(); assert_eq!(h.board().len(), 12); assert_eq!(h.board()[0], "=== ROULETTE ==="); assert!(h.board()[1].contains("Balance: 1000 Kr")); assert!(h.board()[1].contains("Chip: 10 Kr")); assert!(h.board()[9].contains("Bets: (none)")); // The cursor starts on number 1, the bottom-left cell. assert!(h.board()[5].contains("[ 1 ]")); assert!(h.board()[10].contains("Bet: 1 · pays 36x")); // One step up is the split between 1 and 2, two steps is number 2. h.key(Key::Up); assert!(h.board()[5].contains("[ 1 ]")); assert!(h.board()[4].contains("[ 2 ]")); assert!(h.board()[10].contains("Bet: 1/2 · pays 18x")); h.key(Key::Up); assert!(!h.board()[5].contains("[ 1 ]")); assert!(h.board()[4].contains("[ 2 ]")); assert!(h.board()[10].contains("Bet: 2 · pays 36x")); h.key(Key::Enter); assert!(h.board()[9].contains("2 10")); // Right from 2 is the split with 5. h.key(Key::Right); assert!(h.board()[10].contains("Bet: 2/5 · pays 18x")); h.key(Key::Enter); assert!(h.board()[9].contains("2/5 10")); assert!(h.board()[9].contains("Total: 20 Kr")); 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 20 {\"chips\":[{\"target\":{\"bet\":\"straight\",\"number\":2},\"amount\":10},{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":5},\"amount\":10}]}" ); let bets = r#"{"chips":[{"target":{"bet":"straight","number":2},"amount":10},{"target":{"bet":"split","a":2,"b":5},"amount":10}]}"#; h.reply(&format!( "ROULETTE_RESULT session-1 540 20 1520 2 black {bets}" )); assert!(h.board()[1].contains("Balance: 1520 Kr")); assert!(h.board()[10].contains("Won 540 Kr at 2 BLACK EVEN")); assert!(h.board()[10].contains("+520")); } #[test] fn roulette_cursor_reaches_the_outside_rows() { let mut h = Harness::new(); h.enter_roulette(); h.key(Key::Down); assert!(h.board()[6].starts_with("[") && h.board()[6].contains("1st 12")); h.key(Key::Down); assert!(h.board()[7].starts_with("[") && h.board()[7].contains("col 1 2:1")); h.key(Key::Down); assert!(h.board()[8].starts_with("[") && h.board()[8].contains("1-18")); h.key(Key::Enter); assert!(h.board()[9].contains("1-18 10")); // Back up to the felt: the dozens return to number 1. h.key(Key::Up); h.key(Key::Up); h.key(Key::Up); assert!(h.board()[5].contains("[ 1 ]")); } #[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()[10].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 Kr")); h.key(Key::Enter); assert!(h.board()[9].contains("1 20")); assert!(h.board()[9].contains("Total: 20 Kr")); } 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 Kr")); 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 Kr 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 Kr")); 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 Kr")); 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 Kr")); 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 Kr")); assert!(h.board()[5].contains("[SPACE] Play")); assert_eq!(h.board()[1], "Balance: 1010 Kr Bet: 10 Kr"); } #[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 Kr 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 Kr")); assert!(h.board()[3].contains("Potential: 20 Kr")); 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("Kr lost"))); } #[test] fn a_round_is_live_only_while_money_is_reserved() { let mut h = Harness::new(); // Placed chips are not a live round: nothing is debited until the spin. h.enter_roulette(); h.key(Key::Enter); assert!(h.board()[9].contains("1 10")); assert!(!h.core.is_live_game()); h.key(Key::Char('q')); // Blackjack is live while the player owes an action. h.enter_blackjack(); assert!(!h.core.is_live_game()); h.key(Key::Char(' ')); assert!(!h.core.is_live_game(), "not live until the server deals"); let player = bj_state( "bj1", "player", &["10S", "6H"], &["9D", "?"], 16, 9, 10, 10, 990, &["hit", "stand"], "playing", 0, ); h.reply(&format!( "BLACKJACK_STATE {}", serde_json::to_string(&player).unwrap() )); assert!(h.core.is_live_game()); h.key(Key::Char('s')); assert_eq!( h.sent.last().map(String::as_str), Some("BLACKJACK_ACTION bj1 stand") ); let done = bj_state( "bj1", "done", &["10S", "6H"], &["9D", "8C"], 16, 17, 10, 10, 990, &[], "lose", 0, ); h.reply(&format!( "BLACKJACK_STATE {}", serde_json::to_string(&done).unwrap() )); assert!(!h.core.is_live_game()); h.key(Key::Char('q')); // Crash is live while the multiplier runs. h.enter_crash(); h.key(Key::Char(' ')); assert!(!h.core.is_live_game(), "not live until the round starts"); let running = crash_state("running", 1.0, None, 0, 990); h.reply(&crash_line("CRASH_START", &running)); assert!(h.core.is_live_game()); let busted = crash_state("crashed", 1.2, Some(1.2), 0, 990); h.push(&crash_line("CRASH_BUSTED", &busted)); assert!(!h.core.is_live_game()); } #[test] fn history_prints_recent_plays() { let mut h = Harness::new(); h.line("history"); assert_eq!(h.sent.last().map(String::as_str), Some("HISTORY")); let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; h.reply(&format!("HISTORY {json}")); assert!(h .screen .lines .iter() .any(|line| line.starts_with("Recent plays (1)"))); let row = h .screen .lines .iter() .find(|line| line.contains("roulette")) .expect("play row"); assert!(row.contains("2026-10-30 01:20"), "row: {row}"); assert!(row.contains("1010"), "row: {row}"); assert!(row.contains("17 black"), "row: {row}"); } #[test] fn history_without_plays_says_so() { let mut h = Harness::new(); h.line("history"); h.reply("HISTORY []"); assert!(h .screen .lines .iter() .any(|line| line == "No plays recorded yet.")); } #[test] fn history_rejects_a_bad_count_without_sending() { let mut h = Harness::new(); h.line("history 0"); assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); assert!(h .screen .lines .iter() .any(|line| line.contains("Usage: history [1-50]"))); } #[test] fn the_roulette_view_reflects_the_cursor_and_bets() { let mut h = Harness::new(); h.enter_roulette(); let View::Roulette(view) = h.core.view() else { panic!("expected a roulette view"); }; assert_eq!(view.cursor, Cursor::Lattice { x: 1, y: 5 }); assert_eq!( view.cursor.target(), Some(BetTarget::Straight { number: 1 }) ); assert!(view.bets.is_empty()); assert_eq!(view.chip, 10); assert_eq!(view.phase, RoulettePhase::Placing); h.core.command(Command::MoveCursor(Direction::Up)); h.core.command(Command::MoveCursor(Direction::Up)); let effects = h.core.command(Command::PlaceBet); h.apply(effects); let View::Roulette(view) = h.core.view() else { panic!("expected a roulette view"); }; assert_eq!( view.cursor.target(), Some(BetTarget::Straight { number: 2 }) ); assert_eq!(view.bets.total(), 10); assert_eq!( view.bets.amount_on(&BetTarget::Straight { number: 2 }), 10 ); } #[test] fn clicking_a_bet_target_does_not_move_the_cursor() { let mut h = Harness::new(); h.enter_roulette(); let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); h.apply(effects); let View::Roulette(view) = h.core.view() else { panic!("expected a roulette view"); }; assert_eq!(view.bets.amount_on(&BetTarget::Split { a: 1, b: 4 }), 10); assert_eq!( view.cursor.target(), Some(BetTarget::Straight { number: 1 }), "a click places a chip without dragging the keyboard cursor" ); } #[test] fn the_roulette_view_reports_the_result() { let mut h = Harness::new(); h.enter_roulette(); h.core.command(Command::PlaceBet); h.key(Key::Char(' ')); let bets = r#"{"chips":[{"target":{"bet":"straight","number":1},"amount":10}]}"#; h.reply(&format!("ROULETTE_RESULT s1 360 10 1350 1 red {bets}")); let View::Roulette(view) = h.core.view() else { panic!("expected a roulette view"); }; assert_eq!(view.phase, RoulettePhase::Result); assert_eq!(view.number, Some(1)); assert_eq!(view.color.as_deref(), Some("red")); assert_eq!(view.winnings, Some(360)); assert_eq!(view.balance, Some(1350)); assert!(view.error.is_none()); } #[test] fn the_chip_size_is_a_command() { let mut h = Harness::new(); h.enter_roulette(); h.core.command(Command::AdjustChip(10)); let View::Roulette(view) = h.core.view() else { panic!("expected a roulette view"); }; assert_eq!(view.chip, 20); h.core.command(Command::SetChip(5)); let View::Roulette(view) = h.core.view() else { panic!("expected a roulette view"); }; assert_eq!(view.chip, 10, "the chip never goes below 10"); } #[test] fn unported_games_still_reach_the_frontend_as_text() { let mut h = Harness::new(); h.enter_slots("10"); match h.core.view() { View::Text(lines) => assert_eq!(lines.len(), 10), other => panic!("expected the text bridge, got {other:?}"), } } #[test] fn leaving_through_a_command_clears_the_screen() { let mut h = Harness::new(); h.enter_roulette(); let effects = h.core.command(Command::Leave); h.apply(effects); assert!(!h.core.is_in_game()); assert!(h.screen.lines.iter().any(|line| line == "Game exited.")); }