367 lines
12 KiB
Rust
367 lines
12 KiB
Rust
//! Behavioral tests for the swactor-command crate.
|
|
//!
|
|
//! Tests exercise the full dispatch path: parse → route → handle → response.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use swactor::actor::ActorInterface;
|
|
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
|
|
use swactor_command::{
|
|
from_query_params, parse_line, CommandContext, CommandRequest, CommandResponse, CommandRouter,
|
|
};
|
|
|
|
// ── Test Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
fn single_thread_config() -> RuntimeConfig {
|
|
RuntimeConfig {
|
|
num_threads: 1,
|
|
..RuntimeConfig::default()
|
|
}
|
|
}
|
|
|
|
fn make_router_and_ctx() -> (CommandRouter, CommandContext) {
|
|
let rt = Arc::new(Runtime::new(single_thread_config()));
|
|
let router = CommandRouter::with_builtins();
|
|
let ctx = CommandContext::new(rt);
|
|
(router, ctx)
|
|
}
|
|
|
|
fn dispatch_text(router: &CommandRouter, ctx: &CommandContext, line: &str) -> CommandResponse {
|
|
let req = parse_line(line);
|
|
let resp = router.dispatch(&req, ctx);
|
|
// Verify JSON round-trip works
|
|
let json = resp.to_json_line();
|
|
serde_json::from_str::<CommandResponse>(&json)
|
|
.expect("response should be valid JSON")
|
|
}
|
|
|
|
/// A no-op actor for spawning into the runtime.
|
|
struct DummyActor;
|
|
#[derive(Clone)]
|
|
struct DummyMsg;
|
|
impl ActorInterface for DummyActor {
|
|
type Incoming = DummyMsg;
|
|
type Response = ();
|
|
fn handle(&mut self, _ctx: &Ctx, _msg: DummyMsg) {}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
/// Given a router with builtins,
|
|
/// when "help" is dispatched,
|
|
/// then the response lists all registered commands.
|
|
#[test]
|
|
fn help_lists_all_registered_commands() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "help");
|
|
|
|
assert!(resp.ok, "help should succeed");
|
|
assert_eq!(resp.command, "help");
|
|
|
|
let data = resp.data.unwrap();
|
|
let commands = data["commands"].as_array().unwrap();
|
|
|
|
// Should have all builtins + help itself
|
|
let names: Vec<&str> = commands
|
|
.iter()
|
|
.map(|c| c["name"].as_str().unwrap())
|
|
.collect();
|
|
assert!(names.contains(&"overview"), "should list overview");
|
|
assert!(names.contains(&"workers"), "should list workers");
|
|
assert!(names.contains(&"worker"), "should list worker");
|
|
assert!(names.contains(&"actors"), "should list actors");
|
|
assert!(names.contains(&"hot"), "should list hot");
|
|
assert!(names.contains(&"phases"), "should list phases");
|
|
assert!(names.contains(&"diff"), "should list diff");
|
|
assert!(names.contains(&"shutdown"), "should list shutdown");
|
|
assert!(names.contains(&"help"), "should list help itself");
|
|
|
|
// Should be sorted
|
|
let mut sorted = names.clone();
|
|
sorted.sort();
|
|
assert_eq!(names, sorted, "commands should be sorted alphabetically");
|
|
}
|
|
|
|
/// Given a router,
|
|
/// when an unknown command is dispatched,
|
|
/// then the response indicates failure with a helpful message.
|
|
#[test]
|
|
fn unknown_command_returns_error() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "nonexistent");
|
|
|
|
assert!(!resp.ok, "unknown command should fail");
|
|
assert_eq!(resp.command, "nonexistent");
|
|
let err = resp.error.unwrap();
|
|
assert!(
|
|
err.contains("unknown command") && err.contains("help"),
|
|
"error should mention 'unknown command' and suggest 'help', got: {err}"
|
|
);
|
|
}
|
|
|
|
/// Given a runtime with no actors,
|
|
/// when "overview" is dispatched,
|
|
/// then the response contains expected summary fields with zero counts.
|
|
#[test]
|
|
fn overview_returns_summary_fields() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "overview");
|
|
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.command, "overview");
|
|
|
|
let data = resp.data.unwrap();
|
|
assert_eq!(data["workers"], 1, "single-threaded = 1 worker");
|
|
assert_eq!(data["actors"], 0, "no actors spawned");
|
|
assert_eq!(data["total_messages_processed"], 0);
|
|
assert_eq!(data["total_panics"], 0);
|
|
assert!(data["sends"].is_object(), "sends should be an object");
|
|
}
|
|
|
|
/// Given a runtime with spawned actors,
|
|
/// when "workers" is dispatched,
|
|
/// then the response contains per-worker stats.
|
|
#[test]
|
|
fn workers_returns_per_worker_info() {
|
|
let rt = Arc::new(Runtime::new(single_thread_config()));
|
|
// Spawn some actors
|
|
rt.spawn(DummyActor).unwrap();
|
|
rt.spawn(DummyActor).unwrap();
|
|
rt.tick();
|
|
|
|
let router = CommandRouter::with_builtins();
|
|
let ctx = CommandContext::new(rt);
|
|
let resp = dispatch_text(&router, &ctx, "workers");
|
|
|
|
assert!(resp.ok);
|
|
let data = resp.data.unwrap();
|
|
let workers = data.as_array().unwrap();
|
|
assert_eq!(workers.len(), 1, "single-threaded has 1 worker");
|
|
assert_eq!(workers[0]["id"], 0);
|
|
assert_eq!(workers[0]["actors"], 2, "2 actors spawned on worker 0");
|
|
}
|
|
|
|
/// Given "worker 0" with a valid ID,
|
|
/// when dispatched,
|
|
/// then the response includes worker detail and tick phase info.
|
|
#[test]
|
|
fn worker_command_with_valid_id() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "worker 0");
|
|
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.command, "worker");
|
|
let data = resp.data.unwrap();
|
|
assert_eq!(data["id"], 0);
|
|
assert!(data["tick_phases"].is_object(), "should include phase breakdown");
|
|
}
|
|
|
|
/// Given "worker 99",
|
|
/// when dispatched,
|
|
/// then the response is an error (worker not found).
|
|
#[test]
|
|
fn worker_command_invalid_id_returns_error() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "worker 99");
|
|
|
|
assert!(!resp.ok);
|
|
assert!(resp.error.unwrap().contains("not found"));
|
|
}
|
|
|
|
/// Given "worker" with no ID,
|
|
/// when dispatched,
|
|
/// then the response is a usage error.
|
|
#[test]
|
|
fn worker_command_missing_id_returns_usage() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "worker");
|
|
|
|
assert!(!resp.ok);
|
|
assert!(resp.error.unwrap().contains("usage"));
|
|
}
|
|
|
|
/// Given "phases",
|
|
/// when dispatched,
|
|
/// then the response includes phase breakdown per worker.
|
|
#[test]
|
|
fn phases_command_returns_breakdown() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "phases");
|
|
|
|
assert!(resp.ok);
|
|
let data = resp.data.unwrap();
|
|
let phases = data.as_array().unwrap();
|
|
assert_eq!(phases.len(), 1, "single-threaded = 1 worker");
|
|
assert_eq!(phases[0]["worker_id"], 0);
|
|
}
|
|
|
|
/// Given a runtime, when "shutdown" is dispatched,
|
|
/// then the response indicates success.
|
|
#[test]
|
|
fn shutdown_command_signals_runtime() {
|
|
let (router, ctx) = make_router_and_ctx();
|
|
let resp = dispatch_text(&router, &ctx, "shutdown");
|
|
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.command, "shutdown");
|
|
let data = resp.data.unwrap();
|
|
assert_eq!(data["status"], "shutdown signaled");
|
|
}
|
|
|
|
// ── REPL Parser Tests ────────────────────────────────────────────────────────
|
|
|
|
/// Given a simple command with no args,
|
|
/// when parsed,
|
|
/// then the command name is extracted correctly.
|
|
#[test]
|
|
fn parse_line_simple_command() {
|
|
let req = parse_line("overview");
|
|
assert_eq!(req.command, "overview");
|
|
assert!(req.args.is_empty());
|
|
}
|
|
|
|
/// Given a command with positional args,
|
|
/// when parsed,
|
|
/// then positional args are mapped to named parameters.
|
|
#[test]
|
|
fn parse_line_positional_args() {
|
|
let req = parse_line("worker 3");
|
|
assert_eq!(req.command, "worker");
|
|
assert_eq!(req.args["id"], "3");
|
|
|
|
let req = parse_line("hot 5");
|
|
assert_eq!(req.command, "hot");
|
|
assert_eq!(req.args["n"], "5");
|
|
|
|
let req = parse_line("diff 2.5");
|
|
assert_eq!(req.command, "diff");
|
|
assert_eq!(req.args["seconds"], "2.5");
|
|
|
|
let req = parse_line("actor a1b2");
|
|
assert_eq!(req.command, "actor");
|
|
assert_eq!(req.args["prefix"], "a1b2");
|
|
}
|
|
|
|
/// Given a command with --flag value pairs,
|
|
/// when parsed,
|
|
/// then flags are mapped to named args.
|
|
#[test]
|
|
fn parse_line_flags() {
|
|
let req = parse_line("actors --sort mailbox --limit 5");
|
|
assert_eq!(req.command, "actors");
|
|
assert_eq!(req.args["sort"], "mailbox");
|
|
assert_eq!(req.args["limit"], "5");
|
|
}
|
|
|
|
/// Given a command with mixed positional and flag args,
|
|
/// when parsed,
|
|
/// then both are captured correctly.
|
|
#[test]
|
|
fn parse_line_mixed_args() {
|
|
let req = parse_line("actors --sort worker --worker 2 --limit 10");
|
|
assert_eq!(req.command, "actors");
|
|
assert_eq!(req.args["sort"], "worker");
|
|
assert_eq!(req.args["worker"], "2");
|
|
assert_eq!(req.args["limit"], "10");
|
|
}
|
|
|
|
/// Given empty input,
|
|
/// when parsed,
|
|
/// then default to "help".
|
|
#[test]
|
|
fn parse_line_empty_defaults_to_help() {
|
|
let req = parse_line("");
|
|
assert_eq!(req.command, "help");
|
|
}
|
|
|
|
// ── REST Adapter Tests ───────────────────────────────────────────────────────
|
|
|
|
/// Given query params with cmd and other params,
|
|
/// when converted,
|
|
/// then cmd becomes the command and others become args.
|
|
#[test]
|
|
fn from_query_params_extracts_cmd() {
|
|
let mut params = HashMap::new();
|
|
params.insert("cmd".to_string(), "actor".to_string());
|
|
params.insert("prefix".to_string(), "a1b2".to_string());
|
|
|
|
let req = from_query_params(¶ms);
|
|
assert_eq!(req.command, "actor");
|
|
assert_eq!(req.args["prefix"], "a1b2");
|
|
assert!(!req.args.contains_key("cmd"), "cmd should not be in args");
|
|
}
|
|
|
|
/// Given query params with no cmd,
|
|
/// when converted,
|
|
/// then default to "help".
|
|
#[test]
|
|
fn from_query_params_defaults_to_help() {
|
|
let params = HashMap::new();
|
|
let req = from_query_params(¶ms);
|
|
assert_eq!(req.command, "help");
|
|
}
|
|
|
|
// ── Custom Handler Test ──────────────────────────────────────────────────────
|
|
|
|
/// Given a custom command handler registered on the router,
|
|
/// when that command is dispatched,
|
|
/// then the custom handler runs and returns its response.
|
|
#[test]
|
|
fn custom_command_handler() {
|
|
struct PingCommand;
|
|
impl swactor_command::CommandHandler for PingCommand {
|
|
fn meta(&self) -> swactor_command::CommandMeta {
|
|
swactor_command::CommandMeta {
|
|
name: "ping",
|
|
description: "Respond with pong",
|
|
usage: "ping",
|
|
is_write: false,
|
|
}
|
|
}
|
|
fn handle(
|
|
&self,
|
|
_args: &HashMap<String, serde_json::Value>,
|
|
_ctx: &CommandContext,
|
|
) -> CommandResponse {
|
|
CommandResponse::ok("ping", serde_json::json!({"reply": "pong"}))
|
|
}
|
|
}
|
|
|
|
let rt = Arc::new(Runtime::new(single_thread_config()));
|
|
let mut router = CommandRouter::with_builtins();
|
|
router.register(Box::new(PingCommand));
|
|
let ctx = CommandContext::new(rt);
|
|
|
|
let resp = dispatch_text(&router, &ctx, "ping");
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.data.unwrap()["reply"], "pong");
|
|
|
|
// Should also appear in help
|
|
let help = dispatch_text(&router, &ctx, "help");
|
|
let commands = help.data.unwrap()["commands"].as_array().unwrap().clone();
|
|
let names: Vec<&str> = commands.iter().map(|c| c["name"].as_str().unwrap()).collect();
|
|
assert!(names.contains(&"ping"), "custom command should appear in help");
|
|
}
|
|
|
|
/// Given a JSON-serialized CommandResponse,
|
|
/// when deserialized,
|
|
/// then ok/false fields, data, and error are preserved.
|
|
#[test]
|
|
fn response_json_roundtrip() {
|
|
let ok_resp = CommandResponse::ok("test", serde_json::json!({"key": "value"}));
|
|
let json = ok_resp.to_json_line();
|
|
let parsed: CommandResponse = serde_json::from_str(&json).unwrap();
|
|
assert!(parsed.ok);
|
|
assert_eq!(parsed.command, "test");
|
|
assert_eq!(parsed.data.unwrap()["key"], "value");
|
|
assert!(parsed.error.is_none());
|
|
|
|
let err_resp = CommandResponse::err("bad", "something went wrong");
|
|
let json = err_resp.to_json_line();
|
|
let parsed: CommandResponse = serde_json::from_str(&json).unwrap();
|
|
assert!(!parsed.ok);
|
|
assert_eq!(parsed.command, "bad");
|
|
assert!(parsed.data.is_none());
|
|
assert_eq!(parsed.error.unwrap(), "something went wrong");
|
|
}
|