Skip to main content

max / audiofiles

34.0 KB · 1105 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use crate::db::Database;
5 use crate::store::SampleStore;
6 use crate::vfs;
7 use std::fs;
8 use std::io::Write;
9 use std::path::Path;
10 use std::sync::atomic::AtomicBool;
11
12 fn setup_vfs_with_samples(db: &Database, store: &SampleStore, dir: &Path) -> crate::VfsId {
13 let vfs_id = vfs::create_vfs(db, "TestVFS").unwrap();
14
15 // Create a real audio file in a temp location, then import it
16 let wav_path = dir.join("kick.wav");
17 write_test_wav(&wav_path, 1, 44100, &[0.5, -0.5, 0.25, 0.0]);
18 let hash = store.import(&wav_path, db).unwrap();
19
20 // Create directory structure in VFS
21 let drums_id = vfs::create_directory(db, vfs_id, None, "Drums").unwrap();
22 vfs::create_sample_link(
23 db,
24 vfs_id,
25 Some(drums_id),
26 "kick.wav",
27 &crate::SampleHash::from_trusted(hash.clone()),
28 )
29 .unwrap();
30
31 vfs_id
32 }
33
34 fn write_test_wav(path: &Path, channels: u16, sample_rate: u32, samples: &[f32]) {
35 let bytes_per_sample = 4u16;
36 let block_align = channels * bytes_per_sample;
37 let data_size = (samples.len() as u32) * 4;
38 let file_size = 36 + data_size;
39
40 let mut buf = Vec::with_capacity(44 + data_size as usize);
41 buf.extend_from_slice(b"RIFF");
42 buf.extend_from_slice(&file_size.to_le_bytes());
43 buf.extend_from_slice(b"WAVE");
44 buf.extend_from_slice(b"fmt ");
45 buf.extend_from_slice(&16u32.to_le_bytes());
46 buf.extend_from_slice(&3u16.to_le_bytes());
47 buf.extend_from_slice(&channels.to_le_bytes());
48 buf.extend_from_slice(&sample_rate.to_le_bytes());
49 buf.extend_from_slice(&(sample_rate * block_align as u32).to_le_bytes());
50 buf.extend_from_slice(&block_align.to_le_bytes());
51 buf.extend_from_slice(&(bytes_per_sample * 8).to_le_bytes());
52 buf.extend_from_slice(b"data");
53 buf.extend_from_slice(&data_size.to_le_bytes());
54 for &s in samples {
55 buf.extend_from_slice(&s.to_le_bytes());
56 }
57
58 let mut file = fs::File::create(path).unwrap();
59 file.write_all(&buf).unwrap();
60 }
61
62 #[test]
63 fn collect_export_items_builds_relative_paths() {
64 let dir = tempfile::tempdir().unwrap();
65 let db = Database::open_in_memory().unwrap();
66 let store = SampleStore::new(dir.path().join("store")).unwrap();
67 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
68
69 let items = collect_export_items(&db, vfs_id, None).unwrap();
70 assert_eq!(items.len(), 1);
71 assert_eq!(items[0].name, "kick.wav");
72 assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav"));
73 }
74
75 #[test]
76 fn export_single_original_copies_file() {
77 let dir = tempfile::tempdir().unwrap();
78 let db = Database::open_in_memory().unwrap();
79 let store = SampleStore::new(dir.path().join("store")).unwrap();
80 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
81
82 let items = collect_export_items(&db, vfs_id, None).unwrap();
83 let dest_dir = dir.path().join("export");
84
85 let config = ExportConfig {
86 format: ExportFormat::Original,
87 sample_rate: None,
88 bit_depth: None,
89 channels: ExportChannels::Original,
90 naming_pattern: None,
91 flatten: false,
92 metadata_sidecar: false,
93 destination: dest_dir.clone(),
94 device_profile: None,
95 naming_rules: None,
96 max_file_size_bytes: None,
97 name_overrides: None,
98 };
99
100 let summary = run_export(
101 &items,
102 &config,
103 &store,
104 &AtomicBool::new(false),
105 |_, _, _| true,
106 )
107 .unwrap();
108 assert_eq!(summary.total, 1);
109 assert!(summary.errors.is_empty());
110
111 // Verify the file was copied with directory structure
112 let exported = dest_dir.join("Drums").join("kick.wav");
113 assert!(exported.exists());
114
115 // Verify content matches (hardlink or copy)
116 let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap();
117 let source_bytes = fs::read(&source_path).unwrap();
118 let export_bytes = fs::read(&exported).unwrap();
119 assert_eq!(source_bytes, export_bytes);
120 }
121
122 #[test]
123 fn export_single_wav_16bit() {
124 let dir = tempfile::tempdir().unwrap();
125 let db = Database::open_in_memory().unwrap();
126 let store = SampleStore::new(dir.path().join("store")).unwrap();
127 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
128
129 let items = collect_export_items(&db, vfs_id, None).unwrap();
130 let dest_dir = dir.path().join("export");
131
132 let config = ExportConfig {
133 format: ExportFormat::Wav,
134 sample_rate: Some(44100),
135 bit_depth: Some(16),
136 channels: ExportChannels::Original,
137 naming_pattern: None,
138 flatten: false,
139 metadata_sidecar: false,
140 destination: dest_dir.clone(),
141 device_profile: None,
142 naming_rules: None,
143 max_file_size_bytes: None,
144 name_overrides: None,
145 };
146
147 let summary = run_export(
148 &items,
149 &config,
150 &store,
151 &AtomicBool::new(false),
152 |_, _, _| true,
153 )
154 .unwrap();
155 assert_eq!(summary.total, 1);
156 assert!(summary.errors.is_empty());
157
158 // Verify the WAV was created
159 let exported = dest_dir.join("Drums").join("kick.wav");
160 assert!(exported.exists());
161
162 // Verify it's a valid 16-bit WAV
163 let reader = hound::WavReader::open(&exported).unwrap();
164 assert_eq!(reader.spec().bits_per_sample, 16);
165 assert_eq!(reader.spec().sample_rate, 44100);
166 }
167
168 #[test]
169 fn export_single_wav_24bit() {
170 let dir = tempfile::tempdir().unwrap();
171 let db = Database::open_in_memory().unwrap();
172 let store = SampleStore::new(dir.path().join("store")).unwrap();
173 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
174
175 let items = collect_export_items(&db, vfs_id, None).unwrap();
176 let dest_dir = dir.path().join("export");
177
178 let config = ExportConfig {
179 format: ExportFormat::Wav,
180 sample_rate: None,
181 bit_depth: Some(24),
182 channels: ExportChannels::Mono,
183 naming_pattern: None,
184 flatten: false,
185 metadata_sidecar: false,
186 destination: dest_dir.clone(),
187 device_profile: None,
188 naming_rules: None,
189 max_file_size_bytes: None,
190 name_overrides: None,
191 };
192
193 let summary = run_export(
194 &items,
195 &config,
196 &store,
197 &AtomicBool::new(false),
198 |_, _, _| true,
199 )
200 .unwrap();
201 assert!(summary.errors.is_empty());
202
203 let exported = dest_dir.join("Drums").join("kick.wav");
204 let reader = hound::WavReader::open(&exported).unwrap();
205 assert_eq!(reader.spec().bits_per_sample, 24);
206 assert_eq!(reader.spec().channels, 1);
207 }
208
209 #[test]
210 fn export_flat_with_pattern() {
211 let dir = tempfile::tempdir().unwrap();
212 let db = Database::open_in_memory().unwrap();
213 let store = SampleStore::new(dir.path().join("store")).unwrap();
214 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
215
216 let items = collect_export_items(&db, vfs_id, None).unwrap();
217 let dest_dir = dir.path().join("export_flat");
218
219 let config = ExportConfig {
220 format: ExportFormat::Original,
221 sample_rate: None,
222 bit_depth: None,
223 channels: ExportChannels::Original,
224 naming_pattern: Some("{nn}_{name}".to_string()),
225 flatten: true,
226 metadata_sidecar: false,
227 destination: dest_dir.clone(),
228 device_profile: None,
229 naming_rules: None,
230 max_file_size_bytes: None,
231 name_overrides: None,
232 };
233
234 let summary = run_export(
235 &items,
236 &config,
237 &store,
238 &AtomicBool::new(false),
239 |_, _, _| true,
240 )
241 .unwrap();
242 assert!(summary.errors.is_empty());
243
244 // Should be flat (no Drums/ subdirectory)
245 let exported = dest_dir.join("01_kick.wav");
246 assert!(exported.exists(), "expected 01_kick.wav in flat export");
247 assert!(!dest_dir.join("Drums").exists());
248 }
249
250 #[test]
251 fn split_name_ext_works() {
252 use crate::util::split_name_ext;
253 assert_eq!(split_name_ext("kick.wav"), ("kick".into(), "wav".into()));
254 assert_eq!(split_name_ext("noext"), ("noext".into(), String::new()));
255 assert_eq!(
256 split_name_ext("archive.tar.gz"),
257 ("archive.tar".into(), "gz".into())
258 );
259 }
260
261 #[test]
262 fn export_with_sidecar_writes_json() {
263 let dir = tempfile::tempdir().unwrap();
264 let db = Database::open_in_memory().unwrap();
265 let store = SampleStore::new(dir.path().join("store")).unwrap();
266 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
267
268 let mut items = collect_export_items(&db, vfs_id, None).unwrap();
269 enrich_with_tags(&db, &mut items);
270 let dest_dir = dir.path().join("export_sidecar");
271
272 let config = ExportConfig {
273 format: ExportFormat::Original,
274 sample_rate: None,
275 bit_depth: None,
276 channels: ExportChannels::Original,
277 naming_pattern: None,
278 flatten: false,
279 metadata_sidecar: true,
280 destination: dest_dir.clone(),
281 device_profile: None,
282 naming_rules: None,
283 max_file_size_bytes: None,
284 name_overrides: None,
285 };
286
287 let summary = run_export(
288 &items,
289 &config,
290 &store,
291 &AtomicBool::new(false),
292 |_, _, _| true,
293 )
294 .unwrap();
295 assert!(summary.errors.is_empty());
296
297 let sidecar = dest_dir.join("Drums").join("kick.wav.audiofiles.json");
298 assert!(sidecar.exists(), "sidecar file should exist");
299
300 let content: serde_json::Value =
301 serde_json::from_str(&fs::read_to_string(&sidecar).unwrap()).unwrap();
302 assert_eq!(content["name"], "kick.wav");
303 assert!(content["hash"].is_string());
304 }
305
306 #[test]
307 fn hardlink_or_copy_works() {
308 let dir = tempfile::tempdir().unwrap();
309 let db = Database::open_in_memory().unwrap();
310 let store = SampleStore::new(dir.path().join("store")).unwrap();
311 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
312
313 let items = collect_export_items(&db, vfs_id, None).unwrap();
314 let dest_dir = dir.path().join("export_hl");
315
316 let config = ExportConfig {
317 format: ExportFormat::Original,
318 sample_rate: None,
319 bit_depth: None,
320 channels: ExportChannels::Original,
321 naming_pattern: None,
322 flatten: false,
323 metadata_sidecar: false,
324 destination: dest_dir.clone(),
325 device_profile: None,
326 naming_rules: None,
327 max_file_size_bytes: None,
328 name_overrides: None,
329 };
330
331 let summary = run_export(
332 &items,
333 &config,
334 &store,
335 &AtomicBool::new(false),
336 |_, _, _| true,
337 )
338 .unwrap();
339 assert!(summary.errors.is_empty());
340
341 let exported = dest_dir.join("Drums").join("kick.wav");
342 assert!(exported.exists());
343
344 let source_path = store.sample_path(&items[0].hash, &items[0].ext).unwrap();
345 let source_bytes = fs::read(&source_path).unwrap();
346 let export_bytes = fs::read(&exported).unwrap();
347 assert_eq!(source_bytes, export_bytes);
348 }
349
350 #[test]
351 fn enrich_with_tags_populates() {
352 let dir = tempfile::tempdir().unwrap();
353 let db = Database::open_in_memory().unwrap();
354 let store = SampleStore::new(dir.path().join("store")).unwrap();
355 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
356
357 let mut items = collect_export_items(&db, vfs_id, None).unwrap();
358 assert!(items[0].tags.is_empty());
359
360 crate::tags::add_tag(&db, &items[0].hash, "kick").unwrap();
361 crate::tags::add_tag(&db, &items[0].hash, "drums").unwrap();
362
363 enrich_with_tags(&db, &mut items);
364 assert_eq!(items[0].tags.len(), 2);
365 assert!(items[0].tags.contains(&"drums".to_string()));
366 assert!(items[0].tags.contains(&"kick".to_string()));
367 }
368
369 #[test]
370 fn export_with_naming_rules_sanitizes() {
371 use crate::export::profile::{NamingCase, NamingRules};
372
373 let dir = tempfile::tempdir().unwrap();
374 let db = Database::open_in_memory().unwrap();
375 let store = SampleStore::new(dir.path().join("store")).unwrap();
376 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
377
378 let items = collect_export_items(&db, vfs_id, None).unwrap();
379 let dest_dir = dir.path().join("export_sanitize");
380
381 let config = ExportConfig {
382 format: ExportFormat::Original,
383 sample_rate: None,
384 bit_depth: None,
385 channels: ExportChannels::Original,
386 naming_pattern: None,
387 flatten: true,
388 metadata_sidecar: false,
389 destination: dest_dir.clone(),
390 device_profile: None,
391 naming_rules: Some(NamingRules {
392 case: NamingCase::Upper,
393 separator: '_',
394 max_length: 8,
395 strip_special: true,
396 }),
397 max_file_size_bytes: None,
398 name_overrides: None,
399 };
400
401 let summary = run_export(
402 &items,
403 &config,
404 &store,
405 &AtomicBool::new(false),
406 |_, _, _| true,
407 )
408 .unwrap();
409 assert!(summary.errors.is_empty());
410
411 // "kick" uppercased -> "KICK", truncated to 8 (already short enough)
412 let exported = dest_dir.join("KICK.wav");
413 assert!(
414 exported.exists(),
415 "expected KICK.wav, got: {:?}",
416 fs::read_dir(&dest_dir)
417 .unwrap()
418 .map(|e| e.unwrap().file_name())
419 .collect::<Vec<_>>()
420 );
421 }
422
423 #[test]
424 fn export_with_max_file_size_rejects_oversized() {
425 let dir = tempfile::tempdir().unwrap();
426 let db = Database::open_in_memory().unwrap();
427 let store = SampleStore::new(dir.path().join("store")).unwrap();
428 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
429
430 let items = collect_export_items(&db, vfs_id, None).unwrap();
431 let dest_dir = dir.path().join("export_size");
432
433 let config = ExportConfig {
434 format: ExportFormat::Original,
435 sample_rate: None,
436 bit_depth: None,
437 channels: ExportChannels::Original,
438 naming_pattern: None,
439 flatten: true,
440 metadata_sidecar: false,
441 destination: dest_dir.clone(),
442 device_profile: None,
443 naming_rules: None,
444 max_file_size_bytes: Some(1), // 1 byte: everything will exceed
445 name_overrides: None,
446 };
447
448 let summary = run_export(
449 &items,
450 &config,
451 &store,
452 &AtomicBool::new(false),
453 |_, _, _| true,
454 )
455 .unwrap();
456 assert_eq!(summary.errors.len(), 1);
457 assert!(
458 summary.errors[0]
459 .1
460 .contains("exceeds device file size limit")
461 );
462
463 // Verify the file was cleaned up
464 let exported = dest_dir.join("kick.wav");
465 assert!(
466 !exported.exists(),
467 "oversized file should have been removed"
468 );
469 }
470
471 #[test]
472 fn export_naming_rules_dedup() {
473 use crate::export::profile::{NamingCase, NamingRules};
474
475 let dir = tempfile::tempdir().unwrap();
476 let db = Database::open_in_memory().unwrap();
477 let store = SampleStore::new(dir.path().join("store")).unwrap();
478 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
479
480 // Add a second sample that will collide after sanitization
481 let wav_path = dir.path().join("KICK.wav");
482 write_test_wav(&wav_path, 1, 44100, &[0.1, -0.1]);
483 let hash2 = store.import(&wav_path, &db).unwrap();
484 vfs::create_sample_link(
485 &db,
486 vfs_id,
487 None,
488 "KICK.wav",
489 &crate::SampleHash::from_trusted(hash2.clone()),
490 )
491 .unwrap();
492
493 let items = collect_export_items(&db, vfs_id, None).unwrap();
494 assert_eq!(items.len(), 2);
495
496 let dest_dir = dir.path().join("export_dedup");
497
498 let config = ExportConfig {
499 format: ExportFormat::Original,
500 sample_rate: None,
501 bit_depth: None,
502 channels: ExportChannels::Original,
503 naming_pattern: None,
504 flatten: true,
505 metadata_sidecar: false,
506 destination: dest_dir.clone(),
507 device_profile: None,
508 naming_rules: Some(NamingRules {
509 case: NamingCase::Upper,
510 separator: '_',
511 max_length: 64,
512 strip_special: true,
513 }),
514 max_file_size_bytes: None,
515 name_overrides: None,
516 };
517
518 let summary = run_export(
519 &items,
520 &config,
521 &store,
522 &AtomicBool::new(false),
523 |_, _, _| true,
524 )
525 .unwrap();
526 assert!(summary.errors.is_empty());
527
528 // Both sanitize to "KICK" -> second gets "_2" suffix
529 let first = dest_dir.join("KICK.wav");
530 let second = dest_dir.join("KICK_2.wav");
531 assert!(first.exists(), "first file should be KICK.wav");
532 assert!(second.exists(), "deduped file should be KICK_2.wav");
533 }
534
535 #[test]
536 fn export_naming_rules_plus_pattern() {
537 use crate::export::profile::{NamingCase, NamingRules};
538
539 let dir = tempfile::tempdir().unwrap();
540 let db = Database::open_in_memory().unwrap();
541 let store = SampleStore::new(dir.path().join("store")).unwrap();
542 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
543
544 let items = collect_export_items(&db, vfs_id, None).unwrap();
545 let dest_dir = dir.path().join("export_pattern_rules");
546
547 // Pattern applies first (adds index prefix), then naming rules sanitize
548 let config = ExportConfig {
549 format: ExportFormat::Original,
550 sample_rate: None,
551 bit_depth: None,
552 channels: ExportChannels::Original,
553 naming_pattern: Some("{nn}_{name}".to_string()),
554 flatten: true,
555 metadata_sidecar: false,
556 destination: dest_dir.clone(),
557 device_profile: None,
558 naming_rules: Some(NamingRules {
559 case: NamingCase::Upper,
560 separator: '_',
561 max_length: 64,
562 strip_special: true,
563 }),
564 max_file_size_bytes: None,
565 name_overrides: None,
566 };
567
568 let summary = run_export(
569 &items,
570 &config,
571 &store,
572 &AtomicBool::new(false),
573 |_, _, _| true,
574 )
575 .unwrap();
576 assert!(summary.errors.is_empty());
577
578 // Pattern produces "01_kick", rules uppercase to "01_KICK"
579 let exported = dest_dir.join("01_KICK.wav");
580 assert!(
581 exported.exists(),
582 "expected 01_KICK.wav, got: {:?}",
583 fs::read_dir(&dest_dir)
584 .unwrap()
585 .map(|e| e.unwrap().file_name())
586 .collect::<Vec<_>>()
587 );
588 }
589
590 // resolve_output_names() direct tests
591
592 fn make_item(name: &str, ext: &str) -> ExportItem {
593 ExportItem {
594 hash: crate::SampleHash::from_trusted("deadbeef"),
595 ext: ext.to_string(),
596 relative_path: PathBuf::from(name),
597 name: name.to_string(),
598 bpm: None,
599 musical_key: None,
600 duration: None,
601 tags: vec![],
602 source_path: None,
603 }
604 }
605
606 fn make_item_with_analysis(name: &str, ext: &str, bpm: f64, key: &str) -> ExportItem {
607 ExportItem {
608 hash: crate::SampleHash::from_trusted("deadbeef"),
609 ext: ext.to_string(),
610 relative_path: PathBuf::from(name),
611 name: name.to_string(),
612 bpm: Some(bpm),
613 musical_key: Some(key.to_string()),
614 duration: Some(1.5),
615 tags: vec![],
616 source_path: None,
617 }
618 }
619
620 fn base_config(dest: PathBuf) -> ExportConfig {
621 ExportConfig {
622 format: ExportFormat::Original,
623 sample_rate: None,
624 bit_depth: None,
625 channels: ExportChannels::Original,
626 naming_pattern: None,
627 flatten: true,
628 metadata_sidecar: false,
629 destination: dest,
630 device_profile: None,
631 naming_rules: None,
632 max_file_size_bytes: None,
633 name_overrides: None,
634 }
635 }
636
637 #[test]
638 fn resolve_no_pattern_keeps_original_names() {
639 let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")];
640 let config = base_config(PathBuf::from("/tmp"));
641 let names = resolve_output_names(&items, &config, None);
642 assert_eq!(names, vec!["kick.wav", "snare.wav"]);
643 }
644
645 #[test]
646 fn resolve_wav_format_changes_extension() {
647 let items = vec![
648 make_item("kick.mp3", "mp3"),
649 make_item("snare.flac", "flac"),
650 ];
651 let mut config = base_config(PathBuf::from("/tmp"));
652 config.format = ExportFormat::Wav;
653 let names = resolve_output_names(&items, &config, None);
654 assert_eq!(names, vec!["kick.wav", "snare.wav"]);
655 }
656
657 #[test]
658 fn resolve_aiff_format_changes_extension() {
659 let items = vec![make_item("kick.wav", "wav")];
660 let mut config = base_config(PathBuf::from("/tmp"));
661 config.format = ExportFormat::Aiff;
662 let names = resolve_output_names(&items, &config, None);
663 assert_eq!(names, vec!["kick.aiff"]);
664 }
665
666 #[test]
667 fn resolve_with_pattern() {
668 use crate::rename::RenamePattern;
669
670 let items = vec![
671 make_item_with_analysis("kick.wav", "wav", 120.0, "C"),
672 make_item_with_analysis("snare.wav", "wav", 120.0, "D"),
673 ];
674 let config = base_config(PathBuf::from("/tmp"));
675 let pat = RenamePattern::parse("{nn}_{name}").unwrap();
676 let names = resolve_output_names(&items, &config, Some(&pat));
677 assert_eq!(names, vec!["01_kick.wav", "02_snare.wav"]);
678 }
679
680 #[test]
681 fn resolve_with_pattern_and_format_change() {
682 use crate::rename::RenamePattern;
683
684 let items = vec![make_item("kick.mp3", "mp3")];
685 let mut config = base_config(PathBuf::from("/tmp"));
686 config.format = ExportFormat::Wav;
687 let pat = RenamePattern::parse("{name}").unwrap();
688 let names = resolve_output_names(&items, &config, Some(&pat));
689 assert_eq!(names, vec!["kick.wav"]);
690 }
691
692 #[test]
693 fn resolve_name_overrides_bypass_pattern() {
694 use crate::rename::RenamePattern;
695
696 let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")];
697 let mut config = base_config(PathBuf::from("/tmp"));
698 config.name_overrides = Some(vec!["custom1.wav".to_string(), "custom2.wav".to_string()]);
699 let pat = RenamePattern::parse("{nn}_{name}").unwrap();
700 // Overrides take precedence over pattern
701 let names = resolve_output_names(&items, &config, Some(&pat));
702 assert_eq!(names, vec!["custom1.wav", "custom2.wav"]);
703 }
704
705 #[test]
706 fn resolve_name_overrides_wrong_length_ignored() {
707 let items = vec![make_item("kick.wav", "wav"), make_item("snare.wav", "wav")];
708 let mut config = base_config(PathBuf::from("/tmp"));
709 // Wrong length (1 override for 2 items), should fall through to normal resolution
710 config.name_overrides = Some(vec!["only_one.wav".to_string()]);
711 let names = resolve_output_names(&items, &config, None);
712 assert_eq!(names, vec!["kick.wav", "snare.wav"]);
713 }
714
715 #[test]
716 fn resolve_empty_items() {
717 let items: Vec<ExportItem> = vec![];
718 let config = base_config(PathBuf::from("/tmp"));
719 let names = resolve_output_names(&items, &config, None);
720 assert!(names.is_empty());
721 }
722
723 #[test]
724 fn resolve_dedup_after_sanitization() {
725 use crate::export::profile::{NamingCase, NamingRules};
726
727 // Two items that sanitize to the same name
728 let items = vec![make_item("Kick!.wav", "wav"), make_item("kick.wav", "wav")];
729 let mut config = base_config(PathBuf::from("/tmp"));
730 config.naming_rules = Some(NamingRules {
731 case: NamingCase::Upper,
732 separator: '_',
733 max_length: 64,
734 strip_special: true,
735 });
736 let names = resolve_output_names(&items, &config, None);
737 assert_eq!(names, vec!["KICK.wav", "KICK_2.wav"]);
738 }
739
740 // run_export() additional coverage
741
742 #[test]
743 fn export_aiff_format() {
744 let dir = tempfile::tempdir().unwrap();
745 let db = Database::open_in_memory().unwrap();
746 let store = SampleStore::new(dir.path().join("store")).unwrap();
747 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
748
749 let items = collect_export_items(&db, vfs_id, None).unwrap();
750 let dest_dir = dir.path().join("export_aiff");
751
752 let config = ExportConfig {
753 format: ExportFormat::Aiff,
754 sample_rate: None,
755 bit_depth: Some(16),
756 channels: ExportChannels::Original,
757 naming_pattern: None,
758 flatten: true,
759 metadata_sidecar: false,
760 destination: dest_dir.clone(),
761 device_profile: None,
762 naming_rules: None,
763 max_file_size_bytes: None,
764 name_overrides: None,
765 };
766
767 let summary = run_export(
768 &items,
769 &config,
770 &store,
771 &AtomicBool::new(false),
772 |_, _, _| true,
773 )
774 .unwrap();
775 assert_eq!(summary.total, 1);
776 assert!(summary.errors.is_empty());
777
778 let exported = dest_dir.join("kick.aiff");
779 assert!(exported.exists());
780 // Verify it's a valid AIFF (starts with FORM...AIFF)
781 let bytes = fs::read(&exported).unwrap();
782 assert_eq!(&bytes[0..4], b"FORM");
783 assert_eq!(&bytes[8..12], b"AIFF");
784 }
785
786 #[test]
787 fn export_progress_callback_cancel() {
788 let dir = tempfile::tempdir().unwrap();
789 let db = Database::open_in_memory().unwrap();
790 let store = SampleStore::new(dir.path().join("store")).unwrap();
791 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
792
793 // Add a second sample
794 let wav2 = dir.path().join("snare.wav");
795 write_test_wav(&wav2, 1, 44100, &[0.1, -0.1]);
796 let hash2 = store.import(&wav2, &db).unwrap();
797 vfs::create_sample_link(
798 &db,
799 vfs_id,
800 None,
801 "snare.wav",
802 &crate::SampleHash::from_trusted(hash2.clone()),
803 )
804 .unwrap();
805
806 let items = collect_export_items(&db, vfs_id, None).unwrap();
807 assert!(items.len() >= 2);
808
809 let dest_dir = dir.path().join("export_cancel");
810
811 let config = ExportConfig {
812 format: ExportFormat::Original,
813 sample_rate: None,
814 bit_depth: None,
815 channels: ExportChannels::Original,
816 naming_pattern: None,
817 flatten: true,
818 metadata_sidecar: false,
819 destination: dest_dir.clone(),
820 device_profile: None,
821 naming_rules: None,
822 max_file_size_bytes: None,
823 name_overrides: None,
824 };
825
826 // Cancel after the first item
827 let summary = run_export(
828 &items,
829 &config,
830 &store,
831 &AtomicBool::new(false),
832 |completed, _, _| completed < 1,
833 )
834 .unwrap();
835 // Total reflects all items, but only 1 should have been processed
836 assert_eq!(summary.total, items.len());
837
838 // At most 1 file should exist (the one processed before cancel)
839 let exported_count = fs::read_dir(&dest_dir).map_or(0, std::iter::Iterator::count);
840 assert!(exported_count <= 1);
841 }
842
843 #[test]
844 fn streaming_export_cancel_is_wired_through() {
845 // Regression for the dead-wired streaming cancel: run_export used to pass
846 // a throwaway AtomicBool to the streaming encoder, so a mid-file cancel
847 // could never fire. With the real flag threaded through, requesting cancel
848 // before the item streams aborts it cleanly, no output file, no error.
849 let dir = tempfile::tempdir().unwrap();
850 let db = Database::open_in_memory().unwrap();
851 let store = SampleStore::new(dir.path().join("store")).unwrap();
852 // A PCM WAV with an exact frame count takes the streaming export path.
853 let src = dir.path().join("long.wav");
854 let samples: Vec<f32> = (0..8000)
855 .map(|i| ((i % 100) as f32 / 100.0) - 0.5)
856 .collect();
857 write_test_wav(&src, 1, 44100, &samples);
858 let hash = store.import(&src, &db).unwrap();
859 let vfs_id = vfs::create_vfs(&db, "v").unwrap();
860 vfs::create_sample_link(
861 &db,
862 vfs_id,
863 None,
864 "long.wav",
865 &crate::SampleHash::from_trusted(hash.clone()),
866 )
867 .unwrap();
868 let items = collect_export_items(&db, vfs_id, None).unwrap();
869
870 let dest_dir = dir.path().join("export_stream_cancel");
871 let config = ExportConfig {
872 format: ExportFormat::Wav,
873 sample_rate: None,
874 bit_depth: Some(16),
875 channels: ExportChannels::Original,
876 naming_pattern: None,
877 flatten: true,
878 metadata_sidecar: false,
879 destination: dest_dir.clone(),
880 device_profile: None,
881 naming_rules: None,
882 max_file_size_bytes: None,
883 name_overrides: None,
884 };
885
886 // The callback requests cancel just before the item streams; the streaming
887 // encoder polls the same flag and returns Cancelled on its first check.
888 let cancel = AtomicBool::new(false);
889 let summary = run_export(&items, &config, &store, &cancel, |_, _, _| {
890 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
891 true
892 })
893 .unwrap();
894
895 // Clean stop: not recorded as a per-item failure...
896 assert!(
897 summary.errors.is_empty(),
898 "cancel should be clean, got {:?}",
899 summary.errors
900 );
901 // ...and the streamed file was never completed (write_atomic drops the tmp).
902 let out = dest_dir.join("long.wav");
903 assert!(!out.exists(), "cancelled stream must leave no output file");
904 }
905
906 #[test]
907 fn streaming_export_completes_when_not_cancelled() {
908 // Control for the test above: the same streaming path produces a valid
909 // file when cancel stays false.
910 let dir = tempfile::tempdir().unwrap();
911 let db = Database::open_in_memory().unwrap();
912 let store = SampleStore::new(dir.path().join("store")).unwrap();
913 let src = dir.path().join("long.wav");
914 let samples: Vec<f32> = (0..8000)
915 .map(|i| ((i % 100) as f32 / 100.0) - 0.5)
916 .collect();
917 write_test_wav(&src, 1, 44100, &samples);
918 let hash = store.import(&src, &db).unwrap();
919 let vfs_id = vfs::create_vfs(&db, "v").unwrap();
920 vfs::create_sample_link(
921 &db,
922 vfs_id,
923 None,
924 "long.wav",
925 &crate::SampleHash::from_trusted(hash.clone()),
926 )
927 .unwrap();
928 let items = collect_export_items(&db, vfs_id, None).unwrap();
929
930 let dest_dir = dir.path().join("export_stream_ok");
931 let config = ExportConfig {
932 format: ExportFormat::Wav,
933 sample_rate: None,
934 bit_depth: Some(16),
935 channels: ExportChannels::Original,
936 naming_pattern: None,
937 flatten: true,
938 metadata_sidecar: false,
939 destination: dest_dir.clone(),
940 device_profile: None,
941 naming_rules: None,
942 max_file_size_bytes: None,
943 name_overrides: None,
944 };
945
946 let summary = run_export(
947 &items,
948 &config,
949 &store,
950 &AtomicBool::new(false),
951 |_, _, _| true,
952 )
953 .unwrap();
954 assert!(summary.errors.is_empty());
955 let out = dest_dir.join("long.wav");
956 assert!(out.exists(), "un-cancelled stream must produce output");
957 assert_eq!(&fs::read(&out).unwrap()[0..4], b"RIFF");
958 }
959
960 #[test]
961 fn export_missing_source_records_error() {
962 let dir = tempfile::tempdir().unwrap();
963 let store = SampleStore::new(dir.path().join("store")).unwrap();
964 let dest_dir = dir.path().join("export_missing");
965
966 // Create an item with a hash that doesn't exist in the store
967 let items = vec![ExportItem {
968 hash: crate::SampleHash::from_trusted("nonexistent_hash_value"),
969 ext: "wav".to_string(),
970 relative_path: PathBuf::from("missing.wav"),
971 name: "missing.wav".to_string(),
972 bpm: None,
973 musical_key: None,
974 duration: None,
975 tags: vec![],
976 source_path: None,
977 }];
978
979 let config = ExportConfig {
980 format: ExportFormat::Original,
981 sample_rate: None,
982 bit_depth: None,
983 channels: ExportChannels::Original,
984 naming_pattern: None,
985 flatten: true,
986 metadata_sidecar: false,
987 destination: dest_dir,
988 device_profile: None,
989 naming_rules: None,
990 max_file_size_bytes: None,
991 name_overrides: None,
992 };
993
994 let summary = run_export(
995 &items,
996 &config,
997 &store,
998 &AtomicBool::new(false),
999 |_, _, _| true,
1000 )
1001 .unwrap();
1002 assert_eq!(summary.total, 1);
1003 assert_eq!(summary.errors.len(), 1);
1004 assert_eq!(summary.errors[0].0, "missing.wav");
1005 }
1006
1007 #[test]
1008 fn export_sidecar_contains_all_fields() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let db = Database::open_in_memory().unwrap();
1011 let store = SampleStore::new(dir.path().join("store")).unwrap();
1012 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
1013
1014 let mut items = collect_export_items(&db, vfs_id, None).unwrap();
1015 // Add analysis metadata to the item
1016 items[0].bpm = Some(120.0);
1017 items[0].musical_key = Some("Am".to_string());
1018 items[0].duration = Some(0.5);
1019 items[0].tags = vec!["one-shot".to_string(), "kick".to_string()];
1020
1021 let dest_dir = dir.path().join("export_sidecar_fields");
1022
1023 let config = ExportConfig {
1024 format: ExportFormat::Original,
1025 sample_rate: None,
1026 bit_depth: None,
1027 channels: ExportChannels::Original,
1028 naming_pattern: None,
1029 flatten: true,
1030 metadata_sidecar: true,
1031 destination: dest_dir.clone(),
1032 device_profile: None,
1033 naming_rules: None,
1034 max_file_size_bytes: None,
1035 name_overrides: None,
1036 };
1037
1038 let summary = run_export(
1039 &items,
1040 &config,
1041 &store,
1042 &AtomicBool::new(false),
1043 |_, _, _| true,
1044 )
1045 .unwrap();
1046 assert!(summary.errors.is_empty());
1047
1048 let sidecar_path = dest_dir.join("kick.wav.audiofiles.json");
1049 assert!(sidecar_path.exists());
1050
1051 let contents = fs::read_to_string(&sidecar_path).unwrap();
1052 let json: serde_json::Value = serde_json::from_str(&contents).unwrap();
1053 assert_eq!(json["bpm"], 120.0);
1054 assert_eq!(json["key"], "Am");
1055 assert_eq!(json["duration"], 0.5);
1056 assert_eq!(json["tags"], serde_json::json!(["one-shot", "kick"]));
1057 assert!(json["hash"].is_string());
1058 assert_eq!(json["name"], "kick.wav");
1059 }
1060
1061 #[test]
1062 fn export_preserves_directory_structure() {
1063 let dir = tempfile::tempdir().unwrap();
1064 let db = Database::open_in_memory().unwrap();
1065 let store = SampleStore::new(dir.path().join("store")).unwrap();
1066 let vfs_id = setup_vfs_with_samples(&db, &store, dir.path());
1067
1068 let items = collect_export_items(&db, vfs_id, None).unwrap();
1069 // Verify relative_path includes directory
1070 assert_eq!(items[0].relative_path, PathBuf::from("Drums/kick.wav"));
1071
1072 let dest_dir = dir.path().join("export_dirs");
1073
1074 let config = ExportConfig {
1075 format: ExportFormat::Original,
1076 sample_rate: None,
1077 bit_depth: None,
1078 channels: ExportChannels::Original,
1079 naming_pattern: None,
1080 flatten: false, // preserve structure
1081 metadata_sidecar: false,
1082 destination: dest_dir.clone(),
1083 device_profile: None,
1084 naming_rules: None,
1085 max_file_size_bytes: None,
1086 name_overrides: None,
1087 };
1088
1089 let summary = run_export(
1090 &items,
1091 &config,
1092 &store,
1093 &AtomicBool::new(false),
1094 |_, _, _| true,
1095 )
1096 .unwrap();
1097 assert!(summary.errors.is_empty());
1098
1099 // Should be in Drums/ subdirectory
1100 let exported = dest_dir.join("Drums").join("kick.wav");
1101 assert!(exported.exists());
1102 // Flat version should NOT exist
1103 assert!(!dest_dir.join("kick.wav").exists());
1104 }
1105