1
Fork 0

Add more tests (and fix the score delete command)

Additionally, add a cli analogue of `score magic` (the `analyse`
subcommand)
This commit is contained in:
prescientmoon 2024-09-17 02:00:36 +02:00
parent 4ed0cadeb8
commit 0e0043d2c1
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
14 changed files with 542 additions and 200 deletions

View file

@ -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: 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_DATA_DIR=shimmering/data
SHIMMERING_ASSET_DIR=shimmering/assets SHIMMERING_ASSET_DIR=shimmering/assets
SHIMMERING_CONFIG_DIR=shimmering/config SHIMMERING_CONFIG_DIR=shimmering/config

View file

@ -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;

View file

@ -83,7 +83,12 @@ impl CreatePlay {
self.max_recall, self.max_recall,
self.far_notes, 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(|| { .with_context(|| {
format!( format!(
@ -131,7 +136,7 @@ impl CreatePlay {
} }
// }}} // }}}
// {{{ Score data // {{{ Score data
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]); pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]);
impl ScoreCollection { impl ScoreCollection {
@ -143,7 +148,7 @@ impl ScoreCollection {
} }
// }}} // }}}
// {{{ Play // {{{ Play
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Play { pub struct Play {
pub id: u32, pub id: u32,
#[allow(unused)] #[allow(unused)]
@ -157,6 +162,18 @@ pub struct Play {
pub scores: ScoreCollection, 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<D: Default>(v: D) -> D {
if cfg!(test) {
D::default()
} else {
v
}
}
impl Play { impl Play {
// {{{ Row parsing // {{{ Row parsing
#[inline] #[inline]
@ -165,10 +182,10 @@ impl Play {
id: row.get("id")?, id: row.get("id")?,
chart_id: row.get("chart_id")?, chart_id: row.get("chart_id")?,
user_id: row.get("user_id")?, user_id: row.get("user_id")?,
created_at: row.get("created_at")?,
max_recall: row.get("max_recall")?, max_recall: row.get("max_recall")?,
far_notes: row.get("far_notes")?, far_notes: row.get("far_notes")?,
scores: ScoreCollection::from_standard_score(Score(row.get("score")?), chart), scores: ScoreCollection::from_standard_score(Score(row.get("score")?), chart),
created_at: default_while_testing(row.get("created_at")?),
}) })
} }
// }}} // }}}

View file

@ -42,11 +42,9 @@ pub fn get_asset_dir() -> PathBuf {
// {{{ Font helpers // {{{ Font helpers
#[inline] #[inline]
fn get_font(name: &str) -> RefCell<Face> { fn get_font(name: &str) -> RefCell<Face> {
let face = timed!(format!("load font \"{name}\""), { let face = FREETYPE_LIB.with(|lib| {
FREETYPE_LIB.with(|lib| { lib.new_face(get_asset_dir().join("fonts").join(name), 0)
lib.new_face(get_asset_dir().join("fonts").join(name), 0) .expect(&format!("Could not load {} font", name))
.expect(&format!("Could not load {} font", name))
})
}); });
RefCell::new(face) RefCell::new(face)
} }

18
src/cli/analyse.rs Normal file
View file

@ -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<PathBuf>,
}
pub async fn run(args: Args) -> Result<(), Error> {
let mut ctx = CliContext::new(UserContext::new().await?);
magic_impl(&mut ctx, &args.files).await?;
Ok(())
}

81
src/cli/context.rs Normal file
View file

@ -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<poise::serenity_prelude::User, Error> {
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<Item = CreateAttachment>,
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<Vec<u8>, Error> {
let res = tokio::fs::read(attachment).await?;
Ok(res)
}
// }}}
}

View file

@ -1,3 +1,5 @@
pub mod analyse;
pub mod context;
pub mod prepare_jackets; pub mod prepare_jackets;
#[derive(clap::Parser)] #[derive(clap::Parser)]
@ -12,4 +14,5 @@ pub enum Command {
/// Start the discord bot /// Start the discord bot
Discord {}, Discord {},
PrepareJackets {}, PrepareJackets {},
Analyse(analyse::Args),
} }

View file

@ -16,7 +16,12 @@ use crate::{
recognition::fuzzy_song_name::guess_chart_name, 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 db = connect_db(&get_data_dir());
let song_cache = SongCache::new(&db)?; 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(); let dir_name = raw_dir_name.to_str().unwrap();
// {{{ Update progress live // {{{ Update progress live
print!( if i != 0 {
"{}/{}: {dir_name} \r", clear_line();
i, }
entries.len()
); print!("{}/{}: {dir_name}", i, entries.len());
if i % 5 == 0 { if i % 5 == 0 {
stdout().flush()?; 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 // NOTE: this is N^2, but it's a one-off warning thing, so it's fine
for chart in song_cache.charts() { for chart in song_cache.charts() {
if jacket_vectors.iter().all(|(i, _)| chart.song_id != *i) { if jacket_vectors.iter().all(|(i, _)| chart.song_id != *i) {

View file

@ -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 poise::serenity_prelude::{futures::future::join_all, CreateAttachment, CreateMessage};
use crate::{ use crate::{
arcaea::play::Play,
context::{Error, UserContext}, context::{Error, UserContext},
timed, timed,
}; };
@ -13,6 +14,9 @@ pub trait MessageContext {
fn data(&self) -> &UserContext; fn data(&self) -> &UserContext;
fn author_id(&self) -> u64; fn author_id(&self) -> u64;
/// Fetch info about a user given it's id.
async fn fetch_user(&self, discord_id: &str) -> Result<poise::serenity_prelude::User, Error>;
/// Reply to the current message /// Reply to the current message
async fn reply(&mut self, text: &str) -> Result<(), Error>; async fn reply(&mut self, text: &str) -> Result<(), Error>;
@ -23,6 +27,11 @@ pub trait MessageContext {
message: CreateMessage, message: CreateMessage,
) -> Result<(), Error>; ) -> Result<(), Error>;
/// Deliver a message
async fn send(&mut self, message: CreateMessage) -> Result<(), Error> {
self.send_files([], message).await
}
// {{{ Input attachments // {{{ Input attachments
type Attachment; type Attachment;
@ -36,7 +45,7 @@ pub trait MessageContext {
/// Downloads every image /// Downloads every image
async fn download_images<'a>( async fn download_images<'a>(
&self, &self,
attachments: &'a Vec<Self::Attachment>, attachments: &'a [Self::Attachment],
) -> Result<Vec<(&'a Self::Attachment, Vec<u8>)>, Error> { ) -> Result<Vec<(&'a Self::Attachment, Vec<u8>)>, Error> {
let download_tasks = attachments let download_tasks = attachments
.iter() .iter()
@ -64,6 +73,13 @@ impl<'a> MessageContext for poise::Context<'a, UserContext, Error> {
self.author().id.get() self.author().id.get()
} }
async fn fetch_user(&self, discord_id: &str) -> Result<poise::serenity_prelude::User, Error> {
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> { async fn reply(&mut self, text: &str) -> Result<(), Error> {
Self::reply(*self, text).await?; Self::reply(*self, text).await?;
Ok(()) Ok(())
@ -106,6 +122,9 @@ pub mod mock {
use super::*; 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 struct MockContext {
pub user_id: u64, pub user_id: u64,
pub data: UserContext, 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" { if env::var("SHIMMERING_TEST_REGEN").unwrap_or_default() == "1" {
fs::remove_dir_all(path)?; fs::remove_dir_all(path)?;
} }
@ -152,10 +180,21 @@ pub mod mock {
fs::write(&path, &attachment.data)?; 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(()) Ok(())
} }
// }}}
} }
impl MessageContext for MockContext { impl MessageContext for MockContext {
@ -163,6 +202,16 @@ pub mod mock {
self.user_id self.user_id
} }
async fn fetch_user(
&self,
discord_id: &str,
) -> Result<poise::serenity_prelude::User, Error> {
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 { fn data(&self) -> &UserContext {
&self.data &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)
}
// }}}

View file

@ -2,7 +2,7 @@ use crate::arcaea::play::{CreatePlay, Play};
use crate::arcaea::score::Score; use crate::arcaea::score::Score;
use crate::context::{Context, Error}; use crate::context::{Context, Error};
use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; 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 crate::{get_user, timed};
use anyhow::anyhow; use anyhow::anyhow;
use image::DynamicImage; use image::DynamicImage;
@ -25,9 +25,9 @@ pub async fn score(_ctx: Context<'_>) -> Result<(), Error> {
// }}} // }}}
// {{{ Score magic // {{{ Score magic
// {{{ Implementation // {{{ Implementation
async fn magic_impl<C: MessageContext>( pub async fn magic_impl<C: MessageContext>(
ctx: &mut C, ctx: &mut C,
files: Vec<C::Attachment>, files: &[C::Attachment],
) -> Result<Vec<Play>, Error> { ) -> Result<Vec<Play>, Error> {
let user = get_user!(ctx); let user = get_user!(ctx);
let files = ctx.download_images(&files).await?; let files = ctx.download_images(&files).await?;
@ -44,36 +44,30 @@ async fn magic_impl<C: MessageContext>(
for (i, (attachment, bytes)) in files.into_iter().enumerate() { for (i, (attachment, bytes)) in files.into_iter().enumerate() {
// {{{ Preapare image // {{{ Preapare image
let mut image = timed!("decode image", { image::load_from_memory(&bytes)? }); let mut image = image::load_from_memory(&bytes)?;
let mut grayscale_image = timed!("grayscale image", { let mut grayscale_image = DynamicImage::ImageLuma8(image.to_luma8());
DynamicImage::ImageLuma8(image.to_luma8())
});
// image = image.resize(1024, 1024, FilterType::Nearest);
// }}} // }}}
let result: Result<(), Error> = try { let result: Result<(), Error> = try {
// {{{ Detection // {{{ Detection
// edit_reply!(ctx, handle, "Image {}: reading kind", i + 1).await?;
let kind = timed!("read_score_kind", { let kind = timed!("read_score_kind", {
analyzer.read_score_kind(ctx.data(), &grayscale_image)? 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 // Do not use `ocr_image` because this reads the colors
let difficulty = timed!("read_difficulty", { let difficulty = timed!("read_difficulty", {
analyzer.read_difficulty(ctx.data(), &image, &grayscale_image, kind)? 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", { let (song, chart) = timed!("read_jacket", {
analyzer.read_jacket(ctx.data(), &mut image, kind, difficulty)? analyzer.read_jacket(ctx.data(), &mut image, kind, difficulty)?
}); });
let max_recall = match kind { let max_recall = match kind {
ScoreKind::ScoreScreen => { ScoreKind::ScoreScreen => {
// edit_reply!(ctx, handle, "Image {}: reading max recall", i + 1).await?; // NOTE: are we ok with discarding errors like that?
Some(analyzer.read_max_recall(ctx.data(), &grayscale_image)?) analyzer.read_max_recall(ctx.data(), &grayscale_image).ok()
} }
ScoreKind::SongSelect => None, ScoreKind::SongSelect => None,
}; };
@ -81,13 +75,11 @@ async fn magic_impl<C: MessageContext>(
grayscale_image.invert(); grayscale_image.invert();
let note_distribution = match kind { let note_distribution = match kind {
ScoreKind::ScoreScreen => { ScoreKind::ScoreScreen => {
// edit_reply!(ctx, handle, "Image {}: reading distribution", i + 1).await?;
Some(analyzer.read_distribution(ctx.data(), &grayscale_image)?) Some(analyzer.read_distribution(ctx.data(), &grayscale_image)?)
} }
ScoreKind::SongSelect => None, ScoreKind::SongSelect => None,
}; };
// edit_reply!(ctx, handle, "Image {}: reading score", i + 1).await?;
let score = timed!("read_score", { let score = timed!("read_score", {
analyzer analyzer
.read_score(ctx.data(), Some(chart.note_count), &grayscale_image, kind) .read_score(ctx.data(), Some(chart.note_count), &grayscale_image, kind)
@ -145,19 +137,17 @@ mod magic_tests {
use std::path::PathBuf; use std::path::PathBuf;
use crate::{ 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::*; 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] #[tokio::test]
async fn no_pics() -> Result<(), Error> { async fn no_pics() -> Result<(), Error> {
with_test_ctx!("test/commands/score/magic/no_pics", async |ctx| { with_test_ctx!("test/commands/score/magic/no_pics", async |ctx| {
magic_impl(ctx, vec![]).await?; magic_impl(ctx, &[]).await?;
Ok(()) Ok(())
}) })
} }
@ -167,11 +157,9 @@ mod magic_tests {
with_test_ctx!( with_test_ctx!(
"test/commands/score/magic/single_pic", "test/commands/score/magic/single_pic",
async |ctx: &mut MockContext| { async |ctx: &mut MockContext| {
let plays = magic_impl( let plays =
ctx, magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?])
vec![PathBuf::from_str("test/screenshots/alter_ego.jpg")?], .await?;
)
.await?;
assert_eq!(plays.len(), 1); assert_eq!(plays.len(), 1);
assert_eq!(plays[0].score(ScoringSystem::Standard).0, 9926250); assert_eq!(plays[0].score(ScoringSystem::Standard).0, 9926250);
assert_eq!(play_song_title(ctx, &plays[0])?, "ALTER EGO"); assert_eq!(play_song_title(ctx, &plays[0])?, "ALTER EGO");
@ -187,7 +175,7 @@ mod magic_tests {
async |ctx: &mut MockContext| { async |ctx: &mut MockContext| {
let plays = magic_impl( let plays = magic_impl(
ctx, ctx,
vec![ &[
PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?, PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?,
PathBuf::from_str("test/screenshots/genocider_24_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. /// Identify scores from attached images.
#[poise::command(prefix_command, slash_command)] #[poise::command(prefix_command, slash_command)]
pub async fn magic( pub async fn magic(
mut ctx: Context<'_>, mut ctx: Context<'_>,
#[description = "Images containing scores"] files: Vec<serenity::Attachment>, #[description = "Images containing scores"] files: Vec<serenity::Attachment>,
) -> Result<(), Error> { ) -> Result<(), Error> {
magic_impl(&mut ctx, files).await?; magic_impl(&mut ctx, &files).await?;
Ok(()) 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<u32>,
) -> 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 // {{{ Score show
/// Show scores given their ides // {{{ Implementation
#[poise::command(prefix_command, slash_command)] pub async fn show_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<Vec<Play>, Error> {
pub async fn show(
ctx: Context<'_>,
#[description = "Ids of score to show"] ids: Vec<u32>,
) -> Result<(), Error> {
if ids.len() == 0 { if ids.len() == 0 {
ctx.reply("Empty ID list provided").await?; ctx.reply("Empty ID list provided").await?;
return Ok(()); return Ok(vec![]);
} }
let mut embeds = Vec::with_capacity(ids.len()); let mut embeds = Vec::with_capacity(ids.len());
let mut attachments = 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()?; let conn = ctx.data().db.get()?;
for (i, id) in ids.iter().enumerate() { for (i, id) in ids.iter().enumerate() {
let (song, chart, play, discord_id) = conn let result = conn
.prepare_cached( .prepare_cached(
" "
SELECT SELECT
@ -292,13 +239,24 @@ pub async fn show(
.query_and_then([id], |row| -> Result<_, Error> { .query_and_then([id], |row| -> Result<_, Error> {
let (song, chart) = ctx.data().song_cache.lookup_chart(row.get("chart_id")?)?; let (song, chart) = ctx.data().song_cache.lookup_chart(row.get("chart_id")?)?;
let play = Play::from_sql(chart, row)?; let play = Play::from_sql(chart, row)?;
let discord_id = row.get::<_, String>("discord_id")?; let discord_id = row.get::<_, String>("discord_id")?;
Ok((song, chart, play, discord_id)) Ok((song, chart, play, discord_id))
})? })?
.next() .next();
.ok_or_else(|| anyhow!("Could not find play with id {}", id))??;
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 user = User::by_id(ctx.data(), play.user_id)?;
let (embed, attachment) = let (embed, attachment) =
@ -306,12 +264,215 @@ pub async fn show(
embeds.push(embed); embeds.push(embed);
attachments.extend(attachment); attachments.extend(attachment);
plays.push(play);
} }
ctx.channel_id() if embeds.len() > 0 {
.send_files(ctx.http(), attachments, CreateMessage::new().embeds(embeds)) ctx.send_files(attachments, CreateMessage::new().embeds(embeds))
.await?; .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::<Vec<_>>();
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<u32>,
) -> Result<(), Error> {
show_impl(&mut ctx, &ids).await?;
Ok(()) Ok(())
} }
// }}} // }}}
// }}}
// {{{ Score delete
// {{{ Implementation
pub async fn delete_impl<C: MessageContext>(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<u32>,
) -> Result<(), Error> {
delete_impl(&mut ctx, &ids).await?;
Ok(())
}
// }}}
// }}}

View file

@ -35,24 +35,22 @@ pub struct UserContext {
} }
pub fn connect_db(data_dir: &Path) -> DbConnection { 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 db_path = format!("{}/db.sqlite", data_dir);
let mut conn = rusqlite::Connection::open(&db_path).unwrap(); let mut conn = rusqlite::Connection::open(&db_path).unwrap();
static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations");
static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(|| { static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(|| {
Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations") Migrations::from_directory(&MIGRATIONS_DIR).expect("Could not load migrations")
}); });
MIGRATIONS MIGRATIONS
.to_latest(&mut conn) .to_latest(&mut conn)
.expect("Could not run migrations"); .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 { impl UserContext {
@ -61,9 +59,9 @@ impl UserContext {
timed!("create_context", { timed!("create_context", {
let db = connect_db(&get_data_dir()); 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 jacket_cache = timed!("make_jacket_cache", { JacketCache::new(&mut song_cache)? });
let ui_measurements = timed!("read_ui_measurements", { UIMeasurements::read()? });
// {{{ Font measurements // {{{ Font measurements
static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ"; static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ";
@ -134,7 +132,7 @@ pub mod testing {
let res: Result<(), Error> = $f(&mut ctx).await; let res: Result<(), Error> = $f(&mut ctx).await;
res?; res?;
ctx.write_to(&std::path::PathBuf::from_str($test_path)?)?; ctx.golden(&std::path::PathBuf::from_str($test_path)?)?;
Ok(()) Ok(())
}}; }};
} }

View file

@ -24,7 +24,7 @@ mod user;
use arcaea::play::generate_missing_scores; use arcaea::play::generate_missing_scores;
use clap::Parser; use clap::Parser;
use cli::{prepare_jackets::prepare_jackets, Cli, Command}; use cli::{Cli, Command};
use context::{Error, UserContext}; use context::{Error, UserContext};
use poise::serenity_prelude::{self as serenity}; use poise::serenity_prelude::{self as serenity};
use std::{env::var, sync::Arc, time::Duration}; use std::{env::var, sync::Arc, time::Duration};
@ -115,7 +115,12 @@ async fn main() {
// }}} // }}}
} }
Command::PrepareJackets {} => { 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");
} }
} }
} }

View file

@ -34,7 +34,6 @@ use crate::{
bitmap::{Align, BitmapCanvas, Color, TextStyle}, bitmap::{Align, BitmapCanvas, Color, TextStyle},
context::Error, context::Error,
logs::{debug_image_buffer_log, debug_image_log}, logs::{debug_image_buffer_log, debug_image_log},
timed,
}; };
// {{{ ConponentVec // {{{ ConponentVec
@ -232,69 +231,66 @@ pub struct CharMeasurements {
impl CharMeasurements { impl CharMeasurements {
// {{{ Creation // {{{ Creation
pub fn from_text(face: &mut Face, string: &str, weight: Option<u32>) -> Result<Self, Error> { pub fn from_text(face: &mut Face, string: &str, weight: Option<u32>) -> Result<Self, Error> {
timed!("measure_chars", { // These are bad estimates lol
// These are bad estimates lol let style = TextStyle {
let style = TextStyle { stroke: None,
stroke: None, drop_shadow: None,
drop_shadow: None, align: (Align::Start, Align::Start),
align: (Align::Start, Align::Start), size: 60,
size: 60, color: Color::BLACK,
color: Color::BLACK, // TODO: do we want to use the weight hint for resilience?
// TODO: do we want to use the weight hint for resilience? weight,
weight, };
}; let padding = (5, 5);
let padding = (5, 5); let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?;
let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?;
let mut canvas = BitmapCanvas::new( let mut canvas = BitmapCanvas::new(
(planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32, (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, (planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32,
); );
canvas.text(padding, &mut [face], style, &string)?; canvas.text(padding, &mut [face], style, &string)?;
let buffer = let buffer = ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec())
ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec()) .ok_or_else(|| anyhow!("Failed to turn buffer into canvas"))?;
.ok_or_else(|| anyhow!("Failed to turn buffer into canvas"))?; let image = DynamicImage::ImageRgb8(buffer);
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 // {{{ Compute max width/height
let max_width = components let max_width = components
.bounds .bounds
.iter() .iter()
.filter_map(|o| o.as_ref()) .filter_map(|o| o.as_ref())
.map(|b| b.x_max - b.x_min) .map(|b| b.x_max - b.x_min)
.max() .max()
.ok_or_else(|| anyhow!("No connected components found"))?; .ok_or_else(|| anyhow!("No connected components found"))?;
let max_height = components let max_height = components
.bounds .bounds
.iter() .iter()
.filter_map(|o| o.as_ref()) .filter_map(|o| o.as_ref())
.map(|b| b.y_max - b.y_min) .map(|b| b.y_max - b.y_min)
.max() .max()
.ok_or_else(|| anyhow!("No connected components found"))?; .ok_or_else(|| anyhow!("No connected components found"))?;
// }}} // }}}
let mut chars = Vec::with_capacity(string.len()); let mut chars = Vec::with_capacity(string.len());
for (i, char) in string.chars().enumerate() { for (i, char) in string.chars().enumerate() {
chars.push(( chars.push((
char, char,
ComponentVec::from_component( ComponentVec::from_component(
&components, &components,
(max_width, max_height), (max_width, max_height),
components.bounds_by_position[i] as u32 + 1, components.bounds_by_position[i] as u32 + 1,
)?, )?,
)) ))
} }
Ok(Self { Ok(Self {
chars, chars,
max_width, max_width,
max_height, max_height,
})
}) })
} }
// }}} // }}}
@ -305,9 +301,8 @@ impl CharMeasurements {
whitelist: &str, whitelist: &str,
binarisation_threshold: Option<u8>, binarisation_threshold: Option<u8>,
) -> Result<String, Error> { ) -> Result<String, Error> {
let components = timed!("from_image", { let components =
ComponentsWithBounds::from_image(image, binarisation_threshold.unwrap_or(100))? ComponentsWithBounds::from_image(image, binarisation_threshold.unwrap_or(100))?;
});
let mut result = String::with_capacity(components.bounds.len()); let mut result = String::with_capacity(components.bounds.len());
let max_height = components let max_height = components

View file

@ -19,7 +19,6 @@ use crate::recognition::fuzzy_song_name::guess_chart_name;
use crate::recognition::ui::{ use crate::recognition::ui::{
ScoreScreenRect, SongSelectRect, UIMeasurementRect, UIMeasurementRect::*, ScoreScreenRect, SongSelectRect, UIMeasurementRect, UIMeasurementRect::*,
}; };
use crate::timed;
use crate::transform::rotate; use crate::transform::rotate;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -133,32 +132,28 @@ impl ImageAnalyzer {
image: &DynamicImage, image: &DynamicImage,
kind: ScoreKind, kind: ScoreKind,
) -> Result<Score, Error> { ) -> Result<Score, Error> {
let image = timed!("interp_crop_resize", { let image = self.interp_crop(
self.interp_crop( ctx,
ctx, image,
image, match kind {
match kind { ScoreKind::SongSelect => SongSelect(SongSelectRect::Score),
ScoreKind::SongSelect => SongSelect(SongSelectRect::Score), ScoreKind::ScoreScreen => ScoreScreen(ScoreScreenRect::Score),
ScoreKind::ScoreScreen => ScoreScreen(ScoreScreenRect::Score), },
}, )?;
)?
});
let measurements = match kind { let measurements = match kind {
ScoreKind::SongSelect => &ctx.exo_measurements, ScoreKind::SongSelect => &ctx.exo_measurements,
ScoreKind::ScoreScreen => &ctx.geosans_measurements, ScoreKind::ScoreScreen => &ctx.geosans_measurements,
}; };
let result = timed!("full recognition", { let result = Score(
Score( measurements
measurements .recognise(&image, "0123456789'", None)?
.recognise(&image, "0123456789'", None)? .chars()
.chars() .filter(|c| *c != '\'')
.filter(|c| *c != '\'') .collect::<String>()
.collect::<String>() .parse()?,
.parse()?, );
)
});
// Discard scores if it's impossible // Discard scores if it's impossible
if result.0 <= 10_010_000 if result.0 <= 10_010_000