commit 1fd351421ceb4cc27d01a7e0606e482458f0f629
parent ed759063722fb568f8e4ee6c798eb6fcc1132988
Author: mtmn <miro@haravara.org>
Date: Sun, 14 Jun 2026 20:14:51 +0200
test: init
Diffstat:
| M | Cargo.lock | | | 43 | +++++++++++++++++++++++++++++++++++++++++-- |
| M | Cargo.toml | | | 3 | +++ |
| M | src/audio_processing.rs | | | 102 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/constants.rs | | | 25 | +++++++++++++++++++++++++ |
| M | src/db.rs | | | 293 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/file_io.rs | | | 27 | +++++++++++++++++++++++++++ |
| M | src/import.rs | | | 71 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/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");
+ }
+}