From 86d5f832c8452aad2313489b82eb8fd514215b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 28 Oct 2026 22:28:20 +0100 Subject: v0.2.3: add ClientCore and fix the game board rendering --- tests/client_core.rs | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 tests/client_core.rs (limited to 'tests/client_core.rs') diff --git a/tests/client_core.rs b/tests/client_core.rs new file mode 100644 index 0000000..3dbbfe5 --- /dev/null +++ b/tests/client_core.rs @@ -0,0 +1,216 @@ +//! 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}; + +#[derive(Default)] +struct Screen { + lines: Vec, + region_start: Option, + region_len: usize, + prompt: String, +} + +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, .. } => self.prompt = text.clone(), + 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_seven_line_board_and_fetches_the_balance() { + let mut h = Harness::new(); + h.line("play slots 50"); + + assert_eq!(h.board().len(), 7); + assert!(h.board()[1].contains("Bet: 50 PLN")); + assert!(h.sent.contains(&"INFO".to_string())); + + h.reply("INFO alice 1 1000"); + assert_eq!(h.board().len(), 7); + 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(), 7); + assert!(h.board()[4].contains("Spinning...")); + assert!(h.board()[6].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 60 50 1010 OSS two"); + assert_eq!(h.board().len(), 7); + assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); + assert!(h.board()[4].contains("Two of a kind! Winnings: 60 PLN")); + assert!(h.board()[1].contains("Balance: 1010 PLN")); + assert!(h.board()[6].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 60 50 1010 OSS two"); + let lines_before_bet = h.screen.lines.len(); + + h.key(Key::Char('+')); + + assert_eq!(h.board().len(), 7); + assert!(h.board()[1].contains("Bet: 60 PLN")); + assert!(h.board()[2].contains("[ O ] [ S ] [ S ]")); + assert_eq!(h.screen.lines.len(), lines_before_bet); + assert_eq!( + h.screen + .lines + .iter() + .filter(|line| line.contains("=== SLOTS ===")) + .count(), + 1 + ); +} + +#[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(), 7); + assert!(h.board()[4].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(), 7); + assert!(h.board()[4].contains("No response from server")); +} -- cgit v1.2.3