virittaa

Log | Files | Refs | README | LICENSE

commit 1fd351421ceb4cc27d01a7e0606e482458f0f629
parent ed759063722fb568f8e4ee6c798eb6fcc1132988
Author: mtmn <miro@haravara.org>
Date:   Sun, 14 Jun 2026 20:14:51 +0200

test: init

Diffstat:
MCargo.lock | 43+++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 3+++
Msrc/audio_processing.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/constants.rs | 25+++++++++++++++++++++++++
Msrc/db.rs | 293+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/file_io.rs | 27+++++++++++++++++++++++++++
Msrc/import.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/utils.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 616 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -532,6 +532,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] name = "fault-injection" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -874,6 +880,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1423,11 +1435,24 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] [[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1649,6 +1674,19 @@ dependencies = [ ] [[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1723,6 +1761,7 @@ dependencies = [ "rusqlite", "serde", "sled", + "tempfile", "walkdir", ] @@ -1815,7 +1854,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml @@ -18,3 +18,6 @@ sled = { git = "https://github.com/spacejam/sled", rev = "e449d171" } dirs = "6" regex = "1" rusqlite = "0.39" + +[dev-dependencies] +tempfile = "3" diff --git a/src/audio_processing.rs b/src/audio_processing.rs @@ -186,3 +186,105 @@ fn pearson_correlation( num / (den1.sqrt() * profile_stddev) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_bpm_applies_offset_within_range() { + // 120 + (-1.15) stays within [80, 160]. + assert!((normalize_bpm(120.0) - 118.85).abs() < 1e-4); + } + + #[test] + fn normalize_bpm_halves_when_too_fast() { + // 320 - 1.15 = 318.85 -> /2 = 159.425 (within range). + assert!((normalize_bpm(320.0) - 159.425).abs() < 1e-3); + } + + #[test] + fn normalize_bpm_doubles_when_too_slow() { + // 40 - 1.15 = 38.85 -> *2 = 77.7 -> *2 = 155.4 (within range). + assert!((normalize_bpm(40.0) - 155.4).abs() < 1e-3); + } + + #[test] + fn normalize_bpm_keeps_zero() { + assert_eq!(normalize_bpm(0.0), 0.0); + } + + #[test] + fn normalized_bpm_lands_in_range_for_many_inputs() { + for raw in 1..=2000 { + let bpm = normalize_bpm(raw as f32); + assert!( + (BPM_MIN..=BPM_MAX).contains(&bpm), + "normalize_bpm({raw}) = {bpm} outside [{BPM_MIN}, {BPM_MAX}]" + ); + } + } + + #[test] + fn rotate_array_shifts_right() { + let arr = ndarray::arr1(&[0.0, 1.0, 2.0, 3.0]); + assert_eq!(rotate_array(&arr, 0), arr); + assert_eq!(rotate_array(&arr, 1), ndarray::arr1(&[3.0, 0.0, 1.0, 2.0])); + assert_eq!(rotate_array(&arr, 2), ndarray::arr1(&[2.0, 3.0, 0.0, 1.0])); + // A full rotation returns the original. + assert_eq!(rotate_array(&arr, 4), arr); + } + + #[test] + fn pearson_correlation_of_identical_vectors_is_one() { + let v = ndarray::arr1(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let mean = v.mean().unwrap(); + let stddev: f64 = v.iter().map(|x| (x - mean).powi(2)).sum::<f64>().sqrt(); + let corr = pearson_correlation(&v, &v, mean, stddev); + assert!((corr - 1.0).abs() < 1e-9, "expected 1.0, got {corr}"); + } + + #[test] + fn pearson_correlation_of_constant_input_is_zero() { + // A flat chroma has zero variance, so correlation is defined as 0. + let v = ndarray::arr1(&[2.0; CHROMA_BINS]); + let (mean, stddev) = *MAJOR_PROFILE_STATS; + assert_eq!(pearson_correlation(&v, &MAJOR_ROTATIONS[0], mean, stddev), 0.0); + } + + #[test] + fn profile_stats_share_mean_and_stddev_across_rotations() { + // Rotation is a permutation, so every rotation has identical stats. + let (mean, stddev) = *MAJOR_PROFILE_STATS; + for rot in MAJOR_ROTATIONS.iter() { + let m = rot.mean().unwrap(); + let s: f64 = rot.iter().map(|x| (x - m).powi(2)).sum::<f64>().sqrt(); + assert!((m - mean).abs() < 1e-9); + assert!((s - stddev).abs() < 1e-9); + } + assert!(stddev > 0.0); + } + + #[test] + fn find_best_key_recovers_each_major_profile() { + let key_names = [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", + ]; + for i in 0..CHROMA_BINS { + // A chroma that is exactly the i-th major profile must resolve to + // that key in Major mode (self-correlation is the global maximum). + let (idx, mode) = find_best_key(&MAJOR_ROTATIONS[i]); + assert_eq!(idx, i, "{} major misidentified", key_names[i]); + assert_eq!(mode, "Major"); + } + } + + #[test] + fn find_best_key_recovers_each_minor_profile() { + for i in 0..CHROMA_BINS { + let (idx, mode) = find_best_key(&MINOR_ROTATIONS[i]); + assert_eq!(idx, i); + assert_eq!(mode, "Minor"); + } + } +} diff --git a/src/constants.rs b/src/constants.rs @@ -25,3 +25,28 @@ pub static SUPPORTED_EXTENSIONS: LazyLock<HashSet<&'static str>> = LazyLock::new "wav", "wv", ]) }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supported_extensions_are_lowercase_and_present() { + // The matcher lowercases input, so the set itself must stay lowercase. + for ext in SUPPORTED_EXTENSIONS.iter() { + assert_eq!(*ext, ext.to_lowercase(), "{ext} is not lowercase"); + } + assert!(SUPPORTED_EXTENSIONS.contains("mp3")); + assert!(SUPPORTED_EXTENSIONS.contains("flac")); + assert!(!SUPPORTED_EXTENSIONS.contains("txt")); + } + + #[test] + fn bpm_bounds_are_sane() { + assert!(BPM_MIN > 0.0); + assert!(BPM_MAX > BPM_MIN); + assert!(BPM_HOP_SIZE > 0); + assert!(BPM_WINDOW_SIZE >= BPM_HOP_SIZE); + assert_eq!(CHROMA_BINS, 12); + } +} diff --git a/src/db.rs b/src/db.rs @@ -429,3 +429,296 @@ impl LibraryDb { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + // ---- Pure helper tests ---- + + #[test] + fn prefix_index_key_joins_with_separator() { + assert_eq!(prefix_index_key(b"artist", b"primary"), b"artist\0primary"); + } + + #[test] + fn extract_primary_key_reads_after_separator() { + let key = prefix_index_key(b"aphex twin", b"Aphex Twin - Xtal"); + assert_eq!(extract_primary_key(&key).unwrap(), b"Aphex Twin - Xtal"); + assert_eq!( + extract_primary_key_string(&key).unwrap(), + "Aphex Twin - Xtal" + ); + } + + #[test] + fn extract_primary_key_errors_without_separator() { + assert!(extract_primary_key(b"no-separator-here").is_err()); + } + + #[test] + fn bpm_index_key_is_sorted_by_value() { + // Big-endian f64 bytes preserve ordering for non-negative values, so the + // raw index keys sort the same way as the BPM values they encode. + let slow = bpm_index_key(90.0, b"k"); + let fast = bpm_index_key(140.0, b"k"); + assert!(slow < fast); + assert_eq!(&slow[..BPM_KEY_LEN], &90.0f64.to_be_bytes()); + assert_eq!(slow[BPM_KEY_LEN], SEP); + } + + #[test] + fn time_index_key_layout() { + let k = time_index_key(42, b"primary"); + assert_eq!(&k[..TIME_KEY_LEN], &42i64.to_be_bytes()); + assert_eq!(k[TIME_KEY_LEN], SEP); + assert_eq!(&k[TIME_KEY_LEN + 1..], b"primary"); + } + + #[test] + fn parse_timestamp_handles_good_and_bad_lengths() { + assert_eq!(parse_timestamp(&42i64.to_be_bytes()), 42); + assert_eq!(parse_timestamp(&[]), 0); + assert_eq!(parse_timestamp(&[1, 2, 3]), 0); + } + + #[test] + fn effective_limit_treats_zero_as_unbounded() { + assert_eq!(effective_limit(0), usize::MAX); + assert_eq!(effective_limit(7), 7); + } + + #[test] + fn serialize_roundtrip_preserves_fields() { + let report = TrackReport { + artist: Some("Aphex Twin".to_string()), + track: Some("Xtal".to_string()), + bpm: Some(120.5), + key: Some("A Minor".to_string()), + timestamp: Some(1700), + }; + let bytes = serialize_report(&report).unwrap(); + let back = deserialize_report(&bytes).unwrap(); + assert_eq!(back.artist, report.artist); + assert_eq!(back.track, report.track); + assert_eq!(back.bpm, report.bpm); + assert_eq!(back.key, report.key); + assert_eq!(back.timestamp, report.timestamp); + } + + #[test] + fn display_formats_full_and_empty_reports() { + let full = TrackReport { + artist: Some("Aphex Twin".to_string()), + track: Some("Xtal".to_string()), + bpm: Some(120.04), + key: Some("A Minor".to_string()), + timestamp: Some(1700), + }; + assert_eq!(full.to_string(), "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor"); + + let empty = TrackReport { + artist: None, + track: None, + bpm: None, + key: None, + timestamp: None, + }; + assert_eq!(empty.to_string(), "Unknown - Unknown | BPM: - | Key: -"); + } + + // ---- Integration tests against a real sled database ---- + + fn report(artist: &str, track: &str, bpm: f64, key: &str, ts: i64) -> (String, TrackReport) { + ( + format!("{artist} - {track}"), + TrackReport { + artist: Some(artist.to_string()), + track: Some(track.to_string()), + bpm: Some(bpm), + key: Some(key.to_string()), + timestamp: Some(ts), + }, + ) + } + + fn temp_db() -> (tempfile::TempDir, LibraryDb) { + let dir = tempfile::tempdir().unwrap(); + let db = LibraryDb::open(&dir.path().join("db")).unwrap(); + (dir, db) + } + + #[test] + fn save_and_get_roundtrip() { + let (_dir, db) = temp_db(); + let (key, rep) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); + db.save(&key, &rep).unwrap(); + + let got = db.get(&key).unwrap().unwrap(); + assert_eq!(got.artist.as_deref(), Some("Aphex Twin")); + assert_eq!(got.bpm, Some(120.0)); + assert!(db.get("missing").unwrap().is_none()); + assert_eq!(db.count().unwrap(), 1); + } + + #[test] + fn find_by_artist_is_prefix_and_case_insensitive() { + let (_dir, db) = temp_db(); + let (k1, r1) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); + let (k2, r2) = report("Aphex Twin", "Ageispolis", 110.0, "C Major", 2); + let (k3, r3) = report("Boards of Canada", "Roygbiv", 95.0, "C Major", 3); + db.save(&k1, &r1).unwrap(); + db.save(&k2, &r2).unwrap(); + db.save(&k3, &r3).unwrap(); + + let hits = db.find_by_artist("aphex", 0).unwrap(); + assert_eq!(hits.len(), 2); + assert!(hits.iter().all(|(_, r)| r.artist.as_deref() == Some("Aphex Twin"))); + + assert_eq!(db.find_by_artist("APHEX TWIN", 0).unwrap().len(), 2); + assert!(db.find_by_artist("nobody", 0).unwrap().is_empty()); + } + + #[test] + fn find_by_key_is_prefix_and_case_insensitive() { + let (_dir, db) = temp_db(); + let (k1, r1) = report("A", "x", 120.0, "C Major", 1); + let (k2, r2) = report("B", "y", 110.0, "C Minor", 2); + let (k3, r3) = report("C", "z", 95.0, "D Major", 3); + db.save(&k1, &r1).unwrap(); + db.save(&k2, &r2).unwrap(); + db.save(&k3, &r3).unwrap(); + + assert_eq!(db.find_by_key("c ", 0).unwrap().len(), 2); + assert_eq!(db.find_by_key("c major", 0).unwrap().len(), 1); + assert_eq!(db.find_by_key("D", 0).unwrap().len(), 1); + } + + #[test] + fn find_by_bpm_range_is_inclusive() { + let (_dir, db) = temp_db(); + for (i, bpm) in [90.0, 120.0, 140.0, 160.0].into_iter().enumerate() { + let (k, r) = report("artist", &format!("t{i}"), bpm, "C Major", i as i64); + db.save(&k, &r).unwrap(); + } + + let hits = db.find_by_bpm_range(120.0, 140.0, 0).unwrap(); + let mut bpms: Vec<f64> = hits.iter().map(|(_, r)| r.bpm.unwrap()).collect(); + bpms.sort_by(f64::total_cmp); + assert_eq!(bpms, vec![120.0, 140.0]); + + // Bounds are inclusive on both ends. + assert_eq!(db.find_by_bpm_range(90.0, 160.0, 0).unwrap().len(), 4); + assert!(db.find_by_bpm_range(200.0, 300.0, 0).unwrap().is_empty()); + } + + #[test] + fn list_orders_oldest_last_and_respects_limit() { + let (_dir, db) = temp_db(); + let (k1, r1) = report("A", "old", 100.0, "C Major", 10); + let (k2, r2) = report("B", "mid", 100.0, "C Major", 20); + let (k3, r3) = report("C", "new", 100.0, "C Major", 30); + db.save(&k1, &r1).unwrap(); + db.save(&k2, &r2).unwrap(); + db.save(&k3, &r3).unwrap(); + + // Full listing is chronological: oldest first, newest at the bottom. + let all = db.list(0).unwrap(); + let keys: Vec<&str> = all.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, vec!["A - old", "B - mid", "C - new"]); + + // A limit keeps the most recent N, still shown oldest-first. + let limited = db.list(2).unwrap(); + let keys: Vec<&str> = limited.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, vec!["B - mid", "C - new"]); + } + + #[test] + fn save_overwrites_and_refreshes_indexes() { + let (_dir, db) = temp_db(); + let key = "Artist - Track".to_string(); + let first = TrackReport { + artist: Some("Artist".to_string()), + track: Some("Track".to_string()), + bpm: Some(120.0), + key: Some("C Major".to_string()), + timestamp: Some(1), + }; + db.save(&key, &first).unwrap(); + + // Overwrite with a different key signature and BPM. + let second = TrackReport { + artist: Some("Artist".to_string()), + track: Some("Track".to_string()), + bpm: Some(128.0), + key: Some("A Minor".to_string()), + timestamp: Some(2), + }; + db.save(&key, &second).unwrap(); + + assert_eq!(db.count().unwrap(), 1); + // Stale indexes are gone; only the new ones resolve. + assert!(db.find_by_key("c major", 0).unwrap().is_empty()); + assert_eq!(db.find_by_key("a minor", 0).unwrap().len(), 1); + assert!(db.find_by_bpm_range(119.0, 121.0, 0).unwrap().is_empty()); + assert_eq!(db.find_by_bpm_range(127.0, 129.0, 0).unwrap().len(), 1); + } + + #[test] + fn delete_removes_entry_and_indexes() { + let (_dir, db) = temp_db(); + let (key, rep) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); + db.save(&key, &rep).unwrap(); + db.delete(&key).unwrap(); + + assert!(db.get(&key).unwrap().is_none()); + assert_eq!(db.count().unwrap(), 0); + assert!(db.find_by_artist("aphex", 0).unwrap().is_empty()); + assert!(db.find_by_key("a minor", 0).unwrap().is_empty()); + assert!(db.find_by_bpm_range(119.0, 121.0, 0).unwrap().is_empty()); + assert!(db.list(0).unwrap().is_empty()); + + // Deleting a missing key is a no-op. + db.delete("does not exist").unwrap(); + } + + #[test] + fn save_batch_inserts_all_and_refreshes_stale_indexes() { + let (_dir, db) = temp_db(); + let (k, r) = report("Artist", "Track", 100.0, "C Major", 1); + db.save(&k, &r).unwrap(); + + let batch = vec![ + report("Artist", "Track", 128.0, "A Minor", 5), // overwrites k + report("Other", "Song", 90.0, "G Major", 6), + ]; + db.save_batch(&batch).unwrap(); + + assert_eq!(db.count().unwrap(), 2); + assert!(db.find_by_key("c major", 0).unwrap().is_empty()); + assert_eq!(db.find_by_key("a minor", 0).unwrap().len(), 1); + assert_eq!(db.get("Artist - Track").unwrap().unwrap().bpm, Some(128.0)); + } + + #[test] + fn search_matches_key_artist_track_and_bpm() { + let (_dir, db) = temp_db(); + let (k1, r1) = report("Aphex Twin", "Xtal", 120.0, "A Minor", 1); + let (k2, r2) = report("Boards of Canada", "Roygbiv", 95.5, "C Major", 2); + db.save(&k1, &r1).unwrap(); + db.save(&k2, &r2).unwrap(); + + // Match on the primary key / artist. + assert_eq!(db.search(&Regex::new("^Aphex").unwrap(), 0).unwrap().len(), 1); + // Match on track title. + assert_eq!(db.search(&Regex::new("Roygbiv").unwrap(), 0).unwrap().len(), 1); + // Match on formatted BPM (one decimal place). + assert_eq!(db.search(&Regex::new("95.5").unwrap(), 0).unwrap().len(), 1); + // No match. + assert!(db.search(&Regex::new("zzz").unwrap(), 0).unwrap().is_empty()); + + // Limit caps the number of results. + let both = db.search(&Regex::new(".").unwrap(), 1).unwrap(); + assert_eq!(both.len(), 1); + } +} diff --git a/src/file_io.rs b/src/file_io.rs @@ -128,3 +128,30 @@ pub fn write_metadata(path: &Path, bpm: f32, key: &str) -> Result<()> { tag.save_to_path(path, WriteOptions::default())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_track_info_carries_metadata_and_analysis() { + let metadata = FileMetadata { + artist: Some("Aphex Twin".to_string()), + track: Some("Xtal".to_string()), + }; + let info = build_track_info(Path::new("/music/xtal.flac"), metadata, 120.5, "A Minor".to_string()); + + assert_eq!(info.path, Path::new("/music/xtal.flac")); + assert_eq!(info.artist.as_deref(), Some("Aphex Twin")); + assert_eq!(info.track.as_deref(), Some("Xtal")); + assert!((info.bpm - 120.5).abs() < f32::EPSILON); + assert_eq!(info.key, "A Minor"); + } + + #[test] + fn file_metadata_defaults_to_none() { + let md = FileMetadata::default(); + assert!(md.artist.is_none()); + assert!(md.track.is_none()); + } +} diff --git a/src/import.rs b/src/import.rs @@ -75,3 +75,74 @@ pub fn import_mixxx(db: &LibraryDb, mixxx_path: &str) -> Result<usize> { db.save_batch(&entries)?; Ok(count) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn convert_mixxx_major_keys() { + assert_eq!(convert_mixxx_key("D"), Some("D Major".to_string())); + assert_eq!(convert_mixxx_key("F#"), Some("F# Major".to_string())); + assert_eq!(convert_mixxx_key("Bb"), Some("Bb Major".to_string())); + } + + #[test] + fn convert_mixxx_minor_keys() { + assert_eq!(convert_mixxx_key("Cm"), Some("C Minor".to_string())); + assert_eq!(convert_mixxx_key("C#m"), Some("C# Minor".to_string())); + assert_eq!(convert_mixxx_key("Bbm"), Some("Bb Minor".to_string())); + } + + #[test] + fn convert_mixxx_key_trims_and_rejects_empty() { + assert_eq!(convert_mixxx_key(""), None); + assert_eq!(convert_mixxx_key(" "), None); + assert_eq!(convert_mixxx_key(" Am "), Some("A Minor".to_string())); + } + + fn make_mixxx_db(path: &std::path::Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE library ( + artist TEXT, + title TEXT, + bpm REAL, + key TEXT, + mixxx_deleted INTEGER + ); + INSERT INTO library VALUES ('Aphex Twin', 'Xtal', 120.0, 'Am', 0); + INSERT INTO library VALUES ('Boards of Canada', 'Roygbiv', 95.5, 'C', 0); + -- Deleted rows are skipped. + INSERT INTO library VALUES ('Deleted', 'Track', 100.0, 'D', 1); + -- Rows without a usable BPM are skipped. + INSERT INTO library VALUES ('No', 'Bpm', NULL, 'E', 0); + INSERT INTO library VALUES ('Zero', 'Bpm', 0.0, 'F', 0);", + ) + .unwrap(); + } + + #[test] + fn import_mixxx_filters_and_converts() { + let dir = tempfile::tempdir().unwrap(); + let mixxx_path = dir.path().join("mixxxdb.sqlite"); + make_mixxx_db(&mixxx_path); + + let db = LibraryDb::open(&dir.path().join("library")).unwrap(); + let count = import_mixxx(&db, mixxx_path.to_str().unwrap()).unwrap(); + + // Only the two non-deleted, positive-BPM rows are imported. + assert_eq!(count, 2); + assert_eq!(db.count().unwrap(), 2); + + let xtal = db.get("Aphex Twin - Xtal").unwrap().unwrap(); + assert_eq!(xtal.key.as_deref(), Some("A Minor")); + assert_eq!(xtal.bpm, Some(120.0)); + + let roygbiv = db.get("Boards of Canada - Roygbiv").unwrap().unwrap(); + assert_eq!(roygbiv.key.as_deref(), Some("C Major")); + + assert!(db.get("Deleted - Track").unwrap().is_none()); + assert!(db.get("Zero - Bpm").unwrap().is_none()); + } +} diff --git a/src/utils.rs b/src/utils.rs @@ -32,3 +32,57 @@ pub fn format_primary_key(artist: Option<&str>, track: Option<&str>) -> String { track.unwrap_or("Unknown") ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsStr; + + #[test] + fn supported_extension_is_case_insensitive() { + assert!(is_supported_extension(OsStr::new("mp3"))); + assert!(is_supported_extension(OsStr::new("MP3"))); + assert!(is_supported_extension(OsStr::new("Flac"))); + assert!(is_supported_extension(OsStr::new("OGG"))); + } + + #[test] + fn unsupported_extension_is_rejected() { + assert!(!is_supported_extension(OsStr::new("txt"))); + assert!(!is_supported_extension(OsStr::new("png"))); + assert!(!is_supported_extension(OsStr::new(""))); + } + + #[test] + fn supported_audio_file_requires_existing_file() { + let dir = tempfile::tempdir().unwrap(); + + let audio = dir.path().join("track.flac"); + std::fs::write(&audio, b"not really audio").unwrap(); + assert!(is_supported_audio_file(&audio)); + + // A directory is never a supported audio file, even with an audio suffix. + let fake_dir = dir.path().join("album.mp3"); + std::fs::create_dir(&fake_dir).unwrap(); + assert!(!is_supported_audio_file(&fake_dir)); + + // A real file with no/unsupported extension is rejected. + let doc = dir.path().join("notes.txt"); + std::fs::write(&doc, b"hi").unwrap(); + assert!(!is_supported_audio_file(&doc)); + + // A path that does not exist is rejected. + assert!(!is_supported_audio_file(&dir.path().join("missing.mp3"))); + } + + #[test] + fn primary_key_uses_unknown_for_missing_parts() { + assert_eq!( + format_primary_key(Some("Aphex Twin"), Some("Xtal")), + "Aphex Twin - Xtal" + ); + assert_eq!(format_primary_key(None, Some("Xtal")), "Unknown - Xtal"); + assert_eq!(format_primary_key(Some("Aphex Twin"), None), "Aphex Twin - Unknown"); + assert_eq!(format_primary_key(None, None), "Unknown - Unknown"); + } +}