use super::*; use audiofiles_core::vfs::NodeType; /// Create a BrowserState backed by a temporary directory with an in-memory-like /// on-disk SQLite database. Each test gets full isolation. fn make_state() -> (BrowserState, tempfile::TempDir) { let dir = tempfile::TempDir::new().unwrap(); let shared = Arc::new(SharedState::new()); let state = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap(); (state, dir) } /// Insert a fake sample row so VFS sample links can reference it. /// Opens a separate DB connection since BrowserState no longer exposes db directly. fn insert_fake_sample(state: &BrowserState, hash: &str) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64; let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap(); db.conn() .execute( "INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) VALUES (?1, ?2, 'wav', 100, ?3, ?4)", rusqlite::params![hash, format!("{hash}.wav"), now, now], ) .unwrap(); } /// Insert a fake sample and link it into the current VFS + directory. fn add_sample_to_vfs(state: &BrowserState, hash: &str, name: &str) -> NodeId { insert_fake_sample(state, hash); let vfs_id = state.current_vfs_id().unwrap(); let parent_id = state.current_dir; state.backend.create_sample_link(vfs_id, parent_id, name, hash).unwrap() } /// Create a directory in the current VFS + directory and return its ID. fn add_directory(state: &BrowserState, name: &str) -> NodeId { let vfs_id = state.current_vfs_id().unwrap(); let parent_id = state.current_dir; state.backend.create_directory(vfs_id, parent_id, name).unwrap() } /// Populate the current VFS root with a few sample nodes and refresh. fn populate_samples(state: &mut BrowserState) { add_sample_to_vfs(state, "aaa111", "kick.wav"); add_sample_to_vfs(state, "bbb222", "snare.wav"); add_sample_to_vfs(state, "ccc333", "hihat.wav"); state.refresh_contents(); } // ---- Selection ---- mod selection { use super::*; #[test] fn new_selection_is_empty() { let sel = Selection::new(); assert_eq!(sel.count(), 0); assert_eq!(sel.anchor, 0); assert_eq!(sel.focus, 0); } #[test] fn set_single_selects_one() { let mut sel = Selection::new(); sel.set_single(3); assert_eq!(sel.count(), 1); assert!(sel.contains(3)); assert_eq!(sel.anchor, 3); assert_eq!(sel.focus, 3); } #[test] fn set_single_clears_previous() { let mut sel = Selection::new(); sel.set_single(1); sel.set_single(5); assert_eq!(sel.count(), 1); assert!(!sel.contains(1)); assert!(sel.contains(5)); } #[test] fn toggle_adds_and_removes() { let mut sel = Selection::new(); sel.toggle(2); assert!(sel.contains(2)); assert_eq!(sel.count(), 1); sel.toggle(2); assert!(!sel.contains(2)); assert_eq!(sel.count(), 0); } #[test] fn toggle_preserves_others() { let mut sel = Selection::new(); sel.set_single(1); sel.toggle(3); assert!(sel.contains(1)); assert!(sel.contains(3)); assert_eq!(sel.count(), 2); } #[test] fn extend_to_selects_range() { let mut sel = Selection::new(); sel.set_single(2); sel.extend_to(5, 10); assert_eq!(sel.count(), 4); // 2, 3, 4, 5 for i in 2..=5 { assert!(sel.contains(i)); } assert_eq!(sel.focus, 5); assert_eq!(sel.anchor, 2); } #[test] fn extend_to_backward_range() { let mut sel = Selection::new(); sel.set_single(5); sel.extend_to(2, 10); assert_eq!(sel.count(), 4); // 2, 3, 4, 5 for i in 2..=5 { assert!(sel.contains(i)); } assert_eq!(sel.focus, 2); } #[test] fn select_all_selects_all_items() { let mut sel = Selection::new(); sel.select_all(5); assert_eq!(sel.count(), 5); for i in 0..5 { assert!(sel.contains(i)); } assert_eq!(sel.anchor, 0); assert_eq!(sel.focus, 4); } #[test] fn select_all_empty_list() { let mut sel = Selection::new(); sel.select_all(0); assert_eq!(sel.count(), 0); } #[test] fn clear_resets_everything() { let mut sel = Selection::new(); sel.select_all(10); sel.clear(); assert_eq!(sel.count(), 0); assert_eq!(sel.anchor, 0); assert_eq!(sel.focus, 0); } #[test] fn extend_down_increments_focus() { let mut sel = Selection::new(); sel.set_single(0); sel.extend_down(5); assert!(sel.contains(1)); assert_eq!(sel.focus, 1); } #[test] fn extend_down_at_end_is_noop() { let mut sel = Selection::new(); sel.set_single(4); sel.extend_down(5); // max_len=5, focus=4 => at end assert_eq!(sel.focus, 4); assert_eq!(sel.count(), 1); // no new items added } #[test] fn extend_up_decrements_focus() { let mut sel = Selection::new(); sel.set_single(3); sel.extend_up(); assert!(sel.contains(2)); assert_eq!(sel.focus, 2); } #[test] fn extend_up_at_zero_is_noop() { let mut sel = Selection::new(); sel.set_single(0); sel.extend_up(); assert_eq!(sel.focus, 0); assert_eq!(sel.count(), 1); } } // ---- Bulk operations & undo ---- mod bulk_ops { use super::*; #[test] fn undo_stack_starts_empty() { let (state, _dir) = make_state(); assert!(!state.can_undo()); } #[test] fn push_undo_makes_can_undo_true() { let (mut state, _dir) = make_state(); state.undo_stack.push(UndoOp::BulkTagAdd { tag: "test".to_string(), hashes: vec!["h1".to_string()], }); assert!(state.can_undo()); } #[test] fn undo_stack_capped_at_100() { let (mut state, _dir) = make_state(); for i in 0..120 { state.undo_stack.push(UndoOp::BulkTagAdd { tag: format!("tag{i}"), hashes: vec!["h1".to_string()], }); // Keep the stack at 100 (mimics push_undo cap logic) if state.undo_stack.len() > 100 { let excess = state.undo_stack.len() - 100; state.undo_stack.drain(..excess); } } assert_eq!(state.undo_stack.len(), 100); } #[test] fn bulk_tag_add_and_undo() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); insert_fake_sample(&state, "h2"); add_sample_to_vfs(&state, "h1", "kick.wav"); add_sample_to_vfs(&state, "h2", "snare.wav"); state.refresh_contents(); // Select both items state.selection.select_all(state.visible_len()); // Open bulk tag modal state.open_bulk_tag_modal(); assert!(state.bulk_modal.is_some()); // Set tag and execute if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal { *tag_input = "genre.rock".to_string(); } state.execute_bulk_tag(); assert!(state.bulk_modal.is_none()); assert!(state.can_undo()); // Verify tags were added { let tags1 = state.backend.get_sample_tags("h1").unwrap(); let tags2 = state.backend.get_sample_tags("h2").unwrap(); assert!(tags1.contains(&"genre.rock".to_string())); assert!(tags2.contains(&"genre.rock".to_string())); } // Undo state.undo(); { let tags1 = state.backend.get_sample_tags("h1").unwrap(); let tags2 = state.backend.get_sample_tags("h2").unwrap(); assert!(!tags1.contains(&"genre.rock".to_string())); assert!(!tags2.contains(&"genre.rock".to_string())); } } #[test] fn bulk_tag_remove_and_undo() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); // Pre-add a tag state.backend.add_tag("h1", "mood.dark").unwrap(); // Select and open tag modal for removal state.selection.set_single(0); state.open_bulk_tag_modal(); if let Some(BulkModal::Tag { ref mut tag_input, ref mut adding, .. }) = state.bulk_modal { *tag_input = "mood.dark".to_string(); *adding = false; } state.execute_bulk_tag(); { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(!tags.contains(&"mood.dark".to_string())); } // Undo re-adds the tag state.undo(); { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(tags.contains(&"mood.dark".to_string())); } } #[test] fn bulk_tag_empty_tag_is_noop() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.open_bulk_tag_modal(); // Leave tag_input empty state.execute_bulk_tag(); // Modal stays open (noop)... actually execute_bulk_tag returns early but // doesn't close the modal in that case because the guard returns before closing. // Let's verify no undo was pushed: assert!(!state.can_undo()); } #[test] fn bulk_tag_no_samples_selected_does_not_open() { let (mut state, _dir) = make_state(); // No samples, just an empty VFS root state.open_bulk_tag_modal(); assert!(state.bulk_modal.is_none()); } #[test] fn bulk_move_and_undo() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); let _node_id = add_sample_to_vfs(&state, "h1", "kick.wav"); let dir_id = add_directory(&state, "Drums"); state.refresh_contents(); // Select the sample (not the directory) // Contents are sorted: directories first. So index 0 = Drums, index 1 = kick.wav state.selection.set_single(1); state.open_bulk_move_modal(); assert!(state.bulk_modal.is_some()); // Select the target directory if let Some(BulkModal::Move { ref mut selected_idx, ref directories, .. }) = state.bulk_modal { // Find the index of the Drums directory *selected_idx = directories.iter().position(|(id, _)| *id == dir_id); } state.execute_bulk_move(); assert!(state.bulk_modal.is_none()); assert!(state.can_undo()); // Verify the node was moved: root should only have Drums now state.refresh_contents(); let root_samples: Vec<_> = state.contents.iter() .filter(|n| n.node.node_type == NodeType::Sample) .collect(); assert_eq!(root_samples.len(), 0); // Undo should move it back state.undo(); let root_samples: Vec<_> = state.contents.iter() .filter(|n| n.node.node_type == NodeType::Sample) .collect(); assert_eq!(root_samples.len(), 1); } #[test] fn bulk_move_no_selection_does_not_open() { let (mut state, _dir) = make_state(); state.open_bulk_move_modal(); assert!(state.bulk_modal.is_none()); } #[test] fn bulk_delete_and_undo() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); insert_fake_sample(&state, "h2"); add_sample_to_vfs(&state, "h1", "kick.wav"); add_sample_to_vfs(&state, "h2", "snare.wav"); state.refresh_contents(); assert_eq!(state.contents.len(), 2); // Select all and confirm delete state.selection.select_all(state.visible_len()); state.confirm_delete_selection(); assert!(state.pending_confirm.is_some()); state.execute_confirmed_action(); assert_eq!(state.contents.len(), 0); assert!(state.can_undo()); // Undo restores both nodes state.undo(); assert_eq!(state.contents.len(), 2); } #[test] fn single_delete_confirmed() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); assert_eq!(state.contents.len(), 1); state.selection.set_single(0); state.confirm_delete_selected(); assert!(matches!(state.pending_confirm, Some(ConfirmAction::DeleteNode { .. }))); state.execute_confirmed_action(); assert_eq!(state.contents.len(), 0); } #[test] fn dismiss_confirm_clears_pending() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.confirm_delete_selected(); assert!(state.pending_confirm.is_some()); state.dismiss_confirm(); assert!(state.pending_confirm.is_none()); } #[test] fn close_bulk_modal_clears_it() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.open_bulk_tag_modal(); assert!(state.bulk_modal.is_some()); state.close_bulk_modal(); assert!(state.bulk_modal.is_none()); } #[test] fn selected_nodes_excludes_parent_entry() { let (mut state, _dir) = make_state(); let dir_id = add_directory(&state, "Sub"); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); // Navigate into the directory state.current_dir = Some(dir_id); state.breadcrumb = state.backend.get_breadcrumb(dir_id).unwrap(); state.refresh_contents(); // Now ".." is at index 0. Select index 0 ("..") state.selection.set_single(0); let nodes = state.selected_nodes(); assert!(nodes.is_empty(), "'..' should be excluded from selected_nodes"); } #[test] fn selected_sample_hashes_returns_only_hashes() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); add_directory(&state, "EmptyDir"); state.refresh_contents(); // Select all (dir + sample) state.selection.select_all(state.visible_len()); let hashes = state.selected_sample_hashes(); assert_eq!(hashes.len(), 1); assert_eq!(hashes[0], "h1"); } #[test] fn bulk_delete_with_tags_preserves_tags_in_undo() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); insert_fake_sample(&state, "h2"); add_sample_to_vfs(&state, "h1", "kick.wav"); add_sample_to_vfs(&state, "h2", "snare.wav"); state.backend.add_tag("h1", "genre.rock").unwrap(); state.refresh_contents(); assert_eq!(state.contents.len(), 2); // Need 2+ items for DeleteMultiple (which has undo support) state.selection.select_all(state.visible_len()); state.confirm_delete_selection(); assert!(matches!(state.pending_confirm, Some(ConfirmAction::DeleteMultiple { .. }))); state.execute_confirmed_action(); assert_eq!(state.contents.len(), 0); // Undo restores nodes AND tags state.undo(); assert_eq!(state.contents.len(), 2); { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(tags.contains(&"genre.rock".to_string())); } } #[test] fn multiple_undos_in_sequence() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); // First operation: add tag state.selection.set_single(0); state.open_bulk_tag_modal(); if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal { *tag_input = "genre.rock".to_string(); } state.execute_bulk_tag(); // Second operation: add another tag state.selection.set_single(0); state.open_bulk_tag_modal(); if let Some(BulkModal::Tag { ref mut tag_input, .. }) = state.bulk_modal { *tag_input = "mood.dark".to_string(); } state.execute_bulk_tag(); assert_eq!(state.undo_stack.len(), 2); // Undo second state.undo(); { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(tags.contains(&"genre.rock".to_string())); assert!(!tags.contains(&"mood.dark".to_string())); } // Undo first state.undo(); { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(!tags.contains(&"genre.rock".to_string())); } assert!(!state.can_undo()); } #[test] fn undo_on_empty_stack_is_noop() { let (mut state, _dir) = make_state(); assert!(!state.can_undo()); // Should not panic state.undo(); assert!(!state.can_undo()); } } // ---- Export flow ---- mod export_flow { use super::*; #[test] fn start_export_flow_sets_configure_export() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.start_export_flow(None); assert!(matches!(state.import_mode, ImportMode::ConfigureExport { .. })); if let ImportMode::ConfigureExport { ref items, ref config, .. } = state.import_mode { assert_eq!(items.len(), 1); assert!(matches!(config.format, audiofiles_core::export::ExportFormat::Original)); assert!(!config.flatten); } else { panic!("Expected ConfigureExport mode"); } } #[test] fn start_export_flow_empty_vfs_is_noop() { let (mut state, _dir) = make_state(); state.start_export_flow(None); assert!(matches!(state.import_mode, ImportMode::None)); assert_eq!(state.status, "No samples to export"); } #[test] fn cancel_export_lands_in_acknowledgement() { let (mut state, _dir) = make_state(); state.import_mode = ImportMode::Exporting { completed: 3, total: 10, current_name: "test.wav".to_string(), }; state.cancel_export(); // C-3: cancel with meaningful progress lands in the acknowledgement // screen, not None, so the user sees what landed vs what was discarded. match state.import_mode { ImportMode::OperationCancelled { completed, total, .. } => { assert_eq!(completed, 3); assert_eq!(total, 10); } _ => panic!("Expected OperationCancelled, got {:?}", std::mem::discriminant(&state.import_mode)), } assert_eq!(state.status, "Export cancelled"); } #[test] fn cancel_export_with_zero_progress_returns_to_none() { let (mut state, _dir) = make_state(); // total == 0 means the export hasn't started — no meaningful progress // to acknowledge. Falls through to None. state.import_mode = ImportMode::Exporting { completed: 0, total: 0, current_name: String::new(), }; state.cancel_export(); assert!(matches!(state.import_mode, ImportMode::None)); } #[test] fn poll_workers_without_active_work_returns_false() { let (mut state, _dir) = make_state(); assert!(!state.poll_workers()); } #[test] fn export_flow_state_transitions() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); // Step 1: start_export_flow → ConfigureExport state.start_export_flow(None); assert!(matches!(state.import_mode, ImportMode::ConfigureExport { .. })); // Step 2: Simulate transition to Exporting (run_export spawns a worker, // but we test the state machine by setting it directly to avoid needing // real audio files in the content-addressed store) state.import_mode = ImportMode::Exporting { completed: 0, total: 1, current_name: "kick.wav".to_string(), }; assert!(matches!(state.import_mode, ImportMode::Exporting { .. })); // Step 3: Simulate transition to ExportComplete state.import_mode = ImportMode::ExportComplete { total: 1, errors: vec![], }; if let ImportMode::ExportComplete { total, ref errors } = state.import_mode { assert_eq!(total, 1); assert!(errors.is_empty()); } else { panic!("Expected ExportComplete mode"); } // Step 4: Reset to None (Done button) state.import_mode = ImportMode::None; assert!(matches!(state.import_mode, ImportMode::None)); } #[test] fn export_complete_with_errors() { let (mut state, _dir) = make_state(); state.import_mode = ImportMode::ExportComplete { total: 5, errors: vec![ ("kick.wav".to_string(), "decode error".to_string()), ("snare.wav".to_string(), "io error".to_string()), ], }; if let ImportMode::ExportComplete { total, ref errors } = state.import_mode { assert_eq!(total, 5); assert_eq!(errors.len(), 2); } else { panic!("Expected ExportComplete mode"); } } } // ---- Import & analysis state management ---- mod import_and_analysis { use super::*; #[test] fn initial_import_mode_is_none() { let (state, _dir) = make_state(); assert!(matches!(state.import_mode, ImportMode::None)); } #[test] fn start_analysis_flow_empty_hashes_is_noop() { let (mut state, _dir) = make_state(); state.start_analysis_flow(vec![]); assert!(matches!(state.import_mode, ImportMode::None)); } #[test] fn start_analysis_flow_sets_configure_mode() { let (mut state, _dir) = make_state(); let hashes = vec![("abc".to_string(), "wav".to_string())]; state.start_analysis_flow(hashes); assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. })); } #[test] fn cancel_analysis_resets_state() { let (mut state, _dir) = make_state(); state.import_mode = ImportMode::Analyzing { completed: 5, total: 10, current_name: "test.wav".to_string(), }; state.pending_review_items.push(ReviewItem { hash: audiofiles_core::SampleHash::new("abc"), name: "test.wav".to_string(), result: audiofiles_core::analysis::AnalysisResult { hash: "abc".to_string(), duration: 1.0, sample_rate: 44100, channels: 2, peak_db: Some(-3.0), rms_db: Some(-12.0), lufs: Some(-14.0), bpm: None, musical_key: None, is_loop: None, spectral_centroid: None, spectral_flatness: None, spectral_rolloff: None, zero_crossing_rate: None, onset_strength: None, classification: None, fingerprint: None, spectral_bandwidth: None, centroid_variance: None, crest_factor: None, attack_time: None, classification_confidence: None, }, suggestions: vec![], }); state.cancel_analysis(); // C-3: progress > 0 → acknowledgement screen. match state.import_mode { ImportMode::OperationCancelled { completed, total, .. } => { assert_eq!(completed, 5); assert_eq!(total, 10); } _ => panic!("Expected OperationCancelled"), } assert!(state.pending_review_items.is_empty()); assert_eq!(state.status, "Analysis cancelled"); } #[test] fn show_import_options_sets_configure_import() { let (mut state, _dir) = make_state(); let source = std::env::temp_dir().join("test_samples"); state.show_import_options(source.clone()); if let ImportMode::ConfigureImport { source: ref s, ref source_name, ref available_vfs, .. } = state.import_mode { assert_eq!(s, &source); assert_eq!(source_name, "test_samples"); assert!(!available_vfs.is_empty()); } else { panic!("Expected ConfigureImport mode"); } } #[test] fn cancel_import_lands_in_acknowledgement() { let (mut state, _dir) = make_state(); state.import_mode = ImportMode::Importing { total: 10, completed: 3, current_name: "file.wav".to_string(), walking: false, walking_count: 0, total_bytes: 0, loose_files: false, }; state.cancel_import(); // C-3: cancel during real progress lands in the acknowledgement screen. match state.import_mode { ImportMode::OperationCancelled { completed, total, .. } => { assert_eq!(completed, 3); assert_eq!(total, 10); } _ => panic!("Expected OperationCancelled"), } assert_eq!(state.status, "Import cancelled"); } #[test] fn cancel_import_during_walking_returns_to_none() { let (mut state, _dir) = make_state(); // walking == true means no files have committed yet; falls through to // None so the user isn't shown a "Stopped at 0 of 0" screen. state.import_mode = ImportMode::Importing { total: 0, completed: 0, current_name: String::new(), walking: true, walking_count: 0, total_bytes: 0, loose_files: false, }; state.cancel_import(); assert!(matches!(state.import_mode, ImportMode::None)); } #[test] fn retry_import_reopens_config() { let (mut state, _dir) = make_state(); let source = std::env::temp_dir().join("retry_test"); state.last_import_source = Some(source.clone()); state.import_mode = ImportMode::Importing { total: 10, completed: 3, current_name: "file.wav".to_string(), walking: false, walking_count: 0, total_bytes: 0, loose_files: false, }; state.retry_import(); assert!(matches!(state.import_mode, ImportMode::ConfigureImport { .. })); } #[test] fn retry_import_without_source_stays_cancelled() { let (mut state, _dir) = make_state(); state.last_import_source = None; state.import_mode = ImportMode::Importing { total: 10, completed: 3, current_name: "file.wav".to_string(), walking: false, walking_count: 0, total_bytes: 0, loose_files: false, }; state.retry_import(); // With no last_import_source, retry calls cancel_import which now lands // in OperationCancelled (C-3). The retry path can't reopen config, so // the user is left on the acknowledgement screen. assert!(matches!(state.import_mode, ImportMode::OperationCancelled { .. })); } #[test] fn poll_workers_without_active_analysis_returns_false() { let (mut state, _dir) = make_state(); assert!(!state.poll_workers()); } #[test] fn apply_accepted_suggestions_resets_mode() { let (mut state, _dir) = make_state(); state.import_mode = ImportMode::ReviewSuggestions { items: vec![], current_idx: 0, sort: crate::state::ReviewSort::ImportOrder, }; state.apply_accepted_suggestions(); assert!(matches!(state.import_mode, ImportMode::None)); } #[test] fn apply_folder_tags_with_entries() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.import_mode = ImportMode::TagFolders { entries: vec![FolderTagEntry { folder: crate::import::ImportedFolder { name: "Drums".to_string(), samples: vec![("h1".to_string(), "wav".to_string())], }, tag_input: "instrument.drum".to_string(), }], sample_hashes: vec![("h1".to_string(), "wav".to_string())], }; state.apply_folder_tags(); // Should have tagged h1 and moved to analysis config { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(tags.contains(&"instrument.drum".to_string())); } assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. })); } #[test] fn skip_folder_tags_goes_to_analysis() { let (mut state, _dir) = make_state(); state.import_mode = ImportMode::TagFolders { entries: vec![], sample_hashes: vec![("h1".to_string(), "wav".to_string())], }; state.skip_folder_tags(); assert!(matches!(state.import_mode, ImportMode::ConfigureAnalysis { .. })); } #[test] fn apply_folder_tags_skips_empty_input() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.import_mode = ImportMode::TagFolders { entries: vec![FolderTagEntry { folder: crate::import::ImportedFolder { name: "Drums".to_string(), samples: vec![("h1".to_string(), "wav".to_string())], }, tag_input: " ".to_string(), // whitespace only }], sample_hashes: vec![("h1".to_string(), "wav".to_string())], }; state.apply_folder_tags(); // No tags should have been applied { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(tags.is_empty()); } } #[test] fn apply_folder_tags_multiple_comma_separated() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.import_mode = ImportMode::TagFolders { entries: vec![FolderTagEntry { folder: crate::import::ImportedFolder { name: "Drums".to_string(), samples: vec![("h1".to_string(), "wav".to_string())], }, tag_input: "instrument.drum, mood.dark".to_string(), }], sample_hashes: vec![("h1".to_string(), "wav".to_string())], }; state.apply_folder_tags(); { let tags = state.backend.get_sample_tags("h1").unwrap(); assert!(tags.contains(&"instrument.drum".to_string())); assert!(tags.contains(&"mood.dark".to_string())); } } #[test] fn import_errors_accumulate() { let (mut state, _dir) = make_state(); state.import_file_errors.push(super::ImportFileError { path: "file1.wav".to_string(), error: "decode error".to_string(), }); state.import_file_errors.push(super::ImportFileError { path: "file2.wav".to_string(), error: "io error".to_string(), }); assert_eq!(state.import_file_errors.len(), 2); assert!(state.has_import_errors()); } #[test] fn review_errors_transition_when_errors_exist() { let (mut state, _dir) = make_state(); state.import_file_errors.push(super::ImportFileError { path: "/bad/file.wav".to_string(), error: "corrupt header".to_string(), }); // Simulate what AnalysisBatchComplete does with no review items but errors assert!(state.has_import_errors()); state.import_mode = ImportMode::ReviewErrors; assert!(matches!(state.import_mode, ImportMode::ReviewErrors)); } #[test] fn dismiss_import_errors_clears_and_returns_to_none() { let (mut state, _dir) = make_state(); state.import_file_errors.push(super::ImportFileError { path: "/bad/file.wav".to_string(), error: "corrupt".to_string(), }); state.analysis_errors.push(super::AnalysisFileError { hash: "abc123".to_string(), name: "broken.wav".to_string(), error: "decode failed".to_string(), }); state.import_mode = ImportMode::ReviewErrors; state.dismiss_import_errors(); assert!(matches!(state.import_mode, ImportMode::None)); assert!(state.import_file_errors.is_empty()); assert!(state.analysis_errors.is_empty()); } #[test] fn remove_failed_sample_removes_from_error_list() { let (mut state, _dir) = make_state(); // Use the backend's own import to insert a real sample let sample_path = state.data_dir.join("test_sample.wav"); // Write a minimal valid WAV header (44 bytes, 0 data frames) let header: [u8; 44] = [ 0x52, 0x49, 0x46, 0x46, // "RIFF" 0x24, 0x00, 0x00, 0x00, // file size - 8 0x57, 0x41, 0x56, 0x45, // "WAVE" 0x66, 0x6D, 0x74, 0x20, // "fmt " 0x10, 0x00, 0x00, 0x00, // chunk size 0x01, 0x00, 0x01, 0x00, // PCM, 1 channel 0x44, 0xAC, 0x00, 0x00, // 44100 Hz 0x88, 0x58, 0x01, 0x00, // byte rate 0x02, 0x00, 0x10, 0x00, // block align, bits per sample 0x64, 0x61, 0x74, 0x61, // "data" 0x00, 0x00, 0x00, 0x00, // data size (0 bytes) ]; std::fs::write(&sample_path, header).unwrap(); let hash = state.backend.import_file(&sample_path).unwrap(); let vfs_id = state.current_vfs_id().unwrap(); state.backend.create_sample_link(vfs_id, None, "broken.wav", &hash).unwrap(); state.refresh_contents(); assert_eq!(state.contents.len(), 1); state.analysis_errors.push(super::AnalysisFileError { hash: hash.clone(), name: "broken.wav".to_string(), error: "decode failed".to_string(), }); state.import_mode = ImportMode::ReviewErrors; state.remove_failed_sample(0); assert!(state.analysis_errors.is_empty()); } #[test] fn clean_analysis_skips_review_errors() { let (state, _dir) = make_state(); // No errors accumulated assert!(!state.has_import_errors()); assert!(state.import_file_errors.is_empty()); assert!(state.analysis_errors.is_empty()); } #[test] fn retry_analysis_without_config_is_noop() { let (mut state, _dir) = make_state(); state.last_analysis_hashes = vec![]; state.last_analysis_config = None; state.retry_analysis(); // Should cancel and do nothing further assert!(matches!(state.import_mode, ImportMode::None)); } #[test] fn import_path_nonexistent_sets_status() { let (mut state, _dir) = make_state(); let fake_path = PathBuf::from("/nonexistent/path/to/audio.wav"); state.import_path(&fake_path); assert!(state.status.contains("Path not found")); } } // ---- Navigation & filter ---- mod navigation_and_filter { use super::*; #[test] fn initial_state_has_library_vfs() { let (state, _dir) = make_state(); assert!(!state.vfs_list.is_empty()); assert_eq!(state.vfs_list[0].name, "Vault"); assert_eq!(state.current_vfs_idx, 0); } #[test] fn initial_state_at_root() { let (state, _dir) = make_state(); assert!(state.current_dir.is_none()); assert!(state.breadcrumb.is_empty()); } #[test] fn navigate_into_directory() { let (mut state, _dir) = make_state(); let dir_id = add_directory(&state, "Drums"); state.refresh_contents(); // Directory should be in contents assert_eq!(state.contents.len(), 1); assert_eq!(state.contents[0].node.name, "Drums"); // Select and enter state.selection.set_single(0); state.enter_directory(); assert_eq!(state.current_dir, Some(dir_id)); assert!(!state.breadcrumb.is_empty()); assert_eq!(state.breadcrumb.last().unwrap().name, "Drums"); } #[test] fn go_up_from_subdirectory() { let (mut state, _dir) = make_state(); let dir_id = add_directory(&state, "Drums"); state.refresh_contents(); // Enter the directory state.selection.set_single(0); state.enter_directory(); assert_eq!(state.current_dir, Some(dir_id)); // Go back up state.go_up(); assert!(state.current_dir.is_none()); assert!(state.breadcrumb.is_empty()); } #[test] fn go_up_at_root_is_noop() { let (mut state, _dir) = make_state(); state.go_up(); assert!(state.current_dir.is_none()); } #[test] fn enter_directory_on_sample_is_noop() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.enter_directory(); // Should still be at root since kick.wav is a sample, not a directory assert!(state.current_dir.is_none()); } #[test] fn select_vfs_switches_vfs() { let (mut state, _dir) = make_state(); // Create a second VFS state.backend.create_vfs("Project A").unwrap(); state.refresh_vfs_list(); assert!(state.vfs_list.len() >= 2); let old_idx = state.current_vfs_idx; let new_idx = if old_idx == 0 { 1 } else { 0 }; state.select_vfs(new_idx); assert_eq!(state.current_vfs_idx, new_idx); assert!(state.current_dir.is_none()); } #[test] fn select_vfs_same_index_is_noop() { let (mut state, _dir) = make_state(); let status_before = state.status.clone(); state.select_vfs(0); // same index // Status should not change since select_vfs guards against same index assert_eq!(state.status, status_before); } #[test] fn select_vfs_out_of_bounds_is_noop() { let (mut state, _dir) = make_state(); state.select_vfs(999); assert_eq!(state.current_vfs_idx, 0); } #[test] fn visible_len_includes_parent_entry() { let (mut state, _dir) = make_state(); let dir_id = add_directory(&state, "Drums"); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); // At root: no ".." entry assert_eq!(state.visible_len(), 2); // Drums + kick.wav // Enter directory state.selection.set_single(0); state.enter_directory(); // In subdirectory: ".." + contents assert_eq!(state.current_dir, Some(dir_id)); // Drums is empty, so visible_len = 0 contents + 1 parent entry assert_eq!(state.visible_len(), 1); } #[test] fn selected_node_returns_none_for_parent_entry() { let (mut state, _dir) = make_state(); let _dir_id = add_directory(&state, "Drums"); state.refresh_contents(); // Enter directory state.selection.set_single(0); state.enter_directory(); // Select ".." at index 0 state.selection.set_single(0); assert!(state.selected_node().is_none()); } #[test] fn select_next_and_prev() { let (mut state, _dir) = make_state(); populate_samples(&mut state); assert_eq!(state.contents.len(), 3); state.selection.set_single(0); state.select_next(); assert_eq!(state.selection.focus, 1); state.select_next(); assert_eq!(state.selection.focus, 2); // At end, should stay state.select_next(); assert_eq!(state.selection.focus, 2); state.select_prev(); assert_eq!(state.selection.focus, 1); state.select_prev(); assert_eq!(state.selection.focus, 0); // At beginning, should stay state.select_prev(); assert_eq!(state.selection.focus, 0); } #[test] fn search_filter_default_not_active() { let (state, _dir) = make_state(); assert!(!state.search_filter.is_active()); assert!(state.search_query.is_empty()); } #[test] fn apply_search_clears_selection() { let (mut state, _dir) = make_state(); populate_samples(&mut state); state.selection.select_all(state.visible_len()); assert!(state.selection.count() > 0); state.apply_search(); assert_eq!(state.selection.count(), 0); } #[test] fn refresh_vfs_list_resets_navigation() { let (mut state, _dir) = make_state(); let dir_id = add_directory(&state, "Drums"); state.refresh_contents(); // Navigate into directory state.selection.set_single(0); state.enter_directory(); assert_eq!(state.current_dir, Some(dir_id)); // Refresh VFS list should reset to root state.refresh_vfs_list(); assert!(state.current_dir.is_none()); assert!(state.breadcrumb.is_empty()); assert_eq!(state.selection.count(), 0); } } // ---- Sort & column config ---- mod column_config { use super::*; #[test] fn default_column_config() { let config = ColumnConfig::default(); assert!(config.show_classification); assert!(config.show_bpm); assert!(config.show_key); assert!(config.show_duration); assert!(!config.show_peak_db); assert!(!config.show_tags); } #[test] fn toggle_sort_ascending_to_descending() { let (mut state, _dir) = make_state(); assert_eq!(state.sort_column, SortColumn::Name); assert_eq!(state.sort_direction, SortDirection::Ascending); state.toggle_sort(SortColumn::Name); assert_eq!(state.sort_column, SortColumn::Name); assert_eq!(state.sort_direction, SortDirection::Descending); } #[test] fn toggle_sort_descending_resets_to_name() { let (mut state, _dir) = make_state(); state.sort_column = SortColumn::Bpm; state.sort_direction = SortDirection::Ascending; state.toggle_sort(SortColumn::Bpm); assert_eq!(state.sort_direction, SortDirection::Descending); state.toggle_sort(SortColumn::Bpm); assert_eq!(state.sort_column, SortColumn::Name); assert_eq!(state.sort_direction, SortDirection::Ascending); } #[test] fn toggle_sort_different_column() { let (mut state, _dir) = make_state(); state.toggle_sort(SortColumn::Bpm); assert_eq!(state.sort_column, SortColumn::Bpm); assert_eq!(state.sort_direction, SortDirection::Ascending); } #[test] fn sort_contents_directories_first() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "aaa.wav"); add_directory(&state, "zzz_folder"); state.refresh_contents(); // Even though "aaa" < "zzz", directory should be first assert_eq!(state.contents[0].node.node_type, NodeType::Directory); assert_eq!(state.contents[1].node.node_type, NodeType::Sample); } #[test] fn save_and_load_column_config() { let (mut state, _dir) = make_state(); state.column_config.show_tags = true; state.column_config.show_peak_db = true; state.save_column_config(); // Reset to defaults state.column_config = ColumnConfig::default(); assert!(!state.column_config.show_tags); assert!(!state.column_config.show_peak_db); // Load should restore state.load_column_config(); assert!(state.column_config.show_tags); assert!(state.column_config.show_peak_db); } #[test] fn load_column_config_without_saved_uses_default() { let (mut state, _dir) = make_state(); // No config saved yet state.column_config.show_bpm = false; state.load_column_config(); // Since nothing was saved, the load should leave state unchanged // (it only replaces if it finds a row) assert!(!state.column_config.show_bpm); } #[test] fn sort_column_enum_equality() { assert_eq!(SortColumn::Name, SortColumn::Name); assert_ne!(SortColumn::Name, SortColumn::Bpm); assert_ne!(SortColumn::Bpm, SortColumn::Key); } #[test] fn sort_direction_enum_equality() { assert_eq!(SortDirection::Ascending, SortDirection::Ascending); assert_ne!(SortDirection::Ascending, SortDirection::Descending); } #[test] fn sort_by_name_case_insensitive() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); insert_fake_sample(&state, "h2"); insert_fake_sample(&state, "h3"); add_sample_to_vfs(&state, "h1", "Zebra.wav"); add_sample_to_vfs(&state, "h2", "apple.wav"); add_sample_to_vfs(&state, "h3", "Banana.wav"); state.refresh_contents(); // Should be sorted case-insensitively: apple, Banana, Zebra assert_eq!(state.contents[0].node.name, "apple.wav"); assert_eq!(state.contents[1].node.name, "Banana.wav"); assert_eq!(state.contents[2].node.name, "Zebra.wav"); } } // ---- Rename patterns ---- mod rename_patterns { use super::*; #[test] fn open_bulk_rename_no_selection_does_not_open() { let (mut state, _dir) = make_state(); state.open_bulk_rename_modal(); assert!(state.bulk_modal.is_none()); } #[test] fn open_bulk_rename_sets_default_pattern() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.open_bulk_rename_modal(); if let Some(BulkModal::Rename { ref pattern_input, .. }) = state.bulk_modal { assert_eq!(pattern_input, "{name}"); } else { panic!("Expected Rename modal"); } } #[test] fn rename_preview_updates() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.open_bulk_rename_modal(); if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal { *pattern_input = "renamed_{name}".to_string(); } state.update_rename_previews(); if let Some(BulkModal::Rename { ref previews, ref error, .. }) = state.bulk_modal { assert_eq!(previews.len(), 1); assert!(error.is_none()); // Old name should be "kick.wav", new name "renamed_kick.wav" assert_eq!(previews[0].0, "kick.wav"); assert_eq!(previews[0].1, "renamed_kick.wav"); } else { panic!("Expected Rename modal"); } } #[test] fn rename_preview_literal_deduplicates() { // A literal-only pattern applied to 2 items gets auto-deduplicated // by resolve_all, producing "same_name" and "same_name (2)". let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); insert_fake_sample(&state, "h2"); add_sample_to_vfs(&state, "h1", "kick.wav"); add_sample_to_vfs(&state, "h2", "snare.wav"); state.refresh_contents(); state.selection.select_all(state.visible_len()); state.open_bulk_rename_modal(); if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal { *pattern_input = "same_name".to_string(); } state.update_rename_previews(); if let Some(BulkModal::Rename { ref previews, ref error, .. }) = state.bulk_modal { // resolve_all deduplicates stems, so no duplicate error assert!(error.is_none()); assert_eq!(previews.len(), 2); // One is "same_name.wav", the other "same_name (2).wav" let new_names: Vec<&str> = previews.iter().map(|(_, n)| n.as_str()).collect(); assert!(new_names.contains(&"same_name.wav")); assert!(new_names.contains(&"same_name (2).wav")); } else { panic!("Expected Rename modal"); } } #[test] fn rename_preview_with_empty_token_keeps_extension() { // {bpm} with no analysis data resolves to empty stem, but the // extension is still appended, so the preview shows ".wav". let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.open_bulk_rename_modal(); if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal { *pattern_input = "{bpm}".to_string(); } state.update_rename_previews(); if let Some(BulkModal::Rename { ref previews, ref error, .. }) = state.bulk_modal { // The stem is empty but extension is appended => ".wav" assert_eq!(previews.len(), 1); assert_eq!(previews[0].1, ".wav"); // No error because ".wav" is not empty after trim assert!(error.is_none()); } else { panic!("Expected Rename modal"); } } #[test] fn rename_preview_invalid_pattern() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.open_bulk_rename_modal(); if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal { *pattern_input = "{unknown_token}".to_string(); } state.update_rename_previews(); if let Some(BulkModal::Rename { ref error, ref previews, .. }) = state.bulk_modal { assert!(error.is_some()); assert!(previews.is_empty()); } else { panic!("Expected Rename modal"); } } #[test] fn execute_bulk_rename_and_undo() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); let node_id = add_sample_to_vfs(&state, "h1", "kick.wav"); state.refresh_contents(); state.selection.set_single(0); state.open_bulk_rename_modal(); if let Some(BulkModal::Rename { ref mut pattern_input, .. }) = state.bulk_modal { *pattern_input = "renamed_{name}".to_string(); } state.execute_bulk_rename(); // Verify the name changed { let node = state.backend.get_node(node_id).unwrap(); assert_eq!(node.name, "renamed_kick.wav"); } assert!(state.can_undo()); // Undo state.undo(); { let node = state.backend.get_node(node_id).unwrap(); assert_eq!(node.name, "kick.wav"); } } #[test] fn split_name_ext_basic() { let (stem, ext) = split_name_ext("kick.wav"); assert_eq!(stem, "kick"); assert_eq!(ext, "wav"); } #[test] fn split_name_ext_no_extension() { let (stem, ext) = split_name_ext("noext"); assert_eq!(stem, "noext"); assert_eq!(ext, ""); } #[test] fn split_name_ext_dotfile() { let (stem, ext) = split_name_ext(".hidden"); assert_eq!(stem, ".hidden"); assert_eq!(ext, ""); } #[test] fn split_name_ext_multiple_dots() { let (stem, ext) = split_name_ext("archive.tar.gz"); assert_eq!(stem, "archive.tar"); assert_eq!(ext, "gz"); } } // ---- Miscellaneous state ---- mod misc { use super::*; #[test] fn shared_state_default() { let shared = SharedState::new(); let playback = shared.preview.lock(); assert!(!playback.playing); } #[test] fn stop_preview_clears_state() { let (mut state, _dir) = make_state(); state.previewing_hash = Some("abc".to_string()); state.status = "Playing: kick.wav".to_string(); state.stop_preview(); assert!(state.previewing_hash.is_none()); assert!(state.status.is_empty()); } #[test] fn detail_visible_defaults_true() { let (state, _dir) = make_state(); assert!(state.detail_visible); assert!(state.sidebar_visible); } #[test] fn show_help_defaults_false() { let (state, _dir) = make_state(); assert!(!state.show_help); } #[test] fn focus_search_defaults_false() { let (state, _dir) = make_state(); assert!(!state.focus_search); } #[test] fn confirm_action_variants_constructible() { let _single = ConfirmAction::DeleteNode { node_id: NodeId::from(1), node_name: "test".to_string(), }; let _vfs = ConfirmAction::DeleteVfs { vfs_id: VfsId::from(1), vfs_name: "Library".to_string(), }; let _multi = ConfirmAction::DeleteMultiple { node_ids: vec![NodeId::from(1), NodeId::from(2), NodeId::from(3)], count: 3, }; } #[test] fn refresh_all_tags_loads_tags() { let (mut state, _dir) = make_state(); insert_fake_sample(&state, "h1"); state.backend.add_tag("h1", "genre.rock").unwrap(); state.refresh_all_tags(); assert!(state.all_tags.contains(&"genre.rock".to_string())); } #[test] fn current_vfs_id_matches_first_vfs() { let (state, _dir) = make_state(); assert_eq!(state.current_vfs_id().unwrap(), state.vfs_list[0].id); } #[test] fn delete_vfs_via_confirmed_action() { let (mut state, _dir) = make_state(); // Create a second VFS so we can delete one let vfs_id = state.backend.create_vfs("Temp VFS").unwrap(); state.refresh_vfs_list(); let initial_count = state.vfs_list.len(); state.pending_confirm = Some(ConfirmAction::DeleteVfs { vfs_id, vfs_name: "Temp VFS".to_string(), }); state.execute_confirmed_action(); assert_eq!(state.vfs_list.len(), initial_count - 1); } // C-1 part 2: inline Undo for the most recent edit. #[test] fn undo_last_edit_replace_restores_source_node() { use crate::state::{EditResultMode, EditUndoEntry}; let (mut state, _dir) = make_state(); // Simulate the post-Replace VFS state: source node was deleted, a new // node pointing at result_hash now sits in its place. let source_hash = "src0"; let result_hash = "res0"; insert_fake_sample(&state, source_hash); let vfs_id = state.current_vfs_id().unwrap(); let parent_id = state.current_dir; let _result_node_id = add_sample_to_vfs(&state, result_hash, "kick.wav"); // Stash the entry as if finalize_edit had run. state.edit.last_undo = Some(EditUndoEntry { op_name: "Trim".to_string(), source_hash: source_hash.to_string(), result_hash: result_hash.to_string(), mode: EditResultMode::Replace, vfs_id, replace_targets: vec![(parent_id, "kick.wav".to_string())], sibling_node_id: None, created_at: std::time::Instant::now(), }); // Sanity: result_hash is in the VFS before undo. assert_eq!( state.backend.find_nodes_by_hashes(vfs_id, &[result_hash]).unwrap().len(), 1, ); state.undo_last_edit(); // SQLite reuses rowids after delete, so the original `result_node_id` // may now point at the recreated source node. Assert via hash lookup // instead: no node references result_hash, exactly one references // source_hash, and the recreated node keeps the original name. assert!(state .backend .find_nodes_by_hashes(vfs_id, &[result_hash]) .unwrap() .is_empty()); let restored = state .backend .find_nodes_by_hashes(vfs_id, &[source_hash]) .unwrap(); assert_eq!(restored.len(), 1); assert_eq!(restored[0].node.name, "kick.wav"); assert!(state.edit.last_undo.is_none()); assert_eq!(state.status, "Reverted Trim"); } #[test] fn undo_last_edit_sibling_removes_sibling_node() { use crate::state::{EditResultMode, EditUndoEntry}; let (mut state, _dir) = make_state(); let source_hash = "src1"; let result_hash = "res1"; let _src_node = add_sample_to_vfs(&state, source_hash, "snare.wav"); let sibling_node_id = add_sample_to_vfs(&state, result_hash, "snare_edited.wav"); let vfs_id = state.current_vfs_id().unwrap(); state.edit.last_undo = Some(EditUndoEntry { op_name: "Reverse".to_string(), source_hash: source_hash.to_string(), result_hash: result_hash.to_string(), mode: EditResultMode::Sibling, vfs_id, replace_targets: vec![], sibling_node_id: Some(sibling_node_id), created_at: std::time::Instant::now(), }); state.undo_last_edit(); // The sibling is gone; the original survives. assert!(state .backend .find_nodes_by_hashes(vfs_id, &[result_hash]) .unwrap() .is_empty()); let originals = state .backend .find_nodes_by_hashes(vfs_id, &[source_hash]) .unwrap(); assert_eq!(originals.len(), 1); assert!(state.edit.last_undo.is_none()); let _ = sibling_node_id; } #[test] fn undo_last_edit_with_no_entry_is_noop() { let (mut state, _dir) = make_state(); let prior_status = state.status.clone(); state.undo_last_edit(); assert!(state.edit.last_undo.is_none()); assert_eq!(state.status, prior_status); } }