From 66e7ebd7f11d5e307d3c7cda30b6beca16a3a56c Mon Sep 17 00:00:00 2001 From: Maybe Waffle Date: Thu, 29 Sep 2022 14:08:27 +0400 Subject: [PATCH] Actually tests that macros work --- Cargo.toml | 10 +- micro-teloxide/Cargo.toml | 11 ++ micro-teloxide/src/lib.rs | 212 +++++++++++++++++++++++++++ tests/command.rs | 292 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 micro-teloxide/Cargo.toml create mode 100644 micro-teloxide/src/lib.rs create mode 100644 tests/command.rs diff --git a/Cargo.toml b/Cargo.toml index 83268b75..4a4b43e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,17 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +proc-macro = true + [dependencies] quote = "1.0.7" proc-macro2 = "1.0.19" syn = { version = "1.0.13", features = ["full"] } heck = "0.4.0" -[lib] -proc-macro = true +[dev-dependencies] +teloxide = { path = "micro-teloxide", package = "micro-teloxide" } + +[workspace] +members = [".", "micro-teloxide"] diff --git a/micro-teloxide/Cargo.toml b/micro-teloxide/Cargo.toml new file mode 100644 index 00000000..653d32d0 --- /dev/null +++ b/micro-teloxide/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "micro-teloxide" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +teloxide-macros = { path = ".." } +serde = { version = "1.0.145", features = ["derive"] } +serde_with_macros = "1.5.2" diff --git a/micro-teloxide/src/lib.rs b/micro-teloxide/src/lib.rs new file mode 100644 index 00000000..1131b329 --- /dev/null +++ b/micro-teloxide/src/lib.rs @@ -0,0 +1,212 @@ +//! A minimal "mock" of teloxide crate that is needed to test macros. Changes to +//! `teloxide`[`-core`] should be kept in sync with this... somehow +//! +//! This is a price for placing all crates in separate repositories. + +pub use teloxide_macros as macros; + +pub mod utils { + pub mod command { + use std::{ + error::Error, + fmt::{self, Display, Write}, + marker::PhantomData, + }; + + pub use teloxide_macros::BotCommands; + + use crate::types::{BotCommand, Me}; + pub trait BotCommands: Sized { + fn parse(s: &str, bot_username: N) -> Result + where + N: Into; + + fn descriptions() -> CommandDescriptions<'static>; + + fn bot_commands() -> Vec; + + fn ty() -> PhantomData { + PhantomData + } + } + + pub type PrefixedBotCommand = String; + pub type BotName = String; + + #[derive(Debug)] + pub enum ParseError { + TooFewArguments { expected: usize, found: usize, message: String }, + TooManyArguments { expected: usize, found: usize, message: String }, + + IncorrectFormat(Box), + + UnknownCommand(PrefixedBotCommand), + WrongBotName(BotName), + + Custom(Box), + } + + #[derive(Debug, Clone)] + #[allow(dead_code)] + pub struct CommandDescriptions<'a> { + global_description: Option<&'a str>, + descriptions: &'a [CommandDescription<'a>], + bot_username: Option<&'a str>, + } + + #[derive(Debug, Clone)] + pub struct CommandDescription<'a> { + pub prefix: &'a str, + pub command: &'a str, + pub description: &'a str, + } + + impl<'a> CommandDescriptions<'a> { + pub fn new(descriptions: &'a [CommandDescription<'a>]) -> Self { + Self { + global_description: None, + descriptions, + bot_username: None, + } + } + + pub fn global_description( + self, + global_description: &'a str, + ) -> Self { + Self { global_description: Some(global_description), ..self } + } + + pub fn username(self, bot_username: &'a str) -> Self { + Self { bot_username: Some(bot_username), ..self } + } + + pub fn username_from_me( + self, + me: &'a Me, + ) -> CommandDescriptions<'a> { + self.username( + me.user + .username + .as_deref() + .expect("Bots must have usernames"), + ) + } + } + + impl Display for CommandDescriptions<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(global_description) = self.global_description { + f.write_str(global_description)?; + f.write_str("\n\n")?; + } + + let mut write = + |&CommandDescription { prefix, command, description }, + nls| { + if nls { + f.write_char('\n')?; + } + + f.write_str(prefix)?; + f.write_str(command)?; + + if let Some(username) = self.bot_username { + f.write_char('@')?; + f.write_str(username)?; + } + + if !description.is_empty() { + f.write_str(" — ")?; + f.write_str(description)?; + } + + fmt::Result::Ok(()) + }; + + if let Some(descr) = self.descriptions.first() { + write(descr, false)?; + for descr in &self.descriptions[1..] { + write(descr, true)?; + } + } + + Ok(()) + } + } + } +} + +pub mod types { + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] + pub struct Me { + #[serde(flatten)] + pub user: User, + pub can_join_groups: bool, + pub can_read_all_group_messages: bool, + pub supports_inline_queries: bool, + } + #[serde_with_macros::skip_serializing_none] + #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] + pub struct User { + pub id: UserId, + pub is_bot: bool, + pub first_name: String, + pub last_name: Option, + pub username: Option, + pub language_code: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_premium: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub added_to_attachment_menu: bool, + } + + #[derive( + Clone, + Copy, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + )] + #[serde(transparent)] + pub struct UserId(pub u64); + + #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] + pub struct BotCommand { + pub command: String, + pub description: String, + } + + impl BotCommand { + pub fn new(command: S1, description: S2) -> Self + where + S1: Into, + S2: Into, + { + Self { command: command.into(), description: description.into() } + } + + pub fn command(mut self, val: S) -> Self + where + S: Into, + { + self.command = val.into(); + self + } + + pub fn description(mut self, val: S) -> Self + where + S: Into, + { + self.description = val.into(); + self + } + } +} diff --git a/tests/command.rs b/tests/command.rs new file mode 100644 index 00000000..248ef9e3 --- /dev/null +++ b/tests/command.rs @@ -0,0 +1,292 @@ +//! Test for `teloxide-macros` + +use teloxide::utils::command::BotCommands; + +#[test] +fn parse_command_with_args() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + enum DefaultCommands { + Start(String), + Help, + } + + let data = "/start arg1 arg2"; + let expected = DefaultCommands::Start("arg1 arg2".to_string()); + let actual = DefaultCommands::parse(data, "").unwrap(); + assert_eq!(actual, expected) +} + +#[test] +fn parse_command_with_non_string_arg() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + enum DefaultCommands { + Start(i32), + Help, + } + + let data = "/start -50"; + let expected = DefaultCommands::Start("-50".parse().unwrap()); + let actual = DefaultCommands::parse(data, "").unwrap(); + assert_eq!(actual, expected) +} + +#[test] +fn attribute_prefix() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + enum DefaultCommands { + #[command(prefix = "!")] + Start(String), + Help, + } + + let data = "!start arg1 arg2"; + let expected = DefaultCommands::Start("arg1 arg2".to_string()); + let actual = DefaultCommands::parse(data, "").unwrap(); + assert_eq!(actual, expected) +} + +#[test] +fn many_attributes() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + enum DefaultCommands { + #[command(prefix = "!", description = "desc")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("!start", "").unwrap() + ); + assert_eq!( + DefaultCommands::descriptions().to_string(), + "!start — desc\n/help" + ); +} + +#[test] +fn global_attributes() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(prefix = "!", rename = "lowercase", description = "Bot commands")] + enum DefaultCommands { + #[command(prefix = "/")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("/start", "MyNameBot").unwrap() + ); + assert_eq!( + DefaultCommands::Help, + DefaultCommands::parse("!help", "MyNameBot").unwrap() + ); + assert_eq!( + DefaultCommands::descriptions().to_string(), + "Bot commands\n\n/start\n!help" + ); +} + +#[test] +fn parse_command_with_bot_name() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + enum DefaultCommands { + #[command(prefix = "/")] + Start, + Help, + } + + assert_eq!( + DefaultCommands::Start, + DefaultCommands::parse("/start@MyNameBot", "MyNameBot").unwrap() + ); +} + +#[test] +fn parse_with_split() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + #[command(parse_with = "split")] + enum DefaultCommands { + Start(u8, String), + Help, + } + + assert_eq!( + DefaultCommands::Start(10, "hello".to_string()), + DefaultCommands::parse("/start 10 hello", "").unwrap() + ); +} + +#[test] +fn parse_with_split2() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + #[command(parse_with = "split", separator = "|")] + enum DefaultCommands { + Start(u8, String), + Help, + } + + assert_eq!( + DefaultCommands::Start(10, "hello".to_string()), + DefaultCommands::parse("/start 10|hello", "").unwrap() + ); +} + +#[test] +fn parse_custom_parser() { + mod parser { + use teloxide::utils::command::ParseError; + + pub fn custom_parse_function( + s: String, + ) -> Result<(u8, String), ParseError> { + let vec = s.split_whitespace().collect::>(); + let (left, right) = match vec.as_slice() { + [l, r] => (l, r), + _ => { + return Err(ParseError::IncorrectFormat( + "might be 2 arguments!".into(), + )) + } + }; + left.parse::().map(|res| (res, (*right).to_string())).map_err( + |_| { + ParseError::Custom( + "First argument must be a integer!".to_owned().into(), + ) + }, + ) + } + } + + use parser::custom_parse_function; + + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + enum DefaultCommands { + #[command(parse_with = "custom_parse_function")] + Start(u8, String), + + // Test . + #[command(parse_with = "parser::custom_parse_function")] + TestPath(u8, String), + + Help, + } + + assert_eq!( + DefaultCommands::Start(10, "hello".to_string()), + DefaultCommands::parse("/start 10 hello", "").unwrap() + ); + assert_eq!( + DefaultCommands::TestPath(10, "hello".to_string()), + DefaultCommands::parse("/testpath 10 hello", "").unwrap() + ); +} + +#[test] +fn parse_named_fields() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + #[command(parse_with = "split")] + enum DefaultCommands { + Start { num: u8, data: String }, + Help, + } + + assert_eq!( + DefaultCommands::Start { num: 10, data: "hello".to_string() }, + DefaultCommands::parse("/start 10 hello", "").unwrap() + ); +} + +#[test] +fn descriptions_off() { + #[derive(BotCommands, Debug, PartialEq)] + #[command(rename = "lowercase")] + enum DefaultCommands { + #[command(description = "off")] + Start, + Help, + } + + assert_eq!(DefaultCommands::descriptions().to_string(), "/help".to_owned()); +} + +#[test] +fn rename_rules() { + #[derive(BotCommands, Debug, PartialEq)] + enum DefaultCommands { + #[command(rename = "lowercase")] + AaaAaa, + #[command(rename = "UPPERCASE")] + BbbBbb, + #[command(rename = "PascalCase")] + CccCcc, + #[command(rename = "camelCase")] + DddDdd, + #[command(rename = "snake_case")] + EeeEee, + #[command(rename = "SCREAMING_SNAKE_CASE")] + FffFff, + #[command(rename = "kebab-case")] + GggGgg, + #[command(rename = "SCREAMING-KEBAB-CASE")] + HhhHhh, + //#[command(rename = "Bar")] + //Foo, + } + + assert_eq!( + DefaultCommands::AaaAaa, + DefaultCommands::parse("/aaaaaa", "").unwrap() + ); + assert_eq!( + DefaultCommands::BbbBbb, + DefaultCommands::parse("/BBBBBB", "").unwrap() + ); + assert_eq!( + DefaultCommands::CccCcc, + DefaultCommands::parse("/CccCcc", "").unwrap() + ); + assert_eq!( + DefaultCommands::DddDdd, + DefaultCommands::parse("/dddDdd", "").unwrap() + ); + assert_eq!( + DefaultCommands::EeeEee, + DefaultCommands::parse("/eee_eee", "").unwrap() + ); + assert_eq!( + DefaultCommands::FffFff, + DefaultCommands::parse("/FFF_FFF", "").unwrap() + ); + assert_eq!( + DefaultCommands::GggGgg, + DefaultCommands::parse("/ggg-ggg", "").unwrap() + ); + assert_eq!( + DefaultCommands::HhhHhh, + DefaultCommands::parse("/HHH-HHH", "").unwrap() + ); + //assert_eq!(DefaultCommands::Foo, DefaultCommands::parse("/Bar", + // "").unwrap()); + + // assert_eq!( + // "/aaaaaa\n/BBBBBB\n/CccCcc\n/dddDdd\n/eee_eee\n/FFF_FFF\n/ggg-ggg\n/ + // HHH-HHH\n/Bar", DefaultCommands::descriptions().to_string() + // ); + assert_eq!( + "/aaaaaa\n/BBBBBB\n/CccCcc\n/dddDdd\n/eee_eee\n/FFF_FFF\n/ggg-ggg\n/\ + HHH-HHH", + DefaultCommands::descriptions().to_string() + ); +}