Commit Diff


commit - ed759063722fb568f8e4ee6c798eb6fcc1132988
commit + 1fd351421ceb4cc27d01a7e0606e482458f0f629
blob - ba059189056b4badded2214b9773469883344f97
blob + 9ecb02112a94775e21469e90873a69fabed335fa
--- Cargo.lock
+++ Cargo.lock
@@ -532,6 +532,12 @@ source = "registry+https://github.com/rust-lang/crates
 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
 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]]
blob - 4b26173348efe8ca07de939a7292f93a82e18ebb
blob + f72dc68e32db9a72ac7d720cef27e45516599500
--- Cargo.toml
+++ Cargo.toml
@@ -18,3 +18,6 @@ sled = { git = "https://github.com/spacejam/sled", rev
 dirs = "6"
 regex = "1"
 rusqlite = "0.39"
+
+[dev-dependencies]
+tempfile = "3"
blob - 2261306ee72c936c393c110fc4ce6717fc9cc532
blob + 91c48949e347d1224a9452df7b15cb2dd9132c1a
--- src/audio_processing.rs
+++ 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");
+        }
+    }
+}
blob - 4b74b37f4eba4e5199922c49023bb306eb4f73a8
blob + 8ee24d0b450eac4f33a76fa00b353f0168f8c9a5
--- src/constants.rs
+++ src/constants.rs
@@ -25,3 +25,28 @@ pub static SUPPORTED_EXTENSIONS: LazyLock<HashSet<&'st
         "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);
+    }
+}
blob - e41dde064bf1fc728c64bc3b3990d533c89da583
blob + cbf0262be8cfcc237989c5e8c1ef0153f915c8d6
--- src/db.rs
+++ 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);
+    }
+}
blob - ab6cacd972f1d754a4db29e559f1ed3eac4f23c2
blob + cfe14004f404fc46b2b375ed936b3854940147e7
--- src/file_io.rs
+++ src/file_io.rs
@@ -128,3 +128,30 @@ pub fn write_metadata(path: &Path, bpm: f32, key: &str
     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());
+    }
+}
blob - b0de3cc147ce4b0855d5d2304e4f8bb98845f853
blob + b849ec9c8639f2129b96cb6f604a7efb1faf2461
--- src/import.rs
+++ src/import.rs
@@ -75,3 +75,74 @@ pub fn import_mixxx(db: &LibraryDb, mixxx_path: &str) 
     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());
+    }
+}
blob - d542730063178e9b07b4ef555354f4ad40939588
blob + 13f638d6a5925110b089e9f72a3b9c807a9482e9
--- src/utils.rs
+++ src/utils.rs
@@ -32,3 +32,57 @@ pub fn format_primary_key(artist: Option<&str>, track:
         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");
+    }
+}