mecomp_storage/
test_utils.rs

1use std::{
2    ops::{Range, RangeInclusive},
3    path::PathBuf,
4    str::FromStr,
5    sync::Arc,
6    time::Duration,
7};
8
9use anyhow::Result;
10use lofty::{config::WriteOptions, file::TaggedFileExt, prelude::*, probe::Probe, tag::Accessor};
11use one_or_many::OneOrMany;
12use rand::{Rng, seq::IteratorRandom};
13#[cfg(feature = "db")]
14use surrealdb::{
15    Connection, Surreal,
16    engine::local::{Db, Mem},
17    opt::Config,
18    sql::Id,
19};
20
21#[cfg(not(feature = "db"))]
22use crate::db::schemas::Id;
23#[cfg(feature = "analysis")]
24use crate::db::schemas::analysis::Analysis;
25use crate::db::schemas::{
26    album::Album,
27    artist::Artist,
28    collection::Collection,
29    playlist::Playlist,
30    song::{Song, SongChangeSet, SongMetadata},
31};
32
33pub const ARTIST_NAME_SEPARATOR: &str = ", ";
34
35/// Initialize a test database with the same tables as the main database.
36/// This is useful for testing queries and mutations.
37///
38/// # Errors
39///
40/// This function will return an error if the database cannot be initialized.
41#[cfg(feature = "db")]
42#[allow(clippy::missing_inline_in_public_items)]
43pub async fn init_test_database() -> surrealdb::Result<Surreal<Db>> {
44    use crate::db::{
45        queries::relations::define_relation_tables, schemas::dynamic::DynamicPlaylist,
46    };
47
48    let config = Config::new().strict();
49    let db = Surreal::new::<Mem>(config).await?;
50
51    db.query("DEFINE NAMESPACE IF NOT EXISTS test").await?;
52    db.use_ns("test").await?;
53    db.query("DEFINE DATABASE IF NOT EXISTS test").await?;
54    db.use_db("test").await?;
55
56    crate::db::register_custom_analyzer(&db).await?;
57    surrealqlx::register_tables!(
58        &db,
59        Album,
60        Artist,
61        Song,
62        Collection,
63        Playlist,
64        DynamicPlaylist
65    )?;
66    #[cfg(feature = "analysis")]
67    surrealqlx::register_tables!(&db, Analysis)?;
68
69    define_relation_tables(&db).await?;
70
71    Ok(db)
72}
73
74/// Initialize a test database with some basic state
75///
76/// # What will be created:
77///
78/// - a playlist named "Playlist 0"
79/// - a collection named "Collection 0"
80/// - optionally, a passed `DynamicPlaylist`
81/// - `song_count` arbitrary songs whose values are determined by the given `song_case_func`
82/// - a file in the given `TempDir` for each song
83///
84/// Can optionally also create a dynamic playlist with given information
85///
86/// You can pass functions to be used to create the songs and playlists
87///
88/// `song_case_func` signature
89/// `FnMut(usize) -> (SongCase, bool, bool)`
90/// - `i`: which song this is, 0..`song_count`
91/// - returns: `(the song_case to use when generating the song, whether the song should be added to the playlist, whether it should be added to the collection`
92///
93/// Note: will actually create files for the songs in the passed `TempDir`
94///
95/// # Panics
96///
97/// Panics if an error occurs during the above process, this is intended to only be used for testing
98/// so panicking when something goes wrong ensures that tests will fail and the backtrace will point
99/// to whatever line caused the panic in here.
100#[cfg(feature = "db")]
101#[allow(clippy::missing_inline_in_public_items)]
102pub async fn init_test_database_with_state<SCF>(
103    song_count: std::num::NonZero<usize>,
104    mut song_case_func: SCF,
105    dynamic: Option<crate::db::schemas::dynamic::DynamicPlaylist>,
106    tempdir: &tempfile::TempDir,
107) -> Arc<Surreal<Db>>
108where
109    SCF: FnMut(usize) -> (SongCase, bool, bool) + Send + Sync,
110{
111    use anyhow::Context;
112
113    use crate::db::schemas::dynamic::DynamicPlaylist;
114
115    let db = Arc::new(init_test_database().await.unwrap());
116
117    // create the playlist, collection, and optionally the dynamic playlist
118    let playlist = Playlist {
119        id: Playlist::generate_id(),
120        name: "Playlist 0".into(),
121        runtime: Duration::from_secs(0),
122        song_count: 0,
123    };
124    let playlist = Playlist::create(&db, playlist).await.unwrap().unwrap();
125
126    let collection = Collection {
127        id: Collection::generate_id(),
128        name: "Collection 0".into(),
129        runtime: Duration::from_secs(0),
130        song_count: 0,
131    };
132    let collection = Collection::create(&db, collection).await.unwrap().unwrap();
133
134    if let Some(dynamic) = dynamic {
135        let _ = DynamicPlaylist::create(&db, dynamic)
136            .await
137            .unwrap()
138            .unwrap();
139    }
140
141    // create the songs
142    for i in 0..(song_count.get()) {
143        let (song_case, add_to_playlist, add_to_collection) = song_case_func(i);
144
145        let metadata = create_song_metadata(tempdir, song_case.clone())
146            .context(format!(
147                "failed to create metadata for song case {song_case:?}"
148            ))
149            .unwrap();
150
151        let song = Song::try_load_into_db(&db, metadata)
152            .await
153            .context(format!(
154                "Failed to load into db the song case: {song_case:?}"
155            ))
156            .unwrap();
157
158        if add_to_playlist {
159            Playlist::add_songs(&db, playlist.id.clone(), vec![song.id.clone()])
160                .await
161                .unwrap();
162        }
163        if add_to_collection {
164            Collection::add_songs(&db, collection.id.clone(), vec![song.id.clone()])
165                .await
166                .unwrap();
167        }
168    }
169
170    db
171}
172
173/// Create a song with the given case, and optionally apply the given overrides.
174///
175/// The created song is shallow, meaning that the artists, album artists, and album are not created in the database.
176///
177/// # Errors
178///
179/// This function will return an error if the song cannot be created.
180///
181/// # Panics
182///
183/// Panics if the song can't be read from the database after creation.
184#[cfg(feature = "db")]
185#[allow(clippy::missing_inline_in_public_items)]
186pub async fn create_song_with_overrides<C: Connection>(
187    db: &Surreal<C>,
188    SongCase {
189        song,
190        artists,
191        album_artists,
192        album,
193        genre,
194    }: SongCase,
195    overrides: SongChangeSet,
196) -> Result<Song> {
197    let id = Song::generate_id();
198    let song = Song {
199        id: id.clone(),
200        title: Into::into(format!("Song {song}").as_str()),
201        artist: artists
202            .iter()
203            .map(|a| format!("Artist {a}"))
204            .collect::<Vec<_>>()
205            .into(),
206        album_artist: album_artists
207            .iter()
208            .map(|a| format!("Artist {a}"))
209            .collect::<Vec<_>>()
210            .into(),
211        album: format!("Album {album}"),
212        genre: OneOrMany::One(format!("Genre {genre}")),
213        runtime: Duration::from_secs(120),
214        track: None,
215        disc: None,
216        release_year: None,
217        extension: "mp3".into(),
218        path: PathBuf::from_str(&format!("{}.mp3", id.key()))?,
219    };
220
221    Song::create(db, song.clone()).await?;
222    if overrides != SongChangeSet::default() {
223        Song::update(db, song.id.clone(), overrides).await?;
224    }
225    let song = Song::read(db, song.id).await?.expect("Song should exist");
226    Ok(song)
227}
228
229/// Creates a song file with the given case and overrides.
230/// The song file is created in a temporary directory.
231/// The song metadata is created from the song file.
232/// The song is not added to the database.
233///
234/// # Errors
235///
236/// This function will return an error if the song metadata cannot be created.
237#[allow(clippy::missing_inline_in_public_items)]
238pub fn create_song_metadata(
239    tempdir: &tempfile::TempDir,
240    SongCase {
241        song,
242        artists,
243        album_artists,
244        album,
245        genre,
246    }: SongCase,
247) -> Result<SongMetadata> {
248    // we have an example mp3 in `assets/`, we want to take that and create a new audio file with psuedorandom id3 tags
249    let base_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
250        .join("../assets/music.mp3")
251        .canonicalize()?;
252
253    let mut tagged_file = Probe::open(&base_path)?.read()?;
254    let tag = match tagged_file.primary_tag_mut() {
255        Some(primary_tag) => primary_tag,
256        // If the "primary" tag doesn't exist, we just grab the
257        // first tag we can find. Realistically, a tag reader would likely
258        // iterate through the tags to find a suitable one.
259        None => tagged_file
260            .first_tag_mut()
261            .ok_or_else(|| anyhow::anyhow!("ERROR: No tags found"))?,
262    };
263
264    tag.insert_text(
265        ItemKey::AlbumArtist,
266        album_artists
267            .iter()
268            .map(|a| format!("Artist {a}"))
269            .collect::<Vec<_>>()
270            .join(ARTIST_NAME_SEPARATOR),
271    );
272
273    tag.remove_artist();
274    tag.set_artist(
275        artists
276            .iter()
277            .map(|a| format!("Artist {a}"))
278            .collect::<Vec<_>>()
279            .join(ARTIST_NAME_SEPARATOR),
280    );
281
282    tag.remove_album();
283    tag.set_album(format!("Album {album}"));
284
285    tag.remove_title();
286    tag.set_title(format!("Song {song}"));
287
288    tag.remove_genre();
289    tag.set_genre(format!("Genre {genre}"));
290
291    let new_path = tempdir.path().join(format!("song_{}.mp3", Id::ulid()));
292    // copy the base file to the new path
293    std::fs::copy(&base_path, &new_path)?;
294    // write the new tags to the new file
295    tag.save_to_path(&new_path, WriteOptions::default())?;
296
297    // now, we need to load a SongMetadata from the new file
298    Ok(SongMetadata::load_from_path(
299        new_path,
300        &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
301        &OneOrMany::None,
302        None,
303    )?)
304}
305
306#[derive(Debug, Clone)]
307pub struct SongCase {
308    pub song: u8,
309    pub artists: Vec<u8>,
310    pub album_artists: Vec<u8>,
311    pub album: u8,
312    pub genre: u8,
313}
314
315impl SongCase {
316    #[must_use]
317    #[inline]
318    pub const fn new(
319        song: u8,
320        artists: Vec<u8>,
321        album_artists: Vec<u8>,
322        album: u8,
323        genre: u8,
324    ) -> Self {
325        Self {
326            song,
327            artists,
328            album_artists,
329            album,
330            genre,
331        }
332    }
333}
334
335#[inline]
336pub const fn arb_song_case() -> impl Fn() -> SongCase {
337    || {
338        let artist_item_strategy = move || {
339            (0..=10u8)
340                .choose(&mut rand::thread_rng())
341                .unwrap_or_default()
342        };
343        let rng = &mut rand::thread_rng();
344        let artists = arb_vec(&artist_item_strategy, 1..=10)()
345            .into_iter()
346            .collect::<std::collections::HashSet<_>>()
347            .into_iter()
348            .collect::<Vec<_>>();
349        let album_artists = arb_vec(&artist_item_strategy, 1..=10)()
350            .into_iter()
351            .collect::<std::collections::HashSet<_>>()
352            .into_iter()
353            .collect::<Vec<_>>();
354        let song = (0..=10u8).choose(rng).unwrap_or_default();
355        let album = (0..=10u8).choose(rng).unwrap_or_default();
356        let genre = (0..=10u8).choose(rng).unwrap_or_default();
357
358        SongCase::new(song, artists, album_artists, album, genre)
359    }
360}
361
362#[inline]
363pub const fn arb_vec<T>(
364    item_strategy: &impl Fn() -> T,
365    range: RangeInclusive<usize>,
366) -> impl Fn() -> Vec<T> + '_
367where
368    T: Clone + std::fmt::Debug + Sized,
369{
370    move || {
371        let size = range
372            .clone()
373            .choose(&mut rand::thread_rng())
374            .unwrap_or_default();
375        std::iter::repeat_with(item_strategy).take(size).collect()
376    }
377}
378
379pub enum IndexMode {
380    InBounds,
381    OutOfBounds,
382}
383
384#[inline]
385pub const fn arb_vec_and_index<T>(
386    item_strategy: &impl Fn() -> T,
387    range: RangeInclusive<usize>,
388    index_mode: IndexMode,
389) -> impl Fn() -> (Vec<T>, usize) + '_
390where
391    T: Clone + std::fmt::Debug + Sized,
392{
393    move || {
394        let vec = arb_vec(item_strategy, range.clone())();
395        let index = match index_mode {
396            IndexMode::InBounds => 0..vec.len(),
397            #[allow(clippy::range_plus_one)]
398            IndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
399        }
400        .choose(&mut rand::thread_rng())
401        .unwrap_or_default();
402        (vec, index)
403    }
404}
405
406pub enum RangeStartMode {
407    Standard,
408    Zero,
409    OutOfBounds,
410}
411
412pub enum RangeEndMode {
413    Start,
414    Standard,
415    OutOfBounds,
416}
417
418pub enum RangeIndexMode {
419    InBounds,
420    InRange,
421    AfterRangeInBounds,
422    OutOfBounds,
423    BeforeRange,
424}
425
426// Returns a tuple of a Vec of T and a Range<usize>
427// where the start is a random index in the Vec
428// and the end is a random index in the Vec that is greater than or equal to the start
429#[inline]
430pub const fn arb_vec_and_range_and_index<T>(
431    item_strategy: &impl Fn() -> T,
432    range: RangeInclusive<usize>,
433    range_start_mode: RangeStartMode,
434    range_end_mode: RangeEndMode,
435    index_mode: RangeIndexMode,
436) -> impl Fn() -> (Vec<T>, Range<usize>, Option<usize>) + '_
437where
438    T: Clone + std::fmt::Debug + Sized,
439{
440    move || {
441        let rng = &mut rand::thread_rng();
442        let vec = arb_vec(item_strategy, range.clone())();
443        let start = match range_start_mode {
444            RangeStartMode::Standard => 0..vec.len(),
445            #[allow(clippy::range_plus_one)]
446            RangeStartMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
447            RangeStartMode::Zero => 0..1,
448        }
449        .choose(rng)
450        .unwrap_or_default();
451        let end = match range_end_mode {
452            RangeEndMode::Standard => start..vec.len(),
453            #[allow(clippy::range_plus_one)]
454            RangeEndMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1).max(start),
455            #[allow(clippy::range_plus_one)]
456            RangeEndMode::Start => start..(start + 1),
457        }
458        .choose(rng)
459        .unwrap_or_default();
460
461        let index = match index_mode {
462            RangeIndexMode::InBounds => 0..vec.len(),
463            RangeIndexMode::InRange => start..end,
464            RangeIndexMode::AfterRangeInBounds => end..vec.len(),
465            #[allow(clippy::range_plus_one)]
466            RangeIndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
467            RangeIndexMode::BeforeRange => 0..start,
468        }
469        .choose(rng);
470
471        (vec, start..end, index)
472    }
473}
474
475#[inline]
476pub const fn arb_analysis_features() -> impl Fn() -> [f64; 20] {
477    move || {
478        let rng = &mut rand::thread_rng();
479        let mut features = [0.0; 20];
480        for feature in &mut features {
481            *feature = rng.gen_range(-1.0..1.0);
482        }
483        features
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use pretty_assertions::assert_eq;
491
492    #[tokio::test]
493    async fn test_create_song() {
494        let db = init_test_database().await.unwrap();
495        // Create a test case
496        let song_case = SongCase::new(0, vec![0], vec![0], 0, 0);
497
498        // Call the create_song function
499        let result = create_song_with_overrides(&db, song_case, SongChangeSet::default()).await;
500
501        // Assert that the result is Ok
502        if let Err(e) = result {
503            panic!("Error creating song: {e:?}");
504        }
505
506        // Get the Song from the result
507        let song = result.unwrap();
508
509        // Assert that we can get the song from the database
510        let song_from_db = Song::read(&db, song.id.clone()).await.unwrap().unwrap();
511
512        // Assert that the song from the database is the same as the song we created
513        assert_eq!(song, song_from_db);
514    }
515}