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:
parent
4ed0cadeb8
commit
0e0043d2c1
|
@ -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
|
||||||
|
|
7
migrations/04-auto-delete-scores/up.sql
Normal file
7
migrations/04-auto-delete-scores/up.sql
Normal 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;
|
|
@ -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")?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
|
|
@ -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
18
src/cli/analyse.rs
Normal 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
81
src/cli/context.rs
Normal 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)
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
}
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// }}}
|
||||||
|
|
|
@ -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(())
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue