Add a module (plugin) system for bot features
Introduce a `Module` trait (src/bot/module.rs): a feature declares the commands it provides and turns each invocation into a `Vec<Action>`, without touching the connection. Modules are pure — context in, actions out — so they are unit-testable in isolation and avoid borrowing the `Bot` that owns them. Bot::new builds the module set and a command -> module route table; the `commands` dispatcher routes to the owning module and executes its actions, and `help` is auto-generated from every module's CommandSpec metadata. The existing ping/echo/hello move into a `builtins` module, and a stateful `dice` module (`roll [NdM]`, with its own dependency-free xorshift PRNG) is added as a worked example. Each network gets its own module set, so stateful modules keep independent per-network state with no locking. Adding a feature is now a new file in bot/modules/ plus one line in modules::all(). Tests: new tests/modules.rs drives modules through the trait with no network; end-to-end dispatch and auto-help verified against a fake server. No new deps.
This commit is contained in:
parent
a15fb4f9a9
commit
a5e58493d8
8 changed files with 426 additions and 21 deletions
87
tests/modules.rs
Normal file
87
tests/modules.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! 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"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue