//! Unit tests for feature modules — constructed directly and driven through the //! public `Module` trait, with no network. This is the payoff of the module //! system: features are testable in isolation. use rustbot::bot::module::{Action, Command, Module}; use rustbot::bot::modules::{builtins::Builtins, dice::Dice}; fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> { Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" } } /// Assert the module produced exactly one reply and return its text. fn reply(actions: &[Action]) -> &str { match actions { [Action::Reply(s)] => s, _ => panic!("expected exactly one Reply action"), } } #[test] fn builtins_ping_pongs() { let mut m = Builtins; assert_eq!(reply(&m.on_command(&cmd("ping", &[]))), "pong"); } #[test] fn builtins_echo_joins_args() { let mut m = Builtins; assert_eq!(reply(&m.on_command(&cmd("echo", &["hello", "world"]))), "hello world"); } #[test] fn builtins_hello_greets_sender() { let mut m = Builtins; assert_eq!(reply(&m.on_command(&cmd("hello", &[]))), "hello, alice!"); } #[test] fn dice_declares_roll_command() { let m = Dice::new(); assert_eq!(m.name(), "dice"); assert!(m.commands().iter().any(|c| c.name == "roll")); } #[test] fn dice_2d6_total_is_in_range() { let mut d = Dice::new(); for _ in 0..300 { let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string(); // Format: "2d6: a + b = total" let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap(); assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}"); } } #[test] fn dice_defaults_to_1d6() { let mut d = Dice::new(); let r = reply(&d.on_command(&cmd("roll", &[]))).to_string(); // Format: "1d6: N" assert!(r.starts_with("1d6: "), "unexpected format: {r:?}"); let n: u64 = r.rsplit(':').next().unwrap().trim().parse().unwrap(); assert!((1..=6).contains(&n), "1d6 {n} out of range: {r:?}"); } #[test] fn dice_bare_number_means_one_die() { let mut d = Dice::new(); let r = reply(&d.on_command(&cmd("roll", &["20"]))).to_string(); assert!(r.starts_with("1d20: "), "expected 1d20, got {r:?}"); } #[test] fn dice_rejects_garbage_with_usage() { let mut d = Dice::new(); let actions = d.on_command(&cmd("roll", &["banana"])); let r = reply(&actions); assert!(r.contains("usage"), "expected a usage hint, got {r:?}"); } #[test] fn dice_rejects_out_of_bounds() { let mut d = Dice::new(); // 999 dice exceeds the cap; 1-sided die is below the minimum. assert!(reply(&d.on_command(&cmd("roll", &["999d6"]))).contains("usage")); assert!(reply(&d.on_command(&cmd("roll", &["1d1"]))).contains("usage")); }