summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-29 22:55:49 +0100
committersalmonik <salmonik@tuta.io>2026-10-29 22:55:49 +0100
commit49df948de25d28601469e9ca993f33530989ebd0 (patch)
tree291aae0f57d0d7d12a27eb28038e43f942d6b991 /tests
parentv2.4.0: key stored credentials by certificate fingerprint (diff)
downloadzagan-bet-49df948de25d28601469e9ca993f33530989ebd0.tar.gz
zagan-bet-49df948de25d28601469e9ca993f33530989ebd0.zip
v3.0.0: cursor-driven roulette felt with splits and corners; drop schema migrations; require 80x24
Diffstat (limited to '')
-rw-r--r--tests/client_core.rs86
-rw-r--r--tests/client_ui.rs52
-rw-r--r--tests/roulette.rs334
-rw-r--r--tests/server_core.rs10
-rw-r--r--tests/storage.rs49
5 files changed, 363 insertions, 168 deletions
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 176791e..dea6808 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -591,26 +591,34 @@ fn rejects_a_grid_with_the_wrong_size() {
591} 591}
592 592
593#[test] 593#[test]
594fn roulette_split_bets_spin_and_show_the_result() { 594fn roulette_cursor_places_bets_and_spins() {
595 let mut h = Harness::new(); 595 let mut h = Harness::new();
596 h.enter_roulette(); 596 h.enter_roulette();
597 597
598 assert_eq!(h.board().len(), 8); 598 assert_eq!(h.board().len(), 12);
599 assert_eq!(h.board()[0], "=== ROULETTE ==="); 599 assert_eq!(h.board()[0], "=== ROULETTE ===");
600 assert!(h.board()[1].contains("Balance: 1000 PLN")); 600 assert!(h.board()[1].contains("Balance: 1000 PLN"));
601 assert!(h.board()[1].contains("Chip: 10 PLN")); 601 assert!(h.board()[1].contains("Chip: 10 PLN"));
602 assert!(h.board()[2].contains("Bets: (none)")); 602 assert!(h.board()[9].contains("Bets: (none)"));
603 // The cursor starts on number 1, the bottom-left corner.
604 assert!(h.board()[5].contains("[ 1 ]"));
605
606 // Up twice: straight 2, then the split between 2 and 3.
607 h.key(Key::Up);
608 h.key(Key::Up);
609 assert!(h.board()[4].contains("[ 2 ]"));
610 assert!(h.board()[3].contains("[ 3 ]"));
603 611
604 h.key(Key::Char('r'));
605 h.key(Key::Char('e'));
606 h.key(Key::Char('1'));
607 h.key(Key::Char('7'));
608 h.key(Key::Enter); 612 h.key(Key::Enter);
613 assert!(h.board()[9].contains("2/3 10"));
614 assert!(h.board()[9].contains("Total: 10 PLN"));
609 615
610 assert!(h.board()[2].contains("RED 10")); 616 // Back down to number 1 and place a straight.
611 assert!(h.board()[2].contains("EVEN 10")); 617 h.key(Key::Down);
612 assert!(h.board()[2].contains("#17 10")); 618 h.key(Key::Down);
613 assert!(h.board()[2].contains("Total: 30 PLN")); 619 h.key(Key::Enter);
620 assert!(h.board()[9].contains("1 10"));
621 assert!(h.board()[9].contains("Total: 20 PLN"));
614 622
615 h.key(Key::Char(' ')); 623 h.key(Key::Char(' '));
616 let request = h 624 let request = h
@@ -620,18 +628,41 @@ fn roulette_split_bets_spin_and_show_the_result() {
620 .expect("roulette request"); 628 .expect("roulette request");
621 assert_eq!( 629 assert_eq!(
622 request, 630 request,
623 "PLAY_START roulette 30 {\"red\":10,\"black\":0,\"even\":10,\"odd\":0,\"numbers\":[{\"number\":17,\"amount\":10}]}" 631 "PLAY_START roulette 20 {\"chips\":[{\"target\":{\"bet\":\"split\",\"a\":2,\"b\":3},\"amount\":10},{\"target\":{\"bet\":\"straight\",\"number\":1},\"amount\":10}]}"
624 ); 632 );
625 633
626 let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; 634 let bets = r#"{"chips":[{"target":{"bet":"split","a":2,"b":3},"amount":10},{"target":{"bet":"straight","number":1},"amount":10}]}"#;
627 h.reply(&format!( 635 h.reply(&format!(
628 "ROULETTE_RESULT session-1 360 30 1330 17 black {bets}" 636 "ROULETTE_RESULT session-1 180 20 1160 3 red {bets}"
629 )); 637 ));
630 638
631 assert_eq!(h.board().len(), 8); 639 assert_eq!(h.board().len(), 12);
632 assert!(h.board()[1].contains("Balance: 1330 PLN")); 640 assert!(h.board()[1].contains("Balance: 1160 PLN"));
633 assert!(h.board()[4].contains("Winning: 17 BLACK ODD")); 641 assert!(h.board()[10].contains("Won 180 PLN at 3 RED ODD"));
634 assert!(h.board()[5].contains("Won 360 PLN")); 642 assert!(h.board()[10].contains("+160"));
643}
644
645#[test]
646fn roulette_cursor_reaches_the_outside_rows() {
647 let mut h = Harness::new();
648 h.enter_roulette();
649
650 h.key(Key::Down);
651 assert!(h.board()[6].starts_with("[") && h.board()[6].contains("1st 12"));
652 h.key(Key::Down);
653 assert!(h.board()[7].starts_with("[") && h.board()[7].contains("2:1"));
654 h.key(Key::Down);
655 assert!(h.board()[8].starts_with("[") && h.board()[8].contains("1-18"));
656
657 h.key(Key::Enter);
658 assert!(h.board()[9].contains("1-18 10"));
659
660 // Back up to the felt: the bottom edge of the first dozen is the 1/4 split.
661 h.key(Key::Up);
662 h.key(Key::Up);
663 h.key(Key::Up);
664 assert!(h.board()[5].contains("[ 1 ]"));
665 assert!(h.board()[5].contains("[ 4 ]"));
635} 666}
636 667
637#[test] 668#[test]
@@ -640,7 +671,7 @@ fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() {
640 h.enter_roulette(); 671 h.enter_roulette();
641 672
642 h.key(Key::Char(' ')); 673 h.key(Key::Char(' '));
643 assert!(h.board()[4].contains("ERROR: Place at least one bet")); 674 assert!(h.board()[10].contains("ERROR: Place at least one bet"));
644 assert!(!h 675 assert!(!h
645 .sent 676 .sent
646 .iter() 677 .iter()
@@ -648,22 +679,9 @@ fn roulette_rejects_an_empty_spin_and_accepts_the_equals_chip_key() {
648 679
649 h.key(Key::Char('=')); 680 h.key(Key::Char('='));
650 assert!(h.board()[1].contains("Chip: 20 PLN")); 681 assert!(h.board()[1].contains("Chip: 20 PLN"));
651 h.key(Key::Char('r'));
652 assert!(h.board()[2].contains("RED 20"));
653 assert!(h.board()[2].contains("Total: 20 PLN"));
654}
655
656#[test]
657fn roulette_rejects_an_out_of_range_number() {
658 let mut h = Harness::new();
659 h.enter_roulette();
660
661 h.key(Key::Char('9'));
662 h.key(Key::Char('9'));
663 h.key(Key::Enter); 682 h.key(Key::Enter);
664 683 assert!(h.board()[9].contains("1 20"));
665 assert!(h.board()[4].contains("ERROR: Number must be 0-36")); 684 assert!(h.board()[9].contains("Total: 20 PLN"));
666 assert!(h.board()[2].contains("Bets: (none)"));
667} 685}
668 686
669fn bj_state( 687fn bj_state(
diff --git a/tests/client_ui.rs b/tests/client_ui.rs
index 44febde..7969139 100644
--- a/tests/client_ui.rs
+++ b/tests/client_ui.rs
@@ -2,7 +2,7 @@ use std::time::Duration;
2use zagan_bet::client::ui::{ 2use zagan_bet::client::ui::{
3 command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines, 3 command_names, completion_lines, display_width, first_contact_lines, format_pong, help_lines,
4 host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history, 4 host_lines, input_cursor_column, lines_to_move_up, parse_bet_arg, record_history,
5 visual_line_count, 5 terminal_too_small, visual_line_count, MIN_COLUMNS, MIN_ROWS,
6}; 6};
7 7
8#[test] 8#[test]
@@ -255,3 +255,53 @@ fn hosts_without_a_fingerprint_are_never_merged() {
255 2 255 2
256 ); 256 );
257} 257}
258
259#[test]
260fn a_terminal_smaller_than_every_game_needs_is_refused() {
261 assert!(terminal_too_small(80, 24).is_none());
262 assert!(terminal_too_small(120, 40).is_none());
263
264 assert!(terminal_too_small(79, 24).is_some());
265 assert!(terminal_too_small(80, 23).is_some());
266
267 let message = terminal_too_small(40, 10).unwrap();
268 assert!(message.contains("80x24"));
269 assert!(message.contains("40x10"));
270}
271
272#[test]
273fn the_roulette_felt_fits_the_minimum_terminal() {
274 use zagan_bet::client::games::roulette::{Cursor, RouletteGame, ROULETTE_BOARD_LINES};
275 use zagan_bet::client::games::traits::{Game, GamePhase, GameState};
276 use zagan_bet::roulette::Bets;
277 use std::collections::HashMap;
278
279 let mut data = HashMap::new();
280 data.insert("balance".to_string(), "1000".to_string());
281 data.insert("chip".to_string(), "10".to_string());
282 data.insert("status".to_string(), "place".to_string());
283 data.insert("bets".to_string(), Bets::default().to_json());
284 data.insert("cursor".to_string(), Cursor::Zero.encoded());
285 let state = GameState {
286 game_type: "roulette".to_string(),
287 bet_amount: 0,
288 phase: GamePhase::WaitingToStart,
289 data,
290 };
291
292 let board = RouletteGame::new().render(&state);
293 let lines: Vec<&str> = board.split("\r\n").collect();
294 assert_eq!(lines.len(), ROULETTE_BOARD_LINES);
295 assert_eq!(RouletteGame::new().render_lines(&state), ROULETTE_BOARD_LINES);
296 for line in lines {
297 assert!(
298 display_width(line) <= MIN_COLUMNS as usize,
299 "roulette line is {} wide: {line:?}",
300 display_width(line)
301 );
302 }
303 assert!(
304 ROULETTE_BOARD_LINES + 4 <= MIN_ROWS as usize,
305 "the board must leave room for the prompt and a few transcript lines"
306 );
307}
diff --git a/tests/roulette.rs b/tests/roulette.rs
index 932bace..8f9155c 100644
--- a/tests/roulette.rs
+++ b/tests/roulette.rs
@@ -1,4 +1,7 @@
1use zagan_bet::roulette::{color, is_black, is_even, is_odd, is_red, rtp, Bets, NumberBet, WHEEL}; 1use zagan_bet::client::games::roulette::{Cursor, Direction};
2use zagan_bet::roulette::{
3 color, is_black, is_even, is_odd, is_red, rtp, BetTarget, Bets, Outside, WHEEL,
4};
2use zagan_bet::server::games::roulette::RouletteGame; 5use zagan_bet::server::games::roulette::RouletteGame;
3use zagan_bet::server::games::traits::Game; 6use zagan_bet::server::games::traits::Game;
4 7
@@ -31,102 +34,184 @@ fn european_rtp_is_36_over_37() {
31} 34}
32 35
33#[test] 36#[test]
34fn bets_total_and_winning_number_payouts() { 37fn inside_bets_pay_their_true_odds() {
35 let bets = Bets { 38 let straight = BetTarget::Straight { number: 17 };
36 red: 10, 39 assert_eq!(straight.return_multiplier(17), 36);
37 black: 10, 40 assert_eq!(straight.return_multiplier(18), 0);
38 even: 10, 41
39 odd: 10, 42 let split = BetTarget::Split { a: 1, b: 2 };
40 numbers: vec![NumberBet { 43 assert_eq!(split.return_multiplier(1), 18);
41 number: 17, 44 assert_eq!(split.return_multiplier(2), 18);
42 amount: 5, 45 assert_eq!(split.return_multiplier(3), 0);
43 }], 46 assert_eq!(split.numbers(), vec![1, 2]);
47
48 let corner = BetTarget::Corner {
49 numbers: [1, 2, 4, 5],
44 }; 50 };
51 for number in [1, 2, 4, 5] {
52 assert_eq!(corner.return_multiplier(number), 9);
53 }
54 assert_eq!(corner.return_multiplier(3), 0);
55 assert_eq!(corner.numbers(), vec![1, 2, 4, 5]);
56}
57
58#[test]
59fn dozens_and_columns_pay_three_for_one_and_zero_loses() {
60 let first_dozen = BetTarget::Dozen { index: 0 };
61 assert_eq!(first_dozen.return_multiplier(1), 3);
62 assert_eq!(first_dozen.return_multiplier(12), 3);
63 assert_eq!(first_dozen.return_multiplier(13), 0);
64 assert_eq!(first_dozen.return_multiplier(0), 0);
65
66 let second_dozen = BetTarget::Dozen { index: 1 };
67 assert_eq!(second_dozen.return_multiplier(13), 3);
68 assert_eq!(second_dozen.return_multiplier(24), 3);
69
70 let first_column = BetTarget::Column { index: 0 };
71 assert_eq!(first_column.return_multiplier(1), 3);
72 assert_eq!(first_column.return_multiplier(34), 3);
73 assert_eq!(first_column.return_multiplier(2), 0);
74
75 let third_column = BetTarget::Column { index: 2 };
76 assert_eq!(third_column.return_multiplier(36), 3);
77 assert_eq!(third_column.return_multiplier(0), 0, "0 is not in a column");
45 78
46 assert_eq!(bets.total(), 45); 79 for index in 0..3 {
47 // 17 is black and odd: 10*2 + 10*2 + 5*36. 80 let dozen = BetTarget::Dozen { index };
48 assert_eq!(bets.winnings(17), 220); 81 let winning = index as u8 * 12 + 1;
49 // 18 is red and even. 82 assert_eq!(dozen.return_multiplier(winning), 3);
50 assert_eq!(bets.winnings(18), 40); 83 }
51} 84}
52 85
53#[test] 86#[test]
54fn zero_loses_all_outside_bets_but_pays_a_straight_zero() { 87fn outside_bets_pay_even_money_and_zero_loses_everything() {
55 let outside = Bets { 88 let cases = [
56 red: 10, 89 (Outside::Low, 18u8, 19u8),
57 black: 10, 90 (Outside::High, 19, 18),
58 even: 10, 91 (Outside::Even, 2, 3),
59 odd: 10, 92 (Outside::Odd, 3, 2),
60 numbers: vec![], 93 (Outside::Red, 1, 2),
61 }; 94 (Outside::Black, 2, 1),
62 assert_eq!(outside.winnings(0), 0); 95 ];
63 96 for (kind, winning, losing) in cases {
64 let straight = Bets { 97 let bet = BetTarget::Outside { kind };
65 red: 10, 98 assert_eq!(bet.return_multiplier(winning), 2, "{kind:?} should win");
66 black: 10, 99 assert_eq!(bet.return_multiplier(losing), 0, "{kind:?} should lose");
67 even: 10, 100 assert_eq!(bet.return_multiplier(0), 0, "0 loses every outside bet");
68 odd: 10, 101 }
69 numbers: vec![NumberBet { 102}
70 number: 0, 103
71 amount: 5, 104#[test]
72 }], 105fn chips_on_the_same_target_merge() {
73 }; 106 let mut bets = Bets::default();
74 assert_eq!(straight.total(), 45); 107 assert!(bets.is_empty());
75 assert_eq!(straight.winnings(0), 180); 108
109 bets.add(BetTarget::Straight { number: 17 }, 10);
110 bets.add(BetTarget::Straight { number: 17 }, 5);
111 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
112
113 assert_eq!(bets.chips.len(), 2);
114 assert_eq!(
115 bets.amount_on(&BetTarget::Straight { number: 17 }),
116 15
117 );
118 assert_eq!(bets.total(), 25);
119 assert_eq!(bets.winnings(17), 15 * 36);
76} 120}
77 121
78#[test] 122#[test]
79fn bets_validate_empty_balance_and_invalid_numbers() { 123fn bets_settle_every_chip_independently() {
124 let mut bets = Bets::default();
125 bets.add(BetTarget::Split { a: 1, b: 2 }, 10);
126 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
127 bets.add(BetTarget::Straight { number: 1 }, 5);
128
129 // 2 is black, so the red chip loses; the split pays 18x.
130 assert_eq!(bets.winnings(2), 180);
131 // 3 is red, but the straight is on 1, so only the red chip pays.
132 assert_eq!(bets.winnings(3), 20);
133 // 1 is red, matches the straight, and is half of the split.
134 assert_eq!(bets.winnings(1), 5 * 36 + 10 * 2 + 10 * 18);
135}
136
137#[test]
138fn a_straight_zero_is_paid_while_outside_bets_lose() {
139 let mut bets = Bets::default();
140 bets.add(BetTarget::Straight { number: 0 }, 5);
141 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
142 bets.add(BetTarget::Dozen { index: 0 }, 10);
143
144 assert_eq!(bets.total(), 25);
145 assert_eq!(bets.winnings(0), 180);
146}
147
148#[test]
149fn bets_validate_balance_and_targets() {
80 assert!(Bets::default().validate(1000).is_err()); 150 assert!(Bets::default().validate(1000).is_err());
81 151
82 let too_big = Bets { 152 let mut too_big = Bets::default();
83 red: 2000, 153 too_big.add(BetTarget::Outside { kind: Outside::Black }, 2000);
84 ..Bets::default()
85 };
86 assert!(too_big.validate(1000).is_err()); 154 assert!(too_big.validate(1000).is_err());
87 155
88 let bad_number = Bets { 156 let bad_number = Bets {
89 numbers: vec![NumberBet { 157 chips: vec![zagan_bet::roulette::Chip {
90 number: 37, 158 target: BetTarget::Straight { number: 37 },
91 amount: 10, 159 amount: 10,
92 }], 160 }],
93 ..Bets::default()
94 }; 161 };
95 assert!(bad_number.validate(1000).is_err()); 162 assert!(bad_number.validate(1000).is_err());
96}
97 163
98#[test] 164 let bad_split = Bets {
99fn the_server_settles_a_split_bet() { 165 chips: vec![zagan_bet::roulette::Chip {
100 let bets = Bets { 166 target: BetTarget::Split { a: 4, b: 4 },
101 red: 10, 167 amount: 10,
102 black: 10,
103 even: 10,
104 odd: 10,
105 numbers: vec![NumberBet {
106 number: 17,
107 amount: 5,
108 }], 168 }],
109 }; 169 };
110 let payload = serde_json::to_string(&bets).unwrap(); 170 assert!(bad_split.validate(1000).is_err());
111 let game = RouletteGame::with_result(17); 171
112 let state = game.prepare_state("alice", bets.total()); 172 let bad_corner = Bets {
173 chips: vec![zagan_bet::roulette::Chip {
174 target: BetTarget::Corner {
175 numbers: [1, 2, 3, 3],
176 },
177 amount: 10,
178 }],
179 };
180 assert!(bad_corner.validate(1000).is_err());
113 181
182 let bad_dozen = Bets {
183 chips: vec![zagan_bet::roulette::Chip {
184 target: BetTarget::Dozen { index: 3 },
185 amount: 10,
186 }],
187 };
188 assert!(bad_dozen.validate(1000).is_err());
189}
190
191#[test]
192fn the_server_settles_split_and_outside_chips() {
193 let mut bets = Bets::default();
194 bets.add(BetTarget::Split { a: 1, b: 2 }, 10);
195 bets.add(BetTarget::Outside { kind: Outside::Even }, 10);
196 let payload = bets.to_json();
197
198 let game = RouletteGame::with_result(2);
199 let state = game.prepare_state("alice", bets.total());
114 let state = game.process_action(&state, "spin", &[&payload]).unwrap(); 200 let state = game.process_action(&state, "spin", &[&payload]).unwrap();
115 let result = game.calculate_result(&state).unwrap(); 201 let result = game.calculate_result(&state).unwrap();
116 202
117 assert_eq!(result.winnings, 220); 203 // 2 is even: 10*18 (split) + 10*2 (even money).
118 assert_eq!(result.result_data.get("number").unwrap(), "17"); 204 assert_eq!(result.winnings, 200);
205 assert_eq!(result.result_data.get("number").unwrap(), "2");
119 assert_eq!(result.result_data.get("color").unwrap(), "black"); 206 assert_eq!(result.result_data.get("color").unwrap(), "black");
120 assert_eq!(result.result_data.get("bets").unwrap(), &payload); 207 assert_eq!(result.result_data.get("bets").unwrap(), &payload);
121} 208}
122 209
123#[test] 210#[test]
124fn the_server_rejects_a_total_that_does_not_match_the_bets() { 211fn the_server_rejects_a_total_that_does_not_match_the_bets() {
125 let payload = serde_json::to_string(&Bets { 212 let mut bets = Bets::default();
126 red: 10, 213 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
127 ..Bets::default() 214 let payload = bets.to_json();
128 })
129 .unwrap();
130 let game = RouletteGame::with_result(17); 215 let game = RouletteGame::with_result(17);
131 let state = game.prepare_state("alice", 99); 216 let state = game.prepare_state("alice", 99);
132 217
@@ -134,31 +219,116 @@ fn the_server_rejects_a_total_that_does_not_match_the_bets() {
134} 219}
135 220
136#[test] 221#[test]
137fn the_server_rejects_an_invalid_number() { 222fn the_server_rejects_an_invalid_target() {
138 let payload = serde_json::to_string(&Bets { 223 let payload = r#"{"chips":[{"target":{"bet":"straight","number":37},"amount":10}]}"#;
139 numbers: vec![NumberBet {
140 number: 37,
141 amount: 10,
142 }],
143 ..Bets::default()
144 })
145 .unwrap();
146 let game = RouletteGame::with_result(17); 224 let game = RouletteGame::with_result(17);
147 let state = game.prepare_state("alice", 10); 225 let state = game.prepare_state("alice", 10);
148 226
149 assert!(game.process_action(&state, "spin", &[&payload]).is_err()); 227 assert!(game.process_action(&state, "spin", &[payload]).is_err());
228}
229
230// --- betting cursor -------------------------------------------------------
231
232fn target_at(x: usize, y: usize) -> BetTarget {
233 Cursor::Lattice { x, y }.target().unwrap()
234}
235
236#[test]
237fn lattice_points_become_straights_splits_and_corners() {
238 assert_eq!(
239 target_at(0, 3),
240 BetTarget::Straight { number: 1 },
241 "bottom-left corner is a straight"
242 );
243 assert_eq!(target_at(0, 0), BetTarget::Straight { number: 3 });
244 assert_eq!(target_at(12, 0), BetTarget::Straight { number: 36 });
245 assert_eq!(target_at(12, 3), BetTarget::Straight { number: 34 });
246
247 assert_eq!(target_at(0, 1), BetTarget::Split { a: 2, b: 3 });
248 assert_eq!(target_at(12, 1), BetTarget::Split { a: 35, b: 36 });
249 assert_eq!(target_at(1, 0), BetTarget::Split { a: 3, b: 6 });
250
251 assert_eq!(
252 target_at(1, 1),
253 BetTarget::Corner {
254 numbers: [2, 3, 5, 6]
255 }
256 );
257 assert_eq!(
258 target_at(6, 2),
259 BetTarget::Corner {
260 numbers: [16, 17, 19, 20]
261 }
262 );
263}
264
265#[test]
266fn the_cursor_walks_the_felt_and_the_outside_rows() {
267 let bottom = Cursor::Lattice { x: 0, y: 3 };
268 assert_eq!(bottom.moved(Direction::Down), Cursor::Dozen(0));
269 assert_eq!(
270 Cursor::Lattice { x: 12, y: 3 }.moved(Direction::Down),
271 Cursor::Dozen(2)
272 );
273 assert_eq!(
274 Cursor::Lattice { x: 0, y: 0 }.moved(Direction::Up),
275 Cursor::Zero
276 );
277 assert_eq!(Cursor::Zero.moved(Direction::Down), Cursor::Lattice { x: 0, y: 0 });
278
279 assert_eq!(Cursor::Dozen(0).moved(Direction::Up), Cursor::Lattice { x: 1, y: 3 });
280 assert_eq!(Cursor::Dozen(0).moved(Direction::Down), Cursor::Column(0));
281 assert_eq!(Cursor::Column(2).moved(Direction::Up), Cursor::Dozen(2));
282 assert_eq!(Cursor::Column(1).moved(Direction::Down), Cursor::Outside(2));
283 assert_eq!(Cursor::Outside(3).moved(Direction::Up), Cursor::Column(1));
284
285 // Edges stay put instead of wrapping.
286 assert_eq!(Cursor::Dozen(0).moved(Direction::Left), Cursor::Dozen(0));
287 assert_eq!(Cursor::Outside(5).moved(Direction::Right), Cursor::Outside(5));
288 assert_eq!(
289 Cursor::Lattice { x: 0, y: 2 }.moved(Direction::Left),
290 Cursor::Lattice { x: 0, y: 2 }
291 );
292}
293
294#[test]
295fn every_cursor_round_trips_through_its_encoded_form() {
296 let cursors = [
297 Cursor::Zero,
298 Cursor::Lattice { x: 7, y: 2 },
299 Cursor::Dozen(1),
300 Cursor::Column(2),
301 Cursor::Outside(4),
302 ];
303 for cursor in cursors {
304 assert_eq!(Cursor::parse(&cursor.encoded()), cursor);
305 }
306 assert_eq!(Cursor::parse("nonsense"), Cursor::default());
307 assert_eq!(Cursor::parse("lattice:99:0"), Cursor::default());
308 assert_eq!(Cursor::parse("dozen:7"), Cursor::default());
309}
310
311#[test]
312fn the_outside_cursor_targets_match_the_labels() {
313 let labels: Vec<String> = (0..6)
314 .map(|index| {
315 let target = Cursor::Outside(index).target().unwrap();
316 target.label()
317 })
318 .collect();
319 assert_eq!(labels, vec!["1-18", "EVEN", "RED", "BLACK", "ODD", "19-36"]);
150} 320}
151 321
152#[test] 322#[test]
153fn parses_a_roulette_result() { 323fn parses_a_roulette_result() {
154 let bets = r#"{"red":10,"black":0,"even":10,"odd":0,"numbers":[{"number":17,"amount":10}]}"#; 324 let bets = r#"{"chips":[{"target":{"bet":"outside","kind":"red"},"amount":30}]}"#;
155 let response = format!("ROULETTE_RESULT session-1 360 30 1330 17 black {bets}"); 325 let response = format!("ROULETTE_RESULT session-1 60 30 1030 17 black {bets}");
156 let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap(); 326 let result = zagan_bet::client::games::response::parse_roulette_response(&response).unwrap();
157 327
158 assert_eq!(result.session_id, "session-1"); 328 assert_eq!(result.session_id, "session-1");
159 assert_eq!(result.winnings, 360); 329 assert_eq!(result.winnings, 60);
160 assert_eq!(result.bet_amount, 30); 330 assert_eq!(result.bet_amount, 30);
161 assert_eq!(result.balance, 1330); 331 assert_eq!(result.balance, 1030);
162 assert_eq!(result.number, 17); 332 assert_eq!(result.number, 17);
163 assert_eq!(result.color, "black"); 333 assert_eq!(result.color, "black");
164 assert_eq!(result.bets, bets); 334 assert_eq!(result.bets, bets);
@@ -168,7 +338,7 @@ fn parses_a_roulette_result() {
168fn rejects_bad_roulette_results() { 338fn rejects_bad_roulette_results() {
169 use zagan_bet::client::games::response::parse_roulette_response; 339 use zagan_bet::client::games::response::parse_roulette_response;
170 340
171 let valid_bets = r#"{"red":10}"#; 341 let valid_bets = r#"{"chips":[]}"#;
172 assert!( 342 assert!(
173 parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 37 green {valid_bets}")).is_err() 343 parse_roulette_response(&format!("ROULETTE_RESULT s 1 1 1 37 green {valid_bets}")).is_err()
174 ); 344 );
diff --git a/tests/server_core.rs b/tests/server_core.rs
index 6b7b415..47e80e7 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -4,7 +4,7 @@ use std::sync::Arc;
4use rusqlite::Connection; 4use rusqlite::Connection;
5use zagan_bet::blackjack::BlackjackState; 5use zagan_bet::blackjack::BlackjackState;
6use zagan_bet::crash::CrashState; 6use zagan_bet::crash::CrashState;
7use zagan_bet::roulette::Bets; 7use zagan_bet::roulette::{BetTarget, Bets, Outside};
8use zagan_bet::server::core::{ConnectionState, ServerCore}; 8use zagan_bet::server::core::{ConnectionState, ServerCore};
9use zagan_bet::server::database; 9use zagan_bet::server::database;
10use zagan_bet::server::games::crash::ManualClock; 10use zagan_bet::server::games::crash::ManualClock;
@@ -244,7 +244,13 @@ fn roulette_settles_split_bets_and_records_details() {
244 register(&mut core, &mut state, "alice"); 244 register(&mut core, &mut state, "alice");
245 login(&mut core, &mut state, "alice"); 245 login(&mut core, &mut state, "alice");
246 246
247 let bets = r#"{"red":10,"black":10,"even":10,"odd":10,"numbers":[{"number":17,"amount":5}]}"#; 247 let mut bets = Bets::default();
248 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
249 bets.add(BetTarget::Outside { kind: Outside::Black }, 10);
250 bets.add(BetTarget::Outside { kind: Outside::Even }, 10);
251 bets.add(BetTarget::Outside { kind: Outside::Odd }, 10);
252 bets.add(BetTarget::Straight { number: 17 }, 5);
253 let bets = bets.to_json();
248 let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}")); 254 let response = core.handle_message(&mut state, &format!("PLAY_START roulette 45 {bets}"));
249 255
250 assert!( 256 assert!(
diff --git a/tests/storage.rs b/tests/storage.rs
index 19936c2..e6ad2d1 100644
--- a/tests/storage.rs
+++ b/tests/storage.rs
@@ -89,55 +89,6 @@ fn forgetting_one_address_keeps_the_shared_credentials() {
89} 89}
90 90
91#[test] 91#[test]
92fn legacy_host_keyed_credentials_are_rekeyed_by_fingerprint() {
93 let (_, conn) = tmp_db("creds-migrate");
94 conn.execute_batch(
95 "CREATE TABLE hosts (
96 host TEXT PRIMARY KEY,
97 last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
98 fingerprint TEXT
99 );
100 CREATE TABLE credentials (
101 host TEXT PRIMARY KEY,
102 username TEXT NOT NULL,
103 password TEXT NOT NULL
104 );
105 INSERT INTO hosts (host, last_used, fingerprint)
106 VALUES ('a:1', '2020-01-01 00:00:00', 'server-a');
107 INSERT INTO hosts (host, last_used, fingerprint)
108 VALUES ('b:2', '2021-01-01 00:00:00', 'server-a');
109 INSERT INTO hosts (host, last_used, fingerprint)
110 VALUES ('c:3', '2022-01-01 00:00:00', NULL);
111 INSERT INTO credentials VALUES ('a:1', 'alice', 'old');
112 INSERT INTO credentials VALUES ('b:2', 'alice', 'recent');
113 INSERT INTO credentials VALUES ('c:3', 'carol', 'untrusted');",
114 )
115 .unwrap();
116
117 client_database::init_db(&conn);
118
119 assert_eq!(
120 client_database::get_credentials(&conn, "server-a"),
121 Some(("alice".to_string(), "recent".to_string())),
122 "the most recently used address wins when legacy rows disagree"
123 );
124 assert_eq!(
125 client_database::get_credentials(&conn, "carol"),
126 None,
127 "a host with no trusted key cannot be re-keyed"
128 );
129
130 // The old table is gone: the column set is the fingerprint schema.
131 let mut statement = conn.prepare("PRAGMA table_info(credentials)").unwrap();
132 let columns: Vec<String> = statement
133 .query_map([], |row| row.get(1))
134 .unwrap()
135 .filter_map(|row| row.ok())
136 .collect();
137 assert_eq!(columns, vec!["fingerprint", "username", "password"]);
138}
139
140#[test]
141fn client_last_host_tracking() { 92fn client_last_host_tracking() {
142 let (_, conn) = tmp_db("hosts"); 93 let (_, conn) = tmp_db("hosts");
143 client_database::init_db(&conn); 94 client_database::init_db(&conn);