summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--tests/client_core.rs151
-rw-r--r--tests/crash.rs39
-rw-r--r--tests/protocol.rs150
-rw-r--r--tests/server_core.rs121
-rw-r--r--tests/version.rs57
-rw-r--r--tests/web_types.rs138
6 files changed, 538 insertions, 118 deletions
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 2f43d47..3a2fb27 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -7,17 +7,17 @@
7use std::time::Duration; 7use std::time::Duration;
8use zagan_bet::blackjack::BlackjackState; 8use zagan_bet::blackjack::BlackjackState;
9use zagan_bet::client::core::{ClientCore, Effect}; 9use zagan_bet::client::core::{ClientCore, Effect};
10use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
11use zagan_bet::client::shell::{self, Shell};
12use zagan_bet::client::ui::help_lines;
13use zagan_bet::client::games::response::{ 10use zagan_bet::client::games::response::{
14 parse_blackjack_response, parse_crash_response, parse_spin_response, 11 parse_blackjack_response, parse_crash_response, parse_spin_response,
15}; 12};
16use zagan_bet::client::games::roulette::{Cursor, Direction}; 13use zagan_bet::client::games::roulette::{Cursor, Direction};
14use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input};
15use zagan_bet::client::shell::{self, Shell};
17use zagan_bet::client::ui::command_names; 16use zagan_bet::client::ui::command_names;
17use zagan_bet::client::ui::help_lines;
18use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; 18use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View};
19use zagan_bet::roulette::BetTarget;
20use zagan_bet::crash::{CrashState, multiplier_at}; 19use zagan_bet::crash::{CrashState, multiplier_at};
20use zagan_bet::roulette::BetTarget;
21 21
22const WIN3: &str = "CCCCLLCLODGSDGS"; 22const WIN3: &str = "CCCCLLCLODGSDGS";
23const LOSS: &str = "CLOOGDSLCDCOGLS"; 23const LOSS: &str = "CLOOGDSLCDCOGLS";
@@ -54,7 +54,7 @@ impl Screen {
54 self.region_start = None; 54 self.region_start = None;
55 self.region_len = 0; 55 self.region_len = 0;
56 } 56 }
57 Effect::SendLine(line) => sent.push(line.clone()), 57 Effect::SendRequest { payload, .. } => sent.push(payload.clone()),
58 Effect::Quit | Effect::Disconnect => {} 58 Effect::Quit | Effect::Disconnect => {}
59 } 59 }
60 } 60 }
@@ -601,7 +601,7 @@ fn ping_prints_pong() {
601 h.line("ping"); 601 h.line("ping");
602 assert_eq!(h.sent.last().map(String::as_str), Some("PING")); 602 assert_eq!(h.sent.last().map(String::as_str), Some("PING"));
603 603
604 h.reply("PONG"); 604 h.reply("PONG 1700000000000");
605 605
606 assert!(h.screen.lines.iter().any(|line| line == "Pong")); 606 assert!(h.screen.lines.iter().any(|line| line == "Pong"));
607} 607}
@@ -968,6 +968,13 @@ fn crash_state(
968 crash_point, 968 crash_point,
969 payout, 969 payout,
970 result: phase.to_string(), 970 result: phase.to_string(),
971 started_at: 1_700_000_000_000,
972 commit: Some("00".repeat(32)),
973 seed: if phase == "running" {
974 None
975 } else {
976 Some("00".repeat(32))
977 },
971 } 978 }
972} 979}
973 980
@@ -1221,11 +1228,12 @@ fn history_prints_recent_plays() {
1221 let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#; 1228 let json = r#"[{"game":"roulette","bet":10,"winnings":20,"balance_after":1010,"at":"2026-10-30 01:20:11","details":{"number":"17","color":"black"}}]"#;
1222 h.reply(&format!("HISTORY {json}")); 1229 h.reply(&format!("HISTORY {json}"));
1223 1230
1224 assert!(h 1231 assert!(
1225 .screen 1232 h.screen
1226 .lines 1233 .lines
1227 .iter() 1234 .iter()
1228 .any(|line| line.starts_with("Recent plays (1)"))); 1235 .any(|line| line.starts_with("Recent plays (1)"))
1236 );
1229 let row = h 1237 let row = h
1230 .screen 1238 .screen
1231 .lines 1239 .lines
@@ -1242,11 +1250,12 @@ fn history_without_plays_says_so() {
1242 let mut h = Harness::new(); 1250 let mut h = Harness::new();
1243 h.line("history"); 1251 h.line("history");
1244 h.reply("HISTORY []"); 1252 h.reply("HISTORY []");
1245 assert!(h 1253 assert!(
1246 .screen 1254 h.screen
1247 .lines 1255 .lines
1248 .iter() 1256 .iter()
1249 .any(|line| line == "No plays recorded yet.")); 1257 .any(|line| line == "No plays recorded yet.")
1258 );
1250} 1259}
1251 1260
1252#[test] 1261#[test]
@@ -1254,11 +1263,12 @@ fn history_rejects_a_bad_count_without_sending() {
1254 let mut h = Harness::new(); 1263 let mut h = Harness::new();
1255 h.line("history 0"); 1264 h.line("history 0");
1256 assert!(!h.sent.iter().any(|line| line == "HISTORY 0")); 1265 assert!(!h.sent.iter().any(|line| line == "HISTORY 0"));
1257 assert!(h 1266 assert!(
1258 .screen 1267 h.screen
1259 .lines 1268 .lines
1260 .iter() 1269 .iter()
1261 .any(|line| line.contains("Usage: history [1-50]"))); 1270 .any(|line| line.contains("Usage: history [1-50]"))
1271 );
1262} 1272}
1263 1273
1264#[test] 1274#[test]
@@ -1291,10 +1301,7 @@ fn the_roulette_view_reflects_the_cursor_and_bets() {
1291 Some(BetTarget::Straight { number: 2 }) 1301 Some(BetTarget::Straight { number: 2 })
1292 ); 1302 );
1293 assert_eq!(view.bets.total(), 10); 1303 assert_eq!(view.bets.total(), 10);
1294 assert_eq!( 1304 assert_eq!(view.bets.amount_on(&BetTarget::Straight { number: 2 }), 10);
1295 view.bets.amount_on(&BetTarget::Straight { number: 2 }),
1296 10
1297 );
1298} 1305}
1299 1306
1300#[test] 1307#[test]
@@ -1302,7 +1309,9 @@ fn clicking_a_bet_target_does_not_move_the_cursor() {
1302 let mut h = Harness::new(); 1309 let mut h = Harness::new();
1303 h.enter_roulette(); 1310 h.enter_roulette();
1304 1311
1305 let effects = h.core.command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 })); 1312 let effects = h
1313 .core
1314 .command(Command::PlaceBetAt(BetTarget::Split { a: 1, b: 4 }));
1306 h.apply(effects); 1315 h.apply(effects);
1307 1316
1308 let Some(View::Roulette(view)) = h.core.view() else { 1317 let Some(View::Roulette(view)) = h.core.view() else {
@@ -1387,9 +1396,7 @@ fn the_slots_view_carries_the_result() {
1387 let mut h = Harness::new(); 1396 let mut h = Harness::new();
1388 h.enter_slots("10"); 1397 h.enter_slots("10");
1389 h.key(Key::Char(' ')); 1398 h.key(Key::Char(' '));
1390 h.reply(&format!( 1399 h.reply(&format!("GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6"));
1391 "GAME_RESULT s1 20 10 1010 {WIN3} win 1 0,3,6"
1392 ));
1393 1400
1394 let Some(View::Slots(view)) = h.core.view() else { 1401 let Some(View::Slots(view)) = h.core.view() else {
1395 panic!("expected a slots view"); 1402 panic!("expected a slots view");
@@ -1518,11 +1525,12 @@ fn history_is_a_screen_and_keeps_its_records() {
1518 assert_eq!(view.records[0].summary(), "cashed 1.13x"); 1525 assert_eq!(view.records[0].summary(), "cashed 1.13x");
1519 1526
1520 // The terminal adapter still prints the table. 1527 // The terminal adapter still prints the table.
1521 assert!(h 1528 assert!(
1522 .screen 1529 h.screen
1523 .lines 1530 .lines
1524 .iter() 1531 .iter()
1525 .any(|line| line.starts_with("Recent plays (1)"))); 1532 .any(|line| line.starts_with("Recent plays (1)"))
1533 );
1526} 1534}
1527 1535
1528#[test] 1536#[test]
@@ -1563,11 +1571,12 @@ fn a_login_command_sends_auth_and_sets_the_user() {
1563 }; 1571 };
1564 assert_eq!(lobby.user.as_deref(), Some("alice")); 1572 assert_eq!(lobby.user.as_deref(), Some("alice"));
1565 assert!(lobby.login_error.is_none()); 1573 assert!(lobby.login_error.is_none());
1566 assert!(h 1574 assert!(
1567 .screen 1575 h.screen
1568 .lines 1576 .lines
1569 .iter() 1577 .iter()
1570 .any(|line| line == "Logged in as alice")); 1578 .any(|line| line == "Logged in as alice")
1579 );
1571} 1580}
1572 1581
1573#[test] 1582#[test]
@@ -1600,11 +1609,12 @@ fn a_register_command_sends_register_and_reports_the_result() {
1600 assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash")); 1609 assert_eq!(h.sent.last().map(String::as_str), Some("REGISTER bob hash"));
1601 1610
1602 h.reply("REGISTERED bob"); 1611 h.reply("REGISTERED bob");
1603 assert!(h 1612 assert!(
1604 .screen 1613 h.screen
1605 .lines 1614 .lines
1606 .iter() 1615 .iter()
1607 .any(|line| line == "Account created. Log in to play.")); 1616 .any(|line| line == "Account created. Log in to play.")
1617 );
1608} 1618}
1609 1619
1610#[test] 1620#[test]
@@ -1646,3 +1656,56 @@ fn the_crash_multiplier_is_not_rounded_for_the_chart() {
1646 "a two-decimal store would flatten the curve into steps" 1656 "a two-decimal store would flatten the curve into steps"
1647 ); 1657 );
1648} 1658}
1659
1660#[test]
1661fn a_reply_is_routed_by_request_id_and_late_replies_are_ignored() {
1662 let mut h = Harness::new();
1663 let first = h.core.command(Command::Ping);
1664 let first_id = first
1665 .iter()
1666 .find_map(|effect| match effect {
1667 Effect::SendRequest { id, .. } => Some(*id),
1668 _ => None,
1669 })
1670 .unwrap();
1671
1672 let second = h.core.command(Command::Info);
1673 let second_id = second
1674 .iter()
1675 .find_map(|effect| match effect {
1676 Effect::SendRequest { id, .. } => Some(*id),
1677 _ => None,
1678 })
1679 .unwrap();
1680 assert_ne!(first_id, second_id, "ids must be unique on the connection");
1681
1682 // The ping reply arrives after INFO was sent. It must not complete INFO.
1683 assert!(
1684 h.core
1685 .handle_reply_id(first_id, "PONG 1700000000000")
1686 .is_empty()
1687 );
1688 let effects = h.core.handle_reply_id(second_id, "INFO alice 1 1000");
1689 assert!(
1690 effects.iter().any(|effect| matches!(effect, Effect::Print(lines) if lines.iter().any(|line| line.contains("Balance: 1000")))),
1691 "the matching reply is applied"
1692 );
1693}
1694
1695#[test]
1696fn a_crash_tick_updates_the_board_for_its_own_session() {
1697 let mut h = Harness::new();
1698 h.enter_crash();
1699 h.key(Key::Char(' '));
1700 let running = crash_state("running", 1.0, None, 0, 990);
1701 h.reply(&crash_line("CRASH_START", &running));
1702 assert!(h.board()[2].contains("Multiplier: 1.00x"));
1703
1704 h.push("crash.tick session=cr1 t=1700000000100 m=1.5");
1705 assert!(h.board()[2].contains("Multiplier: 1.50x"));
1706 assert!(h.board()[3].contains("Potential: 15 Kr"));
1707
1708 // A tick for another round cannot move this one.
1709 h.push("crash.tick session=other t=1700000000200 m=9.0");
1710 assert!(h.board()[2].contains("Multiplier: 1.50x"));
1711}
diff --git a/tests/crash.rs b/tests/crash.rs
index 797874b..513f9c8 100644
--- a/tests/crash.rs
+++ b/tests/crash.rs
@@ -102,3 +102,42 @@ fn crash_points_are_clamped_to_the_liability_cap() {
102 assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER); 102 assert_eq!(crash_point_from_uniform(0.999_999), MAX_MULTIPLIER);
103 assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER); 103 assert_eq!(crash_point_from_uniform(1.0), MAX_MULTIPLIER);
104} 104}
105
106#[test]
107fn a_seed_commits_to_its_crash_point_and_can_be_verified() {
108 use zagan_bet::crash::{
109 SEED_LEN, commit_seed, crash_point_from_seed, seed_from_hex, seed_to_uniform, verify_seed,
110 };
111
112 let mut seed = [0u8; SEED_LEN];
113 let value = (0.67_f64 * 18_446_744_073_709_551_616.0) as u64;
114 seed[..8].copy_from_slice(&value.to_be_bytes());
115 let commit = commit_seed(&seed);
116 let hex = format!(
117 "{}",
118 seed.iter()
119 .map(|byte| format!("{byte:02x}"))
120 .collect::<String>()
121 );
122
123 assert_eq!(seed_from_hex(&hex).as_deref(), Some(seed.as_slice()));
124 assert!(verify_seed(&hex, &commit));
125 assert!(!verify_seed(&hex, &"00".repeat(32)), "a wrong commit fails");
126 assert!(!verify_seed("nope", &commit), "bad hex fails");
127
128 // The point the client recomputes is the point the server committed to.
129 close(crash_point_from_seed(&seed), 3.0, 1e-9);
130 close(crash_point_from_seed(&seed), 3.0, 1e-9);
131
132 // The first eight bytes are the uniform; the rest are still hashed.
133 close(seed_to_uniform(&seed), 0.67, 1e-9);
134 let mut altered = seed;
135 altered[SEED_LEN - 1] = 1;
136 assert!(!verify_seed(
137 &altered
138 .iter()
139 .map(|byte| format!("{byte:02x}"))
140 .collect::<String>(),
141 &commit
142 ));
143}
diff --git a/tests/protocol.rs b/tests/protocol.rs
index bf8a4fd..d829ba9 100644
--- a/tests/protocol.rs
+++ b/tests/protocol.rs
@@ -1,84 +1,136 @@
1use std::io::{Cursor, Error, ErrorKind}; 1use std::io::Cursor;
2use zagan_bet::protocol::{
3 FrameBuffer, FrameError, MAX_FRAME_LEN, Packet, encode_frame, event_line, io_failure_message,
4 parse_packet, parse_request, reply_line, request_line,
5};
2 6
3use zagan_bet::protocol::{io_failure_message, LineBuffer}; 7fn framed(payload: &str) -> Vec<u8> {
8 encode_frame(payload)
9}
4 10
5#[test] 11#[test]
6fn keeps_data_after_the_first_newline() { 12fn decodes_every_complete_frame_and_keeps_the_remainder() {
7 let mut buffer = LineBuffer::new(); 13 let mut buffer = FrameBuffer::new();
8 buffer.push(b"PONG\nGAME_RESULT session 60 10 990 OSS two\n"); 14 let mut bytes = framed("RES 1 PONG 1700000000000");
15 bytes.extend(framed("EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5"));
16 buffer.push(&bytes);
9 17
10 assert_eq!(buffer.next_line().as_deref(), Some("PONG"));
11 assert_eq!( 18 assert_eq!(
12 buffer.next_line().as_deref(), 19 buffer.next_frame().unwrap().unwrap(),
13 Some("GAME_RESULT session 60 10 990 OSS two") 20 "RES 1 PONG 1700000000000"
21 );
22 assert_eq!(
23 buffer.next_frame().unwrap().unwrap(),
24 "EVT 2 crash.tick session=cr1 t=1700000000100 m=1.5"
14 ); 25 );
15 assert_eq!(buffer.next_line(), None); 26 assert!(buffer.next_frame().is_none());
27 assert!(buffer.is_empty());
16} 28}
17 29
18#[test] 30#[test]
19fn handles_crlf_and_empty_lines() { 31fn waits_for_a_partial_frame() {
20 let mut buffer = LineBuffer::new(); 32 let mut buffer = FrameBuffer::new();
21 buffer.push(b"PONG\r\n\r\n"); 33 let bytes = framed("RES 7 OK");
34 buffer.push(&bytes[..3]);
35 assert!(buffer.next_frame().is_none());
22 36
23 assert_eq!(buffer.next_line().as_deref(), Some("PONG")); 37 buffer.push(&bytes[3..7]);
24 assert_eq!(buffer.next_line().as_deref(), Some("")); 38 assert!(buffer.next_frame().is_none());
25 assert_eq!(buffer.next_line(), None); 39
40 buffer.push(&bytes[7..]);
41 assert_eq!(buffer.next_frame().unwrap().unwrap(), "RES 7 OK");
26} 42}
27 43
28#[test] 44#[test]
29fn waits_for_a_split_line() { 45fn an_oversized_length_is_rejected_without_waiting_for_bytes() {
30 let mut buffer = LineBuffer::new(); 46 let mut buffer = FrameBuffer::new();
31 buffer.push(b"PO"); 47 buffer.push(&((MAX_FRAME_LEN as u32 + 1).to_be_bytes()));
32 assert_eq!(buffer.next_line(), None); 48 match buffer.next_frame() {
49 Some(Err(FrameError::TooLarge(length))) => {
50 assert_eq!(length, MAX_FRAME_LEN + 1);
51 }
52 other => panic!("expected an oversize error, got {other:?}"),
53 }
54 assert!(buffer.is_empty(), "the buffer must not keep the bad frame");
55}
33 56
34 buffer.push(b"NG\n"); 57#[test]
35 assert_eq!(buffer.next_line().as_deref(), Some("PONG")); 58fn invalid_utf8_is_a_protocol_error() {
59 let mut buffer = FrameBuffer::new();
60 buffer.push(&1u32.to_be_bytes());
61 buffer.push(&[0xff]);
62 assert_eq!(buffer.next_frame(), Some(Err(FrameError::InvalidUtf8)));
36} 63}
37 64
38#[test] 65#[test]
39fn reads_multiple_lines_from_a_reader() { 66fn reads_frames_from_a_blocking_reader() {
40 let mut buffer = LineBuffer::new(); 67 let mut bytes = framed("RES 1 OK");
41 let mut reader = Cursor::new(b"one\ntwo\n".to_vec()); 68 bytes.extend(framed("EVT 1 crash.tick session=cr1 t=1 m=1.0"));
69 let mut reader = Cursor::new(bytes);
70 let mut buffer = FrameBuffer::new();
42 71
43 assert_eq!( 72 assert_eq!(
44 buffer.read_line(&mut reader).unwrap().as_deref(), 73 buffer.read_frame(&mut reader).unwrap().as_deref(),
45 Some("one") 74 Some("RES 1 OK")
46 ); 75 );
47 assert_eq!( 76 assert_eq!(
48 buffer.read_line(&mut reader).unwrap().as_deref(), 77 buffer.read_frame(&mut reader).unwrap().as_deref(),
49 Some("two") 78 Some("EVT 1 crash.tick session=cr1 t=1 m=1.0")
50 ); 79 );
51 assert_eq!(buffer.read_line(&mut reader).unwrap(), None); 80 assert_eq!(buffer.read_frame(&mut reader).unwrap(), None);
52} 81}
53 82
54#[test] 83#[test]
55fn returns_a_partial_line_at_eof() { 84fn builds_and_parses_the_envelope() {
56 let mut buffer = LineBuffer::new();
57 let mut reader = Cursor::new(b"partial".to_vec());
58
59 assert_eq!( 85 assert_eq!(
60 buffer.read_line(&mut reader).unwrap().as_deref(), 86 request_line(42, None, "PLAY_START crash 50"),
61 Some("partial") 87 "REQ 42 PLAY_START crash 50"
62 ); 88 );
63 assert_eq!(buffer.read_line(&mut reader).unwrap(), None); 89 assert_eq!(
90 request_line(42, Some("abc123"), "PLAY_START crash 50"),
91 "REQ 42 idem=abc123 PLAY_START crash 50"
92 );
93
94 let request = parse_request("REQ 42 idem=abc123 PLAY_START crash 50").unwrap();
95 assert_eq!(request.id, 42);
96 assert_eq!(request.idem.as_deref(), Some("abc123"));
97 assert_eq!(request.payload, "PLAY_START crash 50");
98
99 let request = parse_request("REQ 7 PING").unwrap();
100 assert_eq!(request.id, 7);
101 assert_eq!(request.idem, None);
102 assert_eq!(request.payload, "PING");
103
104 assert!(parse_request("PLAY_START crash 50").is_err());
105 assert!(parse_request("REQ nope PING").is_err());
106 assert!(parse_request("REQ 1 idem= PING").is_err());
64} 107}
65 108
66#[test] 109#[test]
67fn timeout_errors_get_a_timeout_message() { 110fn parses_replies_and_events() {
68 assert_eq!(
69 io_failure_message(&Error::from(ErrorKind::TimedOut)),
70 "ERROR Request timed out"
71 );
72 assert_eq!(
73 io_failure_message(&Error::from(ErrorKind::WouldBlock)),
74 "ERROR Request timed out"
75 );
76 assert_eq!( 111 assert_eq!(
77 io_failure_message(&Error::from(ErrorKind::UnexpectedEof)), 112 parse_packet(&reply_line(9, "ERROR Insufficient balance")).unwrap(),
78 "ERROR Connection lost" 113 Packet::Reply {
114 id: 9,
115 body: "ERROR Insufficient balance".to_string()
116 }
79 ); 117 );
80 assert_eq!( 118 assert_eq!(
81 io_failure_message(&Error::from(ErrorKind::ConnectionReset)), 119 parse_packet(&event_line(3, "CRASH_BUSTED {}")).unwrap(),
82 "ERROR Connection lost" 120 Packet::Event {
121 seq: 3,
122 body: "CRASH_BUSTED {}".to_string()
123 }
83 ); 124 );
125 assert!(parse_packet("REQ 1 PING").is_err());
126 assert!(parse_packet("RES nope OK").is_err());
127}
128
129#[test]
130fn io_failures_are_reported_as_short_messages() {
131 let timeout = std::io::Error::new(std::io::ErrorKind::TimedOut, "slow");
132 assert_eq!(io_failure_message(&timeout), "ERROR Request timed out");
133
134 let closed = std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "gone");
135 assert_eq!(io_failure_message(&closed), "ERROR Connection lost");
84} 136}
diff --git a/tests/server_core.rs b/tests/server_core.rs
index e2600e4..6c9c835 100644
--- a/tests/server_core.rs
+++ b/tests/server_core.rs
@@ -101,11 +101,11 @@ fn crash_state(response: &str) -> CrashState {
101} 101}
102 102
103fn hello(core: &mut ServerCore, state: &mut ConnectionState) { 103fn hello(core: &mut ServerCore, state: &mut ConnectionState) {
104 let response = core.handle_message( 104 let response = core.handle_message(state, &format!("HELLO {}", zagan_bet::version::VERSION));
105 state, 105 assert!(
106 &format!("HELLO {}", zagan_bet::version::VERSION), 106 response.starts_with("HELLO "),
107 "handshake failed: {response}"
107 ); 108 );
108 assert!(response.starts_with("HELLO "), "handshake failed: {response}");
109} 109}
110 110
111fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { 111fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) {
@@ -258,8 +258,18 @@ fn roulette_settles_split_bets_and_records_details() {
258 258
259 let mut bets = Bets::default(); 259 let mut bets = Bets::default();
260 bets.add(BetTarget::Outside { kind: Outside::Red }, 10); 260 bets.add(BetTarget::Outside { kind: Outside::Red }, 10);
261 bets.add(BetTarget::Outside { kind: Outside::Black }, 10); 261 bets.add(
262 bets.add(BetTarget::Outside { kind: Outside::Even }, 10); 262 BetTarget::Outside {
263 kind: Outside::Black,
264 },
265 10,
266 );
267 bets.add(
268 BetTarget::Outside {
269 kind: Outside::Even,
270 },
271 10,
272 );
263 bets.add(BetTarget::Outside { kind: Outside::Odd }, 10); 273 bets.add(BetTarget::Outside { kind: Outside::Odd }, 10);
264 bets.add(BetTarget::Straight { number: 17 }, 5); 274 bets.add(BetTarget::Straight { number: 17 }, 5);
265 let bets = bets.to_json(); 275 let bets = bets.to_json();
@@ -671,10 +681,14 @@ fn crash_poll_pushes_the_bust_exactly_once() {
671 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10")); 681 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
672 682
673 clock.advance(1.0); 683 clock.advance(1.0);
684 let ticks = core.poll(&state);
685 assert_eq!(ticks.len(), 1, "a running round pushes coalesced ticks");
674 assert!( 686 assert!(
675 core.poll(&state).is_empty(), 687 ticks[0].starts_with("crash.tick session="),
676 "a running round must not push anything" 688 "tick: {}",
689 ticks[0]
677 ); 690 );
691 assert!(ticks[0].contains(" m="), "tick carries the multiplier");
678 692
679 clock.advance(15.0); 693 clock.advance(15.0);
680 let pushed = core.poll(&state); 694 let pushed = core.poll(&state);
@@ -920,9 +934,10 @@ fn history_lists_recent_plays_newest_first() {
920 login(&mut core, &mut state, "alice"); 934 login(&mut core, &mut state, "alice");
921 935
922 for _ in 0..3 { 936 for _ in 0..3 {
923 assert!(core 937 assert!(
924 .handle_message(&mut state, "PLAY_START slots 10") 938 core.handle_message(&mut state, "PLAY_START slots 10")
925 .starts_with("GAME_RESULT")); 939 .starts_with("GAME_RESULT")
940 );
926 } 941 }
927 942
928 let response = core.handle_message(&mut state, "HISTORY 2"); 943 let response = core.handle_message(&mut state, "HISTORY 2");
@@ -979,7 +994,10 @@ fn hello_reports_the_version_and_download_url() {
979 assert!(state.greeted()); 994 assert!(state.greeted());
980 995
981 // The gate is open now. 996 // The gate is open now.
982 assert_eq!(core.handle_message(&mut state, "PING"), "PONG"); 997 assert!(
998 core.handle_message(&mut state, "PING").starts_with("PONG "),
999 "PING must carry the server time"
1000 );
983} 1001}
984 1002
985#[test] 1003#[test]
@@ -999,7 +1017,10 @@ fn hello_refuses_a_different_major_or_minor() {
999 response.starts_with("ERROR Version mismatch"), 1017 response.starts_with("ERROR Version mismatch"),
1000 "response: {response}" 1018 "response: {response}"
1001 ); 1019 );
1002 assert!(!state.greeted(), "a refused handshake must not open the gate"); 1020 assert!(
1021 !state.greeted(),
1022 "a refused handshake must not open the gate"
1023 );
1003 assert_eq!( 1024 assert_eq!(
1004 core.handle_message(&mut state, "PING"), 1025 core.handle_message(&mut state, "PING"),
1005 "ERROR HELLO required" 1026 "ERROR HELLO required"
@@ -1014,9 +1035,10 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() {
1014 let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1); 1035 let patch = format!("{}.{}.{}", server.major, server.minor, server.patch + 1);
1015 1036
1016 let mut state = ConnectionState::default(); 1037 let mut state = ConnectionState::default();
1017 assert!(core 1038 assert!(
1018 .handle_message(&mut state, &format!("HELLO {patch}")) 1039 core.handle_message(&mut state, &format!("HELLO {patch}"))
1019 .starts_with("HELLO ")); 1040 .starts_with("HELLO ")
1041 );
1020 1042
1021 let mut other = ConnectionState::default(); 1043 let mut other = ConnectionState::default();
1022 assert_eq!( 1044 assert_eq!(
@@ -1028,3 +1050,70 @@ fn hello_accepts_a_patch_difference_and_rejects_bad_input() {
1028 "ERROR Invalid version" 1050 "ERROR Invalid version"
1029 ); 1051 );
1030} 1052}
1053
1054#[test]
1055fn crash_start_commits_to_a_seed_and_the_end_reveals_it() {
1056 use zagan_bet::crash::{crash_point_from_seed, seed_from_hex, verify_seed};
1057
1058 let (mut core, _path, clock) = setup_crash("crash-commit", CRASH_AT_3);
1059 let mut state = ConnectionState::default();
1060 register(&mut core, &mut state, "alice");
1061 login(&mut core, &mut state, "alice");
1062
1063 let started = crash_state(&core.handle_message(&mut state, "PLAY_START crash 10"));
1064 let commit = started.commit.clone().expect("a start reply commits");
1065 assert_eq!(commit.len(), 64, "sha256 hex");
1066 assert_eq!(started.seed, None, "the seed stays hidden while running");
1067 assert_eq!(started.crash_point, None);
1068 assert!(started.started_at > 1_600_000_000_000, "server time anchor");
1069
1070 clock.advance(5.0);
1071 let cashed = crash_state(
1072 &core.handle_message(&mut state, &format!("CRASH_CASHOUT {}", started.session_id)),
1073 );
1074 let seed = cashed.seed.clone().expect("the end reveals the seed");
1075 assert!(verify_seed(&seed, &commit), "the reveal matches the commit");
1076 let bytes = seed_from_hex(&seed).expect("32-byte hex seed");
1077 let point = crash_point_from_seed(&bytes);
1078 assert!(
1079 (point - cashed.crash_point.unwrap()).abs() < 1e-9,
1080 "the client recomputes the same point"
1081 );
1082 assert_eq!(cashed.commit.as_deref(), Some(commit.as_str()));
1083}
1084
1085#[test]
1086fn a_repeated_request_id_replays_its_reply_without_charging_twice() {
1087 use zagan_bet::protocol::Request;
1088
1089 let (mut core, _path) = setup("idempotent-spin", WIN3);
1090 let mut state = ConnectionState::default();
1091 register(&mut core, &mut state, "alice");
1092 login(&mut core, &mut state, "alice");
1093
1094 let request = Request {
1095 id: 41,
1096 idem: Some("spin-1".to_string()),
1097 payload: "PLAY_START slots 10".to_string(),
1098 };
1099 let first = core.handle_request(&mut state, &request);
1100 assert!(first.starts_with("GAME_RESULT"), "first: {first}");
1101 assert_eq!(core.balance("alice"), Some(1000));
1102 assert_eq!(core.play_count("alice"), 1);
1103
1104 let replay = core.handle_request(&mut state, &request);
1105 assert_eq!(replay, first, "the cached reply is returned verbatim");
1106 assert_eq!(core.balance("alice"), Some(1000), "no second debit");
1107 assert_eq!(core.play_count("alice"), 1, "no second audit row");
1108
1109 let different = Request {
1110 id: 41,
1111 idem: Some("spin-2".to_string()),
1112 payload: "PLAY_START slots 20".to_string(),
1113 };
1114 assert_eq!(
1115 core.handle_request(&mut state, &different),
1116 "ERROR Request id reused with a different command"
1117 );
1118 assert_eq!(core.balance("alice"), Some(1000));
1119}
diff --git a/tests/version.rs b/tests/version.rs
index af244db..31bc2b6 100644
--- a/tests/version.rs
+++ b/tests/version.rs
@@ -1,5 +1,6 @@
1use zagan_bet::version::{ 1use zagan_bet::version::{
2 compatible, hello_line, is_newer, parse_hello, Version, VERSION, 2 VERSION, Version, compatible, hello_line, is_newer, parse_hello, parse_pong, pong_line,
3 unix_time_ms,
3}; 4};
4 5
5#[test] 6#[test]
@@ -24,16 +25,16 @@ fn versions_parse_and_reject_nonsense() {
24 Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)), 25 Version::parse("4.0.0-rc1").map(|v| (v.major, v.minor, v.patch)),
25 Some((4, 0, 0)) 26 Some((4, 0, 0))
26 ); 27 );
27 assert_eq!( 28 assert_eq!(Version::parse("4.0.0+build7").map(|v| v.patch), Some(0));
28 Version::parse("4.0.0+build7").map(|v| v.patch),
29 Some(0)
30 );
31 29
32 for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] { 30 for bad in ["", "4", "4.0", "4.0.0.1", "x.0.0", "4.x.0", "4.0.0-"] {
33 assert!(Version::parse(bad).is_none(), "{bad:?} should not parse"); 31 assert!(Version::parse(bad).is_none(), "{bad:?} should not parse");
34 } 32 }
35 33
36 assert!(Version::parse(VERSION).is_some(), "the build version must parse"); 34 assert!(
35 Version::parse(VERSION).is_some(),
36 "the build version must parse"
37 );
37} 38}
38 39
39#[test] 40#[test]
@@ -57,19 +58,45 @@ fn newer_versions_are_ordered_by_major_minor_patch() {
57 58
58#[test] 59#[test]
59fn the_hello_reply_round_trips() { 60fn the_hello_reply_round_trips() {
60 let line = hello_line("4.0.0", "4.1.2", "https://zagan.example/"); 61 let line = hello_line(
62 "4.0.0",
63 "4.1.2",
64 "https://zagan.example/",
65 1_700_000_000_123,
66 );
61 let reply = parse_hello(&line).expect("parse a generated reply"); 67 let reply = parse_hello(&line).expect("parse a generated reply");
62 68
63 assert_eq!(reply.version, "4.0.0"); 69 assert_eq!(reply.version, "4.0.0");
64 assert_eq!(reply.latest, "4.1.2"); 70 assert_eq!(reply.latest, "4.1.2");
65 assert_eq!(reply.url, "https://zagan.example/"); 71 assert_eq!(reply.url, "https://zagan.example/");
72 assert_eq!(reply.time, 1_700_000_000_123);
66 73
67 assert!(parse_hello("HELLO 4.0.0 url=https://x/").is_none(), "latest is required"); 74 assert!(
68 assert!(parse_hello("HELLO 4.0.0 latest=4.0.0").is_none(), "url is required"); 75 parse_hello("HELLO 4.0.0 url=https://x/ time=1").is_none(),
76 "latest is required"
77 );
78 assert!(
79 parse_hello("HELLO 4.0.0 latest=4.0.0 time=1").is_none(),
80 "url is required"
81 );
82 assert!(
83 parse_hello("HELLO 4.0.0 latest=4.0.0 url=https://x/").is_none(),
84 "time is required"
85 );
69 assert!(parse_hello("PONG").is_none()); 86 assert!(parse_hello("PONG").is_none());
70 assert!(parse_hello("HELLO").is_none()); 87 assert!(parse_hello("HELLO").is_none());
71} 88}
72 89
90#[test]
91fn pong_carries_the_server_time() {
92 let line = pong_line(1_700_000_000_456);
93 assert_eq!(line, "PONG 1700000000456");
94 assert_eq!(parse_pong(&line), Some(1_700_000_000_456));
95 assert!(parse_pong("PONG").is_none());
96 assert!(parse_pong("RES 1 PONG 1700000000456").is_none());
97 assert!(unix_time_ms() > 1_600_000_000_000);
98}
99
73/// The GUI is a client, so it must ship in lockstep with the TUI and server. 100/// The GUI is a client, so it must ship in lockstep with the TUI and server.
74/// Keep `gui/Cargo.toml` at the same version as the root package. 101/// Keep `gui/Cargo.toml` at the same version as the root package.
75#[test] 102#[test]
@@ -83,6 +110,18 @@ fn the_gui_member_uses_the_same_version() {
83 assert_eq!(gui_version, VERSION); 110 assert_eq!(gui_version, VERSION);
84} 111}
85 112
113/// The gateway is a frontend too; keep it in lockstep with the core.
114#[test]
115fn the_webd_member_uses_the_same_version() {
116 let manifest = include_str!("../webd/Cargo.toml");
117 let version = manifest
118 .lines()
119 .find_map(|line| line.trim().strip_prefix("version = "))
120 .expect("webd/Cargo.toml declares a version")
121 .trim_matches('"');
122 assert_eq!(version, VERSION);
123}
124
86/// egui's dependency tree must never enter the TUI or server build graph. 125/// egui's dependency tree must never enter the TUI or server build graph.
87/// The GUI is a workspace member that depends on this package, never the 126/// The GUI is a workspace member that depends on this package, never the
88/// other way round. 127/// other way round.
diff --git a/tests/web_types.rs b/tests/web_types.rs
new file mode 100644
index 0000000..21af474
--- /dev/null
+++ b/tests/web_types.rs
@@ -0,0 +1,138 @@
1//! The browser and gateway exchange serde JSON for `Command`/`View`. These
2//! tests pin the wire shape the TypeScript app renders.
3
4use zagan_bet::client::games::roulette::{Cursor, Direction};
5use zagan_bet::client::view::{
6 BlackjackView, Command, CrashView, HistoryView, LobbyView, RoulettePhase, RouletteView,
7 SlotsPhase, SlotsView, View,
8};
9use zagan_bet::roulette::{BetTarget, Bets, Outside};
10
11#[test]
12fn browser_commands_deserialize() {
13 let login: Command =
14 serde_json::from_str(r#"{"Login":{"user":"alice","credential":"abc"}}"#).unwrap();
15 assert_eq!(
16 login,
17 Command::Login {
18 user: "alice".to_string(),
19 credential: "abc".to_string()
20 }
21 );
22
23 let play: Command = serde_json::from_str(r#"{"Play":{"game":"crash","bet":25}}"#).unwrap();
24 assert_eq!(
25 play,
26 Command::Play {
27 game: "crash".to_string(),
28 bet: Some(25)
29 }
30 );
31
32 let bet: Command =
33 serde_json::from_str(r#"{"PlaceBetAt":{"bet":"outside","kind":"red"}}"#).unwrap();
34 assert_eq!(
35 bet,
36 Command::PlaceBetAt(BetTarget::Outside { kind: Outside::Red })
37 );
38
39 let split: Command =
40 serde_json::from_str(r#"{"PlaceBetAt":{"bet":"split","a":1,"b":2}}"#).unwrap();
41 assert_eq!(split, Command::PlaceBetAt(BetTarget::Split { a: 1, b: 2 }));
42
43 assert_eq!(
44 serde_json::from_str::<Command>(r#""Spin""#).unwrap(),
45 Command::Spin
46 );
47 assert_eq!(
48 serde_json::from_str::<Command>(r#""ShowLobby""#).unwrap(),
49 Command::ShowLobby
50 );
51 assert_eq!(
52 serde_json::from_str::<Command>(r#"{"SetBet":50}"#).unwrap(),
53 Command::SetBet(50)
54 );
55 assert_eq!(
56 serde_json::from_str::<Command>(r#"{"MoveCursor":"Left"}"#).unwrap(),
57 Command::MoveCursor(Direction::Left)
58 );
59}
60
61#[test]
62fn views_serialize_with_a_screen_tag() {
63 let lobby = View::Lobby(LobbyView {
64 connected: true,
65 user: Some("alice".to_string()),
66 account: Some("1".to_string()),
67 balance: Some(1000),
68 games: vec![("slots".to_string(), "Slots".to_string())],
69 login_error: None,
70 });
71 let json = serde_json::to_value(&lobby).unwrap();
72 assert_eq!(json["screen"], "lobby");
73 assert_eq!(json["user"], "alice");
74
75 let slots = View::Slots(SlotsView {
76 bet: 10,
77 balance: Some(990),
78 reels: "CLOGD".chars().collect(),
79 win_cells: vec![0],
80 win_lines: vec![1],
81 win_type: "win".to_string(),
82 winnings: Some(20),
83 phase: SlotsPhase::Result,
84 error: None,
85 });
86 let json = serde_json::to_value(&slots).unwrap();
87 assert_eq!(json["screen"], "slots");
88 assert_eq!(json["phase"], "result");
89 assert_eq!(json["reels"][0], "C");
90
91 let roulette = View::Roulette(RouletteView {
92 bet_amount: 10,
93 balance: Some(1000),
94 chip: 25,
95 bets: Bets::default(),
96 cursor: Cursor::default(),
97 phase: RoulettePhase::Placing,
98 number: None,
99 color: None,
100 winnings: None,
101 error: None,
102 });
103 let json = serde_json::to_value(&roulette).unwrap();
104 assert_eq!(json["screen"], "roulette");
105 assert_eq!(json["phase"], "placing");
106
107 let blackjack = View::Blackjack(BlackjackView {
108 status: "ready".to_string(),
109 balance: Some(1000),
110 bet: 10,
111 hand: None,
112 error: None,
113 });
114 assert_eq!(
115 serde_json::to_value(&blackjack).unwrap()["screen"],
116 "blackjack"
117 );
118
119 let crash = View::Crash(CrashView {
120 status: "running".to_string(),
121 balance: Some(990),
122 bet: 10,
123 multiplier: 1.5,
124 potential: 15,
125 crash_point: None,
126 payout: 0,
127 started_at: 1_700_000_000_000,
128 commit: Some("ab".repeat(32)),
129 seed: None,
130 error: None,
131 });
132 let json = serde_json::to_value(&crash).unwrap();
133 assert_eq!(json["screen"], "crash");
134 assert_eq!(json["commit"], "ab".repeat(32));
135
136 let history = View::History(HistoryView { records: vec![] });
137 assert_eq!(serde_json::to_value(&history).unwrap()["screen"], "history");
138}