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