From 89aab73c0d5dd68308d6a306f5c02d0a04be3591 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 16:47:25 +0000 Subject: [PATCH] Add unit tests for every Discord command Extracts each command's reply-building and validation logic out of slash_commands.rs (which mixed business logic with ctx.send() calls) into a new poise_impl/replies.rs module of pure functions and Store-dependency-injected functions, then unit tests all of it: 85 tests covering all 11 commands, helpers.rs, BibleBooks, curated verse selection, and chapter/verse text formatting. Adds a FakeStore in-memory test double (store/fake.rs) so Store-backed behavior (prefs, votd, daily verse role) is testable without a database, and a test_support module for inspecting CreateReply embed content. calc_translation/calc_lang in util/prefs.rs now take an injected `is_valid` predicate so they're testable without depending on the global translation/language cache being populated. Command bodies are now thin wrappers: fetch data, delegate to a pure build_*_reply/apply_* function, send the result. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013FHY7jJsZpnLW8WQdcZgBg --- .../sb-discord-bot/src/poise_impl/helpers.rs | 169 +++- crates/sb-discord-bot/src/poise_impl/mod.rs | 3 + .../sb-discord-bot/src/poise_impl/replies.rs | 918 ++++++++++++++++++ .../src/poise_impl/slash_commands.rs | 569 +++-------- .../src/poise_impl/test_support.rs | 27 + crates/sb-discord-bot/src/store/contract.rs | 82 ++ crates/sb-discord-bot/src/store/curated.rs | 20 + crates/sb-discord-bot/src/store/fake.rs | 169 ++++ crates/sb-discord-bot/src/store/mod.rs | 2 + crates/sb-discord-bot/src/util/format.rs | 109 ++- crates/sb-discord-bot/src/util/prefs.rs | 119 ++- 11 files changed, 1693 insertions(+), 494 deletions(-) create mode 100644 crates/sb-discord-bot/src/poise_impl/replies.rs create mode 100644 crates/sb-discord-bot/src/poise_impl/test_support.rs create mode 100644 crates/sb-discord-bot/src/store/fake.rs diff --git a/crates/sb-discord-bot/src/poise_impl/helpers.rs b/crates/sb-discord-bot/src/poise_impl/helpers.rs index 00667e0..d0432e6 100644 --- a/crates/sb-discord-bot/src/poise_impl/helpers.rs +++ b/crates/sb-discord-bot/src/poise_impl/helpers.rs @@ -7,27 +7,62 @@ use crate::{ util::prefs::{calc_lang, calc_translation}, }; +/// Reply shown when a user-supplied book name doesn't parse into a `BibleBooks` variant. +pub fn unknown_book_reply(book: &str) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Unknown book") + .description(format!("\"{}\" is not a recognised Bible book.", book)) + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +/// Parses a book name string into a `BibleBooks` variant. Pure, no I/O. +pub fn parse_book_str(book: &str) -> Option { + book.parse::().ok() +} + /// Parses a book name string into a `BibleBooks` variant. /// Sends an error embed and returns `None` if unrecognised. pub async fn parse_book(ctx: &Context<'_>, book: &str) -> Result, Error> { - match book.parse::() { - Ok(b) => Ok(Some(b)), - Err(_) => { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Unknown book") - .description(format!("\"{}\" is not a recognised Bible book.", book)) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + match parse_book_str(book) { + Some(b) => Ok(Some(b)), + None => { + ctx.send(unknown_book_reply(book)).await?; Ok(None) } } } +/// Reply shown when `chapter` exceeds `max` chapters available for `book` in `translation`. +pub fn invalid_chapter_reply(book: &BibleBooks, max: i64, translation: &str) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Invalid chapter") + .description(format!( + "{} only has {} chapter(s) in {}.", + book, max, translation + )) + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +/// Whether `chapter` is within range given an optional known chapter count. +/// `None` means the count couldn't be determined, in which case validation +/// is skipped and the API call itself is left to respond. Pure, no I/O. +pub fn chapter_in_range(chapter: i64, max_chapters: Option) -> bool { + match max_chapters { + Some(max) => chapter <= max, + None => true, + } +} + /// Validates that `chapter` is within range for `book` in `translation`. /// Sends an error embed and returns `false` if out of range. pub async fn validate_chapter( @@ -36,23 +71,11 @@ pub async fn validate_chapter( translation: &str, chapter: i64, ) -> Result { - let Some(max) = get_chapter_count(translation, book.get_3c_id()).await else { - return Ok(true); // can't validate, let the API respond - }; - if chapter > max { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Invalid chapter") - .description(format!( - "{} only has {} chapter(s) in {}.", - book, max, translation - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + let max = get_chapter_count(translation, book.get_3c_id()).await; + if !chapter_in_range(chapter, max) { + let max = max.expect("chapter_in_range only rejects a known max"); + ctx.send(invalid_chapter_reply(book, max, translation)) + .await?; return Ok(false); } Ok(true) @@ -92,3 +115,89 @@ pub fn split_reference(reference: &str) -> (String, Option) { } (tokens.join(" "), None) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::poise_impl::test_support::{embed_description, embed_title}; + + #[test] + fn split_reference_separates_trailing_numeric_chapter() { + assert_eq!( + split_reference("John 3"), + ("John".to_string(), Some("3".to_string())) + ); + } + + #[test] + fn split_reference_handles_multi_word_book_with_chapter() { + assert_eq!( + split_reference("1 John 3"), + ("1 John".to_string(), Some("3".to_string())) + ); + } + + #[test] + fn split_reference_treats_whole_reference_as_book_without_chapter() { + assert_eq!(split_reference("Genesis"), ("Genesis".to_string(), None)); + } + + #[test] + fn split_reference_treats_multi_word_book_without_trailing_number_as_book_only() { + assert_eq!( + split_reference("Song of Solomon"), + ("Song of Solomon".to_string(), None) + ); + } + + #[test] + fn split_reference_handles_empty_input() { + assert_eq!(split_reference(""), ("".to_string(), None)); + } + + #[test] + fn parse_book_str_accepts_known_book_case_insensitively() { + assert!(matches!(parse_book_str("john"), Some(BibleBooks::John))); + assert!(matches!( + parse_book_str("1 Samuel"), + Some(BibleBooks::Samuel1) + )); + } + + #[test] + fn parse_book_str_rejects_unknown_book() { + assert!(parse_book_str("Not A Book").is_none()); + } + + #[test] + fn unknown_book_reply_mentions_the_input() { + let reply = unknown_book_reply("Frodo"); + assert_eq!(embed_title(&reply, 0), "Unknown book"); + assert!(embed_description(&reply, 0).contains("Frodo")); + } + + #[test] + fn chapter_in_range_allows_unknown_max() { + assert!(chapter_in_range(999, None)); + } + + #[test] + fn chapter_in_range_allows_chapter_at_max() { + assert!(chapter_in_range(5, Some(5))); + } + + #[test] + fn chapter_in_range_rejects_chapter_over_max() { + assert!(!chapter_in_range(6, Some(5))); + } + + #[test] + fn invalid_chapter_reply_mentions_book_and_max() { + let reply = invalid_chapter_reply(&BibleBooks::John, 21, "BSB"); + assert_eq!(embed_title(&reply, 0), "Invalid chapter"); + let description = embed_description(&reply, 0); + assert!(description.contains("John")); + assert!(description.contains("21")); + assert!(description.contains("BSB")); + } +} diff --git a/crates/sb-discord-bot/src/poise_impl/mod.rs b/crates/sb-discord-bot/src/poise_impl/mod.rs index cbbad11..2b4d8d2 100644 --- a/crates/sb-discord-bot/src/poise_impl/mod.rs +++ b/crates/sb-discord-bot/src/poise_impl/mod.rs @@ -1,5 +1,8 @@ pub mod data; pub mod framework; pub mod helpers; +pub mod replies; pub mod slash_commands; +#[cfg(test)] +pub mod test_support; pub mod types; diff --git a/crates/sb-discord-bot/src/poise_impl/replies.rs b/crates/sb-discord-bot/src/poise_impl/replies.rs new file mode 100644 index 0000000..d263f3e --- /dev/null +++ b/crates/sb-discord-bot/src/poise_impl/replies.rs @@ -0,0 +1,918 @@ +use std::collections::HashMap; + +use poise::CreateReply; +use rand::{Rng, seq::SliceRandom}; +use serenity::all::{ + Colour, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, RoleId, +}; + +use crate::{ + store::{ + bibleapi::{ChapterItem, ChapterVerse}, + contract::BibleBooks, + store::Store, + }, + util::format::{format_verse_content, get_passage_url}, +}; + +pub fn not_in_server_reply() -> CreateReply { + CreateReply { + content: Some("This command can only be used in a server.".into()), + ..Default::default() + } +} + +pub fn build_help_reply() -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Help") + .description("Seed Bible Bot Commands:\n\n`/open` - Open a passage (or the app) in Seed Bible\n`/verse` - Get a specific verse\n`/chapter` — Get a full chapter\n`/random` — Random verse from curated pool\n`/truerandom` — Any verse in the Bible\n`/votd` — Verse of the day\n`/translations` — List available translations\n`/settranslation` — Set your personal default translation") + .color(Colour::from_rgb(178, 255, 237)) + .footer(CreateEmbedFooter::new("(admin) /setservertranslation, /setseedbiblelinks, /setdailychannel, /setdailyverserole")) + ], + ..Default::default() + } +} + +pub fn build_invalid_translation_reply(translation: &str) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title(format!("Invalid Translation: {}", translation)) + .color(Colour::new(16730184)) + .description( + "See this [link](https://bible.helloao.org/api/available_translations.json).", + ), + ], + ..Default::default() + } +} + +pub fn build_translation_set_reply(translation: &str) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title(format!("Translation Set To: {}", translation)) + .color(Colour::new(2736712)), + ], + ..Default::default() + } +} + +/// Persists the user's translation choice and returns the confirmation reply. +/// `Store` is dependency-injected, so this is testable without a live Discord context. +pub async fn apply_translation_choice( + store: &dyn Store, + user_id: String, + translation: &str, +) -> CreateReply { + store + .set_user_translation(user_id, &translation.to_string()) + .await; + build_translation_set_reply(translation) +} + +pub fn build_role_not_found_reply() -> CreateReply { + CreateReply { + content: Some("That role does not exist in this server.".into()), + ..Default::default() + } +} + +pub fn build_role_set_reply(role: RoleId) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Daily verse role set successfully!") + .description(format!("<@{}>", role)), + ], + ..Default::default() + } +} + +/// Persists the server's daily verse role and returns the confirmation reply. +/// `Store` is dependency-injected, so this is testable without a live Discord context. +pub async fn apply_daily_verse_role( + store: &dyn Store, + guild_id: String, + role: RoleId, +) -> CreateReply { + store.set_daily_verse_role(guild_id, role.to_string()).await; + build_role_set_reply(role) +} + +/// Splits a sorted translation list into up to 7 pages and returns the +/// (title, description) for the requested page. Pure, no I/O. +pub fn paginate_translations(all: &[String], page: u8) -> (String, String) { + let chunk_size = (all.len() / 7).max(1); + let chunk = all + .chunks(chunk_size) + .nth((page - 1) as usize) + .unwrap_or_default(); + ( + format!("Available Translations (Page {}/7)", page), + chunk.join(", "), + ) +} + +pub fn build_translations_reply(all: &[String], page: u8) -> CreateReply { + let (title, description) = paginate_translations(all, page); + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title(title) + .description(description) + .color(Colour::from_rgb(178, 255, 237)), + ], + ..Default::default() + } +} + +/// Finds the verse numbered `verse_num` within already-fetched chapter +/// content. Pure, no I/O — takes ownership to mirror how callers consume +/// an owned `ChapterResponse`. +pub fn find_verse(content: Vec, verse_num: i64) -> Option { + content.into_iter().find_map(|item| match item { + ChapterItem::Verse(v) if v.number == verse_num => Some(v), + _ => None, + }) +} + +/// Whether already-fetched chapter content contains the given verse number. Pure, no I/O. +pub fn chapter_has_verse(content: &[ChapterItem], verse_num: i64) -> bool { + content + .iter() + .any(|item| matches!(item, ChapterItem::Verse(v) if v.number == verse_num)) +} + +pub fn build_verse_embed_reply(title: String, verse_data: &ChapterVerse) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title(title) + .description(format_verse_content(verse_data)) + .color(Colour::from_rgb(178, 255, 237)), + ], + ..Default::default() + } +} + +pub fn build_verse_unavailable_reply( + book: &BibleBooks, + chapter: i64, + verse: i64, + translation: &str, +) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Verse unavailable") + .description(format!( + "{} {}:{} is not available in {}. Try a different translation.", + book, chapter, verse, translation + )) + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +pub fn build_verse_not_found_reply(book: &BibleBooks, chapter: i64, verse: i64) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Verse not found") + .description(format!( + "{} {} does not have a verse {}.", + book, chapter, verse + )) + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +pub fn build_no_votd_set_reply() -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("No verse of the day set") + .description("An admin can set one with `/setvotd`.") + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +pub fn build_invalid_votd_book_reply() -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Invalid verse of the day") + .description("The stored verse references an unrecognised book. An admin should reset it with `/setvotd`.") + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +pub fn build_votd_reply( + book: &BibleBooks, + chapter: i64, + verse_data: &ChapterVerse, + translation: &str, +) -> CreateReply { + build_verse_embed_reply( + format!( + "Verse of the Day — {} {}:{} ({})", + book, chapter, verse_data.number, translation + ), + verse_data, + ) +} + +pub fn build_votd_set_reply(book: &BibleBooks, chapter: i64, verse: i64) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Verse of the day set") + .description(format!("{} {}:{}", book, chapter, verse)) + .color(Colour::new(2736712)), + ], + ..Default::default() + } +} + +/// Persists the server's verse of the day and returns the confirmation reply. +/// `Store` is dependency-injected, so this is testable without a live Discord context. +pub async fn apply_set_votd( + store: &dyn Store, + guild_id: String, + book: &BibleBooks, + chapter: i64, + verse: i64, +) -> CreateReply { + store + .set_server_votd(&guild_id, book.get_3c_id(), chapter, verse) + .await; + build_votd_set_reply(book, chapter, verse) +} + +pub fn build_verse_reply( + book: &BibleBooks, + chapter: i64, + verse_data: &ChapterVerse, + translation: &str, +) -> CreateReply { + build_verse_embed_reply( + format!( + "{} {}:{} ({})", + book, chapter, verse_data.number, translation + ), + verse_data, + ) +} + +pub fn build_no_books_reply(translation: &str) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("No books found") + .description(format!( + "Could not load books for translation {}.", + translation + )) + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +pub fn build_no_verses_reply() -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("No verses found") + .description("The randomly selected chapter had no verses.") + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +/// Maps a translation's (book 3c id -> chapter count) map into eligible +/// `BibleBooks` entries, discarding any id that doesn't resolve to a known +/// book. Pure, no I/O. +pub fn eligible_books(book_chapters: &HashMap) -> Vec<(BibleBooks, i64)> { + book_chapters + .iter() + .filter_map(|(id, count)| BibleBooks::from_3c_id(id).map(|b| (b, *count))) + .collect() +} + +pub fn pick_random_book<'a>( + eligible: &'a [(BibleBooks, i64)], + rng: &mut impl Rng, +) -> Option<&'a (BibleBooks, i64)> { + eligible.choose(rng) +} + +pub fn pick_random_chapter(max_chapters: i64, rng: &mut impl Rng) -> i64 { + rng.gen_range(1..=max_chapters) +} + +pub fn pick_random_verse<'a>( + verses: &'a [ChapterVerse], + rng: &mut impl Rng, +) -> Option<&'a ChapterVerse> { + verses.choose(rng) +} + +/// Builds the (possibly paginated) replies for a full chapter's already +/// chunked text. Pure, no I/O. +pub fn build_chapter_replies( + book: &BibleBooks, + chapter: i64, + chunks: Vec, +) -> Vec { + let total = chunks.len(); + chunks + .into_iter() + .enumerate() + .map(|(i, chunk)| { + let title = if total > 1 { + format!("{} {} ({}/{})", book, chapter, i + 1, total) + } else { + format!("{} {}", book, chapter) + }; + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title(title) + .description(chunk) + .color(Colour::from_rgb(178, 255, 237)), + ], + ..Default::default() + } + }) + .collect() +} + +pub fn build_invalid_verse_range_reply() -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Invalid verse range") + .description("End verse must be greater than or equal to the start verse.") + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +/// Validates that `end_verse` (if present) is not before `verse`. Pure, no I/O. +pub fn validate_verse_range(verse: i64, end_verse: Option) -> Result<(), CreateReply> { + if let Some(end) = end_verse { + if end < verse { + return Err(build_invalid_verse_range_reply()); + } + } + Ok(()) +} + +pub fn build_end_verse_not_found_reply(book: &BibleBooks, chapter: i64, end: i64) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("End verse not found") + .description(format!( + "{} {} does not have a verse {}.", + book, chapter, end + )) + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +/// Extracts all verses from already-fetched chapter content, in order. Pure, no I/O. +pub fn verses_in_content(content: Vec) -> Vec { + content + .into_iter() + .filter_map(|item| match item { + ChapterItem::Verse(v) => Some(v), + _ => None, + }) + .collect() +} + +pub fn verse_numbers(verses: &[ChapterVerse]) -> Vec { + verses.iter().map(|v| v.number).collect() +} + +pub fn verses_in_range<'a>( + verses: &'a [ChapterVerse], + start: i64, + end: i64, +) -> Vec<&'a ChapterVerse> { + verses + .iter() + .filter(|v| v.number >= start && v.number <= end) + .collect() +} + +pub fn format_verse_range_content(range_verses: &[&ChapterVerse]) -> String { + range_verses + .iter() + .map(|v| format!("**{}** {}\n", v.number, format_verse_content(v))) + .collect() +} + +/// Builds the (possibly paginated) replies for a verse range's already +/// chunked text. Pure, no I/O. +pub fn build_verse_range_replies( + book: &BibleBooks, + chapter: i64, + verse: i64, + end: i64, + translation: &str, + chunks: Vec, +) -> Vec { + let total = chunks.len(); + chunks + .into_iter() + .enumerate() + .map(|(i, chunk)| { + let title = if total > 1 { + format!( + "{} {}:{}-{} ({}) ({}/{})", + book, + chapter, + verse, + end, + translation, + i + 1, + total + ) + } else { + format!("{} {}:{}-{} ({})", book, chapter, verse, end, translation) + }; + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title(title) + .description(chunk) + .color(Colour::from_rgb(178, 255, 237)), + ], + ..Default::default() + } + }) + .collect() +} + +pub fn build_invalid_chapter_number_reply(chapter_str: &str) -> CreateReply { + CreateReply { + embeds: vec![ + CreateEmbed::default() + .title("Invalid chapter") + .description(format!( + "\"{}\" is not a valid chapter number.", + chapter_str + )) + .color(Colour::new(16730184)), + ], + ..Default::default() + } +} + +/// The descriptive line for `/open`, e.g. "Open John 3 in Seed Bible:". Pure, no I/O. +pub fn open_description(book: Option<&BibleBooks>, chapter: Option) -> String { + match (book, chapter) { + (Some(b), Some(c)) => format!("Open {} {} in Seed Bible:", b, c), + (Some(b), None) => format!("Open {} in Seed Bible:", b), + (None, _) => "Open Seed Bible:".to_string(), + } +} + +pub fn build_open_reply( + book: Option<&BibleBooks>, + chapter: Option, + translation: &str, + lang: &str, +) -> CreateReply { + let description = open_description(book, chapter); + let chapter_string = chapter.map(|c| c.to_string()); + let url = get_passage_url( + book.map(BibleBooks::get_3c_id), + chapter_string.as_deref(), + Some(translation), + Some(lang), + ); + CreateReply { + embeds: vec![ + CreateEmbed::default() + .description(description) + .color(Colour::from_rgb(178, 255, 237)), + ], + components: Some(vec![CreateActionRow::Buttons(vec![ + CreateButton::new_link(url).label("Open →"), + ])]), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + poise_impl::test_support::{embed_description, embed_title}, + store::fake::FakeStore, + }; + + #[test] + fn help_reply_lists_commands() { + let reply = build_help_reply(); + assert_eq!(embed_title(&reply, 0), "Help"); + assert!(embed_description(&reply, 0).contains("/verse")); + } + + #[test] + fn invalid_translation_reply_mentions_input() { + let reply = build_invalid_translation_reply("XXX"); + assert!(embed_title(&reply, 0).contains("XXX")); + } + + #[test] + fn translation_set_reply_mentions_translation() { + let reply = build_translation_set_reply("BSB"); + assert!(embed_title(&reply, 0).contains("BSB")); + } + + #[tokio::test] + async fn apply_translation_choice_persists_to_store() { + let store = FakeStore::new(); + let reply = apply_translation_choice(&store, "u1".to_string(), "BSB").await; + assert_eq!(store.user_translation("u1"), Some("BSB".to_string())); + assert!(embed_title(&reply, 0).contains("BSB")); + } + + #[tokio::test] + async fn apply_daily_verse_role_persists_to_store() { + let store = FakeStore::new(); + let role = RoleId::new(42); + let reply = apply_daily_verse_role(&store, "g1".to_string(), role).await; + assert_eq!(store.daily_verse_role("g1"), Some(role.to_string())); + assert!(embed_description(&reply, 0).contains("42")); + } + + #[test] + fn paginate_translations_splits_evenly() { + let all: Vec = (1..=7).map(|n| format!("T{}", n)).collect(); + let (title, desc) = paginate_translations(&all, 1); + assert_eq!(title, "Available Translations (Page 1/7)"); + assert_eq!(desc, "T1"); + let (_, last) = paginate_translations(&all, 7); + assert_eq!(last, "T7"); + } + + #[test] + fn paginate_translations_handles_fewer_than_seven_entries() { + let all: Vec = vec!["A".to_string(), "B".to_string()]; + let (_, first_page) = paginate_translations(&all, 1); + assert_eq!(first_page, "A"); + let (_, out_of_range) = paginate_translations(&all, 7); + assert_eq!(out_of_range, ""); + } + + #[test] + fn paginate_translations_handles_empty_list() { + let all: Vec = vec![]; + let (_, desc) = paginate_translations(&all, 1); + assert_eq!(desc, ""); + } + + fn verse_item(number: i64, text: &str) -> ChapterItem { + ChapterItem::Verse(ChapterVerse { + number, + content: vec![crate::store::bibleapi::ChapterItemContent::Text( + text.to_string(), + )], + }) + } + + #[test] + fn find_verse_returns_matching_verse() { + let content = vec![ + verse_item(15, "a"), + verse_item(16, "b"), + verse_item(17, "c"), + ]; + let found = find_verse(content, 16).unwrap(); + assert_eq!(found.number, 16); + } + + #[test] + fn find_verse_returns_none_when_absent() { + let content = vec![verse_item(15, "a")]; + assert!(find_verse(content, 99).is_none()); + } + + #[test] + fn find_verse_skips_non_verse_items() { + let content = vec![ChapterItem::LineBreak, verse_item(1, "a")]; + assert_eq!(find_verse(content, 1).unwrap().number, 1); + } + + #[test] + fn chapter_has_verse_true_when_present() { + let content = vec![verse_item(1, "a"), verse_item(2, "b")]; + assert!(chapter_has_verse(&content, 2)); + assert!(!chapter_has_verse(&content, 3)); + } + + #[test] + fn verse_unavailable_reply_mentions_reference_and_translation() { + let reply = build_verse_unavailable_reply(&BibleBooks::John, 3, 16, "KJV"); + assert_eq!(embed_title(&reply, 0), "Verse unavailable"); + let desc = embed_description(&reply, 0); + assert!(desc.contains("John")); + assert!(desc.contains("3:16")); + assert!(desc.contains("KJV")); + } + + #[test] + fn verse_not_found_reply_mentions_reference() { + let reply = build_verse_not_found_reply(&BibleBooks::John, 99, 1); + assert_eq!(embed_title(&reply, 0), "Verse not found"); + assert!(embed_description(&reply, 0).contains("99")); + } + + #[test] + fn no_votd_set_reply_mentions_setvotd() { + let reply = build_no_votd_set_reply(); + assert!(embed_description(&reply, 0).contains("/setvotd")); + } + + #[test] + fn invalid_votd_book_reply_mentions_setvotd() { + let reply = build_invalid_votd_book_reply(); + assert!(embed_description(&reply, 0).contains("/setvotd")); + } + + #[test] + fn votd_reply_contains_verse_text() { + let verse_data = ChapterVerse { + number: 16, + content: vec![crate::store::bibleapi::ChapterItemContent::Text( + "For God so loved the world".to_string(), + )], + }; + let reply = build_votd_reply(&BibleBooks::John, 3, &verse_data, "BSB"); + assert!(embed_title(&reply, 0).contains("Verse of the Day")); + assert!(embed_description(&reply, 0).contains("For God so loved the world")); + } + + #[tokio::test] + async fn apply_set_votd_persists_to_store() { + let store = FakeStore::new(); + let reply = apply_set_votd(&store, "g1".to_string(), &BibleBooks::John, 3, 16).await; + assert_eq!(store.votd_for("g1"), Some(("JHN".to_string(), 3, 16))); + assert!(embed_description(&reply, 0).contains("John 3:16")); + } + + #[test] + fn random_verse_reply_contains_reference_and_text() { + let verse_data = ChapterVerse { + number: 16, + content: vec![crate::store::bibleapi::ChapterItemContent::Text( + "For God so loved the world".to_string(), + )], + }; + let reply = build_verse_reply(&BibleBooks::John, 3, &verse_data, "BSB"); + assert!(embed_title(&reply, 0).contains("John 3:16")); + assert!(embed_description(&reply, 0).contains("For God so loved the world")); + } + + #[test] + fn no_books_reply_mentions_translation() { + let reply = build_no_books_reply("XYZ"); + assert!(embed_description(&reply, 0).contains("XYZ")); + } + + #[test] + fn eligible_books_maps_known_ids_and_drops_unknown() { + let mut map = HashMap::new(); + map.insert("JHN".to_string(), 21); + map.insert("UNKNOWN".to_string(), 5); + let eligible = eligible_books(&map); + assert_eq!(eligible.len(), 1); + assert_eq!(eligible[0].0.get_3c_id(), "JHN"); + assert_eq!(eligible[0].1, 21); + } + + #[test] + fn eligible_books_handles_empty_map() { + assert!(eligible_books(&HashMap::new()).is_empty()); + } + + #[test] + fn pick_random_book_returns_none_for_empty_list() { + let mut rng = rand::thread_rng(); + assert!(pick_random_book(&[], &mut rng).is_none()); + } + + #[test] + fn pick_random_book_always_returns_an_eligible_entry() { + let eligible = vec![(BibleBooks::John, 21), (BibleBooks::Genesis, 50)]; + let mut rng = rand::thread_rng(); + for _ in 0..50 { + let (book, _) = pick_random_book(&eligible, &mut rng).unwrap(); + assert!(matches!(book, BibleBooks::John | BibleBooks::Genesis)); + } + } + + #[test] + fn pick_random_chapter_stays_within_range() { + let mut rng = rand::thread_rng(); + for _ in 0..50 { + let chapter = pick_random_chapter(5, &mut rng); + assert!((1..=5).contains(&chapter)); + } + } + + #[test] + fn pick_random_chapter_handles_single_chapter_book() { + let mut rng = rand::thread_rng(); + assert_eq!(pick_random_chapter(1, &mut rng), 1); + } + + #[test] + fn pick_random_verse_returns_none_for_empty_list() { + let mut rng = rand::thread_rng(); + assert!(pick_random_verse(&[], &mut rng).is_none()); + } + + #[test] + fn pick_random_verse_always_returns_a_provided_verse() { + let verses = vec![ + ChapterVerse { + number: 1, + content: vec![], + }, + ChapterVerse { + number: 2, + content: vec![], + }, + ]; + let mut rng = rand::thread_rng(); + for _ in 0..50 { + let v = pick_random_verse(&verses, &mut rng).unwrap(); + assert!(v.number == 1 || v.number == 2); + } + } + + #[test] + fn chapter_replies_single_chunk_has_no_pagination_suffix() { + let replies = build_chapter_replies(&BibleBooks::John, 3, vec!["content".to_string()]); + assert_eq!(replies.len(), 1); + assert_eq!(embed_title(&replies[0], 0), "John 3"); + assert_eq!(embed_description(&replies[0], 0), "content"); + } + + #[test] + fn chapter_replies_multiple_chunks_are_numbered() { + let replies = build_chapter_replies( + &BibleBooks::John, + 3, + vec!["part1".to_string(), "part2".to_string()], + ); + assert_eq!(replies.len(), 2); + assert_eq!(embed_title(&replies[0], 0), "John 3 (1/2)"); + assert_eq!(embed_title(&replies[1], 0), "John 3 (2/2)"); + } + + #[test] + fn validate_verse_range_accepts_missing_end() { + assert!(validate_verse_range(5, None).is_ok()); + } + + #[test] + fn validate_verse_range_accepts_end_equal_to_start() { + assert!(validate_verse_range(5, Some(5)).is_ok()); + } + + #[test] + fn validate_verse_range_accepts_end_after_start() { + assert!(validate_verse_range(5, Some(10)).is_ok()); + } + + #[test] + fn validate_verse_range_rejects_end_before_start() { + let err = validate_verse_range(10, Some(5)).unwrap_err(); + assert_eq!(embed_title(&err, 0), "Invalid verse range"); + } + + #[test] + fn end_verse_not_found_reply_mentions_end() { + let reply = build_end_verse_not_found_reply(&BibleBooks::John, 3, 99); + assert_eq!(embed_title(&reply, 0), "End verse not found"); + assert!(embed_description(&reply, 0).contains("99")); + } + + #[test] + fn verses_in_content_extracts_only_verses() { + let content = vec![ + ChapterItem::LineBreak, + verse_item(1, "a"), + verse_item(2, "b"), + ]; + let verses = verses_in_content(content); + assert_eq!(verses.len(), 2); + assert_eq!(verse_numbers(&verses), vec![1, 2]); + } + + #[test] + fn verses_in_range_filters_inclusive_bounds() { + let content = vec![verse_item(1, "a"), verse_item(2, "b"), verse_item(3, "c")]; + let verses = verses_in_content(content); + let range = verses_in_range(&verses, 2, 3); + assert_eq!(verse_numbers_of_refs(&range), vec![2, 3]); + } + + fn verse_numbers_of_refs(verses: &[&ChapterVerse]) -> Vec { + verses.iter().map(|v| v.number).collect() + } + + #[test] + fn format_verse_range_content_includes_each_verse_number() { + let content = vec![verse_item(1, "one"), verse_item(2, "two")]; + let verses = verses_in_content(content); + let range: Vec<&ChapterVerse> = verses.iter().collect(); + let text = format_verse_range_content(&range); + assert!(text.contains("**1** one")); + assert!(text.contains("**2** two")); + } + + #[test] + fn verse_range_replies_single_chunk_has_no_pagination_suffix() { + let replies = build_verse_range_replies( + &BibleBooks::John, + 3, + 16, + 17, + "BSB", + vec!["content".to_string()], + ); + assert_eq!(replies.len(), 1); + assert_eq!(embed_title(&replies[0], 0), "John 3:16-17 (BSB)"); + } + + #[test] + fn verse_range_replies_multiple_chunks_are_numbered() { + let replies = build_verse_range_replies( + &BibleBooks::John, + 3, + 16, + 17, + "BSB", + vec!["a".to_string(), "b".to_string()], + ); + assert_eq!(embed_title(&replies[0], 0), "John 3:16-17 (BSB) (1/2)"); + assert_eq!(embed_title(&replies[1], 0), "John 3:16-17 (BSB) (2/2)"); + } + + #[test] + fn invalid_chapter_number_reply_mentions_input() { + let reply = build_invalid_chapter_number_reply("abc"); + assert!(embed_description(&reply, 0).contains("abc")); + } + + #[test] + fn open_description_with_book_and_chapter() { + assert_eq!( + open_description(Some(&BibleBooks::John), Some(3)), + "Open John 3 in Seed Bible:" + ); + } + + #[test] + fn open_description_with_book_only() { + assert_eq!( + open_description(Some(&BibleBooks::John), None), + "Open John in Seed Bible:" + ); + } + + #[test] + fn open_description_with_nothing() { + assert_eq!(open_description(None, None), "Open Seed Bible:"); + } + + #[test] + fn open_reply_uses_description_and_a_link_button() { + let reply = build_open_reply(Some(&BibleBooks::John), Some(3), "BSB", "en"); + assert_eq!(embed_description(&reply, 0), "Open John 3 in Seed Bible:"); + assert!(reply.components.is_some()); + } +} diff --git a/crates/sb-discord-bot/src/poise_impl/slash_commands.rs b/crates/sb-discord-bot/src/poise_impl/slash_commands.rs index c063987..d6ccc5c 100644 --- a/crates/sb-discord-bot/src/poise_impl/slash_commands.rs +++ b/crates/sb-discord-bot/src/poise_impl/slash_commands.rs @@ -1,18 +1,25 @@ -use poise::CreateReply; -use serenity::all::{ - Colour, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, RoleId, -}; +use serenity::all::RoleId; use strum::IntoEnumIterator; -use rand::Rng; -use rand::seq::SliceRandom; - use crate::{ discord_util::user::user_is_admin, poise_impl::{ data::Data, helpers::{ - parse_book, resolve_lang, resolve_translation, split_reference, validate_chapter, + chapter_in_range, parse_book, resolve_lang, resolve_translation, split_reference, + validate_chapter, + }, + replies::{ + apply_daily_verse_role, apply_set_votd, apply_translation_choice, + build_chapter_replies, build_end_verse_not_found_reply, build_help_reply, + build_invalid_chapter_number_reply, build_invalid_translation_reply, + build_invalid_votd_book_reply, build_no_books_reply, build_no_verses_reply, + build_no_votd_set_reply, build_open_reply, build_role_not_found_reply, + build_translations_reply, build_verse_not_found_reply, build_verse_range_replies, + build_verse_reply, build_verse_unavailable_reply, build_votd_reply, chapter_has_verse, + eligible_books, find_verse, format_verse_range_content, not_in_server_reply, + pick_random_book, pick_random_chapter, pick_random_verse, validate_verse_range, + verse_numbers, verses_in_content, verses_in_range, }, types::{Context, Error}, }, @@ -22,9 +29,7 @@ use crate::{ curated::random_curated_verse, valid_cache::{get_all_book_chapters, get_chapter_count, is_valid_translation}, }, - util::format::{ - format_chapter_content, format_verse_content, get_passage_url, split_into_embed_chunks, - }, + util::format::{format_chapter_content, split_into_embed_chunks}, }; async fn autocomplete_book<'a>( @@ -52,17 +57,7 @@ async fn autocomplete_translation<'a>( description_localized("en-US", "Get a list of available commands") )] pub async fn help(ctx: Context<'_>) -> Result<(), Error> { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Help") - .description("Seed Bible Bot Commands:\n\n`/open` - Open a passage (or the app) in Seed Bible\n`/verse` - Get a specific verse\n`/chapter` — Get a full chapter\n`/random` — Random verse from curated pool\n`/truerandom` — Any verse in the Bible\n`/votd` — Verse of the day\n`/translations` — List available translations\n`/settranslation` — Set your personal default translation") - .color(Colour::from_rgb(178, 255, 237)) - .footer(CreateEmbedFooter::new("(admin) /setservertranslation, /setseedbiblelinks, /setdailychannel, /setdailyverserole")) - ], - ..Default::default() - }) - .await?; + ctx.send(build_help_reply()).await?; Ok(()) } @@ -99,19 +94,8 @@ pub async fn open( if let Some(chapter_str) = chapter_str { let Ok(parsed_chapter) = chapter_str.parse::() else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Invalid chapter") - .description(format!( - "\"{}\" is not a valid chapter number.", - chapter_str - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + ctx.send(build_invalid_chapter_number_reply(&chapter_str)) + .await?; return Ok(()); }; if !validate_chapter(&ctx, &parsed_book, &translation, parsed_chapter).await? { @@ -122,31 +106,12 @@ pub async fn open( book = Some(parsed_book); } - let description = match (&book, chapter) { - (Some(b), Some(c)) => format!("Open {} {} in Seed Bible:", b, c), - (Some(b), None) => format!("Open {} in Seed Bible:", b), - (None, _) => "Open Seed Bible:".to_string(), - }; - - let chapter_string = chapter.map(|c| c.to_string()); - let url = get_passage_url( - book.as_ref().map(BibleBooks::get_3c_id), - chapter_string.as_deref(), - Some(&translation), - Some(&lang), - ); - - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .description(description) - .color(Colour::from_rgb(178, 255, 237)), - ], - components: Some(vec![CreateActionRow::Buttons(vec![ - CreateButton::new_link(url).label("Open →"), - ])]), - ..Default::default() - }) + ctx.send(build_open_reply( + book.as_ref(), + chapter, + &translation, + &lang, + )) .await?; Ok(()) } @@ -165,34 +130,13 @@ pub async fn settranslation( translation: String, ) -> Result<(), Error> { if !is_valid_translation(&translation) { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(format!("Invalid Translation: {}", translation)) - .color(Colour::new(16730184)) - .description( - "See this [link](https://bible.helloao.org/api/available_translations.json).", - ), - ], - ..Default::default() - }) - .await?; + ctx.send(build_invalid_translation_reply(&translation)) + .await?; return Ok(()); } let user_id = ctx.author().id.to_string(); - ctx.data() - .store - .set_user_translation(user_id, &translation) - .await; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(format!("Translation Set To: {}", translation)) - .color(Colour::new(2736712)), - ], - ..Default::default() - }) - .await?; + let reply = apply_translation_choice(ctx.data().store.as_ref(), user_id, &translation).await; + ctx.send(reply).await?; Ok(()) } @@ -208,11 +152,7 @@ pub async fn setdailyverserole(ctx: Context<'_>, role: RoleId) -> Result<(), Err let guild_id = match ctx.guild_id() { Some(id) => id.to_string(), None => { - ctx.send(CreateReply { - content: Some("This command can only be used in a server.".into()), - ..Default::default() - }) - .await?; + ctx.send(not_in_server_reply()).await?; return Ok(()); } }; @@ -222,26 +162,11 @@ pub async fn setdailyverserole(ctx: Context<'_>, role: RoleId) -> Result<(), Err .map(|g| g.roles.contains_key(&role)) .unwrap_or(false); if !role_exists { - ctx.send(CreateReply { - content: Some("That role does not exist in this server.".into()), - ..Default::default() - }) - .await?; + ctx.send(build_role_not_found_reply()).await?; return Ok(()); } - ctx.data() - .store - .set_daily_verse_role(guild_id, role.to_string()) - .await; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Daily verse role set successfully!") - .description(format!("<@{}>", role)), - ], - ..Default::default() - }) - .await?; + let reply = apply_daily_verse_role(ctx.data().store.as_ref(), guild_id, role).await; + ctx.send(reply).await?; Ok(()) } @@ -261,22 +186,7 @@ pub async fn translations( .unwrap_or_default(); all.sort(); - let chunk_size = (all.len() / 7).max(1); - let chunk = all - .chunks(chunk_size) - .nth((page - 1) as usize) - .unwrap_or_default(); - - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(format!("Available Translations (Page {}/7)", page)) - .description(chunk.join(", ")) - .color(Colour::from_rgb(178, 255, 237)), - ], - ..Default::default() - }) - .await?; + ctx.send(build_translations_reply(&all, page)).await?; Ok(()) } @@ -292,79 +202,33 @@ pub async fn votd( ) -> Result<(), Error> { ctx.defer().await?; let Some(guild_id) = ctx.guild_id() else { - ctx.send(CreateReply { - content: Some("This command can only be used in a server.".into()), - ..Default::default() - }) - .await?; + ctx.send(not_in_server_reply()).await?; return Ok(()); }; let store = &ctx.data().store; let Some((book_3c_id, chapter, verse_num)) = store.get_server_votd(&guild_id.to_string()).await else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("No verse of the day set") - .description("An admin can set one with `/setvotd`.") - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + ctx.send(build_no_votd_set_reply()).await?; return Ok(()); }; let Some(book) = BibleBooks::from_3c_id(&book_3c_id) else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Invalid verse of the day") - .description("The stored verse references an unrecognised book. An admin should reset it with `/setvotd`.") - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + ctx.send(build_invalid_votd_book_reply()).await?; return Ok(()); }; let translation = resolve_translation(&ctx, translation.as_deref()).await; let response = get_chapter(&translation, &book, chapter).await?; - let found = response.chapter.content.into_iter().find_map(|item| { - if let ChapterItem::Verse(v) = item { - if v.number == verse_num { Some(v) } else { None } - } else { - None - } - }); - let Some(verse_data) = found else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Verse unavailable") - .description(format!( - "{} {}:{} is not available in {}. Try a different translation.", - book, chapter, verse_num, translation - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) + let Some(verse_data) = find_verse(response.chapter.content, verse_num) else { + ctx.send(build_verse_unavailable_reply( + &book, + chapter, + verse_num, + &translation, + )) .await?; return Ok(()); }; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(format!( - "Verse of the Day — {} {}:{} ({})", - book, chapter, verse_data.number, translation - )) - .description(format_verse_content(&verse_data)) - .color(Colour::from_rgb(178, 255, 237)), - ], - ..Default::default() - }) - .await?; + ctx.send(build_votd_reply(&book, chapter, &verse_data, &translation)) + .await?; Ok(()) } @@ -387,11 +251,7 @@ pub async fn setvotd( ) -> Result<(), Error> { ctx.defer().await?; let Some(guild_id) = ctx.guild_id() else { - ctx.send(CreateReply { - content: Some("This command can only be used in a server.".into()), - ..Default::default() - }) - .await?; + ctx.send(not_in_server_reply()).await?; return Ok(()); }; let Some(book) = parse_book(&ctx, &book).await? else { @@ -402,41 +262,20 @@ pub async fn setvotd( return Ok(()); } let response = get_chapter(&translation, &book, chapter).await?; - let verse_exists = response - .chapter - .content - .iter() - .any(|item| matches!(item, ChapterItem::Verse(v) if v.number == verse)); - if !verse_exists { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Verse not found") - .description(format!( - "{} {} does not have a verse {}.", - book, chapter, verse - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + if !chapter_has_verse(&response.chapter.content, verse) { + ctx.send(build_verse_not_found_reply(&book, chapter, verse)) + .await?; return Ok(()); } - ctx.data() - .store - .set_server_votd(&guild_id.to_string(), book.get_3c_id(), chapter, verse) - .await; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Verse of the day set") - .description(format!("{} {}:{}", book, chapter, verse)) - .color(Colour::new(2736712)), - ], - ..Default::default() - }) - .await?; + let reply = apply_set_votd( + ctx.data().store.as_ref(), + guild_id.to_string(), + &book, + chapter, + verse, + ) + .await; + ctx.send(reply).await?; Ok(()) } @@ -453,27 +292,12 @@ pub async fn truerandom( ctx.defer().await?; let translation = resolve_translation(&ctx, translation.as_deref()).await; let book_chapters = get_all_book_chapters(&translation).await; - let eligible: Vec<(BibleBooks, i64)> = book_chapters - .into_iter() - .filter_map(|(id, count)| BibleBooks::from_3c_id(&id).map(|b| (b, count))) - .collect(); - let Some((book, max_chapters)) = eligible.choose(&mut rand::thread_rng()) else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("No books found") - .description(format!( - "Could not load books for translation {}.", - translation - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + let eligible = eligible_books(&book_chapters); + let Some((book, max_chapters)) = pick_random_book(&eligible, &mut rand::thread_rng()) else { + ctx.send(build_no_books_reply(&translation)).await?; return Ok(()); }; - let chapter = rand::thread_rng().gen_range(1..=*max_chapters); + let chapter = pick_random_chapter(*max_chapters, &mut rand::thread_rng()); let response = get_chapter(&translation, book, chapter).await?; let verses: Vec<_> = response .chapter @@ -487,32 +311,12 @@ pub async fn truerandom( } }) .collect(); - let Some(verse_data) = verses.choose(&mut rand::thread_rng()) else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("No verses found") - .description("The randomly selected chapter had no verses.") - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + let Some(verse_data) = pick_random_verse(&verses, &mut rand::thread_rng()) else { + ctx.send(build_no_verses_reply()).await?; return Ok(()); }; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(format!( - "{} {}:{} ({})", - book, chapter, verse_data.number, translation - )) - .description(format_verse_content(verse_data)) - .color(Colour::from_rgb(178, 255, 237)), - ], - ..Default::default() - }) - .await?; + ctx.send(build_verse_reply(book, chapter, verse_data, &translation)) + .await?; Ok(()) } @@ -529,66 +333,34 @@ pub async fn random( ctx.defer().await?; let verse_ref = random_curated_verse(); let translation = resolve_translation(&ctx, translation.as_deref()).await; - let unavailable = match get_chapter_count(&translation, verse_ref.book.get_3c_id()).await { - None => true, - Some(max) => verse_ref.chapter > max, - }; - if unavailable { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Verse unavailable") - .description(format!( - "{} {}:{} is not available in {}. Try a different translation.", - verse_ref.book, verse_ref.chapter, verse_ref.verse, translation - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) + let max = get_chapter_count(&translation, verse_ref.book.get_3c_id()).await; + if !chapter_in_range(verse_ref.chapter, max) { + ctx.send(build_verse_unavailable_reply( + &verse_ref.book, + verse_ref.chapter, + verse_ref.verse, + &translation, + )) .await?; return Ok(()); } let response = get_chapter(&translation, &verse_ref.book, verse_ref.chapter).await?; - let found = response.chapter.content.into_iter().find_map(|item| { - if let ChapterItem::Verse(v) = item { - if v.number == verse_ref.verse { - Some(v) - } else { - None - } - } else { - None - } - }); - let Some(verse_data) = found else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Verse unavailable") - .description(format!( - "{} {}:{} is not available in {}. Try a different translation.", - verse_ref.book, verse_ref.chapter, verse_ref.verse, translation - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) + let Some(verse_data) = find_verse(response.chapter.content, verse_ref.verse) else { + ctx.send(build_verse_unavailable_reply( + &verse_ref.book, + verse_ref.chapter, + verse_ref.verse, + &translation, + )) .await?; return Ok(()); }; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(format!( - "{} {}:{} ({})", - verse_ref.book, verse_ref.chapter, verse_data.number, translation - )) - .description(format_verse_content(&verse_data)) - .color(Colour::from_rgb(178, 255, 237)), - ], - ..Default::default() - }) + ctx.send(build_verse_reply( + &verse_ref.book, + verse_ref.chapter, + &verse_data, + &translation, + )) .await?; Ok(()) } @@ -617,23 +389,8 @@ pub async fn chapter( let response = get_chapter(&translation, &book, chapter).await?; let content = format_chapter_content(&response.chapter); let chunks = split_into_embed_chunks(&content); - let total = chunks.len(); - for (i, chunk) in chunks.into_iter().enumerate() { - let title = if total > 1 { - format!("{} {} ({}/{})", book, chapter, i + 1, total) - } else { - format!("{} {}", book, chapter) - }; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(title) - .description(chunk) - .color(Colour::from_rgb(178, 255, 237)), - ], - ..Default::default() - }) - .await?; + for reply in build_chapter_replies(&book, chapter, chunks) { + ctx.send(reply).await?; } Ok(()) } @@ -659,20 +416,9 @@ pub async fn verse( ) -> Result<(), Error> { ctx.defer().await?; - if let Some(end) = end_verse { - if end < verse { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Invalid verse range") - .description("End verse must be greater than or equal to the start verse.") - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; - return Ok(()); - } + if let Err(reply) = validate_verse_range(verse, end_verse) { + ctx.send(reply).await?; + return Ok(()); } let Some(book) = parse_book(&ctx, &book).await? else { @@ -685,131 +431,36 @@ pub async fn verse( let response = get_chapter(&translation, &book, chapter).await?; let Some(end) = end_verse else { - let found = response.chapter.content.into_iter().find_map(|item| { - if let ChapterItem::Verse(v) = item { - if v.number == verse { Some(v) } else { None } - } else { - None - } - }); - let Some(verse_data) = found else { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Verse not found") - .description(format!( - "{} {} does not have a verse {}.", - book, chapter, verse - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + let Some(verse_data) = find_verse(response.chapter.content, verse) else { + ctx.send(build_verse_not_found_reply(&book, chapter, verse)) + .await?; return Ok(()); }; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(format!( - "{} {}:{} ({})", - book, chapter, verse_data.number, translation - )) - .description(format_verse_content(&verse_data)) - .color(Colour::from_rgb(178, 255, 237)), - ], - ..Default::default() - }) - .await?; + ctx.send(build_verse_reply(&book, chapter, &verse_data, &translation)) + .await?; return Ok(()); }; - let verses_in_chapter: Vec<_> = response - .chapter - .content - .into_iter() - .filter_map(|item| { - if let ChapterItem::Verse(v) = item { - Some(v) - } else { - None - } - }) - .collect(); - - let verse_numbers: Vec = verses_in_chapter.iter().map(|v| v.number).collect(); + let verses_in_chapter = verses_in_content(response.chapter.content); + let numbers = verse_numbers(&verses_in_chapter); - if !verse_numbers.contains(&verse) { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("Verse not found") - .description(format!( - "{} {} does not have a verse {}.", - book, chapter, verse - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + if !numbers.contains(&verse) { + ctx.send(build_verse_not_found_reply(&book, chapter, verse)) + .await?; return Ok(()); } - if !verse_numbers.contains(&end) { - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title("End verse not found") - .description(format!( - "{} {} does not have a verse {}.", - book, chapter, end - )) - .color(Colour::new(16730184)), - ], - ..Default::default() - }) - .await?; + if !numbers.contains(&end) { + ctx.send(build_end_verse_not_found_reply(&book, chapter, end)) + .await?; return Ok(()); } - let range_verses: Vec<_> = verses_in_chapter - .iter() - .filter(|v| v.number >= verse && v.number <= end) - .collect(); - - let content: String = range_verses - .iter() - .map(|v| format!("**{}** {}\n", v.number, format_verse_content(v))) - .collect(); - + let range_verses = verses_in_range(&verses_in_chapter, verse, end); + let content = format_verse_range_content(&range_verses); let chunks = split_into_embed_chunks(&content); - let total = chunks.len(); - for (i, chunk) in chunks.into_iter().enumerate() { - let title = if total > 1 { - format!( - "{} {}:{}-{} ({}) ({}/{})", - book, - chapter, - verse, - end, - translation, - i + 1, - total - ) - } else { - format!("{} {}:{}-{} ({})", book, chapter, verse, end, translation) - }; - ctx.send(CreateReply { - embeds: vec![ - CreateEmbed::default() - .title(title) - .description(chunk) - .color(Colour::from_rgb(178, 255, 237)), - ], - ..Default::default() - }) - .await?; + for reply in build_verse_range_replies(&book, chapter, verse, end, &translation, chunks) { + ctx.send(reply).await?; } Ok(()) } diff --git a/crates/sb-discord-bot/src/poise_impl/test_support.rs b/crates/sb-discord-bot/src/poise_impl/test_support.rs new file mode 100644 index 0000000..ce37f9c --- /dev/null +++ b/crates/sb-discord-bot/src/poise_impl/test_support.rs @@ -0,0 +1,27 @@ +use poise::CreateReply; +use serde_json::Value; + +pub fn embed_json(reply: &CreateReply, index: usize) -> Value { + serde_json::to_value(&reply.embeds[index]).unwrap() +} + +pub fn embed_title(reply: &CreateReply, index: usize) -> String { + embed_json(reply, index)["title"] + .as_str() + .unwrap_or_default() + .to_string() +} + +pub fn embed_description(reply: &CreateReply, index: usize) -> String { + embed_json(reply, index)["description"] + .as_str() + .unwrap_or_default() + .to_string() +} + +pub fn embed_footer_text(reply: &CreateReply, index: usize) -> String { + embed_json(reply, index)["footer"]["text"] + .as_str() + .unwrap_or_default() + .to_string() +} diff --git a/crates/sb-discord-bot/src/store/contract.rs b/crates/sb-discord-bot/src/store/contract.rs index 949a549..7e44b82 100644 --- a/crates/sb-discord-bot/src/store/contract.rs +++ b/crates/sb-discord-bot/src/store/contract.rs @@ -303,6 +303,65 @@ impl BibleBooks { } } +#[cfg(test)] +mod bible_books_tests { + use std::str::FromStr; + + use strum::IntoEnumIterator; + + use super::BibleBooks; + + #[test] + fn every_book_round_trips_through_3c_id() { + for book in BibleBooks::iter() { + let id = book.get_3c_id(); + let parsed = BibleBooks::from_3c_id(id) + .unwrap_or_else(|| panic!("3c id {} did not round-trip", id)); + assert_eq!(parsed.get_3c_id(), id); + } + } + + #[test] + fn every_book_round_trips_through_display_and_from_str() { + for book in BibleBooks::iter() { + let name = book.to_string(); + let parsed = BibleBooks::from_str(&name) + .unwrap_or_else(|_| panic!("display name {} did not parse back", name)); + assert_eq!(parsed.to_string(), name); + } + } + + #[test] + fn from_str_is_case_insensitive() { + assert!(matches!(BibleBooks::from_str("john"), Ok(BibleBooks::John))); + assert!(matches!(BibleBooks::from_str("JOHN"), Ok(BibleBooks::John))); + } + + #[test] + fn from_3c_id_rejects_unknown_id() { + assert!(BibleBooks::from_3c_id("XXX").is_none()); + } + + #[test] + fn every_book_has_a_positive_chapter_count() { + for book in BibleBooks::iter() { + assert!(book.chapter_count() > 0, "{} has no chapters", book); + } + } + + #[test] + fn chapter_count_matches_known_values() { + assert_eq!(BibleBooks::Psalms.chapter_count(), 150); + assert_eq!(BibleBooks::Philemon.chapter_count(), 1); + assert_eq!(BibleBooks::Revelation.chapter_count(), 22); + } + + #[test] + fn exactly_sixty_six_books() { + assert_eq!(BibleBooks::iter().count(), 66); + } +} + #[derive(Debug, Clone, strum::Display, strum::EnumString, strum::EnumIter)] pub enum Translations { AAB, @@ -1559,3 +1618,26 @@ pub enum Translations { zsr_wbt, ztp_wbt, } + +#[cfg(test)] +mod translations_tests { + use std::str::FromStr; + + use strum::IntoEnumIterator; + + use super::Translations; + + #[test] + fn bsb_round_trips() { + assert!(matches!( + Translations::from_str("BSB"), + Ok(Translations::BSB) + )); + assert_eq!(Translations::BSB.to_string(), "BSB"); + } + + #[test] + fn has_many_translations() { + assert!(Translations::iter().count() > 100); + } +} diff --git a/crates/sb-discord-bot/src/store/curated.rs b/crates/sb-discord-bot/src/store/curated.rs index c2074fa..5f7d544 100644 --- a/crates/sb-discord-bot/src/store/curated.rs +++ b/crates/sb-discord-bot/src/store/curated.rs @@ -66,3 +66,23 @@ pub fn random_curated_verse() -> &'static VerseRef { .choose(&mut rand::thread_rng()) .expect("curated pool is non-empty") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn always_returns_a_valid_reference_within_the_books_chapter_count() { + for _ in 0..200 { + let verse_ref = random_curated_verse(); + assert!(verse_ref.chapter >= 1); + assert!(verse_ref.verse >= 1); + assert!( + verse_ref.chapter <= verse_ref.book.chapter_count(), + "{} {} exceeds its chapter count", + verse_ref.book, + verse_ref.chapter + ); + } + } +} diff --git a/crates/sb-discord-bot/src/store/fake.rs b/crates/sb-discord-bot/src/store/fake.rs new file mode 100644 index 0000000..7948609 --- /dev/null +++ b/crates/sb-discord-bot/src/store/fake.rs @@ -0,0 +1,169 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Mutex, +}; + +use serenity::async_trait; + +use crate::store::store::{DailyCache, ServerPref, UserPref, VotdStore}; + +/// In-memory `Store` implementation for tests. Never touches a real database. +#[derive(Default)] +pub struct FakeStore { + user_translations: Mutex>, + user_languages: Mutex>, + server_translations: Mutex>, + server_languages: Mutex>, + daily_verse_roles: Mutex>, + votd: Mutex>, +} + +impl FakeStore { + pub fn new() -> Self { + Self::default() + } + + pub fn with_user_translation(self, user_id: &str, translation: &str) -> Self { + self.user_translations + .lock() + .unwrap() + .insert(user_id.to_string(), translation.to_string()); + self + } + + pub fn with_server_translation(self, guild_id: &str, translation: &str) -> Self { + self.server_translations + .lock() + .unwrap() + .insert(guild_id.to_string(), translation.to_string()); + self + } + + pub fn with_user_language(self, user_id: &str, language: &str) -> Self { + self.user_languages + .lock() + .unwrap() + .insert(user_id.to_string(), language.to_string()); + self + } + + pub fn with_server_language(self, guild_id: &str, language: &str) -> Self { + self.server_languages + .lock() + .unwrap() + .insert(guild_id.to_string(), language.to_string()); + self + } + + pub fn with_daily_verse_role(self, guild_id: &str, role_id: &str) -> Self { + self.daily_verse_roles + .lock() + .unwrap() + .insert(guild_id.to_string(), role_id.to_string()); + self + } + + pub fn with_votd(self, guild_id: &str, book_3c_id: &str, chapter: i64, verse: i64) -> Self { + self.votd.lock().unwrap().insert( + guild_id.to_string(), + (book_3c_id.to_string(), chapter, verse), + ); + self + } + + pub fn user_translation(&self, user_id: &str) -> Option { + self.user_translations.lock().unwrap().get(user_id).cloned() + } + + pub fn server_translation(&self, guild_id: &str) -> Option { + self.server_translations + .lock() + .unwrap() + .get(guild_id) + .cloned() + } + + pub fn daily_verse_role(&self, guild_id: &str) -> Option { + self.daily_verse_roles + .lock() + .unwrap() + .get(guild_id) + .cloned() + } + + pub fn votd_for(&self, guild_id: &str) -> Option<(String, i64, i64)> { + self.votd.lock().unwrap().get(guild_id).cloned() + } +} + +#[async_trait] +impl UserPref for FakeStore { + async fn get_user_translation(&self, user_id: String) -> Option { + self.user_translation(&user_id) + } + + async fn get_user_language(&self, user_id: String) -> Option { + self.user_languages.lock().unwrap().get(&user_id).cloned() + } + + async fn set_user_translation(&self, user_id: String, translation_key: &String) { + self.user_translations + .lock() + .unwrap() + .insert(user_id, translation_key.clone()); + } +} + +#[async_trait] +impl ServerPref for FakeStore { + async fn get_server_translation(&self, guild_id: String) -> Option { + self.server_translation(&guild_id) + } + + async fn get_server_language(&self, guild_id: String) -> Option { + self.server_languages + .lock() + .unwrap() + .get(&guild_id) + .cloned() + } + + async fn set_server_translation(&self, guild_id: String, translation_key: String) { + self.server_translations + .lock() + .unwrap() + .insert(guild_id, translation_key); + } + + async fn get_daily_verse_role(&self, guild_id: String) -> Option { + self.daily_verse_role(&guild_id) + } + + async fn set_daily_verse_role(&self, guild_id: String, role_id: String) { + self.daily_verse_roles + .lock() + .unwrap() + .insert(guild_id, role_id); + } +} + +#[async_trait] +impl DailyCache for FakeStore { + async fn get_all_available_translations(&self) -> Option<&'static HashSet> { + None + } +} + +#[async_trait] +impl VotdStore for FakeStore { + async fn get_server_votd(&self, guild_id: &str) -> Option<(String, i64, i64)> { + self.votd_for(guild_id) + } + + async fn set_server_votd(&self, guild_id: &str, book_3c_id: &str, chapter: i64, verse: i64) { + self.votd.lock().unwrap().insert( + guild_id.to_string(), + (book_3c_id.to_string(), chapter, verse), + ); + } +} diff --git a/crates/sb-discord-bot/src/store/mod.rs b/crates/sb-discord-bot/src/store/mod.rs index 8371980..92cef4d 100644 --- a/crates/sb-discord-bot/src/store/mod.rs +++ b/crates/sb-discord-bot/src/store/mod.rs @@ -1,4 +1,6 @@ pub mod curated; +#[cfg(test)] +pub mod fake; pub mod sqlite; pub mod store; pub mod entity { diff --git a/crates/sb-discord-bot/src/util/format.rs b/crates/sb-discord-bot/src/util/format.rs index fb56209..cd867c8 100644 --- a/crates/sb-discord-bot/src/util/format.rs +++ b/crates/sb-discord-bot/src/util/format.rs @@ -1,4 +1,6 @@ -use crate::store::bibleapi::{Chapter, ChapterItem, ChapterItemContent, ChapterVerse}; +use crate::store::bibleapi::{ + Chapter, ChapterHeading, ChapterItem, ChapterItemContent, ChapterVerse, +}; const EMBED_DESCRIPTION_LIMIT: usize = 4096; @@ -125,3 +127,108 @@ pub fn get_passage_url( } url } + +#[cfg(test)] +mod tests { + use super::*; + + fn verse(number: i64, text: &str) -> ChapterVerse { + ChapterVerse { + number, + content: vec![ChapterItemContent::Text(text.to_string())], + } + } + + #[test] + fn format_verse_content_joins_text_pieces() { + let v = ChapterVerse { + number: 16, + content: vec![ + ChapterItemContent::Text("For God so loved".to_string()), + ChapterItemContent::Text(" the world".to_string()), + ], + }; + assert_eq!(format_verse_content(&v), "For God so loved the world"); + } + + #[test] + fn format_verse_content_skips_note_ids() { + let v = ChapterVerse { + number: 1, + content: vec![ + ChapterItemContent::Text("Hello".to_string()), + ChapterItemContent::NoteId(serde_json::from_str(r#"{"noteId":1}"#).unwrap()), + ], + }; + assert_eq!(format_verse_content(&v), "Hello"); + } + + #[test] + fn format_verse_content_extracts_text_from_unknown_object_shape() { + let v = ChapterVerse { + number: 1, + content: vec![ChapterItemContent::Unknown( + serde_json::json!({"text": "nested"}), + )], + }; + assert_eq!(format_verse_content(&v), "nested"); + } + + #[test] + fn format_chapter_content_includes_verse_numbers_and_headings() { + let chapter = Chapter { + number: 3, + content: vec![ + ChapterItem::Heading(ChapterHeading { + content: vec!["Section".to_string()], + }), + ChapterItem::Verse(verse(16, "For God so loved the world")), + ChapterItem::LineBreak, + ], + }; + let text = format_chapter_content(&chapter); + assert!(text.contains("Section")); + assert!(text.contains("**16**")); + assert!(text.contains("For God so loved the world")); + } + + #[test] + fn split_into_embed_chunks_returns_single_chunk_when_under_limit() { + let chunks = split_into_embed_chunks("short text"); + assert_eq!(chunks, vec!["short text".to_string()]); + } + + #[test] + fn split_into_embed_chunks_splits_long_text_on_newline_boundary() { + let line = "a".repeat(100); + let text = std::iter::repeat_n(line.clone(), 50) + .collect::>() + .join("\n"); + let chunks = split_into_embed_chunks(&text); + assert!(chunks.len() > 1); + for chunk in &chunks { + assert!(chunk.len() <= 4096); + } + assert_eq!(chunks.join("\n"), text); + } + + #[test] + fn split_into_embed_chunks_handles_empty_text() { + assert!(split_into_embed_chunks("").is_empty()); + } + + #[test] + fn get_passage_url_includes_only_provided_params() { + let url = get_passage_url(None, None, None, None); + assert_eq!(url, "https://seedbible.org/?source=discord_bot"); + } + + #[test] + fn get_passage_url_includes_all_provided_params() { + let url = get_passage_url(Some("JHN"), Some("3"), Some("BSB"), Some("en")); + assert!(url.contains("book=JHN")); + assert!(url.contains("chapter=3")); + assert!(url.contains("translation=BSB")); + assert!(url.contains("lang=en")); + } +} diff --git a/crates/sb-discord-bot/src/util/prefs.rs b/crates/sb-discord-bot/src/util/prefs.rs index 0504171..6e6c2c4 100644 --- a/crates/sb-discord-bot/src/util/prefs.rs +++ b/crates/sb-discord-bot/src/util/prefs.rs @@ -6,14 +6,19 @@ use crate::store::{ const DEFAULT_TRANSLATION: &str = "BSB"; const DEFAULT_LANGUAGE: &str = "en"; -pub async fn calc_translation( +/// Resolves the effective translation from the priority chain: explicit +/// choice (if valid per `is_valid`) → user pref → server pref → default. +/// `is_valid` is dependency-injected so this stays testable without the +/// global translation cache in `valid_cache` being populated. +pub async fn calc_translation_with( user_choice: Option<&str>, user_id: &str, guild_id: Option<&str>, store: &dyn Store, + is_valid: impl Fn(&str) -> bool, ) -> String { if let Some(choice) = user_choice { - if is_valid_translation(choice) { + if is_valid(choice) { return choice.to_string(); } } @@ -28,14 +33,28 @@ pub async fn calc_translation( DEFAULT_TRANSLATION.to_string() } -pub async fn calc_lang( +pub async fn calc_translation( user_choice: Option<&str>, user_id: &str, guild_id: Option<&str>, store: &dyn Store, +) -> String { + calc_translation_with(user_choice, user_id, guild_id, store, is_valid_translation).await +} + +/// Resolves the effective language from the priority chain: explicit choice +/// (if valid per `is_valid`) → user pref → server pref → default. +/// `is_valid` is dependency-injected so this stays testable without the +/// global language cache in `valid_cache` being populated. +pub async fn calc_lang_with( + user_choice: Option<&str>, + user_id: &str, + guild_id: Option<&str>, + store: &dyn Store, + is_valid: impl Fn(&str) -> bool, ) -> String { if let Some(choice) = user_choice { - if is_valid_language(choice) { + if is_valid(choice) { return choice.to_string(); } } @@ -49,3 +68,95 @@ pub async fn calc_lang( } DEFAULT_LANGUAGE.to_string() } + +pub async fn calc_lang( + user_choice: Option<&str>, + user_id: &str, + guild_id: Option<&str>, + store: &dyn Store, +) -> String { + calc_lang_with(user_choice, user_id, guild_id, store, is_valid_language).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::fake::FakeStore; + + fn always_valid(_: &str) -> bool { + true + } + + fn never_valid(_: &str) -> bool { + false + } + + #[tokio::test] + async fn explicit_valid_choice_wins() { + let store = FakeStore::new() + .with_user_translation("u1", "KJV") + .with_server_translation("g1", "NIV"); + let result = + calc_translation_with(Some("BSB"), "u1", Some("g1"), &store, always_valid).await; + assert_eq!(result, "BSB"); + } + + #[tokio::test] + async fn invalid_explicit_choice_falls_back_to_user_pref() { + let store = FakeStore::new() + .with_user_translation("u1", "KJV") + .with_server_translation("g1", "NIV"); + let result = + calc_translation_with(Some("bogus"), "u1", Some("g1"), &store, never_valid).await; + assert_eq!(result, "KJV"); + } + + #[tokio::test] + async fn falls_back_to_server_pref_when_no_user_pref() { + let store = FakeStore::new().with_server_translation("g1", "NIV"); + let result = calc_translation_with(None, "u1", Some("g1"), &store, never_valid).await; + assert_eq!(result, "NIV"); + } + + #[tokio::test] + async fn falls_back_to_default_when_nothing_set() { + let store = FakeStore::new(); + let result = calc_translation_with(None, "u1", None, &store, never_valid).await; + assert_eq!(result, DEFAULT_TRANSLATION); + } + + #[tokio::test] + async fn falls_back_to_default_when_no_guild() { + let store = FakeStore::new(); + let result = calc_translation_with(None, "u1", None, &store, never_valid).await; + assert_eq!(result, DEFAULT_TRANSLATION); + } + + #[tokio::test] + async fn lang_explicit_valid_choice_wins() { + let store = FakeStore::new().with_user_language("u1", "es"); + let result = calc_lang_with(Some("fr"), "u1", None, &store, always_valid).await; + assert_eq!(result, "fr"); + } + + #[tokio::test] + async fn lang_falls_back_to_user_pref() { + let store = FakeStore::new().with_user_language("u1", "es"); + let result = calc_lang_with(None, "u1", None, &store, never_valid).await; + assert_eq!(result, "es"); + } + + #[tokio::test] + async fn lang_falls_back_to_server_pref() { + let store = FakeStore::new().with_server_language("g1", "de"); + let result = calc_lang_with(None, "u1", Some("g1"), &store, never_valid).await; + assert_eq!(result, "de"); + } + + #[tokio::test] + async fn lang_falls_back_to_default() { + let store = FakeStore::new(); + let result = calc_lang_with(None, "u1", None, &store, never_valid).await; + assert_eq!(result, DEFAULT_LANGUAGE); + } +}