From f07221b4e1d8aa464a44fe566884ccc0565406e9 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 16:17:19 +0100 Subject: v3.2.0: straight and split bets anywhere on the felt; add history command --- tests/client_core.rs | 90 ++++++++++++++++++++++++++++++++++++++---------- tests/crash.rs | 2 +- tests/roulette.rs | 97 ++++++++++++++++++++++++++++++++++++++-------------- tests/server_core.rs | 57 ++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 45 deletions(-) (limited to 'tests') diff --git a/tests/client_core.rs b/tests/client_core.rs index bb8772e..b0bba6f 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs @@ -608,24 +608,29 @@ fn roulette_cursor_places_bets_and_spins() { 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 corner. + // 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")); - // Up twice: straight 2, then the split between 2 and 3. + // 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()[3].contains("[ 3 ]")); + assert!(h.board()[10].contains("Bet: 2 · pays 36x")); h.key(Key::Enter); - assert!(h.board()[9].contains("2/3 10")); - assert!(h.board()[9].contains("Total: 10 Kr")); + assert!(h.board()[9].contains("2 10")); - // Back down to number 1 and place a straight. - h.key(Key::Down); - h.key(Key::Down); + // 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("1 10")); + assert!(h.board()[9].contains("2/5 10")); assert!(h.board()[9].contains("Total: 20 Kr")); h.key(Key::Char(' ')); @@ -636,18 +641,17 @@ fn roulette_cursor_places_bets_and_spins() { .expect("roulette request"); assert_eq!( request, - "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":3},\"amount\":10},{\"target\":{\"bet\":\"straight\",\"number\":1},\"amount\":10}]}" + "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":"split","a":2,"b":3},"amount":10},{"target":{"bet":"straight","number":1},"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 180 20 1160 3 red {bets}" + "ROULETTE_RESULT session-1 540 20 1520 2 black {bets}" )); - assert_eq!(h.board().len(), 12); - assert!(h.board()[1].contains("Balance: 1160 Kr")); - assert!(h.board()[10].contains("Won 180 Kr at 3 RED ODD")); - assert!(h.board()[10].contains("+160")); + 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] @@ -658,19 +662,18 @@ fn roulette_cursor_reaches_the_outside_rows() { 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("2:1")); + 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 bottom edge of the first dozen is the 1/4 split. + // 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 ]")); - assert!(h.board()[5].contains("[ 4 ]")); } #[test] @@ -1146,3 +1149,52 @@ fn a_round_is_live_only_while_money_is_reserved() { 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]"))); +} diff --git a/tests/crash.rs b/tests/crash.rs index c985ebe..797874b 100644 --- a/tests/crash.rs +++ b/tests/crash.rs @@ -38,7 +38,7 @@ fn the_time_for_a_multiplier_is_the_inverse_of_the_curve() { } #[test] -fn cashout_value_is_floored_to_whole_Kr() { +fn cashout_value_is_floored_to_whole_kr() { assert_eq!(payout(10, 1.0), 10); assert_eq!(payout(10, 1.05), 10, "half a Kr is not paid out"); assert_eq!(payout(10, 1.10), 11); diff --git a/tests/roulette.rs b/tests/roulette.rs index 8f9155c..0c5e195 100644 --- a/tests/roulette.rs +++ b/tests/roulette.rs @@ -234,28 +234,51 @@ fn target_at(x: usize, y: usize) -> BetTarget { } #[test] -fn lattice_points_become_straights_splits_and_corners() { - assert_eq!( - target_at(0, 3), - BetTarget::Straight { number: 1 }, - "bottom-left corner is a straight" - ); - assert_eq!(target_at(0, 0), BetTarget::Straight { number: 3 }); - assert_eq!(target_at(12, 0), BetTarget::Straight { number: 36 }); - assert_eq!(target_at(12, 3), BetTarget::Straight { number: 34 }); - - assert_eq!(target_at(0, 1), BetTarget::Split { a: 2, b: 3 }); - assert_eq!(target_at(12, 1), BetTarget::Split { a: 35, b: 36 }); - assert_eq!(target_at(1, 0), BetTarget::Split { a: 3, b: 6 }); +fn cell_centres_are_straights() { + assert_eq!(target_at(1, 5), BetTarget::Straight { number: 1 }); + assert_eq!(target_at(1, 1), BetTarget::Straight { number: 3 }); + assert_eq!(target_at(23, 1), BetTarget::Straight { number: 36 }); + assert_eq!(target_at(23, 5), BetTarget::Straight { number: 34 }); + assert_eq!(target_at(5, 3), BetTarget::Straight { number: 8 }); + + // Every one of the 36 numbers can be bet straight. + let mut reachable = Vec::new(); + for column in 0..12usize { + for row in 0..3usize { + let cursor = Cursor::Lattice { + x: 2 * column + 1, + y: 2 * row + 1, + }; + if let Some(BetTarget::Straight { number }) = cursor.target() { + reachable.push(number); + } + } + } + reachable.sort_unstable(); + assert_eq!(reachable, (1..=36).collect::>()); +} +#[test] +fn lines_between_cells_are_splits_and_crossings_are_corners() { + // Vertical lines sit between two columns. + assert_eq!(target_at(2, 5), BetTarget::Split { a: 1, b: 4 }); + assert_eq!(target_at(2, 3), BetTarget::Split { a: 2, b: 5 }); + assert_eq!(target_at(22, 1), BetTarget::Split { a: 33, b: 36 }); + + // Horizontal lines sit between two rows. + assert_eq!(target_at(1, 4), BetTarget::Split { a: 1, b: 2 }); + assert_eq!(target_at(3, 2), BetTarget::Split { a: 5, b: 6 }); + assert_eq!(target_at(23, 4), BetTarget::Split { a: 34, b: 35 }); + + // Crossings cover four cells. assert_eq!( - target_at(1, 1), + target_at(2, 4), BetTarget::Corner { - numbers: [2, 3, 5, 6] + numbers: [1, 2, 4, 5] } ); assert_eq!( - target_at(6, 2), + target_at(12, 4), BetTarget::Corner { numbers: [16, 17, 19, 20] } @@ -264,31 +287,54 @@ fn lattice_points_become_straights_splits_and_corners() { #[test] fn the_cursor_walks_the_felt_and_the_outside_rows() { - let bottom = Cursor::Lattice { x: 0, y: 3 }; + let bottom = Cursor::Lattice { x: 1, y: 5 }; + // One step right is the split between 1 and 4, two steps is number 4. + assert_eq!( + bottom.moved(Direction::Right), + Cursor::Lattice { x: 2, y: 5 } + ); + assert_eq!( + bottom.moved(Direction::Right).moved(Direction::Right), + Cursor::Lattice { x: 3, y: 5 } + ); + // One step up is the split between 1 and 2. + assert_eq!(bottom.moved(Direction::Up), Cursor::Lattice { x: 1, y: 4 }); + assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0)); assert_eq!( - Cursor::Lattice { x: 12, y: 3 }.moved(Direction::Down), + Cursor::Lattice { x: 23, y: 5 }.moved(Direction::Down), Cursor::Dozen(2) ); assert_eq!( - Cursor::Lattice { x: 0, y: 0 }.moved(Direction::Up), + Cursor::Lattice { x: 1, y: 1 }.moved(Direction::Up), Cursor::Zero ); - assert_eq!(Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 0, y: 0 }); + assert_eq!( + Cursor::Zero.moved(Direction::Down), + Cursor::Lattice { x: 1, y: 1 } + ); - assert_eq!(Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 3 }); + assert_eq!( + Cursor::Dozen(0).moved(Direction::Up), + Cursor::Lattice { x: 1, y: 5 } + ); assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0)); + assert_eq!(Cursor::Dozen(2).moved(Direction::Up), Cursor::Lattice { x: 17, y: 5 }); assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2)); assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2)); assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1)); // Edges stay put instead of wrapping. - assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); - assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); assert_eq!( - Cursor::Lattice { x: 0, y: 2 }.moved(Direction::Left), - Cursor::Lattice { x: 0, y: 2 } + Cursor::Lattice { x: 1, y: 3 }.moved(Direction::Left), + Cursor::Lattice { x: 1, y: 3 } + ); + assert_eq!( + Cursor::Lattice { x: 23, y: 3 }.moved(Direction::Right), + Cursor::Lattice { x: 23, y: 3 } ); + assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0)); + assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5)); } #[test] @@ -305,6 +351,7 @@ fn every_cursor_round_trips_through_its_encoded_form() { } assert_eq!(Cursor::parse("nonsense"), Cursor::default()); assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default()); + assert_eq!(Cursor::parse("lattice:0:1"), Cursor::default()); assert_eq!(Cursor::parse("dozen:7"), Cursor::default()); } diff --git a/tests/server_core.rs b/tests/server_core.rs index 47e80e7..6d6cffd 100644 --- a/tests/server_core.rs +++ b/tests/server_core.rs @@ -871,3 +871,60 @@ fn an_insufficient_bet_leaves_no_reservation() { .unwrap(); assert_eq!(pending, 0); } + +#[test] +fn history_requires_login_and_valid_counts() { + let (mut core, _path) = setup("history-guards", WIN3); + let mut state = ConnectionState::default(); + + assert_eq!( + core.handle_message(&mut state, "HISTORY"), + "ERROR Not logged in" + ); + + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + assert_eq!( + core.handle_message(&mut state, "HISTORY nope"), + "ERROR Invalid HISTORY count" + ); + assert_eq!( + core.handle_message(&mut state, "HISTORY 0"), + "ERROR Invalid HISTORY count" + ); + assert_eq!( + core.handle_message(&mut state, "HISTORY 1 2"), + "ERROR Invalid HISTORY format. Usage: HISTORY [count]" + ); +} + +#[test] +fn history_lists_recent_plays_newest_first() { + let (mut core, _path) = setup("history-list", WIN3); + let mut state = ConnectionState::default(); + register(&mut core, &mut state, "alice"); + login(&mut core, &mut state, "alice"); + + for _ in 0..3 { + assert!(core + .handle_message(&mut state, "PLAY_START slots 10") + .starts_with("GAME_RESULT")); + } + + let response = core.handle_message(&mut state, "HISTORY 2"); + let json = response.strip_prefix("HISTORY ").expect("history response"); + let records: Vec = serde_json::from_str(json).unwrap(); + + assert_eq!(records.len(), 2, "the limit must be honoured"); + for record in &records { + assert_eq!(record.game, "slots"); + assert_eq!(record.bet, 10); + assert_eq!(record.winnings, 10, "three C pay the stake back"); + assert_eq!(record.summary(), "win"); + assert_eq!(record.details["win_type"], "win"); + } + + // Newest first: the timestamps must not increase across the list. + assert!(records[0].at >= records[1].at); +} -- cgit v1.2.3