summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:19:49 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:19:49 +0100
commit24e3bb54db579fb9aad8df2a591527dc77fe4cb4 (patch)
treeaf9a0c290f2675068e8396823f641f24bbe4d308 /tests
parentv4.0.6: move the prompt editor and key mapping out of the core (diff)
downloadzagan-bet-24e3bb54db579fb9aad8df2a591527dc77fe4cb4.tar.gz
zagan-bet-24e3bb54db579fb9aad8df2a591527dc77fe4cb4.zip
v4.0.7: finish the Phase 1 boundary with a command shell
The terminal command line moves to client::shell: a typed line becomes a core Command or an adapter action (connect, login, register, set-credential, host maintenance). ClientCore loses submit_line and the last TUI-shaped effects (Interactive, SetPrompt, ListHosts, ForgetHost, AnimateRoulette); the adapter animates roulette from the Result view. Adds forget-login, which drops the stored credential for the pinned key and keeps trust, distinct from logout (session only) and forget-host (trust too).
Diffstat (limited to '')
-rw-r--r--tests/client_core.rs51
-rw-r--r--tests/client_shell.rs152
-rw-r--r--tests/client_ui.rs3
-rw-r--r--tests/storage.rs21
4 files changed, 202 insertions, 25 deletions
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 722cf0d..49abca2 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -6,8 +6,10 @@
6 6
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, InteractiveCommand}; 9use zagan_bet::client::core::{ClientCore, Effect};
10use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; 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;
11use zagan_bet::client::games::response::{ 13use zagan_bet::client::games::response::{
12 parse_blackjack_response, parse_crash_response, parse_spin_response, 14 parse_blackjack_response, parse_crash_response, parse_spin_response,
13}; 15};
@@ -31,12 +33,7 @@ struct Screen {
31} 33}
32 34
33impl Screen { 35impl Screen {
34 fn apply( 36 fn apply(&mut self, effects: &[Effect], sent: &mut Vec<String>) {
35 &mut self,
36 effects: &[Effect],
37 sent: &mut Vec<String>,
38 interactive: &mut Vec<InteractiveCommand>,
39 ) {
40 for effect in effects { 37 for effect in effects {
41 match effect { 38 match effect {
42 Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), 39 Effect::Print(lines) => self.lines.extend(lines.iter().cloned()),
@@ -57,19 +54,8 @@ impl Screen {
57 self.region_start = None; 54 self.region_start = None;
58 self.region_len = 0; 55 self.region_len = 0;
59 } 56 }
60 Effect::AnimateRoulette { view, .. } => {
61 let lines = zagan_bet::client::tui::render_view(view);
62 if let Some(start) = self.region_start {
63 self.lines.truncate(start);
64 } else {
65 self.region_start = Some(self.lines.len());
66 }
67 self.lines.extend(lines.iter().cloned());
68 self.region_len = lines.len();
69 }
70 Effect::SendLine(line) => sent.push(line.clone()), 57 Effect::SendLine(line) => sent.push(line.clone()),
71 Effect::Interactive(command) => interactive.push(command.clone()), 58 Effect::Quit | Effect::Disconnect => {}
72 Effect::Quit | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {}
73 } 59 }
74 } 60 }
75 } 61 }
@@ -87,7 +73,6 @@ struct Harness {
87 editor: Editor, 73 editor: Editor,
88 screen: Screen, 74 screen: Screen,
89 sent: Vec<String>, 75 sent: Vec<String>,
90 interactive: Vec<InteractiveCommand>,
91} 76}
92 77
93impl Harness { 78impl Harness {
@@ -100,13 +85,11 @@ impl Harness {
100 editor: Editor::new(), 85 editor: Editor::new(),
101 screen: Screen::default(), 86 screen: Screen::default(),
102 sent: Vec::new(), 87 sent: Vec::new(),
103 interactive: Vec::new(),
104 } 88 }
105 } 89 }
106 90
107 fn apply(&mut self, effects: Vec<Effect>) { 91 fn apply(&mut self, effects: Vec<Effect>) {
108 self.screen 92 self.screen.apply(&effects, &mut self.sent);
109 .apply(&effects, &mut self.sent, &mut self.interactive);
110 } 93 }
111 94
112 fn key(&mut self, key: Key) { 95 fn key(&mut self, key: Key) {
@@ -119,7 +102,7 @@ impl Harness {
119 } else { 102 } else {
120 match self.editor.handle(key) { 103 match self.editor.handle(key) {
121 Input::Quit => vec![Effect::Quit], 104 Input::Quit => vec![Effect::Quit],
122 Input::Submit(line) => self.core.submit_line(&line), 105 Input::Submit(line) => self.dispatch(&line),
123 Input::Print(lines) => vec![Effect::Print(lines)], 106 Input::Print(lines) => vec![Effect::Print(lines)],
124 Input::Prompt | Input::None => Vec::new(), 107 Input::Prompt | Input::None => Vec::new(),
125 } 108 }
@@ -128,6 +111,26 @@ impl Harness {
128 self.apply_prompt(); 111 self.apply_prompt();
129 } 112 }
130 113
114 /// Route a submitted line the way the terminal adapter does.
115 fn dispatch(&mut self, line: &str) -> Vec<Effect> {
116 match shell::parse(line) {
117 Shell::Run(command) => self.core.command(command),
118 Shell::Disconnect => {
119 self.core.reset_after_disconnect();
120 vec![
121 Effect::ClearRegion,
122 Effect::Print(vec!["Disconnected".to_string()]),
123 ]
124 }
125 Shell::Help => vec![Effect::Print(help_lines())],
126 Shell::Quit => vec![Effect::Quit],
127 Shell::Empty => Vec::new(),
128 Shell::Usage(message) => vec![Effect::Print(vec![message])],
129 // Connect/login prompts are adapter flows, not core state.
130 _ => Vec::new(),
131 }
132 }
133
131 fn apply_prompt(&mut self) { 134 fn apply_prompt(&mut self) {
132 if self.core.is_in_game() { 135 if self.core.is_in_game() {
133 return; 136 return;
diff --git a/tests/client_shell.rs b/tests/client_shell.rs
new file mode 100644
index 0000000..d286b46
--- /dev/null
+++ b/tests/client_shell.rs
@@ -0,0 +1,152 @@
1//! The terminal command line is a thin parser over `Command`.
2//!
3//! Anything the core can do on its own becomes `Shell::Run`; connecting,
4//! prompting for credentials and host maintenance stay with the adapter.
5
6use zagan_bet::client::shell::{Shell, parse};
7use zagan_bet::client::view::Command;
8use zagan_bet::history::{DEFAULT_HISTORY, MAX_HISTORY};
9
10fn run(line: &str) -> Command {
11 match parse(line) {
12 Shell::Run(command) => command,
13 other => panic!("expected a command, got {other:?}"),
14 }
15}
16
17#[test]
18fn blank_lines_do_nothing() {
19 assert_eq!(parse(""), Shell::Empty);
20 assert_eq!(parse(" \t "), Shell::Empty);
21}
22
23#[test]
24fn play_without_a_game_prints_usage() {
25 assert_eq!(
26 parse("play"),
27 Shell::Usage("Usage: play <game> [bet]".to_string())
28 );
29}
30
31#[test]
32fn play_carries_the_game_and_an_optional_bet() {
33 assert_eq!(
34 run("play slots"),
35 Command::Play {
36 game: "slots".to_string(),
37 bet: None,
38 }
39 );
40 assert_eq!(
41 run("play roulette 25"),
42 Command::Play {
43 game: "roulette".to_string(),
44 bet: Some(25),
45 }
46 );
47}
48
49#[test]
50fn play_rejects_a_bet_below_the_table_minimum() {
51 assert_eq!(
52 parse("play slots 5"),
53 Shell::Usage("Bet must be at least 10 Kr".to_string())
54 );
55 assert_eq!(
56 parse("play slots ten"),
57 Shell::Usage("Invalid bet: ten".to_string())
58 );
59}
60
61#[test]
62fn history_defaults_to_the_recent_limit() {
63 let expected = Command::History {
64 limit: DEFAULT_HISTORY,
65 };
66 assert_eq!(run("history"), expected);
67 assert_eq!(run("history 5"), Command::History { limit: 5 });
68}
69
70#[test]
71fn history_rejects_counts_outside_the_range() {
72 let usage = Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]"));
73 assert_eq!(parse("history 0"), usage);
74 assert_eq!(parse(&format!("history {}", MAX_HISTORY + 1)), usage);
75 assert_eq!(parse("history many"), usage);
76}
77
78#[test]
79fn simple_commands_are_pure_core_commands() {
80 assert_eq!(run("games"), Command::Games);
81 assert_eq!(run("logout"), Command::Logout);
82 assert_eq!(run("ping"), Command::Ping);
83 assert_eq!(run("info"), Command::Info);
84}
85
86#[test]
87fn send_keeps_the_whole_message_including_extra_spaces() {
88 assert_eq!(
89 run("send hello there friend"),
90 Command::Echo("hello there friend".to_string())
91 );
92 assert_eq!(
93 parse("send"),
94 Shell::Usage("Usage: send <message>".to_string())
95 );
96}
97
98#[test]
99fn connection_flows_stay_with_the_adapter() {
100 assert_eq!(parse("connect"), Shell::Connect(None));
101 assert_eq!(
102 parse("connect 127.0.0.1:11525"),
103 Shell::Connect(Some("127.0.0.1:11525".to_string()))
104 );
105 assert_eq!(parse("login"), Shell::Login);
106 assert_eq!(parse("register"), Shell::Register);
107 assert_eq!(parse("setcred"), Shell::SetCred);
108 assert_eq!(parse("disconnect"), Shell::Disconnect);
109}
110
111#[test]
112fn host_maintenance_is_adapter_owned() {
113 assert_eq!(parse("hosts"), Shell::Hosts);
114 assert_eq!(parse("forget-host"), Shell::ForgetHost(None));
115 assert_eq!(
116 parse("forget-host 10.0.0.2:11525"),
117 Shell::ForgetHost(Some("10.0.0.2:11525".to_string()))
118 );
119}
120
121#[test]
122fn forgetting_a_saved_login_is_separate_from_forgetting_trust() {
123 assert_eq!(parse("forget-login"), Shell::ForgetLogin(None));
124 assert_eq!(
125 parse("forget-login 10.0.0.2:11525"),
126 Shell::ForgetLogin(Some("10.0.0.2:11525".to_string()))
127 );
128}
129
130#[test]
131fn help_and_quit_are_terminal_only() {
132 assert_eq!(parse("help"), Shell::Help);
133 assert_eq!(parse("quit"), Shell::Quit);
134}
135
136#[test]
137fn an_unknown_word_falls_back_to_help() {
138 assert_eq!(parse("frobnicate"), Shell::Help);
139 assert_eq!(parse("quit now"), Shell::Quit);
140}
141
142#[test]
143fn surrounding_whitespace_is_ignored() {
144 assert_eq!(run(" ping "), Command::Ping);
145 assert_eq!(
146 run(" play slots 30 "),
147 Command::Play {
148 game: "slots".to_string(),
149 bet: Some(30),
150 }
151 );
152}
diff --git a/tests/client_ui.rs b/tests/client_ui.rs
index 6e00903..bd9ccbc 100644
--- a/tests/client_ui.rs
+++ b/tests/client_ui.rs
@@ -27,7 +27,8 @@ fn help_documents_every_command() {
27fn completion_knows_every_command() { 27fn completion_knows_every_command() {
28 let commands = command_names(); 28 let commands = command_names();
29 for expected in [ 29 for expected in [
30 "connect", "games", "play", "hosts", "send", "logout", "quit", 30 "connect", "games", "play", "hosts", "forget-host", "forget-login", "send", "logout",
31 "quit",
31 ] { 32 ] {
32 assert!( 33 assert!(
33 commands.contains(&expected), 34 commands.contains(&expected),
diff --git a/tests/storage.rs b/tests/storage.rs
index e6ad2d1..21e76d4 100644
--- a/tests/storage.rs
+++ b/tests/storage.rs
@@ -89,6 +89,27 @@ fn forgetting_one_address_keeps_the_shared_credentials() {
89} 89}
90 90
91#[test] 91#[test]
92fn forgetting_the_login_keeps_the_trusted_key() {
93 let (_, conn) = tmp_db("creds-forget-login");
94 client_database::init_db(&conn);
95
96 client_database::save_host(&conn, "127.0.0.1:11525");
97 client_database::save_fingerprint(&conn, "127.0.0.1:11525", "server-a").unwrap();
98 client_database::save_credentials(&conn, "server-a", "alice", "pw").unwrap();
99
100 assert!(client_database::forget_credentials(&conn, "server-a").unwrap());
101 assert_eq!(client_database::get_credentials(&conn, "server-a"), None);
102 assert_eq!(
103 client_database::get_fingerprint(&conn, "127.0.0.1:11525"),
104 Some("server-a".to_string()),
105 "forgetting the login must not unpin the key"
106 );
107
108 // A second call is a no-op, not an error.
109 assert!(!client_database::forget_credentials(&conn, "server-a").unwrap());
110}
111
112#[test]
92fn client_last_host_tracking() { 113fn client_last_host_tracking() {
93 let (_, conn) = tmp_db("hosts"); 114 let (_, conn) = tmp_db("hosts");
94 client_database::init_db(&conn); 115 client_database::init_db(&conn);