diff --git a/README.md b/README.md index 9c4c63c..0d1cb0b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ No neural-networks/machine-learning is used by this project. All image analysis The bot needs the following environment variables to be set in order to run: ``` -SHIMMERING_DISCORD_ID=yourtoken +SHIMMERING_DISCORD_TOKEN=yourtoken SHIMMERING_DATA_DIR=shimmering/data SHIMMERING_ASSET_DIR=shimmering/assets SHIMMERING_CONFIG_DIR=shimmering/config diff --git a/migrations/04-auto-delete-scores/up.sql b/migrations/04-auto-delete-scores/up.sql new file mode 100644 index 0000000..d284d63 --- /dev/null +++ b/migrations/04-auto-delete-scores/up.sql @@ -0,0 +1,7 @@ +-- Automatically delete all associated scores +-- every time a play is deleted. +CREATE TRIGGER auto_delete_scores AFTER DELETE ON plays +BEGIN + DELETE FROM scores + WHERE play_id = OLD.id; +END; diff --git a/src/arcaea/play.rs b/src/arcaea/play.rs index 64103be..8db4438 100644 --- a/src/arcaea/play.rs +++ b/src/arcaea/play.rs @@ -83,7 +83,12 @@ impl CreatePlay { self.max_recall, self.far_notes, ), - |row| Ok((row.get("id")?, row.get("created_at")?)), + |row| { + Ok(( + row.get("id")?, + default_while_testing(row.get("created_at")?), + )) + }, ) .with_context(|| { format!( @@ -131,7 +136,7 @@ impl CreatePlay { } // }}} // {{{ Score data -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]); impl ScoreCollection { @@ -143,7 +148,7 @@ impl ScoreCollection { } // }}} // {{{ Play -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Play { pub id: u32, #[allow(unused)] @@ -157,6 +162,18 @@ pub struct Play { pub scores: ScoreCollection, } +/// Timestamps and other similar values break golden testing. +/// This function can be used to replace such values with [Default::default] +/// while testing. +#[inline] +fn default_while_testing(v: D) -> D { + if cfg!(test) { + D::default() + } else { + v + } +} + impl Play { // {{{ Row parsing #[inline] @@ -165,10 +182,10 @@ impl Play { id: row.get("id")?, chart_id: row.get("chart_id")?, user_id: row.get("user_id")?, - created_at: row.get("created_at")?, max_recall: row.get("max_recall")?, far_notes: row.get("far_notes")?, scores: ScoreCollection::from_standard_score(Score(row.get("score")?), chart), + created_at: default_while_testing(row.get("created_at")?), }) } // }}} diff --git a/src/assets.rs b/src/assets.rs index 379113f..7896ec9 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -42,11 +42,9 @@ pub fn get_asset_dir() -> PathBuf { // {{{ Font helpers #[inline] fn get_font(name: &str) -> RefCell { - let face = timed!(format!("load font \"{name}\""), { - FREETYPE_LIB.with(|lib| { - lib.new_face(get_asset_dir().join("fonts").join(name), 0) - .expect(&format!("Could not load {} font", name)) - }) + let face = FREETYPE_LIB.with(|lib| { + lib.new_face(get_asset_dir().join("fonts").join(name), 0) + .expect(&format!("Could not load {} font", name)) }); RefCell::new(face) } diff --git a/src/cli/analyse.rs b/src/cli/analyse.rs new file mode 100644 index 0000000..ab0bd94 --- /dev/null +++ b/src/cli/analyse.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use crate::{ + cli::context::CliContext, + commands::score::magic_impl, + context::{Error, UserContext}, +}; + +#[derive(clap::Args)] +pub struct Args { + files: Vec, +} + +pub async fn run(args: Args) -> Result<(), Error> { + let mut ctx = CliContext::new(UserContext::new().await?); + magic_impl(&mut ctx, &args.files).await?; + Ok(()) +} diff --git a/src/cli/context.rs b/src/cli/context.rs new file mode 100644 index 0000000..d81b255 --- /dev/null +++ b/src/cli/context.rs @@ -0,0 +1,81 @@ +use std::num::NonZeroU64; +use std::path::PathBuf; +use std::str::FromStr; + +use poise::serenity_prelude::{CreateAttachment, CreateMessage}; + +use crate::assets::get_var; +use crate::context::Error; +use crate::{commands::discord::MessageContext, context::UserContext}; + +pub struct CliContext { + pub user_id: u64, + pub data: UserContext, +} + +impl CliContext { + pub fn new(data: UserContext) -> Self { + Self { + data, + user_id: get_var("SHIMMERING_DISCORD_USER_ID") + .parse() + .expect("invalid user id"), + } + } +} + +impl MessageContext for CliContext { + fn author_id(&self) -> u64 { + self.user_id + } + + async fn fetch_user(&self, discord_id: &str) -> Result { + let mut user = poise::serenity_prelude::User::default(); + user.id = poise::serenity_prelude::UserId::from_str(discord_id)?; + user.name = "shimmeringuser".to_string(); + Ok(user) + } + + fn data(&self) -> &UserContext { + &self.data + } + + async fn reply(&mut self, text: &str) -> Result<(), Error> { + println!("[Reply] {text}"); + Ok(()) + } + + async fn send_files( + &mut self, + _attachments: impl IntoIterator, + message: CreateMessage, + ) -> Result<(), Error> { + let all = toml::to_string(&message).unwrap(); + println!("\n========== Message =========="); + println!("{all}"); + Ok(()) + } + + // {{{ Input attachments + type Attachment = PathBuf; + + fn filename(attachment: &Self::Attachment) -> &str { + attachment.file_name().unwrap().to_str().unwrap() + } + + // This is a dumb implementation, but it works for testing... + fn is_image(attachment: &Self::Attachment) -> bool { + let ext = attachment.extension().unwrap(); + ext == "png" || ext == "jpg" || ext == "webp" + } + + fn attachment_id(_attachment: &Self::Attachment) -> NonZeroU64 { + NonZeroU64::new(666).unwrap() + } + + async fn download(&self, attachment: &Self::Attachment) -> Result, Error> { + let res = tokio::fs::read(attachment).await?; + Ok(res) + } + // }}} +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a4090bd..db9d9e4 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,3 +1,5 @@ +pub mod analyse; +pub mod context; pub mod prepare_jackets; #[derive(clap::Parser)] @@ -12,4 +14,5 @@ pub enum Command { /// Start the discord bot Discord {}, PrepareJackets {}, + Analyse(analyse::Args), } diff --git a/src/cli/prepare_jackets.rs b/src/cli/prepare_jackets.rs index 427c5ab..9e0302f 100644 --- a/src/cli/prepare_jackets.rs +++ b/src/cli/prepare_jackets.rs @@ -16,7 +16,12 @@ use crate::{ recognition::fuzzy_song_name::guess_chart_name, }; -pub fn prepare_jackets() -> Result<(), Error> { +#[inline] +fn clear_line() { + print!("\r \r"); +} + +pub fn run() -> Result<(), Error> { let db = connect_db(&get_data_dir()); let song_cache = SongCache::new(&db)?; @@ -41,11 +46,12 @@ pub fn prepare_jackets() -> Result<(), Error> { let dir_name = raw_dir_name.to_str().unwrap(); // {{{ Update progress live - print!( - "{}/{}: {dir_name} \r", - i, - entries.len() - ); + if i != 0 { + clear_line(); + } + + print!("{}/{}: {dir_name}", i, entries.len()); + if i % 5 == 0 { stdout().flush()?; } @@ -132,6 +138,8 @@ pub fn prepare_jackets() -> Result<(), Error> { } } + clear_line(); + // NOTE: this is N^2, but it's a one-off warning thing, so it's fine for chart in song_cache.charts() { if jacket_vectors.iter().all(|(i, _)| chart.song_id != *i) { diff --git a/src/commands/discord.rs b/src/commands/discord.rs index 3384681..98a021c 100644 --- a/src/commands/discord.rs +++ b/src/commands/discord.rs @@ -1,8 +1,9 @@ -use std::num::NonZeroU64; +use std::{num::NonZeroU64, str::FromStr}; use poise::serenity_prelude::{futures::future::join_all, CreateAttachment, CreateMessage}; use crate::{ + arcaea::play::Play, context::{Error, UserContext}, timed, }; @@ -13,6 +14,9 @@ pub trait MessageContext { fn data(&self) -> &UserContext; fn author_id(&self) -> u64; + /// Fetch info about a user given it's id. + async fn fetch_user(&self, discord_id: &str) -> Result; + /// Reply to the current message async fn reply(&mut self, text: &str) -> Result<(), Error>; @@ -23,6 +27,11 @@ pub trait MessageContext { message: CreateMessage, ) -> Result<(), Error>; + /// Deliver a message + async fn send(&mut self, message: CreateMessage) -> Result<(), Error> { + self.send_files([], message).await + } + // {{{ Input attachments type Attachment; @@ -36,7 +45,7 @@ pub trait MessageContext { /// Downloads every image async fn download_images<'a>( &self, - attachments: &'a Vec, + attachments: &'a [Self::Attachment], ) -> Result)>, Error> { let download_tasks = attachments .iter() @@ -64,6 +73,13 @@ impl<'a> MessageContext for poise::Context<'a, UserContext, Error> { self.author().id.get() } + async fn fetch_user(&self, discord_id: &str) -> Result { + poise::serenity_prelude::UserId::from_str(discord_id)? + .to_user(self.http()) + .await + .map_err(|e| e.into()) + } + async fn reply(&mut self, text: &str) -> Result<(), Error> { Self::reply(*self, text).await?; Ok(()) @@ -106,6 +122,9 @@ pub mod mock { use super::*; + /// A mock context usable for testing. Messages and attachments are + /// accumulated inside a vec, and can be used for golden testing + /// (see [MockContext::golden]) pub struct MockContext { pub user_id: u64, pub data: UserContext, @@ -121,7 +140,16 @@ pub mod mock { } } - pub fn write_to(&self, path: &PathBuf) -> Result<(), Error> { + // {{{ golden + /// This function implements the logic for "golden testing". We essentially + /// make sure a command's output doesn't change, by writing it to disk, + /// and comparing new outputs to the "golden" copy. + /// + /// 1. This will attempt to write the data to disk (at the given path) + /// 2. If the data already exists on disk, the two copies will be + /// compared. A panic will occur on disagreements. + /// 3. `SHIMMERING_TEST_REGEN=1` can be passed to overwrite disagreements. + pub fn golden(&self, path: &PathBuf) -> Result<(), Error> { if env::var("SHIMMERING_TEST_REGEN").unwrap_or_default() == "1" { fs::remove_dir_all(path)?; } @@ -152,10 +180,21 @@ pub mod mock { fs::write(&path, &attachment.data)?; } } + + // Ensure there's no extra attachments on disk + let file_count = fs::read_dir(dir)?.count(); + if file_count != attachments.len() + 1 { + panic!( + "Only {} attachments found instead of {}", + attachments.len(), + file_count - 1 + ); + } } Ok(()) } + // }}} } impl MessageContext for MockContext { @@ -163,6 +202,16 @@ pub mod mock { self.user_id } + async fn fetch_user( + &self, + discord_id: &str, + ) -> Result { + let mut user = poise::serenity_prelude::User::default(); + user.id = poise::serenity_prelude::UserId::from_str(discord_id)?; + user.name = "testinguser".to_string(); + Ok(user) + } + fn data(&self) -> &UserContext { &self.data } @@ -208,3 +257,10 @@ pub mod mock { } } // }}} +// {{{ Helpers +#[inline] +#[allow(dead_code)] // Currently only used for testing +pub fn play_song_title<'a>(ctx: &'a impl MessageContext, play: &'a Play) -> Result<&'a str, Error> { + Ok(&ctx.data().song_cache.lookup_chart(play.chart_id)?.0.title) +} +// }}} diff --git a/src/commands/score.rs b/src/commands/score.rs index 0a3f1d1..8f1b937 100644 --- a/src/commands/score.rs +++ b/src/commands/score.rs @@ -2,7 +2,7 @@ use crate::arcaea::play::{CreatePlay, Play}; use crate::arcaea::score::Score; use crate::context::{Context, Error}; use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; -use crate::user::{discord_id_to_discord_user, User}; +use crate::user::User; use crate::{get_user, timed}; use anyhow::anyhow; use image::DynamicImage; @@ -25,9 +25,9 @@ pub async fn score(_ctx: Context<'_>) -> Result<(), Error> { // }}} // {{{ Score magic // {{{ Implementation -async fn magic_impl( +pub async fn magic_impl( ctx: &mut C, - files: Vec, + files: &[C::Attachment], ) -> Result, Error> { let user = get_user!(ctx); let files = ctx.download_images(&files).await?; @@ -44,36 +44,30 @@ async fn magic_impl( for (i, (attachment, bytes)) in files.into_iter().enumerate() { // {{{ Preapare image - let mut image = timed!("decode image", { image::load_from_memory(&bytes)? }); - let mut grayscale_image = timed!("grayscale image", { - DynamicImage::ImageLuma8(image.to_luma8()) - }); - // image = image.resize(1024, 1024, FilterType::Nearest); + let mut image = image::load_from_memory(&bytes)?; + let mut grayscale_image = DynamicImage::ImageLuma8(image.to_luma8()); // }}} let result: Result<(), Error> = try { // {{{ Detection - // edit_reply!(ctx, handle, "Image {}: reading kind", i + 1).await?; let kind = timed!("read_score_kind", { analyzer.read_score_kind(ctx.data(), &grayscale_image)? }); - // edit_reply!(ctx, handle, "Image {}: reading difficulty", i + 1).await?; // Do not use `ocr_image` because this reads the colors let difficulty = timed!("read_difficulty", { analyzer.read_difficulty(ctx.data(), &image, &grayscale_image, kind)? }); - // edit_reply!(ctx, handle, "Image {}: reading jacket", i + 1).await?; let (song, chart) = timed!("read_jacket", { analyzer.read_jacket(ctx.data(), &mut image, kind, difficulty)? }); let max_recall = match kind { ScoreKind::ScoreScreen => { - // edit_reply!(ctx, handle, "Image {}: reading max recall", i + 1).await?; - Some(analyzer.read_max_recall(ctx.data(), &grayscale_image)?) + // NOTE: are we ok with discarding errors like that? + analyzer.read_max_recall(ctx.data(), &grayscale_image).ok() } ScoreKind::SongSelect => None, }; @@ -81,13 +75,11 @@ async fn magic_impl( grayscale_image.invert(); let note_distribution = match kind { ScoreKind::ScoreScreen => { - // edit_reply!(ctx, handle, "Image {}: reading distribution", i + 1).await?; Some(analyzer.read_distribution(ctx.data(), &grayscale_image)?) } ScoreKind::SongSelect => None, }; - // edit_reply!(ctx, handle, "Image {}: reading score", i + 1).await?; let score = timed!("read_score", { analyzer .read_score(ctx.data(), Some(chart.note_count), &grayscale_image, kind) @@ -145,19 +137,17 @@ mod magic_tests { use std::path::PathBuf; use crate::{ - arcaea::score::ScoringSystem, commands::discord::mock::MockContext, with_test_ctx, + arcaea::score::ScoringSystem, + commands::discord::{mock::MockContext, play_song_title}, + with_test_ctx, }; use super::*; - fn play_song_title<'a>(ctx: &'a MockContext, play: &'a Play) -> Result<&'a str, Error> { - Ok(&ctx.data().song_cache.lookup_chart(play.chart_id)?.0.title) - } - #[tokio::test] async fn no_pics() -> Result<(), Error> { with_test_ctx!("test/commands/score/magic/no_pics", async |ctx| { - magic_impl(ctx, vec![]).await?; + magic_impl(ctx, &[]).await?; Ok(()) }) } @@ -167,11 +157,9 @@ mod magic_tests { with_test_ctx!( "test/commands/score/magic/single_pic", async |ctx: &mut MockContext| { - let plays = magic_impl( - ctx, - vec![PathBuf::from_str("test/screenshots/alter_ego.jpg")?], - ) - .await?; + let plays = + magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?]) + .await?; assert_eq!(plays.len(), 1); assert_eq!(plays[0].score(ScoringSystem::Standard).0, 9926250); assert_eq!(play_song_title(ctx, &plays[0])?, "ALTER EGO"); @@ -187,7 +175,7 @@ mod magic_tests { async |ctx: &mut MockContext| { let plays = magic_impl( ctx, - vec![ + &[ PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?, PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?, ], @@ -206,74 +194,33 @@ mod magic_tests { } } // }}} - +// {{{ Discord wrapper /// Identify scores from attached images. #[poise::command(prefix_command, slash_command)] pub async fn magic( mut ctx: Context<'_>, #[description = "Images containing scores"] files: Vec, ) -> Result<(), Error> { - magic_impl(&mut ctx, files).await?; + magic_impl(&mut ctx, &files).await?; Ok(()) } // }}} -// {{{ Score delete -/// Delete scores, given their IDs. -#[poise::command(prefix_command, slash_command)] -pub async fn delete( - mut ctx: Context<'_>, - #[description = "Id of score to delete"] ids: Vec, -) -> Result<(), Error> { - let user = get_user!(&mut ctx); - - if ids.len() == 0 { - ctx.reply("Empty ID list provided").await?; - return Ok(()); - } - - let mut count = 0; - - for id in ids { - let res = ctx - .data() - .db - .get()? - .prepare_cached("DELETE FROM plays WHERE id=? AND user_id=?")? - .execute((id, user.id))?; - - if res == 0 { - ctx.reply(format!("No play with id {} found", id)).await?; - } else { - count += 1; - } - } - - if count > 0 { - ctx.reply(format!("Deleted {} play(s) successfully!", count)) - .await?; - } - - Ok(()) -} // }}} // {{{ Score show -/// Show scores given their ides -#[poise::command(prefix_command, slash_command)] -pub async fn show( - ctx: Context<'_>, - #[description = "Ids of score to show"] ids: Vec, -) -> Result<(), Error> { +// {{{ Implementation +pub async fn show_impl(ctx: &mut C, ids: &[u32]) -> Result, Error> { if ids.len() == 0 { ctx.reply("Empty ID list provided").await?; - return Ok(()); + return Ok(vec![]); } let mut embeds = Vec::with_capacity(ids.len()); let mut attachments = Vec::with_capacity(ids.len()); + let mut plays = Vec::with_capacity(ids.len()); let conn = ctx.data().db.get()?; for (i, id) in ids.iter().enumerate() { - let (song, chart, play, discord_id) = conn + let result = conn .prepare_cached( " SELECT @@ -292,13 +239,24 @@ pub async fn show( .query_and_then([id], |row| -> Result<_, Error> { let (song, chart) = ctx.data().song_cache.lookup_chart(row.get("chart_id")?)?; let play = Play::from_sql(chart, row)?; + let discord_id = row.get::<_, String>("discord_id")?; Ok((song, chart, play, discord_id)) })? - .next() - .ok_or_else(|| anyhow!("Could not find play with id {}", id))??; + .next(); - let author = discord_id_to_discord_user(&ctx, &discord_id).await?; + let (song, chart, play, discord_id) = match result { + None => { + ctx.send( + CreateMessage::new().content(format!("Could not find play with id {}", id)), + ) + .await?; + continue; + } + Some(result) => result?, + }; + + let author = ctx.fetch_user(&discord_id).await?; let user = User::by_id(ctx.data(), play.user_id)?; let (embed, attachment) = @@ -306,12 +264,215 @@ pub async fn show( embeds.push(embed); attachments.extend(attachment); + plays.push(play); } - ctx.channel_id() - .send_files(ctx.http(), attachments, CreateMessage::new().embeds(embeds)) - .await?; + if embeds.len() > 0 { + ctx.send_files(attachments, CreateMessage::new().embeds(embeds)) + .await?; + } + + Ok(plays) +} +/// }}} +// {{{ Tests +#[cfg(test)] +mod show_tests { + use super::*; + use crate::{commands::discord::mock::MockContext, with_test_ctx}; + use std::path::PathBuf; + + #[tokio::test] + async fn no_ids() -> Result<(), Error> { + with_test_ctx!("test/commands/score/show/no_ids", async |ctx| { + show_impl(ctx, &[]).await?; + Ok(()) + }) + } + + #[tokio::test] + async fn nonexistent_id() -> Result<(), Error> { + with_test_ctx!("test/commands/score/show/nonexistent_id", async |ctx| { + show_impl(ctx, &[666]).await?; + Ok(()) + }) + } + + #[tokio::test] + async fn agrees_with_magic() -> Result<(), Error> { + with_test_ctx!( + "test/commands/score/show/agrees_with_magic", + async |ctx: &mut MockContext| { + let created_plays = magic_impl( + ctx, + &[ + PathBuf::from_str("test/screenshots/alter_ego.jpg")?, + PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?, + PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?, + ], + ) + .await?; + + let ids = created_plays.iter().map(|p| p.id).collect::>(); + let plays = show_impl(ctx, &ids).await?; + + assert_eq!(plays.len(), 3); + assert_eq!(created_plays, plays); + Ok(()) + } + ) + } +} +// }}} +// {{{ Discord wrapper +/// Show scores given their ides +#[poise::command(prefix_command, slash_command)] +pub async fn show( + mut ctx: Context<'_>, + #[description = "Ids of score to show"] ids: Vec, +) -> Result<(), Error> { + show_impl(&mut ctx, &ids).await?; Ok(()) } // }}} +// }}} +// {{{ Score delete +// {{{ Implementation +pub async fn delete_impl(ctx: &mut C, ids: &[u32]) -> Result<(), Error> { + let user = get_user!(ctx); + + if ids.len() == 0 { + ctx.reply("Empty ID list provided").await?; + return Ok(()); + } + + let mut count = 0; + + for id in ids { + let res = ctx + .data() + .db + .get()? + .prepare_cached("DELETE FROM plays WHERE id=? AND user_id=?")? + .execute((id, user.id))?; + + if res == 0 { + ctx.reply(&format!("No play with id {} found", id)).await?; + } else { + count += 1; + } + } + + if count > 0 { + ctx.reply(&format!("Deleted {} play(s) successfully!", count)) + .await?; + } + + Ok(()) +} +/// }}} +// {{{ Tests +#[cfg(test)] +mod delete_tests { + use super::*; + use crate::{ + commands::discord::{mock::MockContext, play_song_title}, + with_test_ctx, + }; + use std::path::PathBuf; + + #[tokio::test] + async fn no_ids() -> Result<(), Error> { + with_test_ctx!("test/commands/score/delete/no_ids", async |ctx| { + delete_impl(ctx, &[]).await?; + Ok(()) + }) + } + + #[tokio::test] + async fn nonexistent_id() -> Result<(), Error> { + with_test_ctx!("test/commands/score/delete/nonexistent_id", async |ctx| { + delete_impl(ctx, &[666]).await?; + Ok(()) + }) + } + + #[tokio::test] + async fn delete_twice() -> Result<(), Error> { + with_test_ctx!( + "test/commands/score/delete/delete_twice", + async |ctx: &mut MockContext| { + let plays = + magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?]) + .await?; + + let id = plays[0].id; + delete_impl(ctx, &[id, id]).await?; + Ok(()) + } + ) + } + + #[tokio::test] + async fn no_show_after_delete() -> Result<(), Error> { + with_test_ctx!( + "test/commands/score/delete/no_show_after_delete", + async |ctx: &mut MockContext| { + let plays = + magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?]) + .await?; + + // Showcase proper usage + let ids = [plays[0].id]; + delete_impl(ctx, &ids).await?; + + // This will tell the user the play doesn't exist + let shown_plays = show_impl(ctx, &ids).await?; + assert_eq!(shown_plays.len(), 0); + + Ok(()) + } + ) + } + + #[tokio::test] + async fn delete_multiple() -> Result<(), Error> { + with_test_ctx!( + "test/commands/score/delete/delete_multiple", + async |ctx: &mut MockContext| { + let plays = magic_impl( + ctx, + &[ + PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?, + PathBuf::from_str("test/screenshots/alter_ego.jpg")?, + PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?, + ], + ) + .await?; + + delete_impl(ctx, &[plays[0].id, plays[2].id]).await?; + + // Ensure the second play still exists + let shown_plays = show_impl(ctx, &[plays[1].id]).await?; + assert_eq!(play_song_title(ctx, &shown_plays[0])?, "ALTER EGO"); + + Ok(()) + } + ) + } +} +// }}} +// {{{ Discord wrapper +/// Delete scores, given their IDs. +#[poise::command(prefix_command, slash_command)] +pub async fn delete( + mut ctx: Context<'_>, + #[description = "Id of score to delete"] ids: Vec, +) -> Result<(), Error> { + delete_impl(&mut ctx, &ids).await?; + + Ok(()) +} +// }}} +// }}} diff --git a/src/context.rs b/src/context.rs index 4539558..f0d8c43 100644 --- a/src/context.rs +++ b/src/context.rs @@ -35,24 +35,22 @@ pub struct UserContext { } pub fn connect_db(data_dir: &Path) -> DbConnection { - timed!("create_sqlite_pool", { - fs::create_dir_all(data_dir).expect("Could not create $SHIMMERING_DATA_DIR"); + fs::create_dir_all(data_dir).expect("Could not create $SHIMMERING_DATA_DIR"); - let data_dir = data_dir.to_str().unwrap().to_owned(); + let data_dir = data_dir.to_str().unwrap().to_owned(); - let db_path = format!("{}/db.sqlite", data_dir); - let mut conn = rusqlite::Connection::open(&db_path).unwrap(); - static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); - static MIGRATIONS: LazyLock = LazyLock::new(|| { - Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations") - }); + let db_path = format!("{}/db.sqlite", data_dir); + let mut conn = rusqlite::Connection::open(&db_path).unwrap(); + static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); + static MIGRATIONS: LazyLock = LazyLock::new(|| { + Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations") + }); - MIGRATIONS - .to_latest(&mut conn) - .expect("Could not run migrations"); + MIGRATIONS + .to_latest(&mut conn) + .expect("Could not run migrations"); - Pool::new(SqliteConnectionManager::file(&db_path)).expect("Could not open sqlite database.") - }) + Pool::new(SqliteConnectionManager::file(&db_path)).expect("Could not open sqlite database.") } impl UserContext { @@ -61,9 +59,9 @@ impl UserContext { timed!("create_context", { let db = connect_db(&get_data_dir()); - let mut song_cache = timed!("make_song_cache", { SongCache::new(&db)? }); + let mut song_cache = SongCache::new(&db)?; + let ui_measurements = UIMeasurements::read()?; let jacket_cache = timed!("make_jacket_cache", { JacketCache::new(&mut song_cache)? }); - let ui_measurements = timed!("read_ui_measurements", { UIMeasurements::read()? }); // {{{ Font measurements static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ"; @@ -134,7 +132,7 @@ pub mod testing { let res: Result<(), Error> = $f(&mut ctx).await; res?; - ctx.write_to(&std::path::PathBuf::from_str($test_path)?)?; + ctx.golden(&std::path::PathBuf::from_str($test_path)?)?; Ok(()) }}; } diff --git a/src/main.rs b/src/main.rs index 66eb916..86696fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ mod user; use arcaea::play::generate_missing_scores; use clap::Parser; -use cli::{prepare_jackets::prepare_jackets, Cli, Command}; +use cli::{Cli, Command}; use context::{Error, UserContext}; use poise::serenity_prelude::{self as serenity}; use std::{env::var, sync::Arc, time::Duration}; @@ -115,7 +115,12 @@ async fn main() { // }}} } Command::PrepareJackets {} => { - prepare_jackets().expect("Could not prepare jackets"); + cli::prepare_jackets::run().expect("Could not prepare jackets"); + } + Command::Analyse(args) => { + cli::analyse::run(args) + .await + .expect("Could not analyse screenshot"); } } } diff --git a/src/recognition/hyperglass.rs b/src/recognition/hyperglass.rs index cb1646f..ff3681f 100644 --- a/src/recognition/hyperglass.rs +++ b/src/recognition/hyperglass.rs @@ -34,7 +34,6 @@ use crate::{ bitmap::{Align, BitmapCanvas, Color, TextStyle}, context::Error, logs::{debug_image_buffer_log, debug_image_log}, - timed, }; // {{{ ConponentVec @@ -232,69 +231,66 @@ pub struct CharMeasurements { impl CharMeasurements { // {{{ Creation pub fn from_text(face: &mut Face, string: &str, weight: Option) -> Result { - timed!("measure_chars", { - // These are bad estimates lol - let style = TextStyle { - stroke: None, - drop_shadow: None, - align: (Align::Start, Align::Start), - size: 60, - color: Color::BLACK, - // TODO: do we want to use the weight hint for resilience? - weight, - }; - let padding = (5, 5); - let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?; + // These are bad estimates lol + let style = TextStyle { + stroke: None, + drop_shadow: None, + align: (Align::Start, Align::Start), + size: 60, + color: Color::BLACK, + // TODO: do we want to use the weight hint for resilience? + weight, + }; + let padding = (5, 5); + let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?; - let mut canvas = BitmapCanvas::new( - (planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32, - (planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32, - ); + let mut canvas = BitmapCanvas::new( + (planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32, + (planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32, + ); - canvas.text(padding, &mut [face], style, &string)?; - let buffer = - ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec()) - .ok_or_else(|| anyhow!("Failed to turn buffer into canvas"))?; - let image = DynamicImage::ImageRgb8(buffer); + canvas.text(padding, &mut [face], style, &string)?; + let buffer = ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec()) + .ok_or_else(|| anyhow!("Failed to turn buffer into canvas"))?; + let image = DynamicImage::ImageRgb8(buffer); - debug_image_log(&image); + debug_image_log(&image); - let components = ComponentsWithBounds::from_image(&image, 100)?; + let components = ComponentsWithBounds::from_image(&image, 100)?; - // {{{ Compute max width/height - let max_width = components - .bounds - .iter() - .filter_map(|o| o.as_ref()) - .map(|b| b.x_max - b.x_min) - .max() - .ok_or_else(|| anyhow!("No connected components found"))?; - let max_height = components - .bounds - .iter() - .filter_map(|o| o.as_ref()) - .map(|b| b.y_max - b.y_min) - .max() - .ok_or_else(|| anyhow!("No connected components found"))?; - // }}} + // {{{ Compute max width/height + let max_width = components + .bounds + .iter() + .filter_map(|o| o.as_ref()) + .map(|b| b.x_max - b.x_min) + .max() + .ok_or_else(|| anyhow!("No connected components found"))?; + let max_height = components + .bounds + .iter() + .filter_map(|o| o.as_ref()) + .map(|b| b.y_max - b.y_min) + .max() + .ok_or_else(|| anyhow!("No connected components found"))?; + // }}} - let mut chars = Vec::with_capacity(string.len()); - for (i, char) in string.chars().enumerate() { - chars.push(( - char, - ComponentVec::from_component( - &components, - (max_width, max_height), - components.bounds_by_position[i] as u32 + 1, - )?, - )) - } + let mut chars = Vec::with_capacity(string.len()); + for (i, char) in string.chars().enumerate() { + chars.push(( + char, + ComponentVec::from_component( + &components, + (max_width, max_height), + components.bounds_by_position[i] as u32 + 1, + )?, + )) + } - Ok(Self { - chars, - max_width, - max_height, - }) + Ok(Self { + chars, + max_width, + max_height, }) } // }}} @@ -305,9 +301,8 @@ impl CharMeasurements { whitelist: &str, binarisation_threshold: Option, ) -> Result { - let components = timed!("from_image", { - ComponentsWithBounds::from_image(image, binarisation_threshold.unwrap_or(100))? - }); + let components = + ComponentsWithBounds::from_image(image, binarisation_threshold.unwrap_or(100))?; let mut result = String::with_capacity(components.bounds.len()); let max_height = components diff --git a/src/recognition/recognize.rs b/src/recognition/recognize.rs index bfecf0d..4e27810 100644 --- a/src/recognition/recognize.rs +++ b/src/recognition/recognize.rs @@ -19,7 +19,6 @@ use crate::recognition::fuzzy_song_name::guess_chart_name; use crate::recognition::ui::{ ScoreScreenRect, SongSelectRect, UIMeasurementRect, UIMeasurementRect::*, }; -use crate::timed; use crate::transform::rotate; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -133,32 +132,28 @@ impl ImageAnalyzer { image: &DynamicImage, kind: ScoreKind, ) -> Result { - let image = timed!("interp_crop_resize", { - self.interp_crop( - ctx, - image, - match kind { - ScoreKind::SongSelect => SongSelect(SongSelectRect::Score), - ScoreKind::ScoreScreen => ScoreScreen(ScoreScreenRect::Score), - }, - )? - }); + let image = self.interp_crop( + ctx, + image, + match kind { + ScoreKind::SongSelect => SongSelect(SongSelectRect::Score), + ScoreKind::ScoreScreen => ScoreScreen(ScoreScreenRect::Score), + }, + )?; let measurements = match kind { ScoreKind::SongSelect => &ctx.exo_measurements, ScoreKind::ScoreScreen => &ctx.geosans_measurements, }; - let result = timed!("full recognition", { - Score( - measurements - .recognise(&image, "0123456789'", None)? - .chars() - .filter(|c| *c != '\'') - .collect::() - .parse()?, - ) - }); + let result = Score( + measurements + .recognise(&image, "0123456789'", None)? + .chars() + .filter(|c| *c != '\'') + .collect::() + .parse()?, + ); // Discard scores if it's impossible if result.0 <= 10_010_000