//! Tests for [`super`]. use super::*; use crate::db::Database; use crate::store::SampleStore; use crate::vfs; use std::fs; use std::io::Write; use std::path::Path; use std::sync::atomic::AtomicBool; fn setup_vfs_with_samples(db: &Database, store: &SampleStore, dir: &Path) -> crate::VfsId { let vfs_id = vfs::create_vfs(db, "TestVFS").unwrap(); // Create a real audio file in a temp location, then import it let wav_path = dir.join("kick.wav"); write_test_wav(&wav_path, 1, 44100, &[0.5, -0.5, 0.25, 0.0]); let hash = store.import(&wav_path, db).unwrap(); // Create directory structure in VFS let drums_id = vfs::create_directory(db, vfs_id, None, "Drums").unwrap(); vfs::create_sample_link( db, vfs_id, Some(drums_id), "kick.wav", &crate::SampleHash::from_trusted(hash.clone()), ) .unwrap(); vfs_id } fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) { let bytes_per_sample = 4u16; let block_align = channels * bytes_per_sample; let data_size = (samples.len() as u32) * 4; let file_size = 36 + data_size; let mut buf = Vec::with_capacity(44 + data_size as usize); buf.extend_from_slice(b"RIFF"); buf.extend_from_slice(&file_size.to_le_bytes()); buf.extend_from_slice(b"WAVE"); buf.extend_from_slice(b"fmt "); buf.extend_from_slice(&16u32.to_le_bytes()); buf.extend_from_slice(&3u16.to_le_bytes()); buf.extend_from_slice(&channels.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes()); buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes()); buf.extend_from_slice(&block_align.to_le_bytes()); buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes()); buf.extend_from_slice(b"data"); buf.extend_from_slice(&data_size.to_le_bytes()); for &s in samples { buf.extend_from_slice(&s.to_le_bytes()); } let mut file = fs::File::create(path).unwrap(); file.write_all(&buf).unwrap(); } #[test] fn collect_export_items_builds_relative_paths() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0].name, "kick.wav"); assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav")); } #[test] fn export_single_original_copies_file() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert_eq!(summary.total, 1); assert!(summary.errors.is_empty()); // Verify the file was copied with directory structure let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); // Verify content matches (hardlink or copy) let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); let source_bytes = fs::read(&source_path).unwrap(); let export_bytes = fs::read(&exported).unwrap(); assert_eq!(source_bytes, export_bytes); } #[test] fn export_single_wav_16bit() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export"); let config = ExportConfig { format: ExportFormat::Wav, sample_rate: Some(44100), bit_depth: Some(16), channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert_eq!(summary.total, 1); assert!(summary.errors.is_empty()); // Verify the WAV was created let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); // Verify it's a valid 16-bit WAV let reader = hound::WavReader::open(&exported).unwrap(); assert_eq!(reader.spec().bits_per_sample, 16); assert_eq!(reader.spec().sample_rate, 44100); } #[test] fn export_single_wav_24bit() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export"); let config = ExportConfig { format: ExportFormat::Wav, sample_rate: None, bit_depth: Some(24), channels: ExportChannels::Mono, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); let exported = dest_dir.join("Drums").join("kick.wav"); let reader = hound::WavReader::open(&exported).unwrap(); assert_eq!(reader.spec().bits_per_sample, 24); assert_eq!(reader.spec().channels, 1); } #[test] fn export_flat_with_pattern() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_flat"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: Some("{nn}_{name}".to_string()), flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); // Should be flat (no Drums/ subdirectory) let exported = dest_dir.join("01_kick.wav"); assert!(exported.exists(), "expected 01_kick.wav in flat export"); assert!(!dest_dir.join("Drums").exists()); } #[test] fn split_name_ext_works() { use crate::util::split_name_ext; assert_eq!(split_name_ext("kick.wav"), ("kick".into(), "wav".into())); assert_eq!(split_name_ext("noext"), ("noext".into(), String::new())); assert_eq!( split_name_ext("archive.tar.gz"), ("archive.tar".into(), "gz".into()) ); } #[test] fn export_with_sidecar_writes_json() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let mut items = collect_export_items(&db, vfs_id, None).unwrap(); enrich_with_tags(&db, &mut items); let dest_dir = dir.path().join("export_sidecar"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: true, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); let sidecar = dest_dir.join("Drums").join("kick.wav.audiofiles.json"); assert!(sidecar.exists(), "sidecar file should exist"); let content: serde_json::Value = serde_json::from_str(&fs::read_to_string(&sidecar).unwrap()).unwrap(); assert_eq!(content["name"], "kick.wav"); assert!(content["hash"].is_string()); } #[test] fn hardlink_or_copy_works() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_hl"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap(); let source_bytes = fs::read(&source_path).unwrap(); let export_bytes = fs::read(&exported).unwrap(); assert_eq!(source_bytes, export_bytes); } #[test] fn enrich_with_tags_populates() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let mut items = collect_export_items(&db, vfs_id, None).unwrap(); assert!(items[0].tags.is_empty()); crate::tags::add_tag(&db, &items[0].hash, "kick").unwrap(); crate::tags::add_tag(&db, &items[0].hash, "drums").unwrap(); enrich_with_tags(&db, &mut items); assert_eq!(items[0].tags.len(), 2); assert!(items[0].tags.contains(&"drums".to_string())); assert!(items[0].tags.contains(&"kick".to_string())); } #[test] fn export_with_naming_rules_sanitizes() { use crate::export::profile::{NamingCase, NamingRules}; let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_sanitize"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 8, strip_special: true, }), max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); // "kick" uppercased -> "KICK", truncated to 8 (already short enough) let exported = dest_dir.join("KICK.wav"); assert!( exported.exists(), "expected KICK.wav, got: {:?}", fs::read_dir(&dest_dir) .unwrap() .map(|e| e.unwrap().file_name()) .collect::>() ); } #[test] fn export_with_max_file_size_rejects_oversized() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_size"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: Some(1), // 1 byte: everything will exceed name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert_eq!(summary.errors.len(), 1); assert!( summary.errors[0] .1 .contains("exceeds device file size limit") ); // Verify the file was cleaned up let exported = dest_dir.join("kick.wav"); assert!( !exported.exists(), "oversized file should have been removed" ); } #[test] fn export_naming_rules_dedup() { use crate::export::profile::{NamingCase, NamingRules}; let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); // Add a second sample that will collide after sanitization let wav_path = dir.path().join("KICK.wav"); write_test_wav(&wav_path, 1, 44100, &[0.1, -0.1]); let hash2 = store.import(&wav_path, &db).unwrap(); vfs::create_sample_link( &db, vfs_id, None, "KICK.wav", &crate::SampleHash::from_trusted(hash2.clone()), ) .unwrap(); let items = collect_export_items(&db, vfs_id, None).unwrap(); assert_eq!(items.len(), 2); let dest_dir = dir.path().join("export_dedup"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 64, strip_special: true, }), max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); // Both sanitize to "KICK" -> second gets "_2" suffix let first = dest_dir.join("KICK.wav"); let second = dest_dir.join("KICK_2.wav"); assert!(first.exists(), "first file should be KICK.wav"); assert!(second.exists(), "deduped file should be KICK_2.wav"); } #[test] fn export_naming_rules_plus_pattern() { use crate::export::profile::{NamingCase, NamingRules}; let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_pattern_rules"); // Pattern applies first (adds index prefix), then naming rules sanitize let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: Some("{nn}_{name}".to_string()), flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 64, strip_special: true, }), max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); // Pattern produces "01_kick", rules uppercase to "01_KICK" let exported = dest_dir.join("01_KICK.wav"); assert!( exported.exists(), "expected 01_KICK.wav, got: {:?}", fs::read_dir(&dest_dir) .unwrap() .map(|e| e.unwrap().file_name()) .collect::>() ); } // resolve_output_names() direct tests fn make_item(name: &str, ext: &str) -> ExportItem { ExportItem { hash: crate::SampleHash::from_trusted("deadbeef"), ext: ext.to_string(), relative_path: PathBuf::from(name), name: name.to_string(), bpm: None, musical_key: None, duration: None, tags: vec![], source_path: None, } } fn make_item_with_analysis(name: &str, ext: &str, bpm: f64, key: &str) -> ExportItem { ExportItem { hash: crate::SampleHash::from_trusted("deadbeef"), ext: ext.to_string(), relative_path: PathBuf::from(name), name: name.to_string(), bpm: Some(bpm), musical_key: Some(key.to_string()), duration: Some(1.5), tags: vec![], source_path: None, } } fn base_config(dest: PathBuf) -> ExportConfig { ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest, device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, } } #[test] fn resolve_no_pattern_keeps_original_names() { let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")]; let config = base_config(PathBuf::from("/tmp")); let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.wav", "snare.wav"]); } #[test] fn resolve_wav_format_changes_extension() { let items = vec![ make_item("kick.mp3", "mp3"), make_item("snare.flac", "flac"), ]; let mut config = base_config(PathBuf::from("/tmp")); config.format = ExportFormat::Wav; let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.wav", "snare.wav"]); } #[test] fn resolve_aiff_format_changes_extension() { let items = vec![make_item("kick.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); config.format = ExportFormat::Aiff; let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.aiff"]); } #[test] fn resolve_with_pattern() { use crate::rename::RenamePattern; let items = vec![ make_item_with_analysis("kick.wav", "wav", 120.0, "C"), make_item_with_analysis("snare.wav", "wav", 120.0, "D"), ]; let config = base_config(PathBuf::from("/tmp")); let pat = RenamePattern::parse("{nn}_{name}").unwrap(); let names = resolve_output_names(&items, &config, Some(&pat)); assert_eq!(names, vec!["01_kick.wav", "02_snare.wav"]); } #[test] fn resolve_with_pattern_and_format_change() { use crate::rename::RenamePattern; let items = vec![make_item("kick.mp3", "mp3")]; let mut config = base_config(PathBuf::from("/tmp")); config.format = ExportFormat::Wav; let pat = RenamePattern::parse("{name}").unwrap(); let names = resolve_output_names(&items, &config, Some(&pat)); assert_eq!(names, vec!["kick.wav"]); } #[test] fn resolve_name_overrides_bypass_pattern() { use crate::rename::RenamePattern; let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); config.name_overrides = Some(vec!["custom1.wav".to_string(), "custom2.wav".to_string()]); let pat = RenamePattern::parse("{nn}_{name}").unwrap(); // Overrides take precedence over pattern let names = resolve_output_names(&items, &config, Some(&pat)); assert_eq!(names, vec!["custom1.wav", "custom2.wav"]); } #[test] fn resolve_name_overrides_wrong_length_ignored() { let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); // Wrong length (1 override for 2 items), should fall through to normal resolution config.name_overrides = Some(vec!["only_one.wav".to_string()]); let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["kick.wav", "snare.wav"]); } #[test] fn resolve_empty_items() { let items: Vec = vec![]; let config = base_config(PathBuf::from("/tmp")); let names = resolve_output_names(&items, &config, None); assert!(names.is_empty()); } #[test] fn resolve_dedup_after_sanitization() { use crate::export::profile::{NamingCase, NamingRules}; // Two items that sanitize to the same name let items = vec![make_item("Kick!.wav", "wav"), make_item("kick.wav", "wav")]; let mut config = base_config(PathBuf::from("/tmp")); config.naming_rules = Some(NamingRules { case: NamingCase::Upper, separator: '_', max_length: 64, strip_special: true, }); let names = resolve_output_names(&items, &config, None); assert_eq!(names, vec!["KICK.wav", "KICK_2.wav"]); } // run_export() additional coverage #[test] fn export_aiff_format() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_aiff"); let config = ExportConfig { format: ExportFormat::Aiff, sample_rate: None, bit_depth: Some(16), channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert_eq!(summary.total, 1); assert!(summary.errors.is_empty()); let exported = dest_dir.join("kick.aiff"); assert!(exported.exists()); // Verify it's a valid AIFF (starts with FORM...AIFF) let bytes = fs::read(&exported).unwrap(); assert_eq!(&bytes[0..4], b"FORM"); assert_eq!(&bytes[8..12], b"AIFF"); } #[test] fn export_progress_callback_cancel() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); // Add a second sample let wav2 = dir.path().join("snare.wav"); write_test_wav(&wav2, 1, 44100, &[0.1, -0.1]); let hash2 = store.import(&wav2, &db).unwrap(); vfs::create_sample_link( &db, vfs_id, None, "snare.wav", &crate::SampleHash::from_trusted(hash2.clone()), ) .unwrap(); let items = collect_export_items(&db, vfs_id, None).unwrap(); assert!(items.len() >= 2); let dest_dir = dir.path().join("export_cancel"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; // Cancel after the first item let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |completed, _, _| completed < 1, ) .unwrap(); // Total reflects all items, but only 1 should have been processed assert_eq!(summary.total, items.len()); // At most 1 file should exist (the one processed before cancel) let exported_count = fs::read_dir(&dest_dir).map_or(0, std::iter::Iterator::count); assert!(exported_count <= 1); } #[test] fn streaming_export_cancel_is_wired_through() { // Regression for the dead-wired streaming cancel: run_export used to pass // a throwaway AtomicBool to the streaming encoder, so a mid-file cancel // could never fire. With the real flag threaded through, requesting cancel // before the item streams aborts it cleanly, no output file, no error. let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); // A PCM WAV with an exact frame count takes the streaming export path. let src = dir.path().join("long.wav"); let samples: Vec = (0..8000) .map(|i| ((i % 100) as f32 / 100.0) - 0.5) .collect(); write_test_wav(&src, 1, 44100, &samples); let hash = store.import(&src, &db).unwrap(); let vfs_id = vfs::create_vfs(&db, "v").unwrap(); vfs::create_sample_link( &db, vfs_id, None, "long.wav", &crate::SampleHash::from_trusted(hash.clone()), ) .unwrap(); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_stream_cancel"); let config = ExportConfig { format: ExportFormat::Wav, sample_rate: None, bit_depth: Some(16), channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; // The callback requests cancel just before the item streams; the streaming // encoder polls the same flag and returns Cancelled on its first check. let cancel = AtomicBool::new(false); let summary = run_export(&items, &config, &store, &cancel, |_, _, _| { cancel.store(true, std::sync::atomic::Ordering::Relaxed); true }) .unwrap(); // Clean stop: not recorded as a per-item failure... assert!( summary.errors.is_empty(), "cancel should be clean, got {:?}", summary.errors ); // ...and the streamed file was never completed (write_atomic drops the tmp). let out = dest_dir.join("long.wav"); assert!(!out.exists(), "cancelled stream must leave no output file"); } #[test] fn streaming_export_completes_when_not_cancelled() { // Control for the test above: the same streaming path produces a valid // file when cancel stays false. let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let src = dir.path().join("long.wav"); let samples: Vec = (0..8000) .map(|i| ((i % 100) as f32 / 100.0) - 0.5) .collect(); write_test_wav(&src, 1, 44100, &samples); let hash = store.import(&src, &db).unwrap(); let vfs_id = vfs::create_vfs(&db, "v").unwrap(); vfs::create_sample_link( &db, vfs_id, None, "long.wav", &crate::SampleHash::from_trusted(hash.clone()), ) .unwrap(); let items = collect_export_items(&db, vfs_id, None).unwrap(); let dest_dir = dir.path().join("export_stream_ok"); let config = ExportConfig { format: ExportFormat::Wav, sample_rate: None, bit_depth: Some(16), channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); let out = dest_dir.join("long.wav"); assert!(out.exists(), "un-cancelled stream must produce output"); assert_eq!(&fs::read(&out).unwrap()[0..4], b"RIFF"); } #[test] fn export_missing_source_records_error() { let dir = tempfile::tempdir().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let dest_dir = dir.path().join("export_missing"); // Create an item with a hash that doesn't exist in the store let items = vec![ExportItem { hash: crate::SampleHash::from_trusted("nonexistent_hash_value"), ext: "wav".to_string(), relative_path: PathBuf::from("missing.wav"), name: "missing.wav".to_string(), bpm: None, musical_key: None, duration: None, tags: vec![], source_path: None, }]; let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: false, destination: dest_dir, device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert_eq!(summary.total, 1); assert_eq!(summary.errors.len(), 1); assert_eq!(summary.errors[0].0, "missing.wav"); } #[test] fn export_sidecar_contains_all_fields() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let mut items = collect_export_items(&db, vfs_id, None).unwrap(); // Add analysis metadata to the item items[0].bpm = Some(120.0); items[0].musical_key = Some("Am".to_string()); items[0].duration = Some(0.5); items[0].tags = vec!["one-shot".to_string(), "kick".to_string()]; let dest_dir = dir.path().join("export_sidecar_fields"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: true, metadata_sidecar: true, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); let sidecar_path = dest_dir.join("kick.wav.audiofiles.json"); assert!(sidecar_path.exists()); let contents = fs::read_to_string(&sidecar_path).unwrap(); let json: serde_json::Value = serde_json::from_str(&contents).unwrap(); assert_eq!(json["bpm"], 120.0); assert_eq!(json["key"], "Am"); assert_eq!(json["duration"], 0.5); assert_eq!(json["tags"], serde_json::json!(["one-shot", "kick"])); assert!(json["hash"].is_string()); assert_eq!(json["name"], "kick.wav"); } #[test] fn export_preserves_directory_structure() { let dir = tempfile::tempdir().unwrap(); let db = Database::open_in_memory().unwrap(); let store = SampleStore::new(dir.path().join("store")).unwrap(); let vfs_id = setup_vfs_with_samples(&db, &store, dir.path()); let items = collect_export_items(&db, vfs_id, None).unwrap(); // Verify relative_path includes directory assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav")); let dest_dir = dir.path().join("export_dirs"); let config = ExportConfig { format: ExportFormat::Original, sample_rate: None, bit_depth: None, channels: ExportChannels::Original, naming_pattern: None, flatten: false, // preserve structure metadata_sidecar: false, destination: dest_dir.clone(), device_profile: None, naming_rules: None, max_file_size_bytes: None, name_overrides: None, }; let summary = run_export( &items, &config, &store, &AtomicBool::new(false), |_, _, _| true, ) .unwrap(); assert!(summary.errors.is_empty()); // Should be in Drums/ subdirectory let exported = dest_dir.join("Drums").join("kick.wav"); assert!(exported.exists()); // Flat version should NOT exist assert!(!dest_dir.join("kick.wav").exists()); }