diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 18:08:38 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 18:08:38 +0100 |
| commit | 50fdaa8e5ad9454e783f01e2c369767834120d11 (patch) | |
| tree | b68fabbb7a8750a363cf41c1301acd0903cd0c73 | |
| parent | v4.0.2: port slots, blackjack and crash to structured views (diff) | |
| download | zagan-bet-50fdaa8e5ad9454e783f01e2c369767834120d11.tar.gz zagan-bet-50fdaa8e5ad9454e783f01e2c369767834120d11.zip | |
v4.0.3: lobby and history screens behind commands
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 6 | ||||
| -rw-r--r-- | src/client/core.rs | 151 | ||||
| -rw-r--r-- | src/client/tui.rs | 2 | ||||
| -rw-r--r-- | src/client/view.rs | 21 | ||||
| -rw-r--r-- | tests/client_core.rs | 80 |
6 files changed, 222 insertions, 40 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "4.0.2" | 3 | version = "4.0.3" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 5 | license = "GPL-3.0-or-later" |
| 6 | 6 | ||
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md index 83bab1c..c3d9b3f 100644 --- a/docs/GUI-PLAN.md +++ b/docs/GUI-PLAN.md | |||
| @@ -28,8 +28,10 @@ existing game flows unchanged, new tests for version parsing and gating. | |||
| 28 | 28 | ||
| 29 | **In progress.** `Command`/`View` exist and all four games are ported: the | 29 | **In progress.** `Command`/`View` exist and all four games are ported: the |
| 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. | 30 | core builds a typed view, frontends send `Command`, and the TUI renders it. |
| 31 | Remaining for this phase: the app screens (`Connect`, `Trust`, `Login`, | 31 | Done so far: `Lobby` and `History` are structured views driven by |
| 32 | `Lobby`, `History`) and removing `Key` from the core. | 32 | `Command::Play`, `History`, `ShowLobby` and `Logout`. Remaining: the |
| 33 | connection-facing screens (`Connect`, `Trust`, `TrustMismatch`, `Login`) with | ||
| 34 | the transport moved into the library, and removing `Key` from the core. | ||
| 33 | 35 | ||
| 34 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, | 36 | - Introduce `Command` and `View`; move terminal-only concerns (prompt editing, |
| 35 | wheel animation, interactive prompts) into the TUI adapter. | 37 | wheel animation, interactive prompts) into the TUI adapter. |
diff --git a/src/client/core.rs b/src/client/core.rs index 3e3126a..37ba916 100644 --- a/src/client/core.rs +++ b/src/client/core.rs | |||
| @@ -4,13 +4,15 @@ use crate::client::games::response::{ | |||
| 4 | parse_roulette_response, parse_spin_response, | 4 | parse_roulette_response, parse_spin_response, |
| 5 | }; | 5 | }; |
| 6 | use crate::client::games::roulette::Direction; | 6 | use crate::client::games::roulette::Direction; |
| 7 | use crate::client::view::{BlackjackView, Command, CrashView, RouletteView, SlotsView, View}; | 7 | use crate::client::view::{ |
| 8 | BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, | ||
| 9 | }; | ||
| 8 | use crate::client::ui::{ | 10 | use crate::client::ui::{ |
| 9 | command_names, completion_lines, display_width, help_lines, history_lines, | 11 | command_names, completion_lines, display_width, help_lines, history_lines, |
| 10 | input_cursor_column, parse_bet_arg, record_history, | 12 | input_cursor_column, parse_bet_arg, record_history, |
| 11 | }; | 13 | }; |
| 12 | use crate::crash::{CrashState, multiplier_at}; | 14 | use crate::crash::{CrashState, multiplier_at}; |
| 13 | use crate::history::MAX_HISTORY; | 15 | use crate::history::{PlayRecord, MAX_HISTORY}; |
| 14 | use crate::roulette::{BetTarget, Bets}; | 16 | use crate::roulette::{BetTarget, Bets}; |
| 15 | use std::time::{Duration, Instant}; | 17 | use std::time::{Duration, Instant}; |
| 16 | 18 | ||
| @@ -80,6 +82,12 @@ enum Pending { | |||
| 80 | Info, | 82 | Info, |
| 81 | } | 83 | } |
| 82 | 84 | ||
| 85 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 86 | enum Screen { | ||
| 87 | Lobby, | ||
| 88 | History, | ||
| 89 | } | ||
| 90 | |||
| 83 | pub struct ClientCore { | 91 | pub struct ClientCore { |
| 84 | input: String, | 92 | input: String, |
| 85 | history: Vec<String>, | 93 | history: Vec<String>, |
| @@ -93,6 +101,10 @@ pub struct ClientCore { | |||
| 93 | fingerprint: Option<String>, | 101 | fingerprint: Option<String>, |
| 94 | /// Local anchor for the crash multiplier, set when a round starts. | 102 | /// Local anchor for the crash multiplier, set when a round starts. |
| 95 | crash_started: Option<Instant>, | 103 | crash_started: Option<Instant>, |
| 104 | /// App screen shown when no game is open. | ||
| 105 | screen: Screen, | ||
| 106 | /// Last plays fetched with `history`. | ||
| 107 | play_history: Vec<PlayRecord>, | ||
| 96 | } | 108 | } |
| 97 | 109 | ||
| 98 | impl ClientCore { | 110 | impl ClientCore { |
| @@ -109,6 +121,8 @@ impl ClientCore { | |||
| 109 | host: None, | 121 | host: None, |
| 110 | fingerprint: None, | 122 | fingerprint: None, |
| 111 | crash_started: None, | 123 | crash_started: None, |
| 124 | screen: Screen::Lobby, | ||
| 125 | play_history: Vec::new(), | ||
| 112 | } | 126 | } |
| 113 | } | 127 | } |
| 114 | 128 | ||
| @@ -118,6 +132,7 @@ impl ClientCore { | |||
| 118 | self.logged_in = None; | 132 | self.logged_in = None; |
| 119 | self.pending = None; | 133 | self.pending = None; |
| 120 | self.crash_started = None; | 134 | self.crash_started = None; |
| 135 | self.screen = Screen::Lobby; | ||
| 121 | } | 136 | } |
| 122 | } | 137 | } |
| 123 | 138 | ||
| @@ -291,7 +306,10 @@ impl ClientCore { | |||
| 291 | } | 306 | } |
| 292 | }, | 307 | }, |
| 293 | Some(Pending::History) => match parse_history_response(reply) { | 308 | Some(Pending::History) => match parse_history_response(reply) { |
| 294 | Ok(records) => vec![Effect::Print(history_lines(&records))], | 309 | Ok(records) => { |
| 310 | self.play_history = records.clone(); | ||
| 311 | vec![Effect::Print(history_lines(&records))] | ||
| 312 | } | ||
| 295 | Err(message) => print_line(message), | 313 | Err(message) => print_line(message), |
| 296 | }, | 314 | }, |
| 297 | Some(Pending::Ping) => { | 315 | Some(Pending::Ping) => { |
| @@ -386,6 +404,17 @@ impl ClientCore { | |||
| 386 | let game = self.current_game_type().map(str::to_string); | 404 | let game = self.current_game_type().map(str::to_string); |
| 387 | 405 | ||
| 388 | match command { | 406 | match command { |
| 407 | Command::Play { game, bet } => self.play_game(&game, bet), | ||
| 408 | Command::History { limit } => self.load_history(limit), | ||
| 409 | Command::ShowLobby => { | ||
| 410 | self.screen = Screen::Lobby; | ||
| 411 | Vec::new() | ||
| 412 | } | ||
| 413 | Command::Logout => { | ||
| 414 | self.logged_in = None; | ||
| 415 | self.screen = Screen::Lobby; | ||
| 416 | print_line("Logged out") | ||
| 417 | } | ||
| 389 | Command::SetBet(bet) => { | 418 | Command::SetBet(bet) => { |
| 390 | if game.as_deref() == Some("roulette") { | 419 | if game.as_deref() == Some("roulette") { |
| 391 | return self.command(Command::SetChip(bet)); | 420 | return self.command(Command::SetChip(bet)); |
| @@ -479,6 +508,7 @@ impl ClientCore { | |||
| 479 | 508 | ||
| 480 | fn exit_game(&mut self) -> Vec<Effect> { | 509 | fn exit_game(&mut self) -> Vec<Effect> { |
| 481 | self.crash_started = None; | 510 | self.crash_started = None; |
| 511 | self.screen = Screen::Lobby; | ||
| 482 | self.game.end_game(); | 512 | self.game.end_game(); |
| 483 | vec![ | 513 | vec![ |
| 484 | Effect::ClearRegion, | 514 | Effect::ClearRegion, |
| @@ -850,30 +880,18 @@ impl ClientCore { | |||
| 850 | } | 880 | } |
| 851 | } | 881 | } |
| 852 | 882 | ||
| 853 | fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> { | 883 | fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { |
| 854 | if !self.connected { | 884 | if !self.connected { |
| 855 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | 885 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); |
| 856 | } | 886 | } |
| 857 | if self.logged_in.is_none() { | 887 | if self.logged_in.is_none() { |
| 858 | return print_line("Not logged in. Use 'login' first."); | 888 | return print_line("Not logged in. Use 'login' first."); |
| 859 | } | 889 | } |
| 860 | if parts.len() < 2 { | ||
| 861 | return self.available_games(Some("Usage: play <game> [bet]")); | ||
| 862 | } | ||
| 863 | |||
| 864 | let bet = match parts.get(2) { | ||
| 865 | Some(raw) => match parse_bet_arg(raw) { | ||
| 866 | Ok(bet) => Some(bet), | ||
| 867 | Err(message) => return print_line(message), | ||
| 868 | }, | ||
| 869 | None => None, | ||
| 870 | }; | ||
| 871 | |||
| 872 | if self.game.is_in_game() { | 890 | if self.game.is_in_game() { |
| 873 | return print_line("Already in a game. Use 'quit' to exit current game."); | 891 | return print_line("Already in a game. Use 'quit' to exit current game."); |
| 874 | } | 892 | } |
| 875 | 893 | ||
| 876 | match self.game.enter_game(parts[1]) { | 894 | match self.game.enter_game(game_type) { |
| 877 | Ok(()) => { | 895 | Ok(()) => { |
| 878 | if let Some(bet) = bet { | 896 | if let Some(bet) = bet { |
| 879 | let _ = self.game.set_bet(bet); | 897 | let _ = self.game.set_bet(bet); |
| @@ -891,6 +909,52 @@ impl ClientCore { | |||
| 891 | } | 909 | } |
| 892 | } | 910 | } |
| 893 | 911 | ||
| 912 | fn load_history(&mut self, limit: u32) -> Vec<Effect> { | ||
| 913 | if !self.connected { | ||
| 914 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 915 | } | ||
| 916 | if self.logged_in.is_none() { | ||
| 917 | return print_line("Not logged in. Use 'login' first."); | ||
| 918 | } | ||
| 919 | if !(1..=MAX_HISTORY).contains(&limit) { | ||
| 920 | return print_line(format!("Usage: history [1-{MAX_HISTORY}]")); | ||
| 921 | } | ||
| 922 | |||
| 923 | self.screen = Screen::History; | ||
| 924 | self.pending = Some(Pending::History); | ||
| 925 | let request = if limit == crate::history::DEFAULT_HISTORY { | ||
| 926 | "HISTORY".to_string() | ||
| 927 | } else { | ||
| 928 | format!("HISTORY {limit}") | ||
| 929 | }; | ||
| 930 | vec![Effect::SendLine(request)] | ||
| 931 | } | ||
| 932 | |||
| 933 | fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> { | ||
| 934 | if !self.connected { | ||
| 935 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 936 | } | ||
| 937 | if self.logged_in.is_none() { | ||
| 938 | return print_line("Not logged in. Use 'login' first."); | ||
| 939 | } | ||
| 940 | if parts.len() < 2 { | ||
| 941 | return self.available_games(Some("Usage: play <game> [bet]")); | ||
| 942 | } | ||
| 943 | |||
| 944 | let bet = match parts.get(2) { | ||
| 945 | Some(raw) => match parse_bet_arg(raw) { | ||
| 946 | Ok(bet) => Some(bet), | ||
| 947 | Err(message) => return print_line(message), | ||
| 948 | }, | ||
| 949 | None => None, | ||
| 950 | }; | ||
| 951 | |||
| 952 | self.command(Command::Play { | ||
| 953 | game: parts[1].to_string(), | ||
| 954 | bet, | ||
| 955 | }) | ||
| 956 | } | ||
| 957 | |||
| 894 | fn available_games(&self, usage: Option<&str>) -> Vec<Effect> { | 958 | fn available_games(&self, usage: Option<&str>) -> Vec<Effect> { |
| 895 | let mut lines = vec!["Available games:".to_string()]; | 959 | let mut lines = vec!["Available games:".to_string()]; |
| 896 | for (game_type, name) in self.game.get_available_games() { | 960 | for (game_type, name) in self.game.get_available_games() { |
| @@ -904,23 +968,15 @@ impl ClientCore { | |||
| 904 | } | 968 | } |
| 905 | 969 | ||
| 906 | fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> { | 970 | fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> { |
| 907 | if !self.connected { | 971 | let limit = match parts.get(1) { |
| 908 | return print_line("Not connected. Use 'connect <ip>:<port>' first."); | ||
| 909 | } | ||
| 910 | if self.logged_in.is_none() { | ||
| 911 | return print_line("Not logged in. Use 'login' first."); | ||
| 912 | } | ||
| 913 | |||
| 914 | let request = match parts.get(1) { | ||
| 915 | Some(raw) => match raw.parse::<u32>() { | 972 | Some(raw) => match raw.parse::<u32>() { |
| 916 | Ok(count) if (1..=MAX_HISTORY).contains(&count) => format!("HISTORY {count}"), | 973 | Ok(count) => count, |
| 917 | _ => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), | 974 | Err(_) => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")), |
| 918 | }, | 975 | }, |
| 919 | None => "HISTORY".to_string(), | 976 | None => crate::history::DEFAULT_HISTORY, |
| 920 | }; | 977 | }; |
| 921 | 978 | ||
| 922 | self.pending = Some(Pending::History); | 979 | self.command(Command::History { limit }) |
| 923 | vec![Effect::SendLine(request)] | ||
| 924 | } | 980 | } |
| 925 | 981 | ||
| 926 | fn cmd_info(&mut self) -> Vec<Effect> { | 982 | fn cmd_info(&mut self) -> Vec<Effect> { |
| @@ -990,17 +1046,38 @@ impl ClientCore { | |||
| 990 | 1046 | ||
| 991 | /// Snapshot of the current screen for a frontend to draw. | 1047 | /// Snapshot of the current screen for a frontend to draw. |
| 992 | pub fn view(&self) -> Option<View> { | 1048 | pub fn view(&self) -> Option<View> { |
| 993 | let state = self.game.get_current_state()?; | 1049 | if let Some(state) = self.game.get_current_state() { |
| 994 | Some(match state.game_type.as_str() { | 1050 | return Some(match state.game_type.as_str() { |
| 995 | "slots" => View::Slots(SlotsView::from_state(state)), | 1051 | "slots" => View::Slots(SlotsView::from_state(state)), |
| 996 | "roulette" => View::Roulette(RouletteView::from_state(state)), | 1052 | "roulette" => View::Roulette(RouletteView::from_state(state)), |
| 997 | "blackjack" => View::Blackjack(BlackjackView::from_state(state)), | 1053 | "blackjack" => View::Blackjack(BlackjackView::from_state(state)), |
| 998 | "crash" => View::Crash(CrashView::from_state(state)), | 1054 | "crash" => View::Crash(CrashView::from_state(state)), |
| 999 | _ => return None, | 1055 | _ => return None, |
| 1056 | }); | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | Some(match self.screen { | ||
| 1060 | Screen::History => View::History(HistoryView { | ||
| 1061 | records: self.play_history.clone(), | ||
| 1062 | }), | ||
| 1063 | Screen::Lobby => View::Lobby(LobbyView { | ||
| 1064 | connected: self.connected, | ||
| 1065 | user: self.logged_in.clone(), | ||
| 1066 | balance: self.balance, | ||
| 1067 | games: self | ||
| 1068 | .game | ||
| 1069 | .get_available_games() | ||
| 1070 | .into_iter() | ||
| 1071 | .map(|(game, name)| (game.to_string(), name.to_string())) | ||
| 1072 | .collect(), | ||
| 1073 | }), | ||
| 1000 | }) | 1074 | }) |
| 1001 | } | 1075 | } |
| 1002 | 1076 | ||
| 1003 | fn board_effects(&self) -> Vec<Effect> { | 1077 | fn board_effects(&self) -> Vec<Effect> { |
| 1078 | if !self.is_in_game() { | ||
| 1079 | return Vec::new(); | ||
| 1080 | } | ||
| 1004 | self.view().map(Effect::Render).into_iter().collect() | 1081 | self.view().map(Effect::Render).into_iter().collect() |
| 1005 | } | 1082 | } |
| 1006 | 1083 | ||
diff --git a/src/client/tui.rs b/src/client/tui.rs index ddea5e2..9853edf 100644 --- a/src/client/tui.rs +++ b/src/client/tui.rs | |||
| @@ -16,6 +16,8 @@ pub fn render_view(view: &View) -> Vec<String> { | |||
| 16 | View::Roulette(roulette) => RouletteGame::render_view(roulette), | 16 | View::Roulette(roulette) => RouletteGame::render_view(roulette), |
| 17 | View::Blackjack(blackjack) => BlackjackGame::render_view(blackjack), | 17 | View::Blackjack(blackjack) => BlackjackGame::render_view(blackjack), |
| 18 | View::Crash(crash) => CrashGame::render_view(crash), | 18 | View::Crash(crash) => CrashGame::render_view(crash), |
| 19 | // The terminal adapter keeps its command prompt for these screens. | ||
| 20 | View::Lobby(_) | View::History(_) => String::new(), | ||
| 19 | }; | 21 | }; |
| 20 | rendered.lines().map(str::to_string).collect() | 22 | rendered.lines().map(str::to_string).collect() |
| 21 | } | 23 | } |
diff --git a/src/client/view.rs b/src/client/view.rs index 77efec6..903403c 100644 --- a/src/client/view.rs +++ b/src/client/view.rs | |||
| @@ -241,9 +241,26 @@ impl CrashView { | |||
| 241 | } | 241 | } |
| 242 | } | 242 | } |
| 243 | 243 | ||
| 244 | /// The lobby: who is logged in, the balance, and what can be played. | ||
| 245 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 246 | pub struct LobbyView { | ||
| 247 | pub connected: bool, | ||
| 248 | pub user: Option<String>, | ||
| 249 | pub balance: Option<i64>, | ||
| 250 | pub games: Vec<(String, String)>, | ||
| 251 | } | ||
| 252 | |||
| 253 | /// The play log, already parsed. | ||
| 254 | #[derive(Debug, Clone, PartialEq)] | ||
| 255 | pub struct HistoryView { | ||
| 256 | pub records: Vec<crate::history::PlayRecord>, | ||
| 257 | } | ||
| 258 | |||
| 244 | /// Everything a frontend can draw. | 259 | /// Everything a frontend can draw. |
| 245 | #[derive(Debug, Clone, PartialEq)] | 260 | #[derive(Debug, Clone, PartialEq)] |
| 246 | pub enum View { | 261 | pub enum View { |
| 262 | Lobby(LobbyView), | ||
| 263 | History(HistoryView), | ||
| 247 | Slots(SlotsView), | 264 | Slots(SlotsView), |
| 248 | Roulette(RouletteView), | 265 | Roulette(RouletteView), |
| 249 | Blackjack(BlackjackView), | 266 | Blackjack(BlackjackView), |
| @@ -253,6 +270,10 @@ pub enum View { | |||
| 253 | /// Semantic input. Frontends turn keys and clicks into these. | 270 | /// Semantic input. Frontends turn keys and clicks into these. |
| 254 | #[derive(Debug, Clone, PartialEq, Eq)] | 271 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 255 | pub enum Command { | 272 | pub enum Command { |
| 273 | Play { game: String, bet: Option<u64> }, | ||
| 274 | History { limit: u32 }, | ||
| 275 | ShowLobby, | ||
| 276 | Logout, | ||
| 256 | SetBet(u64), | 277 | SetBet(u64), |
| 257 | AdjustBet(i64), | 278 | AdjustBet(i64), |
| 258 | Leave, | 279 | Leave, |
diff --git a/tests/client_core.rs b/tests/client_core.rs index 0a2cb7b..d070a5b 100644 --- a/tests/client_core.rs +++ b/tests/client_core.rs | |||
| @@ -1407,3 +1407,83 @@ fn the_crash_view_follows_the_round() { | |||
| 1407 | assert!((view.multiplier - 2.0).abs() < 1e-6); | 1407 | assert!((view.multiplier - 2.0).abs() < 1e-6); |
| 1408 | assert_eq!(view.potential, 20); | 1408 | assert_eq!(view.potential, 20); |
| 1409 | } | 1409 | } |
| 1410 | |||
| 1411 | #[test] | ||
| 1412 | fn the_lobby_view_lists_the_games_and_the_account() { | ||
| 1413 | let mut h = Harness::new(); | ||
| 1414 | h.core.set_balance(1000); | ||
| 1415 | |||
| 1416 | let Some(View::Lobby(lobby)) = h.core.view() else { | ||
| 1417 | panic!("expected the lobby"); | ||
| 1418 | }; | ||
| 1419 | assert!(lobby.connected); | ||
| 1420 | assert_eq!(lobby.user.as_deref(), Some("alice")); | ||
| 1421 | assert_eq!(lobby.balance, Some(1000)); | ||
| 1422 | |||
| 1423 | let games: Vec<&str> = lobby.games.iter().map(|(game, _)| game.as_str()).collect(); | ||
| 1424 | assert_eq!(games, vec!["blackjack", "crash", "roulette", "slots"]); | ||
| 1425 | } | ||
| 1426 | |||
| 1427 | #[test] | ||
| 1428 | fn a_play_command_opens_the_game_view() { | ||
| 1429 | let mut h = Harness::new(); | ||
| 1430 | |||
| 1431 | let effects = h.core.command(Command::Play { | ||
| 1432 | game: "slots".to_string(), | ||
| 1433 | bet: Some(25), | ||
| 1434 | }); | ||
| 1435 | h.apply(effects); | ||
| 1436 | |||
| 1437 | let Some(View::Slots(view)) = h.core.view() else { | ||
| 1438 | panic!("expected a slots view"); | ||
| 1439 | }; | ||
| 1440 | assert_eq!(view.bet, 25); | ||
| 1441 | assert!(h.sent.iter().any(|line| line == "INFO")); | ||
| 1442 | assert_eq!(h.board().len(), 10); | ||
| 1443 | } | ||
| 1444 | |||
| 1445 | #[test] | ||
| 1446 | fn history_is_a_screen_and_keeps_its_records() { | ||
| 1447 | let mut h = Harness::new(); | ||
| 1448 | |||
| 1449 | let effects = h.core.command(Command::History { limit: 5 }); | ||
| 1450 | h.apply(effects); | ||
| 1451 | assert_eq!(h.sent.last().map(String::as_str), Some("HISTORY 5")); | ||
| 1452 | |||
| 1453 | let json = r#"[{"game":"crash","bet":10,"winnings":11,"balance_after":1011,"at":"2026-11-01 12:00:00","details":{"result":"cashed","multiplier":1.13}}]"#; | ||
| 1454 | h.reply(&format!("HISTORY {json}")); | ||
| 1455 | |||
| 1456 | let Some(View::History(view)) = h.core.view() else { | ||
| 1457 | panic!("expected the history screen"); | ||
| 1458 | }; | ||
| 1459 | assert_eq!(view.records.len(), 1); | ||
| 1460 | assert_eq!(view.records[0].game, "crash"); | ||
| 1461 | assert_eq!(view.records[0].summary(), "cashed 1.13x"); | ||
| 1462 | |||
| 1463 | // The terminal adapter still prints the table. | ||
| 1464 | assert!(h | ||
| 1465 | .screen | ||
| 1466 | .lines | ||
| 1467 | .iter() | ||
| 1468 | .any(|line| line.starts_with("Recent plays (1)"))); | ||
| 1469 | } | ||
| 1470 | |||
| 1471 | #[test] | ||
| 1472 | fn logging_out_and_showing_the_lobby_switch_the_screen() { | ||
| 1473 | let mut h = Harness::new(); | ||
| 1474 | h.core.command(Command::History { limit: 5 }); | ||
| 1475 | assert!(matches!(h.core.view(), Some(View::History(_)))); | ||
| 1476 | |||
| 1477 | h.core.command(Command::ShowLobby); | ||
| 1478 | let Some(View::Lobby(lobby)) = h.core.view() else { | ||
| 1479 | panic!("expected the lobby"); | ||
| 1480 | }; | ||
| 1481 | assert_eq!(lobby.user.as_deref(), Some("alice")); | ||
| 1482 | |||
| 1483 | let effects = h.core.command(Command::Logout); | ||
| 1484 | h.apply(effects); | ||
| 1485 | let Some(View::Lobby(lobby)) = h.core.view() else { | ||
| 1486 | panic!("expected the lobby"); | ||
| 1487 | }; | ||
| 1488 | assert_eq!(lobby.user, None); | ||
| 1489 | } | ||
