ircbot 0.1.8

An async IRC bot framework for Rust powered by Tokio and procedural macros
Documentation
//! Runtime tests for the `#[bot]` / `#[command]` / `#[on(...)]` procedural
//! macros: the `Trigger` variants they generate, trigger precedence, the
//! handler count/shape, and the argument-extraction wrapper.
//!
//! Compile-time behaviour (invalid cron / timezone / non-simple type) is
//! covered separately by the trybuild UI tests in `tests/macro_ui.rs`.
//!
//! These tests inspect the private `__handlers()` function generated by
//! `#[bot]`; because they live in the same module as the annotated impl, the
//! private function is accessible.
//!
//! Run with:
//!   cargo test --test macros

use ircbot::handler::HandlerEntry;
use ircbot::testing::TestContext;
use ircbot::{bot, Context, Result, Trigger, User};

// ─── bot under test ──────────────────────────────────────────────────────────

#[bot]
impl MacroBot {
    // [0]
    #[command("ping")]
    async fn ping(&self, ctx: Context) -> Result {
        ctx.say("pong")
    }

    // [1] — target propagation
    #[command("hi", target = "#rust")]
    async fn hi_rust(&self, ctx: Context) -> Result {
        ctx.say("hi")
    }

    // [2] — Message trigger with a single String capture arg
    #[on(message = "hello *")]
    async fn greet(&self, ctx: Context, who: String) -> Result {
        ctx.say(format!("hi {who}"))
    }

    // [3] — Event trigger with a regex
    #[on(event = "JOIN", regex = "(.+)")]
    async fn on_join(&self, ctx: Context) -> Result {
        ctx.say("joined")
    }

    // [4] — Mention trigger
    #[on(mention)]
    async fn on_mention(&self, ctx: Context) -> Result {
        ctx.say("mentioned")
    }

    // [5] — Cron trigger
    #[on(cron = "0 0 * * * *", tz = "UTC")]
    async fn on_cron(&self, ctx: Context) -> Result {
        ctx.say("cron")
    }

    // [6] — precedence: message wins over command/event/mention/cron
    #[on(
        message = "winner",
        command = "loser",
        event = "ALSO_LOSER",
        mention,
        cron = "0 0 * * * *"
    )]
    async fn precedence(&self, ctx: Context) -> Result {
        ctx.say("p")
    }

    // [7] — two String capture args
    #[on(event = "PRIVMSG", regex = r"(\w+) (\w+)")]
    async fn two_args(&self, ctx: Context, a: String, b: String) -> Result {
        ctx.say(format!("{a}-{b}"))
    }

    // [8] — User arg extraction
    #[command("whoami")]
    async fn whoami(&self, ctx: Context, user: User) -> Result {
        ctx.say(user.nick)
    }

    // Plain (non-annotated) method — must remain callable and produce NO entry.
    fn helper(&self) -> u32 {
        42
    }
}

// ─── helpers ─────────────────────────────────────────────────────────────────

fn handlers() -> Vec<HandlerEntry<MacroBot>> {
    MacroBot::__handlers()
}

/// Invoke a handler entry with a context taken from `tc`, returning the first
/// reply line written.
async fn invoke(entry: &HandlerEntry<MacroBot>, mut tc: TestContext) -> Option<String> {
    let bot = std::sync::Arc::new(MacroBot::default());
    (entry.handler)(bot, tc.take_ctx()).await.unwrap();
    tc.next_reply()
}

// ─── trigger variants ────────────────────────────────────────────────────────

#[test]
fn command_attr_yields_command_trigger() {
    match &handlers()[0].trigger {
        Trigger::Command { name, target } => {
            assert_eq!(name, "ping");
            assert_eq!(target.as_deref(), None);
        }
        other => panic!("expected Command, got {other:?}"),
    }
}

#[test]
fn command_target_propagates() {
    match &handlers()[1].trigger {
        Trigger::Command { name, target } => {
            assert_eq!(name, "hi");
            assert_eq!(target.as_deref(), Some("#rust"));
        }
        other => panic!("expected Command, got {other:?}"),
    }
}

#[test]
fn on_message_yields_message_trigger() {
    match &handlers()[2].trigger {
        Trigger::Message { pattern, .. } => assert_eq!(pattern, "hello *"),
        other => panic!("expected Message, got {other:?}"),
    }
}

#[test]
fn on_event_with_regex_yields_event_trigger() {
    match &handlers()[3].trigger {
        Trigger::Event {
            event,
            target,
            regex,
        } => {
            assert_eq!(event, "JOIN");
            assert_eq!(target.as_deref(), None);
            assert_eq!(regex.as_deref(), Some("(.+)"));
        }
        other => panic!("expected Event, got {other:?}"),
    }
}

#[test]
fn on_mention_yields_mention_trigger() {
    assert!(matches!(
        &handlers()[4].trigger,
        Trigger::Mention { target: None }
    ));
}

#[test]
fn on_cron_yields_cron_trigger() {
    match &handlers()[5].trigger {
        Trigger::Cron { schedule, tz, .. } => {
            assert_eq!(schedule, "0 0 * * * *");
            assert_eq!(tz, "UTC");
        }
        other => panic!("expected Cron, got {other:?}"),
    }
}

#[test]
fn message_wins_trigger_precedence() {
    // message > command > event > mention > cron — only the message survives.
    match &handlers()[6].trigger {
        Trigger::Message { pattern, .. } => assert_eq!(pattern, "winner"),
        other => panic!("expected Message (precedence), got {other:?}"),
    }
}

// ─── handler count / shape ───────────────────────────────────────────────────

#[test]
fn only_annotated_methods_produce_handler_entries() {
    // 9 annotated methods; the plain `helper` produces no entry.
    assert_eq!(handlers().len(), 9);
}

#[test]
fn plain_method_remains_callable() {
    assert_eq!(MacroBot::default().helper(), 42);
}

// ─── argument extraction (build_wrapper) ─────────────────────────────────────

#[tokio::test]
async fn string_arg_filled_from_captures_when_present() {
    let entry = &handlers()[2]; // greet(who: String)
    let tc = TestContext::builder()
        .target("#test")
        .captures(vec!["world".to_string()])
        .build();
    assert_eq!(
        invoke(entry, tc).await,
        Some("PRIVMSG #test :hi world\r\n".to_string())
    );
}

#[tokio::test]
async fn string_arg_falls_back_to_message_text_when_no_captures() {
    let entry = &handlers()[2]; // greet(who: String)
    let tc = TestContext::builder()
        .target("#test")
        .text("raw body")
        .build();
    assert_eq!(
        invoke(entry, tc).await,
        Some("PRIVMSG #test :hi raw body\r\n".to_string())
    );
}

#[tokio::test]
async fn two_string_args_pull_successive_captures() {
    let entry = &handlers()[7]; // two_args(a, b: String)
    let tc = TestContext::builder()
        .target("#test")
        .captures(vec!["foo".to_string(), "bar".to_string()])
        .build();
    assert_eq!(
        invoke(entry, tc).await,
        Some("PRIVMSG #test :foo-bar\r\n".to_string())
    );
}

#[tokio::test]
async fn user_arg_filled_from_sender() {
    let entry = &handlers()[8]; // whoami(user: User)
    let tc = TestContext::builder()
        .target("#test")
        .sender_nick("zaphod")
        .build();
    assert_eq!(
        invoke(entry, tc).await,
        Some("PRIVMSG #test :zaphod\r\n".to_string())
    );
}