diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-08 03:29:35 +0100 |
| commit | 058432130628d00764663ecf819a9adc6793768d (patch) | |
| tree | d3f49f111fbfaa48647cca2600adcf9017af2d30 /tests | |
| parent | web: serve latest.php through PHP and refuse stray PHP files (diff) | |
| download | zagan-bet-058432130628d00764663ecf819a9adc6793768d.tar.gz zagan-bet-058432130628d00764663ecf819a9adc6793768d.zip | |
v5.0.0: protocol v5 and the browser frontend
Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point.
Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials.
Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph.
The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/client_core.rs | 151 | ||||
| -rw-r--r-- | tests/crash.rs | 39 | ||||
| -rw-r--r-- | tests/protocol.rs | 150 | ||||
| -rw-r--r-- | tests/server_core.rs | 121 | ||||
| -rw-r--r-- | tests/version.rs | 57 | ||||
| -rw-r--r-- | tests/web_types.rs | 138 |
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 @@ | |||
| 7 | use std::time::Duration; | 7 | use std::time::Duration; |
| 8 | use zagan_bet::blackjack::BlackjackState; | 8 | use zagan_bet::blackjack::BlackjackState; |
| 9 | use zagan_bet::client::core::{ClientCore, Effect}; | 9 | use zagan_bet::client::core::{ClientCore, Effect}; |
| 10 | use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; | ||
| 11 | use zagan_bet::client::shell::{self, Shell}; | ||
| 12 | use zagan_bet::client::ui::help_lines; | ||
| 13 | use zagan_bet::client::games::response::{ | 10 | use 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 | }; |
| 16 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; |
| 14 | use zagan_bet::client::input::{Editor, GameInput, Input, Key, game_input}; | ||
| 15 | use zagan_bet::client::shell::{self, Shell}; | ||
| 17 | use zagan_bet::client::ui::command_names; | 16 | use zagan_bet::client::ui::command_names; |
| 17 | use zagan_bet::client::ui::help_lines; | ||
| 18 | use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; | 18 | use zagan_bet::client::view::{Command, RoulettePhase, SlotsPhase, View}; |
| 19 | use zagan_bet::roulette::BetTarget; | ||
| 20 | use zagan_bet::crash::{CrashState, multiplier_at}; | 19 | use zagan_bet::crash::{CrashState, multiplier_at}; |
| 20 | use zagan_bet::roulette::BetTarget; | ||
| 21 | 21 | ||
| 22 | const WIN3: &str = "CCCCLLCLODGSDGS"; | 22 | const WIN3: &str = "CCCCLLCLODGSDGS"; |
| 23 | const LOSS: &str = "CLOOGDSLCDCOGLS"; | 23 | const 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] | ||
| 1661 | fn 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] | ||
| 1696 | fn 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] | ||
| 107 | fn 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 @@ | |||
| 1 | use std::io::{Cursor, Error, ErrorKind}; | 1 | use std::io::Cursor; |
| 2 | use 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 | ||
| 3 | use zagan_bet::protocol::{io_failure_message, LineBuffer}; | 7 | fn framed(payload: &str) -> Vec<u8> { |
| 8 | encode_frame(payload) | ||
| 9 | } | ||
| 4 | 10 | ||
| 5 | #[test] | 11 | #[test] |
| 6 | fn keeps_data_after_the_first_newline() { | 12 | fn 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] |
| 19 | fn handles_crlf_and_empty_lines() { | 31 | fn 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] |
| 29 | fn waits_for_a_split_line() { | 45 | fn 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")); | 58 | fn 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] |
| 39 | fn reads_multiple_lines_from_a_reader() { | 66 | fn 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] |
| 55 | fn returns_a_partial_line_at_eof() { | 84 | fn 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] |
| 67 | fn timeout_errors_get_a_timeout_message() { | 110 | fn 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] | ||
| 130 | fn 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 | ||
| 103 | fn hello(core: &mut ServerCore, state: &mut ConnectionState) { | 103 | fn 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 | ||
| 111 | fn register(core: &mut ServerCore, state: &mut ConnectionState, user: &str) { | 111 | fn 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] | ||
| 1055 | fn 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] | ||
| 1086 | fn 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 @@ | |||
| 1 | use zagan_bet::version::{ | 1 | use 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] |
| 59 | fn the_hello_reply_round_trips() { | 60 | fn 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] | ||
| 91 | fn 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] | ||
| 115 | fn 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 | |||
| 4 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | ||
| 5 | use zagan_bet::client::view::{ | ||
| 6 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RoulettePhase, RouletteView, | ||
| 7 | SlotsPhase, SlotsView, View, | ||
| 8 | }; | ||
| 9 | use zagan_bet::roulette::{BetTarget, Bets, Outside}; | ||
| 10 | |||
| 11 | #[test] | ||
| 12 | fn 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] | ||
| 62 | fn 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 | } | ||
