Skip to main content

max / audiofiles

58.8 KB · 1817 lines History Blame Raw
1 use super::*;
2 use audiofiles_core::vfs::NodeType;
3
4 /// Create a BrowserState backed by a temporary directory with an in-memory-like
5 /// on-disk SQLite database. Each test gets full isolation.
6 fn make_state() -> (BrowserState, tempfile::TempDir) {
7 let dir = tempfile::TempDir::new().unwrap();
8 let shared = Arc::new(SharedState::new());
9 let state = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap();
10 (state, dir)
11 }
12
13 /// Insert a fake sample row so VFS sample links can reference it.
14 /// Opens a separate DB connection since BrowserState no longer exposes db directly.
15 fn insert_fake_sample(state: &BrowserState, hash: &str) {
16 let now = std::time::SystemTime::now()
17 .duration_since(std::time::UNIX_EPOCH)
18 .unwrap()
19 .as_secs() as i64;
20 let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap();
21 db.conn()
22 .execute(
23 "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified)
24 VALUES (?1, ?2, 'wav', 100, ?3, ?4)",
25 rusqlite::params![hash, format!("{hash}.wav"), now, now],
26 )
27 .unwrap();
28 }
29
30 /// Insert a fake sample and link it into the current VFS + directory.
31 fn add_sample_to_vfs(state: &BrowserState, hash: &str, name: &str) -> NodeId {
32 insert_fake_sample(state, hash);
33 let vfs_id = state.current_vfs_id().unwrap();
34 let parent_id = state.current_dir;
35 state.backend.create_sample_link(vfs_id, parent_id, name, hash).unwrap()
36 }
37
38 /// Create a directory in the current VFS + directory and return its ID.
39 fn add_directory(state: &BrowserState, name: &str) -> NodeId {
40 let vfs_id = state.current_vfs_id().unwrap();
41 let parent_id = state.current_dir;
42 state.backend.create_directory(vfs_id, parent_id, name).unwrap()
43 }
44
45 /// Populate the current VFS root with a few sample nodes and refresh.
46 fn populate_samples(state: &mut BrowserState) {
47 add_sample_to_vfs(state, "aaa111", "kick.wav");
48 add_sample_to_vfs(state, "bbb222", "snare.wav");
49 add_sample_to_vfs(state, "ccc333", "hihat.wav");
50 state.refresh_contents();
51 }
52
53 // ---- Selection ----
54
55 mod selection {
56 use super::*;
57
58 #[test]
59 fn new_selection_is_empty() {
60 let sel = Selection::new();
61 assert_eq!(sel.count(), 0);
62 assert_eq!(sel.anchor, 0);
63 assert_eq!(sel.focus, 0);
64 }
65
66 #[test]
67 fn set_single_selects_one() {
68 let mut sel = Selection::new();
69 sel.set_single(3);
70 assert_eq!(sel.count(), 1);
71 assert!(sel.contains(3));
72 assert_eq!(sel.anchor, 3);
73 assert_eq!(sel.focus, 3);
74 }
75
76 #[test]
77 fn set_single_clears_previous() {
78 let mut sel = Selection::new();
79 sel.set_single(1);
80 sel.set_single(5);
81 assert_eq!(sel.count(), 1);
82 assert!(!sel.contains(1));
83 assert!(sel.contains(5));
84 }
85
86 #[test]
87 fn toggle_adds_and_removes() {
88 let mut sel = Selection::new();
89 sel.toggle(2);
90 assert!(sel.contains(2));
91 assert_eq!(sel.count(), 1);
92
93 sel.toggle(2);
94 assert!(!sel.contains(2));
95 assert_eq!(sel.count(), 0);
96 }
97
98 #[test]
99 fn toggle_preserves_others() {
100 let mut sel = Selection::new();
101 sel.set_single(1);
102 sel.toggle(3);
103 assert!(sel.contains(1));
104 assert!(sel.contains(3));
105 assert_eq!(sel.count(), 2);
106 }
107
108 #[test]
109 fn extend_to_selects_range() {
110 let mut sel = Selection::new();
111 sel.set_single(2);
112 sel.extend_to(5, 10);
113 assert_eq!(sel.count(), 4); // 2, 3, 4, 5
114 for i in 2..=5 {
115 assert!(sel.contains(i));
116 }
117 assert_eq!(sel.focus, 5);
118 assert_eq!(sel.anchor, 2);
119 }
120
121 #[test]
122 fn extend_to_backward_range() {
123 let mut sel = Selection::new();
124 sel.set_single(5);
125 sel.extend_to(2, 10);
126 assert_eq!(sel.count(), 4); // 2, 3, 4, 5
127 for i in 2..=5 {
128 assert!(sel.contains(i));
129 }
130 assert_eq!(sel.focus, 2);
131 }
132
133 #[test]
134 fn select_all_selects_all_items() {
135 let mut sel = Selection::new();
136 sel.select_all(5);
137 assert_eq!(sel.count(), 5);
138 for i in 0..5 {
139 assert!(sel.contains(i));
140 }
141 assert_eq!(sel.anchor, 0);
142 assert_eq!(sel.focus, 4);
143 }
144
145 #[test]
146 fn select_all_empty_list() {
147 let mut sel = Selection::new();
148 sel.select_all(0);
149 assert_eq!(sel.count(), 0);
150 }
151
152 #[test]
153 fn clear_resets_everything() {
154 let mut sel = Selection::new();
155 sel.select_all(10);
156 sel.clear();
157 assert_eq!(sel.count(), 0);
158 assert_eq!(sel.anchor, 0);
159 assert_eq!(sel.focus, 0);
160 }
161
162 #[test]
163 fn extend_down_increments_focus() {
164 let mut sel = Selection::new();
165 sel.set_single(0);
166 sel.extend_down(5);
167 assert!(sel.contains(1));
168 assert_eq!(sel.focus, 1);
169 }
170
171 #[test]
172 fn extend_down_at_end_is_noop() {
173 let mut sel = Selection::new();
174 sel.set_single(4);
175 sel.extend_down(5); // max_len=5, focus=4 => at end
176 assert_eq!(sel.focus, 4);
177 assert_eq!(sel.count(), 1); // no new items added
178 }
179
180 #[test]
181 fn extend_up_decrements_focus() {
182 let mut sel = Selection::new();
183 sel.set_single(3);
184 sel.extend_up();
185 assert!(sel.contains(2));
186 assert_eq!(sel.focus, 2);
187 }
188
189 #[test]
190 fn extend_up_at_zero_is_noop() {
191 let mut sel = Selection::new();
192 sel.set_single(0);
193 sel.extend_up();
194 assert_eq!(sel.focus, 0);
195 assert_eq!(sel.count(), 1);
196 }
197 }
198
199 // ---- Bulk operations & undo ----
200
201 mod bulk_ops {
202 use super::*;
203
204 #[test]
205 fn undo_stack_starts_empty() {
206 let (state, _dir) = make_state();
207 assert!(!state.can_undo());
208 }
209
210 #[test]
211 fn push_undo_makes_can_undo_true() {
212 let (mut state, _dir) = make_state();
213 state.undo_stack.push(UndoOp::BulkTagAdd {
214 tag: "test".to_string(),
215 hashes: vec!["h1".to_string()],
216 });
217 assert!(state.can_undo());
218 }
219
220 #[test]
221 fn undo_stack_capped_at_100() {
222 let (mut state, _dir) = make_state();
223 for i in 0..120 {
224 state.undo_stack.push(UndoOp::BulkTagAdd {
225 tag: format!("tag{i}"),
226 hashes: vec!["h1".to_string()],
227 });
228 // Keep the stack at 100 (mimics push_undo cap logic)
229 if state.undo_stack.len() > 100 {
230 let excess = state.undo_stack.len() - 100;
231 state.undo_stack.drain(..excess);
232 }
233 }
234 assert_eq!(state.undo_stack.len(), 100);
235 }
236
237 #[test]
238 fn bulk_tag_add_and_undo() {
239 let (mut state, _dir) = make_state();
240 insert_fake_sample(&state, "h1");
241 insert_fake_sample(&state, "h2");
242 add_sample_to_vfs(&state, "h1", "kick.wav");
243 add_sample_to_vfs(&state, "h2", "snare.wav");
244 state.refresh_contents();
245
246 // Select both items
247 state.selection.select_all(state.visible_len());
248
249 // Open bulk tag modal
250 state.open_bulk_tag_modal();
251 assert!(state.bulk_modal.is_some());
252
253 // Set tag and execute
254 if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal {
255 *tag_input = "genre.rock".to_string();
256 }
257 state.execute_bulk_tag();
258 assert!(state.bulk_modal.is_none());
259 assert!(state.can_undo());
260
261 // Verify tags were added
262 {
263 let tags1 = state.backend.get_sample_tags("h1").unwrap();
264 let tags2 = state.backend.get_sample_tags("h2").unwrap();
265 assert!(tags1.contains(&"genre.rock".to_string()));
266 assert!(tags2.contains(&"genre.rock".to_string()));
267 }
268
269 // Undo
270 state.undo();
271 {
272 let tags1 = state.backend.get_sample_tags("h1").unwrap();
273 let tags2 = state.backend.get_sample_tags("h2").unwrap();
274 assert!(!tags1.contains(&"genre.rock".to_string()));
275 assert!(!tags2.contains(&"genre.rock".to_string()));
276 }
277 }
278
279 #[test]
280 fn bulk_tag_remove_and_undo() {
281 let (mut state, _dir) = make_state();
282 insert_fake_sample(&state, "h1");
283 add_sample_to_vfs(&state, "h1", "kick.wav");
284 state.refresh_contents();
285
286 // Pre-add a tag
287 state.backend.add_tag("h1", "mood.dark").unwrap();
288
289 // Select and open tag modal for removal
290 state.selection.set_single(0);
291 state.open_bulk_tag_modal();
292 if let Some(BulkModal::Tag {
293 ref mut tag_input,
294 ref mut adding,
295 ..
296 }) = state.bulk_modal
297 {
298 *tag_input = "mood.dark".to_string();
299 *adding = false;
300 }
301 state.execute_bulk_tag();
302
303 {
304 let tags = state.backend.get_sample_tags("h1").unwrap();
305 assert!(!tags.contains(&"mood.dark".to_string()));
306 }
307
308 // Undo re-adds the tag
309 state.undo();
310 {
311 let tags = state.backend.get_sample_tags("h1").unwrap();
312 assert!(tags.contains(&"mood.dark".to_string()));
313 }
314 }
315
316 #[test]
317 fn bulk_tag_empty_tag_is_noop() {
318 let (mut state, _dir) = make_state();
319 insert_fake_sample(&state, "h1");
320 add_sample_to_vfs(&state, "h1", "kick.wav");
321 state.refresh_contents();
322
323 state.selection.set_single(0);
324 state.open_bulk_tag_modal();
325 // Leave tag_input empty
326 state.execute_bulk_tag();
327 // Modal stays open (noop)... actually execute_bulk_tag returns early but
328 // doesn't close the modal in that case because the guard returns before closing.
329 // Let's verify no undo was pushed:
330 assert!(!state.can_undo());
331 }
332
333 #[test]
334 fn bulk_tag_no_samples_selected_does_not_open() {
335 let (mut state, _dir) = make_state();
336 // No samples, just an empty VFS root
337 state.open_bulk_tag_modal();
338 assert!(state.bulk_modal.is_none());
339 }
340
341 #[test]
342 fn bulk_move_and_undo() {
343 let (mut state, _dir) = make_state();
344 insert_fake_sample(&state, "h1");
345 let _node_id = add_sample_to_vfs(&state, "h1", "kick.wav");
346 let dir_id = add_directory(&state, "Drums");
347 state.refresh_contents();
348
349 // Select the sample (not the directory)
350 // Contents are sorted: directories first. So index 0 = Drums, index 1 = kick.wav
351 state.selection.set_single(1);
352 state.open_bulk_move_modal();
353 assert!(state.bulk_modal.is_some());
354
355 // Select the target directory
356 if let Some(BulkModal::Move { ref mut selected_idx, ref directories, .. }) = state.bulk_modal {
357 // Find the index of the Drums directory
358 *selected_idx = directories.iter().position(|(id, _)| *id == dir_id);
359 }
360 state.execute_bulk_move();
361 assert!(state.bulk_modal.is_none());
362 assert!(state.can_undo());
363
364 // Verify the node was moved: root should only have Drums now
365 state.refresh_contents();
366 let root_samples: Vec<_> = state.contents.iter()
367 .filter(|n| n.node.node_type == NodeType::Sample)
368 .collect();
369 assert_eq!(root_samples.len(), 0);
370
371 // Undo should move it back
372 state.undo();
373 let root_samples: Vec<_> = state.contents.iter()
374 .filter(|n| n.node.node_type == NodeType::Sample)
375 .collect();
376 assert_eq!(root_samples.len(), 1);
377 }
378
379 #[test]
380 fn bulk_move_no_selection_does_not_open() {
381 let (mut state, _dir) = make_state();
382 state.open_bulk_move_modal();
383 assert!(state.bulk_modal.is_none());
384 }
385
386 #[test]
387 fn bulk_delete_and_undo() {
388 let (mut state, _dir) = make_state();
389 insert_fake_sample(&state, "h1");
390 insert_fake_sample(&state, "h2");
391 add_sample_to_vfs(&state, "h1", "kick.wav");
392 add_sample_to_vfs(&state, "h2", "snare.wav");
393 state.refresh_contents();
394 assert_eq!(state.contents.len(), 2);
395
396 // Select all and confirm delete
397 state.selection.select_all(state.visible_len());
398 state.confirm_delete_selection();
399 assert!(state.pending_confirm.is_some());
400
401 state.execute_confirmed_action();
402 assert_eq!(state.contents.len(), 0);
403 assert!(state.can_undo());
404
405 // Undo restores both nodes
406 state.undo();
407 assert_eq!(state.contents.len(), 2);
408 }
409
410 #[test]
411 fn single_delete_confirmed() {
412 let (mut state, _dir) = make_state();
413 insert_fake_sample(&state, "h1");
414 add_sample_to_vfs(&state, "h1", "kick.wav");
415 state.refresh_contents();
416 assert_eq!(state.contents.len(), 1);
417
418 state.selection.set_single(0);
419 state.confirm_delete_selected();
420 assert!(matches!(state.pending_confirm, Some(ConfirmAction::DeleteNode { .. })));
421
422 state.execute_confirmed_action();
423 assert_eq!(state.contents.len(), 0);
424 }
425
426 #[test]
427 fn dismiss_confirm_clears_pending() {
428 let (mut state, _dir) = make_state();
429 insert_fake_sample(&state, "h1");
430 add_sample_to_vfs(&state, "h1", "kick.wav");
431 state.refresh_contents();
432
433 state.selection.set_single(0);
434 state.confirm_delete_selected();
435 assert!(state.pending_confirm.is_some());
436
437 state.dismiss_confirm();
438 assert!(state.pending_confirm.is_none());
439 }
440
441 #[test]
442 fn close_bulk_modal_clears_it() {
443 let (mut state, _dir) = make_state();
444 insert_fake_sample(&state, "h1");
445 add_sample_to_vfs(&state, "h1", "kick.wav");
446 state.refresh_contents();
447
448 state.selection.set_single(0);
449 state.open_bulk_tag_modal();
450 assert!(state.bulk_modal.is_some());
451
452 state.close_bulk_modal();
453 assert!(state.bulk_modal.is_none());
454 }
455
456 #[test]
457 fn selected_nodes_excludes_parent_entry() {
458 let (mut state, _dir) = make_state();
459 let dir_id = add_directory(&state, "Sub");
460 insert_fake_sample(&state, "h1");
461 add_sample_to_vfs(&state, "h1", "kick.wav");
462 state.refresh_contents();
463
464 // Navigate into the directory
465 state.current_dir = Some(dir_id);
466 state.breadcrumb = state.backend.get_breadcrumb(dir_id).unwrap();
467 state.refresh_contents();
468
469 // Now ".." is at index 0. Select index 0 ("..")
470 state.selection.set_single(0);
471 let nodes = state.selected_nodes();
472 assert!(nodes.is_empty(), "'..' should be excluded from selected_nodes");
473 }
474
475 #[test]
476 fn selected_sample_hashes_returns_only_hashes() {
477 let (mut state, _dir) = make_state();
478 insert_fake_sample(&state, "h1");
479 add_sample_to_vfs(&state, "h1", "kick.wav");
480 add_directory(&state, "EmptyDir");
481 state.refresh_contents();
482
483 // Select all (dir + sample)
484 state.selection.select_all(state.visible_len());
485 let hashes = state.selected_sample_hashes();
486 assert_eq!(hashes.len(), 1);
487 assert_eq!(hashes[0], "h1");
488 }
489
490 #[test]
491 fn bulk_delete_with_tags_preserves_tags_in_undo() {
492 let (mut state, _dir) = make_state();
493 insert_fake_sample(&state, "h1");
494 insert_fake_sample(&state, "h2");
495 add_sample_to_vfs(&state, "h1", "kick.wav");
496 add_sample_to_vfs(&state, "h2", "snare.wav");
497 state.backend.add_tag("h1", "genre.rock").unwrap();
498 state.refresh_contents();
499 assert_eq!(state.contents.len(), 2);
500
501 // Need 2+ items for DeleteMultiple (which has undo support)
502 state.selection.select_all(state.visible_len());
503 state.confirm_delete_selection();
504 assert!(matches!(state.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. })));
505 state.execute_confirmed_action();
506 assert_eq!(state.contents.len(), 0);
507
508 // Undo restores nodes AND tags
509 state.undo();
510 assert_eq!(state.contents.len(), 2);
511 {
512 let tags = state.backend.get_sample_tags("h1").unwrap();
513 assert!(tags.contains(&"genre.rock".to_string()));
514 }
515 }
516
517 #[test]
518 fn multiple_undos_in_sequence() {
519 let (mut state, _dir) = make_state();
520 insert_fake_sample(&state, "h1");
521 add_sample_to_vfs(&state, "h1", "kick.wav");
522 state.refresh_contents();
523
524 // First operation: add tag
525 state.selection.set_single(0);
526 state.open_bulk_tag_modal();
527 if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal {
528 *tag_input = "genre.rock".to_string();
529 }
530 state.execute_bulk_tag();
531
532 // Second operation: add another tag
533 state.selection.set_single(0);
534 state.open_bulk_tag_modal();
535 if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal {
536 *tag_input = "mood.dark".to_string();
537 }
538 state.execute_bulk_tag();
539
540 assert_eq!(state.undo_stack.len(), 2);
541
542 // Undo second
543 state.undo();
544 {
545 let tags = state.backend.get_sample_tags("h1").unwrap();
546 assert!(tags.contains(&"genre.rock".to_string()));
547 assert!(!tags.contains(&"mood.dark".to_string()));
548 }
549
550 // Undo first
551 state.undo();
552 {
553 let tags = state.backend.get_sample_tags("h1").unwrap();
554 assert!(!tags.contains(&"genre.rock".to_string()));
555 }
556
557 assert!(!state.can_undo());
558 }
559
560 #[test]
561 fn undo_on_empty_stack_is_noop() {
562 let (mut state, _dir) = make_state();
563 assert!(!state.can_undo());
564 // Should not panic
565 state.undo();
566 assert!(!state.can_undo());
567 }
568 }
569
570 // ---- Export flow ----
571
572 mod export_flow {
573 use super::*;
574
575 #[test]
576 fn start_export_flow_sets_configure_export() {
577 let (mut state, _dir) = make_state();
578 insert_fake_sample(&state, "h1");
579 add_sample_to_vfs(&state, "h1", "kick.wav");
580 state.refresh_contents();
581
582 state.start_export_flow(None);
583 assert!(matches!(state.import_mode, ImportMode::ConfigureExport { .. }));
584
585 if let ImportMode::ConfigureExport { ref items, ref config, .. } = state.import_mode {
586 assert_eq!(items.len(), 1);
587 assert!(matches!(config.format, audiofiles_core::export::ExportFormat::Original));
588 assert!(!config.flatten);
589 } else {
590 panic!("Expected ConfigureExport mode");
591 }
592 }
593
594 #[test]
595 fn start_export_flow_empty_vfs_is_noop() {
596 let (mut state, _dir) = make_state();
597 state.start_export_flow(None);
598 assert!(matches!(state.import_mode, ImportMode::None));
599 assert_eq!(state.status, "No samples to export");
600 }
601
602 #[test]
603 fn cancel_export_lands_in_acknowledgement() {
604 let (mut state, _dir) = make_state();
605 state.import_mode = ImportMode::Exporting {
606 completed: 3,
607 total: 10,
608 current_name: "test.wav".to_string(),
609 };
610 state.cancel_export();
611 // C-3: cancel with meaningful progress lands in the acknowledgement
612 // screen, not None, so the user sees what landed vs what was discarded.
613 match state.import_mode {
614 ImportMode::OperationCancelled { completed, total, .. } => {
615 assert_eq!(completed, 3);
616 assert_eq!(total, 10);
617 }
618 _ => panic!("Expected OperationCancelled, got {:?}", std::mem::discriminant(&state.import_mode)),
619 }
620 assert_eq!(state.status, "Export cancelled");
621 }
622
623 #[test]
624 fn cancel_export_with_zero_progress_returns_to_none() {
625 let (mut state, _dir) = make_state();
626 // total == 0 means the export hasn't started — no meaningful progress
627 // to acknowledge. Falls through to None.
628 state.import_mode = ImportMode::Exporting {
629 completed: 0,
630 total: 0,
631 current_name: String::new(),
632 };
633 state.cancel_export();
634 assert!(matches!(state.import_mode, ImportMode::None));
635 }
636
637 #[test]
638 fn poll_workers_without_active_work_returns_false() {
639 let (mut state, _dir) = make_state();
640 assert!(!state.poll_workers());
641 }
642
643 #[test]
644 fn export_flow_state_transitions() {
645 let (mut state, _dir) = make_state();
646 insert_fake_sample(&state, "h1");
647 add_sample_to_vfs(&state, "h1", "kick.wav");
648 state.refresh_contents();
649
650 // Step 1: start_export_flow → ConfigureExport
651 state.start_export_flow(None);
652 assert!(matches!(state.import_mode, ImportMode::ConfigureExport { .. }));
653
654 // Step 2: Simulate transition to Exporting (run_export spawns a worker,
655 // but we test the state machine by setting it directly to avoid needing
656 // real audio files in the content-addressed store)
657 state.import_mode = ImportMode::Exporting {
658 completed: 0,
659 total: 1,
660 current_name: "kick.wav".to_string(),
661 };
662 assert!(matches!(state.import_mode, ImportMode::Exporting { .. }));
663
664 // Step 3: Simulate transition to ExportComplete
665 state.import_mode = ImportMode::ExportComplete {
666 total: 1,
667 errors: vec![],
668 };
669 if let ImportMode::ExportComplete { total, ref errors } = state.import_mode {
670 assert_eq!(total, 1);
671 assert!(errors.is_empty());
672 } else {
673 panic!("Expected ExportComplete mode");
674 }
675
676 // Step 4: Reset to None (Done button)
677 state.import_mode = ImportMode::None;
678 assert!(matches!(state.import_mode, ImportMode::None));
679 }
680
681 #[test]
682 fn export_complete_with_errors() {
683 let (mut state, _dir) = make_state();
684 state.import_mode = ImportMode::ExportComplete {
685 total: 5,
686 errors: vec![
687 ("kick.wav".to_string(), "decode error".to_string()),
688 ("snare.wav".to_string(), "io error".to_string()),
689 ],
690 };
691 if let ImportMode::ExportComplete { total, ref errors } = state.import_mode {
692 assert_eq!(total, 5);
693 assert_eq!(errors.len(), 2);
694 } else {
695 panic!("Expected ExportComplete mode");
696 }
697 }
698 }
699
700 // ---- Import & analysis state management ----
701
702 mod import_and_analysis {
703 use super::*;
704
705 #[test]
706 fn initial_import_mode_is_none() {
707 let (state, _dir) = make_state();
708 assert!(matches!(state.import_mode, ImportMode::None));
709 }
710
711 #[test]
712 fn start_analysis_flow_empty_hashes_is_noop() {
713 let (mut state, _dir) = make_state();
714 state.start_analysis_flow(vec![]);
715 assert!(matches!(state.import_mode, ImportMode::None));
716 }
717
718 #[test]
719 fn start_analysis_flow_sets_configure_mode() {
720 let (mut state, _dir) = make_state();
721 let hashes = vec![("abc".to_string(), "wav".to_string())];
722 state.start_analysis_flow(hashes);
723 assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. }));
724 }
725
726 #[test]
727 fn cancel_analysis_resets_state() {
728 let (mut state, _dir) = make_state();
729 state.import_mode = ImportMode::Analyzing {
730 completed: 5,
731 total: 10,
732 current_name: "test.wav".to_string(),
733 };
734 state.pending_review_items.push(ReviewItem {
735 hash: audiofiles_core::SampleHash::new("abc"),
736 name: "test.wav".to_string(),
737 result: audiofiles_core::analysis::AnalysisResult {
738 hash: "abc".to_string(),
739 duration: 1.0,
740 sample_rate: 44100,
741 channels: 2,
742 peak_db: Some(-3.0),
743 rms_db: Some(-12.0),
744 lufs: Some(-14.0),
745 bpm: None,
746 musical_key: None,
747 is_loop: None,
748 spectral_centroid: None,
749 spectral_flatness: None,
750 spectral_rolloff: None,
751 zero_crossing_rate: None,
752 onset_strength: None,
753 classification: None,
754 fingerprint: None,
755 spectral_bandwidth: None,
756 centroid_variance: None,
757 crest_factor: None,
758 attack_time: None,
759 classification_confidence: None,
760 },
761 suggestions: vec![],
762 });
763
764 state.cancel_analysis();
765 // C-3: progress > 0 → acknowledgement screen.
766 match state.import_mode {
767 ImportMode::OperationCancelled { completed, total, .. } => {
768 assert_eq!(completed, 5);
769 assert_eq!(total, 10);
770 }
771 _ => panic!("Expected OperationCancelled"),
772 }
773 assert!(state.pending_review_items.is_empty());
774 assert_eq!(state.status, "Analysis cancelled");
775 }
776
777 #[test]
778 fn show_import_options_sets_configure_import() {
779 let (mut state, _dir) = make_state();
780 let source = std::env::temp_dir().join("test_samples");
781 state.show_import_options(source.clone());
782
783 if let ImportMode::ConfigureImport {
784 source: ref s,
785 ref source_name,
786 ref available_vfs,
787 ..
788 } = state.import_mode
789 {
790 assert_eq!(s, &source);
791 assert_eq!(source_name, "test_samples");
792 assert!(!available_vfs.is_empty());
793 } else {
794 panic!("Expected ConfigureImport mode");
795 }
796 }
797
798 #[test]
799 fn cancel_import_lands_in_acknowledgement() {
800 let (mut state, _dir) = make_state();
801 state.import_mode = ImportMode::Importing {
802 total: 10,
803 completed: 3,
804 current_name: "file.wav".to_string(),
805 walking: false,
806 walking_count: 0,
807 total_bytes: 0,
808 loose_files: false,
809 };
810 state.cancel_import();
811 // C-3: cancel during real progress lands in the acknowledgement screen.
812 match state.import_mode {
813 ImportMode::OperationCancelled { completed, total, .. } => {
814 assert_eq!(completed, 3);
815 assert_eq!(total, 10);
816 }
817 _ => panic!("Expected OperationCancelled"),
818 }
819 assert_eq!(state.status, "Import cancelled");
820 }
821
822 #[test]
823 fn cancel_import_during_walking_returns_to_none() {
824 let (mut state, _dir) = make_state();
825 // walking == true means no files have committed yet; falls through to
826 // None so the user isn't shown a "Stopped at 0 of 0" screen.
827 state.import_mode = ImportMode::Importing {
828 total: 0,
829 completed: 0,
830 current_name: String::new(),
831 walking: true,
832 walking_count: 0,
833 total_bytes: 0,
834 loose_files: false,
835 };
836 state.cancel_import();
837 assert!(matches!(state.import_mode, ImportMode::None));
838 }
839
840 #[test]
841 fn retry_import_reopens_config() {
842 let (mut state, _dir) = make_state();
843 let source = std::env::temp_dir().join("retry_test");
844 state.last_import_source = Some(source.clone());
845 state.import_mode = ImportMode::Importing {
846 total: 10,
847 completed: 3,
848 current_name: "file.wav".to_string(),
849 walking: false,
850 walking_count: 0,
851 total_bytes: 0,
852 loose_files: false,
853 };
854 state.retry_import();
855 assert!(matches!(state.import_mode, ImportMode::ConfigureImport { .. }));
856 }
857
858 #[test]
859 fn retry_import_without_source_stays_cancelled() {
860 let (mut state, _dir) = make_state();
861 state.last_import_source = None;
862 state.import_mode = ImportMode::Importing {
863 total: 10,
864 completed: 3,
865 current_name: "file.wav".to_string(),
866 walking: false,
867 walking_count: 0,
868 total_bytes: 0,
869 loose_files: false,
870 };
871 state.retry_import();
872 // With no last_import_source, retry calls cancel_import which now lands
873 // in OperationCancelled (C-3). The retry path can't reopen config, so
874 // the user is left on the acknowledgement screen.
875 assert!(matches!(state.import_mode, ImportMode::OperationCancelled { .. }));
876 }
877
878 #[test]
879 fn poll_workers_without_active_analysis_returns_false() {
880 let (mut state, _dir) = make_state();
881 assert!(!state.poll_workers());
882 }
883
884 #[test]
885 fn apply_accepted_suggestions_resets_mode() {
886 let (mut state, _dir) = make_state();
887 state.import_mode = ImportMode::ReviewSuggestions {
888 items: vec![],
889 current_idx: 0,
890 sort: crate::state::ReviewSort::ImportOrder,
891 };
892 state.apply_accepted_suggestions();
893 assert!(matches!(state.import_mode, ImportMode::None));
894 }
895
896 #[test]
897 fn apply_folder_tags_with_entries() {
898 let (mut state, _dir) = make_state();
899 insert_fake_sample(&state, "h1");
900 add_sample_to_vfs(&state, "h1", "kick.wav");
901
902 state.import_mode = ImportMode::TagFolders {
903 entries: vec![FolderTagEntry {
904 folder: crate::import::ImportedFolder {
905 name: "Drums".to_string(),
906 samples: vec![("h1".to_string(), "wav".to_string())],
907 },
908 tag_input: "instrument.drum".to_string(),
909 }],
910 sample_hashes: vec![("h1".to_string(), "wav".to_string())],
911 };
912
913 state.apply_folder_tags();
914
915 // Should have tagged h1 and moved to analysis config
916 {
917 let tags = state.backend.get_sample_tags("h1").unwrap();
918 assert!(tags.contains(&"instrument.drum".to_string()));
919 }
920 assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. }));
921 }
922
923 #[test]
924 fn skip_folder_tags_goes_to_analysis() {
925 let (mut state, _dir) = make_state();
926 state.import_mode = ImportMode::TagFolders {
927 entries: vec![],
928 sample_hashes: vec![("h1".to_string(), "wav".to_string())],
929 };
930 state.skip_folder_tags();
931 assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. }));
932 }
933
934 #[test]
935 fn apply_folder_tags_skips_empty_input() {
936 let (mut state, _dir) = make_state();
937 insert_fake_sample(&state, "h1");
938 add_sample_to_vfs(&state, "h1", "kick.wav");
939
940 state.import_mode = ImportMode::TagFolders {
941 entries: vec![FolderTagEntry {
942 folder: crate::import::ImportedFolder {
943 name: "Drums".to_string(),
944 samples: vec![("h1".to_string(), "wav".to_string())],
945 },
946 tag_input: " ".to_string(), // whitespace only
947 }],
948 sample_hashes: vec![("h1".to_string(), "wav".to_string())],
949 };
950
951 state.apply_folder_tags();
952
953 // No tags should have been applied
954 {
955 let tags = state.backend.get_sample_tags("h1").unwrap();
956 assert!(tags.is_empty());
957 }
958 }
959
960 #[test]
961 fn apply_folder_tags_multiple_comma_separated() {
962 let (mut state, _dir) = make_state();
963 insert_fake_sample(&state, "h1");
964 add_sample_to_vfs(&state, "h1", "kick.wav");
965
966 state.import_mode = ImportMode::TagFolders {
967 entries: vec![FolderTagEntry {
968 folder: crate::import::ImportedFolder {
969 name: "Drums".to_string(),
970 samples: vec![("h1".to_string(), "wav".to_string())],
971 },
972 tag_input: "instrument.drum, mood.dark".to_string(),
973 }],
974 sample_hashes: vec![("h1".to_string(), "wav".to_string())],
975 };
976
977 state.apply_folder_tags();
978
979 {
980 let tags = state.backend.get_sample_tags("h1").unwrap();
981 assert!(tags.contains(&"instrument.drum".to_string()));
982 assert!(tags.contains(&"mood.dark".to_string()));
983 }
984 }
985
986 #[test]
987 fn import_errors_accumulate() {
988 let (mut state, _dir) = make_state();
989 state.import_file_errors.push(super::ImportFileError {
990 path: "file1.wav".to_string(),
991 error: "decode error".to_string(),
992 });
993 state.import_file_errors.push(super::ImportFileError {
994 path: "file2.wav".to_string(),
995 error: "io error".to_string(),
996 });
997 assert_eq!(state.import_file_errors.len(), 2);
998 assert!(state.has_import_errors());
999 }
1000
1001 #[test]
1002 fn review_errors_transition_when_errors_exist() {
1003 let (mut state, _dir) = make_state();
1004 state.import_file_errors.push(super::ImportFileError {
1005 path: "/bad/file.wav".to_string(),
1006 error: "corrupt header".to_string(),
1007 });
1008 // Simulate what AnalysisBatchComplete does with no review items but errors
1009 assert!(state.has_import_errors());
1010 state.import_mode = ImportMode::ReviewErrors;
1011 assert!(matches!(state.import_mode, ImportMode::ReviewErrors));
1012 }
1013
1014 #[test]
1015 fn dismiss_import_errors_clears_and_returns_to_none() {
1016 let (mut state, _dir) = make_state();
1017 state.import_file_errors.push(super::ImportFileError {
1018 path: "/bad/file.wav".to_string(),
1019 error: "corrupt".to_string(),
1020 });
1021 state.analysis_errors.push(super::AnalysisFileError {
1022 hash: "abc123".to_string(),
1023 name: "broken.wav".to_string(),
1024 error: "decode failed".to_string(),
1025 });
1026 state.import_mode = ImportMode::ReviewErrors;
1027
1028 state.dismiss_import_errors();
1029 assert!(matches!(state.import_mode, ImportMode::None));
1030 assert!(state.import_file_errors.is_empty());
1031 assert!(state.analysis_errors.is_empty());
1032 }
1033
1034 #[test]
1035 fn remove_failed_sample_removes_from_error_list() {
1036 let (mut state, _dir) = make_state();
1037 // Use the backend's own import to insert a real sample
1038 let sample_path = state.data_dir.join("test_sample.wav");
1039 // Write a minimal valid WAV header (44 bytes, 0 data frames)
1040 let header: [u8; 44] = [
1041 0x52, 0x49, 0x46, 0x46, // "RIFF"
1042 0x24, 0x00, 0x00, 0x00, // file size - 8
1043 0x57, 0x41, 0x56, 0x45, // "WAVE"
1044 0x66, 0x6D, 0x74, 0x20, // "fmt "
1045 0x10, 0x00, 0x00, 0x00, // chunk size
1046 0x01, 0x00, 0x01, 0x00, // PCM, 1 channel
1047 0x44, 0xAC, 0x00, 0x00, // 44100 Hz
1048 0x88, 0x58, 0x01, 0x00, // byte rate
1049 0x02, 0x00, 0x10, 0x00, // block align, bits per sample
1050 0x64, 0x61, 0x74, 0x61, // "data"
1051 0x00, 0x00, 0x00, 0x00, // data size (0 bytes)
1052 ];
1053 std::fs::write(&sample_path, header).unwrap();
1054
1055 let hash = state.backend.import_file(&sample_path).unwrap();
1056 let vfs_id = state.current_vfs_id().unwrap();
1057 state.backend.create_sample_link(vfs_id, None, "broken.wav", &hash).unwrap();
1058 state.refresh_contents();
1059 assert_eq!(state.contents.len(), 1);
1060
1061 state.analysis_errors.push(super::AnalysisFileError {
1062 hash: hash.clone(),
1063 name: "broken.wav".to_string(),
1064 error: "decode failed".to_string(),
1065 });
1066 state.import_mode = ImportMode::ReviewErrors;
1067
1068 state.remove_failed_sample(0);
1069 assert!(state.analysis_errors.is_empty());
1070 }
1071
1072 #[test]
1073 fn clean_analysis_skips_review_errors() {
1074 let (state, _dir) = make_state();
1075 // No errors accumulated
1076 assert!(!state.has_import_errors());
1077 assert!(state.import_file_errors.is_empty());
1078 assert!(state.analysis_errors.is_empty());
1079 }
1080
1081 #[test]
1082 fn retry_analysis_without_config_is_noop() {
1083 let (mut state, _dir) = make_state();
1084 state.last_analysis_hashes = vec![];
1085 state.last_analysis_config = None;
1086 state.retry_analysis();
1087 // Should cancel and do nothing further
1088 assert!(matches!(state.import_mode, ImportMode::None));
1089 }
1090
1091 #[test]
1092 fn import_path_nonexistent_sets_status() {
1093 let (mut state, _dir) = make_state();
1094 let fake_path = PathBuf::from("/nonexistent/path/to/audio.wav");
1095 state.import_path(&fake_path);
1096 assert!(state.status.contains("Path not found"));
1097 }
1098 }
1099
1100 // ---- Navigation & filter ----
1101
1102 mod navigation_and_filter {
1103 use super::*;
1104
1105 #[test]
1106 fn initial_state_has_library_vfs() {
1107 let (state, _dir) = make_state();
1108 assert!(!state.vfs_list.is_empty());
1109 assert_eq!(state.vfs_list[0].name, "Vault");
1110 assert_eq!(state.current_vfs_idx, 0);
1111 }
1112
1113 #[test]
1114 fn initial_state_at_root() {
1115 let (state, _dir) = make_state();
1116 assert!(state.current_dir.is_none());
1117 assert!(state.breadcrumb.is_empty());
1118 }
1119
1120 #[test]
1121 fn navigate_into_directory() {
1122 let (mut state, _dir) = make_state();
1123 let dir_id = add_directory(&state, "Drums");
1124 state.refresh_contents();
1125
1126 // Directory should be in contents
1127 assert_eq!(state.contents.len(), 1);
1128 assert_eq!(state.contents[0].node.name, "Drums");
1129
1130 // Select and enter
1131 state.selection.set_single(0);
1132 state.enter_directory();
1133
1134 assert_eq!(state.current_dir, Some(dir_id));
1135 assert!(!state.breadcrumb.is_empty());
1136 assert_eq!(state.breadcrumb.last().unwrap().name, "Drums");
1137 }
1138
1139 #[test]
1140 fn go_up_from_subdirectory() {
1141 let (mut state, _dir) = make_state();
1142 let dir_id = add_directory(&state, "Drums");
1143 state.refresh_contents();
1144
1145 // Enter the directory
1146 state.selection.set_single(0);
1147 state.enter_directory();
1148 assert_eq!(state.current_dir, Some(dir_id));
1149
1150 // Go back up
1151 state.go_up();
1152 assert!(state.current_dir.is_none());
1153 assert!(state.breadcrumb.is_empty());
1154 }
1155
1156 #[test]
1157 fn go_up_at_root_is_noop() {
1158 let (mut state, _dir) = make_state();
1159 state.go_up();
1160 assert!(state.current_dir.is_none());
1161 }
1162
1163 #[test]
1164 fn enter_directory_on_sample_is_noop() {
1165 let (mut state, _dir) = make_state();
1166 insert_fake_sample(&state, "h1");
1167 add_sample_to_vfs(&state, "h1", "kick.wav");
1168 state.refresh_contents();
1169
1170 state.selection.set_single(0);
1171 state.enter_directory();
1172 // Should still be at root since kick.wav is a sample, not a directory
1173 assert!(state.current_dir.is_none());
1174 }
1175
1176 #[test]
1177 fn select_vfs_switches_vfs() {
1178 let (mut state, _dir) = make_state();
1179 // Create a second VFS
1180 state.backend.create_vfs("Project A").unwrap();
1181 state.refresh_vfs_list();
1182 assert!(state.vfs_list.len() >= 2);
1183
1184 let old_idx = state.current_vfs_idx;
1185 let new_idx = if old_idx == 0 { 1 } else { 0 };
1186 state.select_vfs(new_idx);
1187 assert_eq!(state.current_vfs_idx, new_idx);
1188 assert!(state.current_dir.is_none());
1189 }
1190
1191 #[test]
1192 fn select_vfs_same_index_is_noop() {
1193 let (mut state, _dir) = make_state();
1194 let status_before = state.status.clone();
1195 state.select_vfs(0); // same index
1196 // Status should not change since select_vfs guards against same index
1197 assert_eq!(state.status, status_before);
1198 }
1199
1200 #[test]
1201 fn select_vfs_out_of_bounds_is_noop() {
1202 let (mut state, _dir) = make_state();
1203 state.select_vfs(999);
1204 assert_eq!(state.current_vfs_idx, 0);
1205 }
1206
1207 #[test]
1208 fn visible_len_includes_parent_entry() {
1209 let (mut state, _dir) = make_state();
1210 let dir_id = add_directory(&state, "Drums");
1211 insert_fake_sample(&state, "h1");
1212 add_sample_to_vfs(&state, "h1", "kick.wav");
1213 state.refresh_contents();
1214
1215 // At root: no ".." entry
1216 assert_eq!(state.visible_len(), 2); // Drums + kick.wav
1217
1218 // Enter directory
1219 state.selection.set_single(0);
1220 state.enter_directory();
1221 // In subdirectory: ".." + contents
1222 assert_eq!(state.current_dir, Some(dir_id));
1223 // Drums is empty, so visible_len = 0 contents + 1 parent entry
1224 assert_eq!(state.visible_len(), 1);
1225 }
1226
1227 #[test]
1228 fn selected_node_returns_none_for_parent_entry() {
1229 let (mut state, _dir) = make_state();
1230 let _dir_id = add_directory(&state, "Drums");
1231 state.refresh_contents();
1232
1233 // Enter directory
1234 state.selection.set_single(0);
1235 state.enter_directory();
1236
1237 // Select ".." at index 0
1238 state.selection.set_single(0);
1239 assert!(state.selected_node().is_none());
1240 }
1241
1242 #[test]
1243 fn select_next_and_prev() {
1244 let (mut state, _dir) = make_state();
1245 populate_samples(&mut state);
1246 assert_eq!(state.contents.len(), 3);
1247
1248 state.selection.set_single(0);
1249 state.select_next();
1250 assert_eq!(state.selection.focus, 1);
1251
1252 state.select_next();
1253 assert_eq!(state.selection.focus, 2);
1254
1255 // At end, should stay
1256 state.select_next();
1257 assert_eq!(state.selection.focus, 2);
1258
1259 state.select_prev();
1260 assert_eq!(state.selection.focus, 1);
1261
1262 state.select_prev();
1263 assert_eq!(state.selection.focus, 0);
1264
1265 // At beginning, should stay
1266 state.select_prev();
1267 assert_eq!(state.selection.focus, 0);
1268 }
1269
1270 #[test]
1271 fn search_filter_default_not_active() {
1272 let (state, _dir) = make_state();
1273 assert!(!state.search_filter.is_active());
1274 assert!(state.search_query.is_empty());
1275 }
1276
1277 #[test]
1278 fn apply_search_clears_selection() {
1279 let (mut state, _dir) = make_state();
1280 populate_samples(&mut state);
1281 state.selection.select_all(state.visible_len());
1282 assert!(state.selection.count() > 0);
1283
1284 state.apply_search();
1285 assert_eq!(state.selection.count(), 0);
1286 }
1287
1288 #[test]
1289 fn refresh_vfs_list_resets_navigation() {
1290 let (mut state, _dir) = make_state();
1291 let dir_id = add_directory(&state, "Drums");
1292 state.refresh_contents();
1293
1294 // Navigate into directory
1295 state.selection.set_single(0);
1296 state.enter_directory();
1297 assert_eq!(state.current_dir, Some(dir_id));
1298
1299 // Refresh VFS list should reset to root
1300 state.refresh_vfs_list();
1301 assert!(state.current_dir.is_none());
1302 assert!(state.breadcrumb.is_empty());
1303 assert_eq!(state.selection.count(), 0);
1304 }
1305 }
1306
1307 // ---- Sort & column config ----
1308
1309 mod column_config {
1310 use super::*;
1311
1312 #[test]
1313 fn default_column_config() {
1314 let config = ColumnConfig::default();
1315 assert!(config.show_classification);
1316 assert!(config.show_bpm);
1317 assert!(config.show_key);
1318 assert!(config.show_duration);
1319 assert!(!config.show_peak_db);
1320 assert!(!config.show_tags);
1321 }
1322
1323 #[test]
1324 fn toggle_sort_ascending_to_descending() {
1325 let (mut state, _dir) = make_state();
1326 assert_eq!(state.sort_column, SortColumn::Name);
1327 assert_eq!(state.sort_direction, SortDirection::Ascending);
1328
1329 state.toggle_sort(SortColumn::Name);
1330 assert_eq!(state.sort_column, SortColumn::Name);
1331 assert_eq!(state.sort_direction, SortDirection::Descending);
1332 }
1333
1334 #[test]
1335 fn toggle_sort_descending_resets_to_name() {
1336 let (mut state, _dir) = make_state();
1337 state.sort_column = SortColumn::Bpm;
1338 state.sort_direction = SortDirection::Ascending;
1339
1340 state.toggle_sort(SortColumn::Bpm);
1341 assert_eq!(state.sort_direction, SortDirection::Descending);
1342
1343 state.toggle_sort(SortColumn::Bpm);
1344 assert_eq!(state.sort_column, SortColumn::Name);
1345 assert_eq!(state.sort_direction, SortDirection::Ascending);
1346 }
1347
1348 #[test]
1349 fn toggle_sort_different_column() {
1350 let (mut state, _dir) = make_state();
1351 state.toggle_sort(SortColumn::Bpm);
1352 assert_eq!(state.sort_column, SortColumn::Bpm);
1353 assert_eq!(state.sort_direction, SortDirection::Ascending);
1354 }
1355
1356 #[test]
1357 fn sort_contents_directories_first() {
1358 let (mut state, _dir) = make_state();
1359 insert_fake_sample(&state, "h1");
1360 add_sample_to_vfs(&state, "h1", "aaa.wav");
1361 add_directory(&state, "zzz_folder");
1362 state.refresh_contents();
1363
1364 // Even though "aaa" < "zzz", directory should be first
1365 assert_eq!(state.contents[0].node.node_type, NodeType::Directory);
1366 assert_eq!(state.contents[1].node.node_type, NodeType::Sample);
1367 }
1368
1369 #[test]
1370 fn save_and_load_column_config() {
1371 let (mut state, _dir) = make_state();
1372 state.column_config.show_tags = true;
1373 state.column_config.show_peak_db = true;
1374 state.save_column_config();
1375
1376 // Reset to defaults
1377 state.column_config = ColumnConfig::default();
1378 assert!(!state.column_config.show_tags);
1379 assert!(!state.column_config.show_peak_db);
1380
1381 // Load should restore
1382 state.load_column_config();
1383 assert!(state.column_config.show_tags);
1384 assert!(state.column_config.show_peak_db);
1385 }
1386
1387 #[test]
1388 fn load_column_config_without_saved_uses_default() {
1389 let (mut state, _dir) = make_state();
1390 // No config saved yet
1391 state.column_config.show_bpm = false;
1392 state.load_column_config();
1393 // Since nothing was saved, the load should leave state unchanged
1394 // (it only replaces if it finds a row)
1395 assert!(!state.column_config.show_bpm);
1396 }
1397
1398 #[test]
1399 fn sort_column_enum_equality() {
1400 assert_eq!(SortColumn::Name, SortColumn::Name);
1401 assert_ne!(SortColumn::Name, SortColumn::Bpm);
1402 assert_ne!(SortColumn::Bpm, SortColumn::Key);
1403 }
1404
1405 #[test]
1406 fn sort_direction_enum_equality() {
1407 assert_eq!(SortDirection::Ascending, SortDirection::Ascending);
1408 assert_ne!(SortDirection::Ascending, SortDirection::Descending);
1409 }
1410
1411 #[test]
1412 fn sort_by_name_case_insensitive() {
1413 let (mut state, _dir) = make_state();
1414 insert_fake_sample(&state, "h1");
1415 insert_fake_sample(&state, "h2");
1416 insert_fake_sample(&state, "h3");
1417 add_sample_to_vfs(&state, "h1", "Zebra.wav");
1418 add_sample_to_vfs(&state, "h2", "apple.wav");
1419 add_sample_to_vfs(&state, "h3", "Banana.wav");
1420 state.refresh_contents();
1421
1422 // Should be sorted case-insensitively: apple, Banana, Zebra
1423 assert_eq!(state.contents[0].node.name, "apple.wav");
1424 assert_eq!(state.contents[1].node.name, "Banana.wav");
1425 assert_eq!(state.contents[2].node.name, "Zebra.wav");
1426 }
1427 }
1428
1429 // ---- Rename patterns ----
1430
1431 mod rename_patterns {
1432 use super::*;
1433
1434 #[test]
1435 fn open_bulk_rename_no_selection_does_not_open() {
1436 let (mut state, _dir) = make_state();
1437 state.open_bulk_rename_modal();
1438 assert!(state.bulk_modal.is_none());
1439 }
1440
1441 #[test]
1442 fn open_bulk_rename_sets_default_pattern() {
1443 let (mut state, _dir) = make_state();
1444 insert_fake_sample(&state, "h1");
1445 add_sample_to_vfs(&state, "h1", "kick.wav");
1446 state.refresh_contents();
1447
1448 state.selection.set_single(0);
1449 state.open_bulk_rename_modal();
1450
1451 if let Some(BulkModal::Rename { ref pattern_input, .. }) = state.bulk_modal {
1452 assert_eq!(pattern_input, "{name}");
1453 } else {
1454 panic!("Expected Rename modal");
1455 }
1456 }
1457
1458 #[test]
1459 fn rename_preview_updates() {
1460 let (mut state, _dir) = make_state();
1461 insert_fake_sample(&state, "h1");
1462 add_sample_to_vfs(&state, "h1", "kick.wav");
1463 state.refresh_contents();
1464
1465 state.selection.set_single(0);
1466 state.open_bulk_rename_modal();
1467
1468 if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal {
1469 *pattern_input = "renamed_{name}".to_string();
1470 }
1471 state.update_rename_previews();
1472
1473 if let Some(BulkModal::Rename { ref previews, ref error, .. }) = state.bulk_modal {
1474 assert_eq!(previews.len(), 1);
1475 assert!(error.is_none());
1476 // Old name should be "kick.wav", new name "renamed_kick.wav"
1477 assert_eq!(previews[0].0, "kick.wav");
1478 assert_eq!(previews[0].1, "renamed_kick.wav");
1479 } else {
1480 panic!("Expected Rename modal");
1481 }
1482 }
1483
1484 #[test]
1485 fn rename_preview_literal_deduplicates() {
1486 // A literal-only pattern applied to 2 items gets auto-deduplicated
1487 // by resolve_all, producing "same_name" and "same_name (2)".
1488 let (mut state, _dir) = make_state();
1489 insert_fake_sample(&state, "h1");
1490 insert_fake_sample(&state, "h2");
1491 add_sample_to_vfs(&state, "h1", "kick.wav");
1492 add_sample_to_vfs(&state, "h2", "snare.wav");
1493 state.refresh_contents();
1494
1495 state.selection.select_all(state.visible_len());
1496 state.open_bulk_rename_modal();
1497
1498 if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal {
1499 *pattern_input = "same_name".to_string();
1500 }
1501 state.update_rename_previews();
1502
1503 if let Some(BulkModal::Rename { ref previews, ref error, .. }) = state.bulk_modal {
1504 // resolve_all deduplicates stems, so no duplicate error
1505 assert!(error.is_none());
1506 assert_eq!(previews.len(), 2);
1507 // One is "same_name.wav", the other "same_name (2).wav"
1508 let new_names: Vec<&str> = previews.iter().map(|(_, n)| n.as_str()).collect();
1509 assert!(new_names.contains(&"same_name.wav"));
1510 assert!(new_names.contains(&"same_name (2).wav"));
1511 } else {
1512 panic!("Expected Rename modal");
1513 }
1514 }
1515
1516 #[test]
1517 fn rename_preview_with_empty_token_keeps_extension() {
1518 // {bpm} with no analysis data resolves to empty stem, but the
1519 // extension is still appended, so the preview shows ".wav".
1520 let (mut state, _dir) = make_state();
1521 insert_fake_sample(&state, "h1");
1522 add_sample_to_vfs(&state, "h1", "kick.wav");
1523 state.refresh_contents();
1524
1525 state.selection.set_single(0);
1526 state.open_bulk_rename_modal();
1527
1528 if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal {
1529 *pattern_input = "{bpm}".to_string();
1530 }
1531 state.update_rename_previews();
1532
1533 if let Some(BulkModal::Rename { ref previews, ref error, .. }) = state.bulk_modal {
1534 // The stem is empty but extension is appended => ".wav"
1535 assert_eq!(previews.len(), 1);
1536 assert_eq!(previews[0].1, ".wav");
1537 // No error because ".wav" is not empty after trim
1538 assert!(error.is_none());
1539 } else {
1540 panic!("Expected Rename modal");
1541 }
1542 }
1543
1544 #[test]
1545 fn rename_preview_invalid_pattern() {
1546 let (mut state, _dir) = make_state();
1547 insert_fake_sample(&state, "h1");
1548 add_sample_to_vfs(&state, "h1", "kick.wav");
1549 state.refresh_contents();
1550
1551 state.selection.set_single(0);
1552 state.open_bulk_rename_modal();
1553
1554 if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal {
1555 *pattern_input = "{unknown_token}".to_string();
1556 }
1557 state.update_rename_previews();
1558
1559 if let Some(BulkModal::Rename { ref error, ref previews, .. }) = state.bulk_modal {
1560 assert!(error.is_some());
1561 assert!(previews.is_empty());
1562 } else {
1563 panic!("Expected Rename modal");
1564 }
1565 }
1566
1567 #[test]
1568 fn execute_bulk_rename_and_undo() {
1569 let (mut state, _dir) = make_state();
1570 insert_fake_sample(&state, "h1");
1571 let node_id = add_sample_to_vfs(&state, "h1", "kick.wav");
1572 state.refresh_contents();
1573
1574 state.selection.set_single(0);
1575 state.open_bulk_rename_modal();
1576
1577 if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal {
1578 *pattern_input = "renamed_{name}".to_string();
1579 }
1580 state.execute_bulk_rename();
1581
1582 // Verify the name changed
1583 {
1584 let node = state.backend.get_node(node_id).unwrap();
1585 assert_eq!(node.name, "renamed_kick.wav");
1586 }
1587 assert!(state.can_undo());
1588
1589 // Undo
1590 state.undo();
1591 {
1592 let node = state.backend.get_node(node_id).unwrap();
1593 assert_eq!(node.name, "kick.wav");
1594 }
1595 }
1596
1597 #[test]
1598 fn split_name_ext_basic() {
1599 let (stem, ext) = split_name_ext("kick.wav");
1600 assert_eq!(stem, "kick");
1601 assert_eq!(ext, "wav");
1602 }
1603
1604 #[test]
1605 fn split_name_ext_no_extension() {
1606 let (stem, ext) = split_name_ext("noext");
1607 assert_eq!(stem, "noext");
1608 assert_eq!(ext, "");
1609 }
1610
1611 #[test]
1612 fn split_name_ext_dotfile() {
1613 let (stem, ext) = split_name_ext(".hidden");
1614 assert_eq!(stem, ".hidden");
1615 assert_eq!(ext, "");
1616 }
1617
1618 #[test]
1619 fn split_name_ext_multiple_dots() {
1620 let (stem, ext) = split_name_ext("archive.tar.gz");
1621 assert_eq!(stem, "archive.tar");
1622 assert_eq!(ext, "gz");
1623 }
1624 }
1625
1626 // ---- Miscellaneous state ----
1627
1628 mod misc {
1629 use super::*;
1630
1631 #[test]
1632 fn shared_state_default() {
1633 let shared = SharedState::new();
1634 let playback = shared.preview.lock();
1635 assert!(!playback.playing);
1636 }
1637
1638 #[test]
1639 fn stop_preview_clears_state() {
1640 let (mut state, _dir) = make_state();
1641 state.previewing_hash = Some("abc".to_string());
1642 state.status = "Playing: kick.wav".to_string();
1643 state.stop_preview();
1644 assert!(state.previewing_hash.is_none());
1645 assert!(state.status.is_empty());
1646 }
1647
1648 #[test]
1649 fn detail_visible_defaults_true() {
1650 let (state, _dir) = make_state();
1651 assert!(state.detail_visible);
1652 assert!(state.sidebar_visible);
1653 }
1654
1655 #[test]
1656 fn show_help_defaults_false() {
1657 let (state, _dir) = make_state();
1658 assert!(!state.show_help);
1659 }
1660
1661 #[test]
1662 fn focus_search_defaults_false() {
1663 let (state, _dir) = make_state();
1664 assert!(!state.focus_search);
1665 }
1666
1667 #[test]
1668 fn confirm_action_variants_constructible() {
1669 let _single = ConfirmAction::DeleteNode {
1670 node_id: NodeId::from(1),
1671 node_name: "test".to_string(),
1672 };
1673 let _vfs = ConfirmAction::DeleteVfs {
1674 vfs_id: VfsId::from(1),
1675 vfs_name: "Library".to_string(),
1676 };
1677 let _multi = ConfirmAction::DeleteMultiple {
1678 node_ids: vec![NodeId::from(1), NodeId::from(2), NodeId::from(3)],
1679 count: 3,
1680 };
1681 }
1682
1683 #[test]
1684 fn refresh_all_tags_loads_tags() {
1685 let (mut state, _dir) = make_state();
1686 insert_fake_sample(&state, "h1");
1687 state.backend.add_tag("h1", "genre.rock").unwrap();
1688 state.refresh_all_tags();
1689 assert!(state.all_tags.contains(&"genre.rock".to_string()));
1690 }
1691
1692 #[test]
1693 fn current_vfs_id_matches_first_vfs() {
1694 let (state, _dir) = make_state();
1695 assert_eq!(state.current_vfs_id().unwrap(), state.vfs_list[0].id);
1696 }
1697
1698 #[test]
1699 fn delete_vfs_via_confirmed_action() {
1700 let (mut state, _dir) = make_state();
1701 // Create a second VFS so we can delete one
1702 let vfs_id = state.backend.create_vfs("Temp VFS").unwrap();
1703 state.refresh_vfs_list();
1704 let initial_count = state.vfs_list.len();
1705
1706 state.pending_confirm = Some(ConfirmAction::DeleteVfs {
1707 vfs_id,
1708 vfs_name: "Temp VFS".to_string(),
1709 });
1710 state.execute_confirmed_action();
1711 assert_eq!(state.vfs_list.len(), initial_count - 1);
1712 }
1713
1714 // C-1 part 2: inline Undo for the most recent edit.
1715
1716 #[test]
1717 fn undo_last_edit_replace_restores_source_node() {
1718 use crate::state::{EditResultMode, EditUndoEntry};
1719
1720 let (mut state, _dir) = make_state();
1721 // Simulate the post-Replace VFS state: source node was deleted, a new
1722 // node pointing at result_hash now sits in its place.
1723 let source_hash = "src0";
1724 let result_hash = "res0";
1725 insert_fake_sample(&state, source_hash);
1726 let vfs_id = state.current_vfs_id().unwrap();
1727 let parent_id = state.current_dir;
1728 let _result_node_id = add_sample_to_vfs(&state, result_hash, "kick.wav");
1729
1730 // Stash the entry as if finalize_edit had run.
1731 state.edit.last_undo = Some(EditUndoEntry {
1732 op_name: "Trim".to_string(),
1733 source_hash: source_hash.to_string(),
1734 result_hash: result_hash.to_string(),
1735 mode: EditResultMode::Replace,
1736 vfs_id,
1737 replace_targets: vec![(parent_id, "kick.wav".to_string())],
1738 sibling_node_id: None,
1739 created_at: std::time::Instant::now(),
1740 });
1741
1742 // Sanity: result_hash is in the VFS before undo.
1743 assert_eq!(
1744 state.backend.find_nodes_by_hashes(vfs_id, &[result_hash]).unwrap().len(),
1745 1,
1746 );
1747
1748 state.undo_last_edit();
1749
1750 // SQLite reuses rowids after delete, so the original `result_node_id`
1751 // may now point at the recreated source node. Assert via hash lookup
1752 // instead: no node references result_hash, exactly one references
1753 // source_hash, and the recreated node keeps the original name.
1754 assert!(state
1755 .backend
1756 .find_nodes_by_hashes(vfs_id, &[result_hash])
1757 .unwrap()
1758 .is_empty());
1759 let restored = state
1760 .backend
1761 .find_nodes_by_hashes(vfs_id, &[source_hash])
1762 .unwrap();
1763 assert_eq!(restored.len(), 1);
1764 assert_eq!(restored[0].node.name, "kick.wav");
1765 assert!(state.edit.last_undo.is_none());
1766 assert_eq!(state.status, "Reverted Trim");
1767 }
1768
1769 #[test]
1770 fn undo_last_edit_sibling_removes_sibling_node() {
1771 use crate::state::{EditResultMode, EditUndoEntry};
1772
1773 let (mut state, _dir) = make_state();
1774 let source_hash = "src1";
1775 let result_hash = "res1";
1776 let _src_node = add_sample_to_vfs(&state, source_hash, "snare.wav");
1777 let sibling_node_id = add_sample_to_vfs(&state, result_hash, "snare_edited.wav");
1778 let vfs_id = state.current_vfs_id().unwrap();
1779
1780 state.edit.last_undo = Some(EditUndoEntry {
1781 op_name: "Reverse".to_string(),
1782 source_hash: source_hash.to_string(),
1783 result_hash: result_hash.to_string(),
1784 mode: EditResultMode::Sibling,
1785 vfs_id,
1786 replace_targets: vec![],
1787 sibling_node_id: Some(sibling_node_id),
1788 created_at: std::time::Instant::now(),
1789 });
1790
1791 state.undo_last_edit();
1792
1793 // The sibling is gone; the original survives.
1794 assert!(state
1795 .backend
1796 .find_nodes_by_hashes(vfs_id, &[result_hash])
1797 .unwrap()
1798 .is_empty());
1799 let originals = state
1800 .backend
1801 .find_nodes_by_hashes(vfs_id, &[source_hash])
1802 .unwrap();
1803 assert_eq!(originals.len(), 1);
1804 assert!(state.edit.last_undo.is_none());
1805 let _ = sibling_node_id;
1806 }
1807
1808 #[test]
1809 fn undo_last_edit_with_no_entry_is_noop() {
1810 let (mut state, _dir) = make_state();
1811 let prior_status = state.status.clone();
1812 state.undo_last_edit();
1813 assert!(state.edit.last_undo.is_none());
1814 assert_eq!(state.status, prior_status);
1815 }
1816 }
1817