From 24e3bb54db579fb9aad8df2a591527dc77fe4cb4 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 19:19:49 +0100 Subject: 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). --- tests/client_core.rs | 51 +++++++++-------- tests/client_shell.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++ tests/client_ui.rs | 3 +- tests/storage.rs | 21 +++++++ 4 files changed, 202 insertions(+), 25 deletions(-) create mode 100644 tests/client_shell.rs (limited to 'tests') 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 @@ use std::time::Duration; use zagan_bet::blackjack::BlackjackState; -use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; +use zagan_bet::client::core::{ClientCore, Effect}; use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; +use zagan_bet::client::shell::{self, Shell}; +use zagan_bet::client::ui::help_lines; use zagan_bet::client::games::response::{ parse_blackjack_response, parse_crash_response, parse_spin_response, }; @@ -31,12 +33,7 @@ struct Screen { } impl Screen { - fn apply( - &mut self, - effects: &[Effect], - sent: &mut Vec, - interactive: &mut Vec, - ) { + fn apply(&mut self, effects: &[Effect], sent: &mut Vec) { for effect in effects { match effect { Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), @@ -57,19 +54,8 @@ impl Screen { self.region_start = None; self.region_len = 0; } - Effect::AnimateRoulette { view, .. } => { - let lines = zagan_bet::client::tui::render_view(view); - if let Some(start) = self.region_start { - self.lines.truncate(start); - } else { - self.region_start = Some(self.lines.len()); - } - self.lines.extend(lines.iter().cloned()); - self.region_len = lines.len(); - } Effect::SendLine(line) => sent.push(line.clone()), - Effect::Interactive(command) => interactive.push(command.clone()), - Effect::Quit | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {} + Effect::Quit | Effect::Disconnect => {} } } } @@ -87,7 +73,6 @@ struct Harness { editor: Editor, screen: Screen, sent: Vec, - interactive: Vec, } impl Harness { @@ -100,13 +85,11 @@ impl Harness { editor: Editor::new(), screen: Screen::default(), sent: Vec::new(), - interactive: Vec::new(), } } fn apply(&mut self, effects: Vec) { - self.screen - .apply(&effects, &mut self.sent, &mut self.interactive); + self.screen.apply(&effects, &mut self.sent); } fn key(&mut self, key: Key) { @@ -119,7 +102,7 @@ impl Harness { } else { match self.editor.handle(key) { Input::Quit => vec![Effect::Quit], - Input::Submit(line) => self.core.submit_line(&line), + Input::Submit(line) => self.dispatch(&line), Input::Print(lines) => vec![Effect::Print(lines)], Input::Prompt | Input::None => Vec::new(), } @@ -128,6 +111,26 @@ impl Harness { self.apply_prompt(); } + /// Route a submitted line the way the terminal adapter does. + fn dispatch(&mut self, line: &str) -> Vec { + match shell::parse(line) { + Shell::Run(command) => self.core.command(command), + Shell::Disconnect => { + self.core.reset_after_disconnect(); + vec![ + Effect::ClearRegion, + Effect::Print(vec!["Disconnected".to_string()]), + ] + } + Shell::Help => vec![Effect::Print(help_lines())], + Shell::Quit => vec![Effect::Quit], + Shell::Empty => Vec::new(), + Shell::Usage(message) => vec![Effect::Print(vec![message])], + // Connect/login prompts are adapter flows, not core state. + _ => Vec::new(), + } + } + fn apply_prompt(&mut self) { if self.core.is_in_game() { 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 @@ +//! The terminal command line is a thin parser over `Command`. +//! +//! Anything the core can do on its own becomes `Shell::Run`; connecting, +//! prompting for credentials and host maintenance stay with the adapter. + +use zagan_bet::client::shell::{Shell, parse}; +use zagan_bet::client::view::Command; +use zagan_bet::history::{DEFAULT_HISTORY, MAX_HISTORY}; + +fn run(line: &str) -> Command { + match parse(line) { + Shell::Run(command) => command, + other => panic!("expected a command, got {other:?}"), + } +} + +#[test] +fn blank_lines_do_nothing() { + assert_eq!(parse(""), Shell::Empty); + assert_eq!(parse(" \t "), Shell::Empty); +} + +#[test] +fn play_without_a_game_prints_usage() { + assert_eq!( + parse("play"), + Shell::Usage("Usage: play [bet]".to_string()) + ); +} + +#[test] +fn play_carries_the_game_and_an_optional_bet() { + assert_eq!( + run("play slots"), + Command::Play { + game: "slots".to_string(), + bet: None, + } + ); + assert_eq!( + run("play roulette 25"), + Command::Play { + game: "roulette".to_string(), + bet: Some(25), + } + ); +} + +#[test] +fn play_rejects_a_bet_below_the_table_minimum() { + assert_eq!( + parse("play slots 5"), + Shell::Usage("Bet must be at least 10 Kr".to_string()) + ); + assert_eq!( + parse("play slots ten"), + Shell::Usage("Invalid bet: ten".to_string()) + ); +} + +#[test] +fn history_defaults_to_the_recent_limit() { + let expected = Command::History { + limit: DEFAULT_HISTORY, + }; + assert_eq!(run("history"), expected); + assert_eq!(run("history 5"), Command::History { limit: 5 }); +} + +#[test] +fn history_rejects_counts_outside_the_range() { + let usage = Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]")); + assert_eq!(parse("history 0"), usage); + assert_eq!(parse(&format!("history {}", MAX_HISTORY + 1)), usage); + assert_eq!(parse("history many"), usage); +} + +#[test] +fn simple_commands_are_pure_core_commands() { + assert_eq!(run("games"), Command::Games); + assert_eq!(run("logout"), Command::Logout); + assert_eq!(run("ping"), Command::Ping); + assert_eq!(run("info"), Command::Info); +} + +#[test] +fn send_keeps_the_whole_message_including_extra_spaces() { + assert_eq!( + run("send hello there friend"), + Command::Echo("hello there friend".to_string()) + ); + assert_eq!( + parse("send"), + Shell::Usage("Usage: send ".to_string()) + ); +} + +#[test] +fn connection_flows_stay_with_the_adapter() { + assert_eq!(parse("connect"), Shell::Connect(None)); + assert_eq!( + parse("connect 127.0.0.1:11525"), + Shell::Connect(Some("127.0.0.1:11525".to_string())) + ); + assert_eq!(parse("login"), Shell::Login); + assert_eq!(parse("register"), Shell::Register); + assert_eq!(parse("setcred"), Shell::SetCred); + assert_eq!(parse("disconnect"), Shell::Disconnect); +} + +#[test] +fn host_maintenance_is_adapter_owned() { + assert_eq!(parse("hosts"), Shell::Hosts); + assert_eq!(parse("forget-host"), Shell::ForgetHost(None)); + assert_eq!( + parse("forget-host 10.0.0.2:11525"), + Shell::ForgetHost(Some("10.0.0.2:11525".to_string())) + ); +} + +#[test] +fn forgetting_a_saved_login_is_separate_from_forgetting_trust() { + assert_eq!(parse("forget-login"), Shell::ForgetLogin(None)); + assert_eq!( + parse("forget-login 10.0.0.2:11525"), + Shell::ForgetLogin(Some("10.0.0.2:11525".to_string())) + ); +} + +#[test] +fn help_and_quit_are_terminal_only() { + assert_eq!(parse("help"), Shell::Help); + assert_eq!(parse("quit"), Shell::Quit); +} + +#[test] +fn an_unknown_word_falls_back_to_help() { + assert_eq!(parse("frobnicate"), Shell::Help); + assert_eq!(parse("quit now"), Shell::Quit); +} + +#[test] +fn surrounding_whitespace_is_ignored() { + assert_eq!(run(" ping "), Command::Ping); + assert_eq!( + run(" play slots 30 "), + Command::Play { + game: "slots".to_string(), + bet: Some(30), + } + ); +} 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() { fn completion_knows_every_command() { let commands = command_names(); for expected in [ - "connect", "games", "play", "hosts", "send", "logout", "quit", + "connect", "games", "play", "hosts", "forget-host", "forget-login", "send", "logout", + "quit", ] { assert!( 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 @@ -88,6 +88,27 @@ fn forgetting_one_address_keeps_the_shared_credentials() { assert_eq!(client_database::get_credentials(&conn, "server-a"), None); } +#[test] +fn forgetting_the_login_keeps_the_trusted_key() { + let (_, conn) = tmp_db("creds-forget-login"); + client_database::init_db(&conn); + + client_database::save_host(&conn, "127.0.0.1:11525"); + client_database::save_fingerprint(&conn, "127.0.0.1:11525", "server-a").unwrap(); + client_database::save_credentials(&conn, "server-a", "alice", "pw").unwrap(); + + assert!(client_database::forget_credentials(&conn, "server-a").unwrap()); + assert_eq!(client_database::get_credentials(&conn, "server-a"), None); + assert_eq!( + client_database::get_fingerprint(&conn, "127.0.0.1:11525"), + Some("server-a".to_string()), + "forgetting the login must not unpin the key" + ); + + // A second call is a no-op, not an error. + assert!(!client_database::forget_credentials(&conn, "server-a").unwrap()); +} + #[test] fn client_last_host_tracking() { let (_, conn) = tmp_db("hosts"); -- cgit v1.2.3