max / audiofiles
9 files changed,
+1588 insertions,
-154 deletions
| @@ -52,22 +52,8 @@ impl AudioFilesApp { | |||
| 52 | 52 | ui.add_space(theme::space::SECTION); | |
| 53 | 53 | ||
| 54 | 54 | // Trial entry (primary path for first-time users) | |
| 55 | - | let trial_expired = matches!( | |
| 56 | - | self.trial_state, | |
| 57 | - | Some(ref t) if super::license::trial_days_remaining(t) <= 0 | |
| 58 | - | ); | |
| 59 | - | let trial_label = match self.trial_state { | |
| 60 | - | Some(ref trial) => { | |
| 61 | - | let days = super::license::trial_days_remaining(trial); | |
| 62 | - | if days > 0 { | |
| 63 | - | let unit = if days == 1 { "day" } else { "days" }; | |
| 64 | - | format!("Continue trial ({days} {unit} left)") | |
| 65 | - | } else { | |
| 66 | - | "Trial expired".to_string() | |
| 67 | - | } | |
| 68 | - | } | |
| 69 | - | None => "Start free trial — 30 days, no card".to_string(), | |
| 70 | - | }; | |
| 55 | + | let trial_expired = trial_is_expired(self.trial_state.as_ref()); | |
| 56 | + | let trial_label = trial_button_label(self.trial_state.as_ref()); | |
| 71 | 57 | let trial_btn = egui::Button::new(egui::RichText::new(trial_label).strong()); | |
| 72 | 58 | if ui.add_enabled(!trial_expired, trial_btn).clicked() { | |
| 73 | 59 | self.start_trial(); | |
| @@ -131,21 +117,19 @@ impl AudioFilesApp { | |||
| 131 | 117 | ui.colored_label(theme::danger(), err.to_string()); | |
| 132 | 118 | ui.add_space(theme::space::SM); | |
| 133 | 119 | // Per-class recovery affordance. | |
| 134 | - | match err { | |
| 135 | - | super::license::ActivationError::Network | |
| 136 | - | | super::license::ActivationError::Server(_) | |
| 137 | - | | super::license::ActivationError::Other(_) => { | |
| 120 | + | match recovery_affordance(&err) { | |
| 121 | + | RecoveryAffordance::Retry => { | |
| 138 | 122 | if ui.button("Try again").clicked() && !self.activating { | |
| 139 | 123 | self.start_activation(); | |
| 140 | 124 | } | |
| 141 | 125 | } | |
| 142 | - | super::license::ActivationError::InvalidKey => { | |
| 126 | + | RecoveryAffordance::GetNewKey => { | |
| 143 | 127 | ui.hyperlink_to( | |
| 144 | 128 | "Get a new license key", | |
| 145 | 129 | "https://makenot.work/store/audiofiles", | |
| 146 | 130 | ); | |
| 147 | 131 | } | |
| 148 | - | super::license::ActivationError::MachineLimit => { | |
| 132 | + | RecoveryAffordance::ContactSupport => { | |
| 149 | 133 | ui.hyperlink_to( | |
| 150 | 134 | "Contact support", | |
| 151 | 135 | "mailto:info@makenot.work?subject=License%20activation%20issue", | |
| @@ -257,3 +241,168 @@ pub(crate) fn mask_key(key: &str) -> String { | |||
| 257 | 241 | "***".to_string() | |
| 258 | 242 | } | |
| 259 | 243 | } | |
| 244 | + | ||
| 245 | + | /// The recovery action offered to the user for a given activation error class. | |
| 246 | + | #[derive(Debug, PartialEq, Eq)] | |
| 247 | + | pub(crate) enum RecoveryAffordance { | |
| 248 | + | /// Transient/server-side failure — offer a "Try again" button. | |
| 249 | + | Retry, | |
| 250 | + | /// The key itself is bad — link to buy a fresh one. | |
| 251 | + | GetNewKey, | |
| 252 | + | /// Key is bound elsewhere / hit its activation limit — link to support. | |
| 253 | + | ContactSupport, | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /// Map a classified activation error to the recovery affordance the UI shows. | |
| 257 | + | /// Keeps the network round-trip out of the decision: this is pure error-class | |
| 258 | + | /// routing, driven by the variant alone. | |
| 259 | + | pub(crate) fn recovery_affordance(err: &super::license::ActivationError) -> RecoveryAffordance { | |
| 260 | + | use super::license::ActivationError as E; | |
| 261 | + | match err { | |
| 262 | + | E::Network | E::Server(_) | E::Other(_) => RecoveryAffordance::Retry, | |
| 263 | + | E::InvalidKey => RecoveryAffordance::GetNewKey, | |
| 264 | + | E::MachineLimit => RecoveryAffordance::ContactSupport, | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Whether the "Continue trial" button should be disabled: a trial exists and | |
| 269 | + | /// has no days left. Absence of a trial is *not* expired (fresh start is offered). | |
| 270 | + | pub(crate) fn trial_is_expired(trial: Option<&super::license::TrialState>) -> bool { | |
| 271 | + | matches!(trial, Some(t) if super::license::trial_days_remaining(t) <= 0) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | /// The label for the primary trial button, reflecting current trial state. | |
| 275 | + | pub(crate) fn trial_button_label(trial: Option<&super::license::TrialState>) -> String { | |
| 276 | + | match trial { | |
| 277 | + | Some(trial) => { | |
| 278 | + | let days = super::license::trial_days_remaining(trial); | |
| 279 | + | if days > 0 { | |
| 280 | + | let unit = if days == 1 { "day" } else { "days" }; | |
| 281 | + | format!("Continue trial ({days} {unit} left)") | |
| 282 | + | } else { | |
| 283 | + | "Trial expired".to_string() | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | None => "Start free trial — 30 days, no card".to_string(), | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[cfg(test)] | |
| 291 | + | mod tests { | |
| 292 | + | use super::*; | |
| 293 | + | use super::super::license::{ActivationError, TrialState}; | |
| 294 | + | ||
| 295 | + | fn trial_starting_days_ago(days: i64) -> TrialState { | |
| 296 | + | let start = chrono::Utc::now() - chrono::Duration::days(days); | |
| 297 | + | TrialState { | |
| 298 | + | first_launch_date: start.to_rfc3339(), | |
| 299 | + | last_seen_date: None, | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // ── recovery_affordance: error-class -> recovery routing ── | |
| 304 | + | ||
| 305 | + | #[test] | |
| 306 | + | fn network_error_offers_retry() { | |
| 307 | + | assert_eq!(recovery_affordance(&ActivationError::Network), RecoveryAffordance::Retry); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | #[test] | |
| 311 | + | fn server_error_offers_retry() { | |
| 312 | + | assert_eq!(recovery_affordance(&ActivationError::Server(503)), RecoveryAffordance::Retry); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | #[test] | |
| 316 | + | fn other_error_offers_retry() { | |
| 317 | + | assert_eq!( | |
| 318 | + | recovery_affordance(&ActivationError::Other("parse blew up".to_string())), | |
| 319 | + | RecoveryAffordance::Retry | |
| 320 | + | ); | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[test] | |
| 324 | + | fn invalid_key_offers_new_key() { | |
| 325 | + | assert_eq!( | |
| 326 | + | recovery_affordance(&ActivationError::InvalidKey), | |
| 327 | + | RecoveryAffordance::GetNewKey | |
| 328 | + | ); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[test] | |
| 332 | + | fn machine_limit_offers_support() { | |
| 333 | + | assert_eq!( | |
| 334 | + | recovery_affordance(&ActivationError::MachineLimit), | |
| 335 | + | RecoveryAffordance::ContactSupport | |
| 336 | + | ); | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // ── trial_is_expired ── | |
| 340 | + | ||
| 341 | + | #[test] | |
| 342 | + | fn no_trial_is_not_expired() { | |
| 343 | + | // None means "never started" -> a fresh trial is offered, not expired. | |
| 344 | + | assert!(!trial_is_expired(None)); | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | #[test] | |
| 348 | + | fn fresh_trial_is_not_expired() { | |
| 349 | + | assert!(!trial_is_expired(Some(&trial_starting_days_ago(1)))); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[test] | |
| 353 | + | fn old_trial_is_expired() { | |
| 354 | + | assert!(trial_is_expired(Some(&trial_starting_days_ago(31)))); | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | #[test] | |
| 358 | + | fn trial_exactly_at_zero_is_expired() { | |
| 359 | + | // day 30 -> 0 remaining, and the guard is `<= 0`, so it counts as expired. | |
| 360 | + | assert!(trial_is_expired(Some(&trial_starting_days_ago(30)))); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | // ── trial_button_label ── | |
| 364 | + | ||
| 365 | + | #[test] | |
| 366 | + | fn label_no_trial_offers_start() { | |
| 367 | + | assert_eq!( | |
| 368 | + | trial_button_label(None), | |
| 369 | + | "Start free trial — 30 days, no card" | |
| 370 | + | ); | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | #[test] | |
| 374 | + | fn label_fresh_trial_shows_days_plural() { | |
| 375 | + | let label = trial_button_label(Some(&trial_starting_days_ago(0))); | |
| 376 | + | assert_eq!(label, "Continue trial (30 days left)"); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | #[test] | |
| 380 | + | fn label_one_day_left_is_singular() { | |
| 381 | + | // 29 days elapsed -> 1 day remaining -> singular "day". | |
| 382 | + | let label = trial_button_label(Some(&trial_starting_days_ago(29))); | |
| 383 | + | assert_eq!(label, "Continue trial (1 day left)"); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | #[test] | |
| 387 | + | fn label_expired_trial() { | |
| 388 | + | assert_eq!( | |
| 389 | + | trial_button_label(Some(&trial_starting_days_ago(45))), | |
| 390 | + | "Trial expired" | |
| 391 | + | ); | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | #[test] | |
| 395 | + | fn label_and_expiry_agree() { | |
| 396 | + | // The two helpers must never disagree about a given trial. | |
| 397 | + | for days in [0, 1, 15, 29, 30, 31, 60] { | |
| 398 | + | let t = trial_starting_days_ago(days); | |
| 399 | + | let expired = trial_is_expired(Some(&t)); | |
| 400 | + | let label = trial_button_label(Some(&t)); | |
| 401 | + | if expired { | |
| 402 | + | assert_eq!(label, "Trial expired", "day {days}"); | |
| 403 | + | } else { | |
| 404 | + | assert!(label.starts_with("Continue trial ("), "day {days}: {label}"); | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | } |
| @@ -25,6 +25,50 @@ pub enum MidiError { | |||
| 25 | 25 | Connect(#[from] ConnectError<MidiInput>), | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | + | /// The instrument action a raw MIDI message maps to. | |
| 29 | + | #[derive(Debug, PartialEq, Eq)] | |
| 30 | + | enum NoteAction { | |
| 31 | + | /// Note On with a non-zero velocity. | |
| 32 | + | On { note: u8, velocity: u8 }, | |
| 33 | + | /// Note Off (status 0x80, or 0x90 with velocity 0 — running-status note-off). | |
| 34 | + | Off { note: u8 }, | |
| 35 | + | /// Not a note message we act on (too short, or a non-note status byte). | |
| 36 | + | Ignore, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Parse a raw MIDI message into the note action it represents. | |
| 40 | + | /// | |
| 41 | + | /// Messages shorter than 3 bytes are ignored. Channel is masked off, so this | |
| 42 | + | /// matches note-on/note-off on any of the 16 channels. Velocity-0 note-on is | |
| 43 | + | /// treated as note-off per the MIDI running-status convention. | |
| 44 | + | fn parse_note_message(message: &[u8]) -> NoteAction { | |
| 45 | + | if message.len() < 3 { | |
| 46 | + | return NoteAction::Ignore; | |
| 47 | + | } | |
| 48 | + | let status = message[0] & 0xF0; | |
| 49 | + | let note = message[1]; | |
| 50 | + | let velocity = message[2]; | |
| 51 | + | match status { | |
| 52 | + | 0x90 if velocity > 0 => NoteAction::On { note, velocity }, | |
| 53 | + | 0x80 | 0x90 => NoteAction::Off { note }, | |
| 54 | + | _ => NoteAction::Ignore, | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Maximum note events buffered on the producer side. Bounds memory when the | |
| 59 | + | /// GUI stalls (minimized/occluded) and stops draining under a dense stream. | |
| 60 | + | const MAX_RECENT_NOTES: usize = 64; | |
| 61 | + | ||
| 62 | + | /// Push a note event, then enforce the producer-side cap so a stalled GUI can't | |
| 63 | + | /// let the buffer grow unbounded. Keeps only the most recent [`MAX_RECENT_NOTES`]. | |
| 64 | + | fn push_note_event_capped(recent: &mut Vec<MidiNoteEvent>, event: MidiNoteEvent) { | |
| 65 | + | recent.push(event); | |
| 66 | + | let len = recent.len(); | |
| 67 | + | if len > MAX_RECENT_NOTES { | |
| 68 | + | recent.drain(..len - MAX_RECENT_NOTES); | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 28 | 72 | /// List available MIDI input port names. | |
| 29 | 73 | #[instrument(skip_all)] | |
| 30 | 74 | pub fn list_input_ports() -> Vec<String> { | |
| @@ -57,37 +101,21 @@ pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnec | |||
| 57 | 101 | port, | |
| 58 | 102 | "audiofiles-midi-in", | |
| 59 | 103 | move |_timestamp, message, _| { | |
| 60 | - | if message.len() < 3 { | |
| 61 | - | return; | |
| 62 | - | } | |
| 63 | - | let status = message[0] & 0xF0; | |
| 64 | - | let note = message[1]; | |
| 65 | - | let velocity = message[2]; | |
| 66 | - | ||
| 67 | - | match status { | |
| 68 | - | 0x90 if velocity > 0 => { | |
| 69 | - | // Note On | |
| 104 | + | match parse_note_message(message) { | |
| 105 | + | NoteAction::On { note, velocity } => { | |
| 70 | 106 | shared_cb.instrument.lock().note_on(note, velocity); | |
| 71 | - | let mut recent = shared_cb.midi_recent_notes.lock(); | |
| 72 | - | recent.push(MidiNoteEvent { | |
| 107 | + | let event = MidiNoteEvent { | |
| 73 | 108 | note, | |
| 74 | 109 | velocity, | |
| 75 | 110 | note_name: note_name(note), | |
| 76 | 111 | timestamp: Instant::now(), | |
| 77 | - | }); | |
| 78 | - | // Cap at the producer so a stalled GUI (minimized/occluded, | |
| 79 | - | // not draining) can't grow this unbounded under a dense MIDI | |
| 80 | - | // stream. The GUI keeps only the last 8 on drain. | |
| 81 | - | let len = recent.len(); | |
| 82 | - | if len > 64 { | |
| 83 | - | recent.drain(..len - 64); | |
| 84 | - | } | |
| 112 | + | }; | |
| 113 | + | push_note_event_capped(&mut shared_cb.midi_recent_notes.lock(), event); | |
| 85 | 114 | } | |
| 86 | - | 0x80 | 0x90 => { | |
| 87 | - | // Note Off (0x80 or 0x90 with velocity 0) | |
| 115 | + | NoteAction::Off { note } => { | |
| 88 | 116 | shared_cb.instrument.lock().note_off(note); | |
| 89 | 117 | } | |
| 90 | - | _ => {} | |
| 118 | + | NoteAction::Ignore => {} | |
| 91 | 119 | } | |
| 92 | 120 | }, | |
| 93 | 121 | (), | |
| @@ -95,3 +123,176 @@ pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnec | |||
| 95 | 123 | ||
| 96 | 124 | Ok(MidiConnection { _conn: conn }) | |
| 97 | 125 | } | |
| 126 | + | ||
| 127 | + | #[cfg(test)] | |
| 128 | + | mod tests { | |
| 129 | + | use super::*; | |
| 130 | + | ||
| 131 | + | fn event(note: u8) -> MidiNoteEvent { | |
| 132 | + | MidiNoteEvent { | |
| 133 | + | note, | |
| 134 | + | velocity: 100, | |
| 135 | + | note_name: note_name(note), | |
| 136 | + | timestamp: Instant::now(), | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // ── parse_note_message ── | |
| 141 | + | ||
| 142 | + | #[test] | |
| 143 | + | fn parse_note_on() { | |
| 144 | + | assert_eq!( | |
| 145 | + | parse_note_message(&[0x90, 60, 100]), | |
| 146 | + | NoteAction::On { note: 60, velocity: 100 } | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[test] | |
| 151 | + | fn parse_note_off_status() { | |
| 152 | + | assert_eq!( | |
| 153 | + | parse_note_message(&[0x80, 60, 64]), | |
| 154 | + | NoteAction::Off { note: 60 } | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[test] | |
| 159 | + | fn parse_note_on_velocity_zero_is_note_off() { | |
| 160 | + | // Running-status convention: 0x90 with velocity 0 == note off. | |
| 161 | + | assert_eq!( | |
| 162 | + | parse_note_message(&[0x90, 60, 0]), | |
| 163 | + | NoteAction::Off { note: 60 } | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn parse_ignores_channel_bits() { | |
| 169 | + | // Low nibble is the channel; note-on on channel 15 (0x9F) still parses. | |
| 170 | + | assert_eq!( | |
| 171 | + | parse_note_message(&[0x9F, 72, 1]), | |
| 172 | + | NoteAction::On { note: 72, velocity: 1 } | |
| 173 | + | ); | |
| 174 | + | // Note-off on channel 7 (0x87). | |
| 175 | + | assert_eq!( | |
| 176 | + | parse_note_message(&[0x87, 45, 0]), | |
| 177 | + | NoteAction::Off { note: 45 } | |
| 178 | + | ); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | #[test] | |
| 182 | + | fn parse_ignores_too_short() { | |
| 183 | + | assert_eq!(parse_note_message(&[]), NoteAction::Ignore); | |
| 184 | + | assert_eq!(parse_note_message(&[0x90]), NoteAction::Ignore); | |
| 185 | + | assert_eq!(parse_note_message(&[0x90, 60]), NoteAction::Ignore); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | #[test] | |
| 189 | + | fn parse_ignores_non_note_status() { | |
| 190 | + | // 0xB0 = control change, 0xE0 = pitch bend — neither is a note message. | |
| 191 | + | assert_eq!(parse_note_message(&[0xB0, 7, 127]), NoteAction::Ignore); | |
| 192 | + | assert_eq!(parse_note_message(&[0xE0, 0, 64]), NoteAction::Ignore); | |
| 193 | + | // 0xA0 = polyphonic aftertouch — shares note/velocity shape but ignored. | |
| 194 | + | assert_eq!(parse_note_message(&[0xA0, 60, 40]), NoteAction::Ignore); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | #[test] | |
| 198 | + | fn parse_extra_bytes_ignored() { | |
| 199 | + | // A message longer than 3 bytes still parses off the first three. | |
| 200 | + | assert_eq!( | |
| 201 | + | parse_note_message(&[0x90, 64, 80, 0xFF, 0xFF]), | |
| 202 | + | NoteAction::On { note: 64, velocity: 80 } | |
| 203 | + | ); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn parse_note_boundaries() { | |
| 208 | + | // Full note range 0..=127. | |
| 209 | + | assert_eq!( | |
| 210 | + | parse_note_message(&[0x90, 0, 1]), | |
| 211 | + | NoteAction::On { note: 0, velocity: 1 } | |
| 212 | + | ); | |
| 213 | + | assert_eq!( | |
| 214 | + | parse_note_message(&[0x90, 127, 127]), | |
| 215 | + | NoteAction::On { note: 127, velocity: 127 } | |
| 216 | + | ); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // ── push_note_event_capped ── | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn push_below_cap_grows() { | |
| 223 | + | let mut recent = Vec::new(); | |
| 224 | + | for n in 0..10 { | |
| 225 | + | push_note_event_capped(&mut recent, event(n)); | |
| 226 | + | } | |
| 227 | + | assert_eq!(recent.len(), 10); | |
| 228 | + | assert_eq!(recent.first().unwrap().note, 0); | |
| 229 | + | assert_eq!(recent.last().unwrap().note, 9); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn push_at_cap_does_not_drain() { | |
| 234 | + | let mut recent = Vec::new(); | |
| 235 | + | for n in 0..MAX_RECENT_NOTES as u8 { | |
| 236 | + | push_note_event_capped(&mut recent, event(n)); | |
| 237 | + | } | |
| 238 | + | assert_eq!(recent.len(), MAX_RECENT_NOTES); | |
| 239 | + | assert_eq!(recent.first().unwrap().note, 0); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[test] | |
| 243 | + | fn push_over_cap_stays_bounded_and_keeps_newest() { | |
| 244 | + | let mut recent = Vec::new(); | |
| 245 | + | // Feed far more than the cap; length must never exceed it and the | |
| 246 | + | // retained window must be the most recent MAX_RECENT_NOTES events. | |
| 247 | + | for n in 0..200u32 { | |
| 248 | + | push_note_event_capped(&mut recent, event((n % 128) as u8)); | |
| 249 | + | assert!(recent.len() <= MAX_RECENT_NOTES, "buffer grew past cap"); | |
| 250 | + | } | |
| 251 | + | assert_eq!(recent.len(), MAX_RECENT_NOTES); | |
| 252 | + | // Last event pushed was n=199 -> note 199 % 128 = 71. | |
| 253 | + | assert_eq!(recent.last().unwrap().note, 71); | |
| 254 | + | // Oldest retained is n = 200 - 64 = 136 -> 136 % 128 = 8. | |
| 255 | + | assert_eq!(recent.first().unwrap().note, 8); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn push_one_over_cap_drops_exactly_one() { | |
| 260 | + | let mut recent = Vec::new(); | |
| 261 | + | for n in 0..=(MAX_RECENT_NOTES as u8) { | |
| 262 | + | push_note_event_capped(&mut recent, event(n)); | |
| 263 | + | } | |
| 264 | + | // Pushed 65 events (0..=64); the oldest (note 0) is dropped. | |
| 265 | + | assert_eq!(recent.len(), MAX_RECENT_NOTES); | |
| 266 | + | assert_eq!(recent.first().unwrap().note, 1); | |
| 267 | + | assert_eq!(recent.last().unwrap().note, MAX_RECENT_NOTES as u8); | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // ── list_input_ports ── | |
| 271 | + | ||
| 272 | + | #[test] | |
| 273 | + | fn list_input_ports_never_panics() { | |
| 274 | + | // No MIDI hardware assumptions: must return a Vec (possibly empty on CI) | |
| 275 | + | // rather than panic or error out. | |
| 276 | + | let _ports: Vec<String> = list_input_ports(); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // ── connect error typing ── | |
| 280 | + | ||
| 281 | + | #[test] | |
| 282 | + | fn connect_out_of_range_is_typed_error() { | |
| 283 | + | // A wildly out-of-range port index yields a typed PortOutOfRange (never a | |
| 284 | + | // panic). On a headless box with zero ports this also covers the empty case. | |
| 285 | + | let shared = std::sync::Arc::new(SharedState::default()); | |
| 286 | + | match connect(9999, shared) { | |
| 287 | + | Err(MidiError::PortOutOfRange { idx, count }) => { | |
| 288 | + | assert_eq!(idx, 9999); | |
| 289 | + | assert!(idx >= count); | |
| 290 | + | } | |
| 291 | + | Err(MidiError::Init(_)) => { | |
| 292 | + | // Acceptable on a host with no MIDI subsystem at all. | |
| 293 | + | } | |
| 294 | + | Err(MidiError::Connect(_)) => panic!("unexpected connect error for bad index"), | |
| 295 | + | Ok(_) => panic!("port index 9999 should never connect"), | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } |
| @@ -2103,3 +2103,638 @@ mod misc { | |||
| 2103 | 2103 | assert!(state.status.contains("Not enough")); | |
| 2104 | 2104 | } | |
| 2105 | 2105 | } | |
| 2106 | + | ||
| 2107 | + | // ---- Sample Forge (chop / conform / batch) ---- | |
| 2108 | + | ||
| 2109 | + | mod forge { | |
| 2110 | + | use super::*; | |
| 2111 | + | use audiofiles_core::forge::ChopMethod; | |
| 2112 | + | ||
| 2113 | + | /// Write a real mono 16-bit 44.1 kHz PCM WAV with `frames` samples of a loud | |
| 2114 | + | /// square-ish signal, so the forge worker has genuine audio to decode/slice. | |
| 2115 | + | fn write_wav(path: &std::path::Path, frames: usize) { | |
| 2116 | + | let data_len = frames * 2; // mono, 16-bit | |
| 2117 | + | let mut buf: Vec<u8> = Vec::with_capacity(44 + data_len); | |
| 2118 | + | buf.extend_from_slice(b"RIFF"); | |
| 2119 | + | buf.extend_from_slice(&((36 + data_len) as u32).to_le_bytes()); | |
| 2120 | + | buf.extend_from_slice(b"WAVE"); | |
| 2121 | + | buf.extend_from_slice(b"fmt "); | |
| 2122 | + | buf.extend_from_slice(&16u32.to_le_bytes()); | |
| 2123 | + | buf.extend_from_slice(&1u16.to_le_bytes()); // PCM | |
| 2124 | + | buf.extend_from_slice(&1u16.to_le_bytes()); // mono | |
| 2125 | + | buf.extend_from_slice(&44100u32.to_le_bytes()); | |
| 2126 | + | buf.extend_from_slice(&(44100u32 * 2).to_le_bytes()); // byte rate | |
| 2127 | + | buf.extend_from_slice(&2u16.to_le_bytes()); // block align | |
| 2128 | + | buf.extend_from_slice(&16u16.to_le_bytes()); // bits | |
| 2129 | + | buf.extend_from_slice(b"data"); | |
| 2130 | + | buf.extend_from_slice(&(data_len as u32).to_le_bytes()); | |
| 2131 | + | for i in 0..frames { | |
| 2132 | + | // Alternating full-scale signal: non-silent, gives clear energy. | |
| 2133 | + | let s: i16 = if (i / 64) % 2 == 0 { 20000 } else { -20000 }; | |
| 2134 | + | buf.extend_from_slice(&s.to_le_bytes()); | |
| 2135 | + | } | |
| 2136 | + | std::fs::write(path, buf).unwrap(); | |
| 2137 | + | } | |
| 2138 | + | ||
| 2139 | + | /// Import a real 1-second WAV, link it into the current dir, and open the | |
| 2140 | + | /// forge window on it. Returns the sample hash. | |
| 2141 | + | fn seed_forge_sample(state: &mut BrowserState) -> String { | |
| 2142 | + | let path = state.data_dir.join("forge_src.wav"); | |
| 2143 | + | write_wav(&path, 44_100); | |
| 2144 | + | let hash = state.backend.import_file(&path).unwrap(); | |
| 2145 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 2146 | + | state.backend.create_sample_link(vfs_id, None, "loop.wav", &hash).unwrap(); | |
| 2147 | + | state.refresh_contents(); | |
| 2148 | + | state.open_forge_window(&hash); | |
| 2149 | + | hash | |
| 2150 | + | } | |
| 2151 | + | ||
| 2152 | + | /// Drive a dispatched forge worker job to completion (chop/conform run | |
| 2153 | + | /// off-thread; `poll_workers` applies the result and clears `forge.busy`). | |
| 2154 | + | fn await_forge(state: &mut BrowserState) { | |
| 2155 | + | for _ in 0..4000 { | |
| 2156 | + | if !state.forge.busy { | |
| 2157 | + | return; | |
| 2158 | + | } | |
| 2159 | + | state.poll_workers(); | |
| 2160 | + | std::thread::sleep(std::time::Duration::from_millis(2)); | |
| 2161 | + | } | |
| 2162 | + | panic!("forge job did not complete"); | |
| 2163 | + | } | |
| 2164 | + | ||
| 2165 | + | // --- Chop-method construction (pure) --- | |
| 2166 | + | ||
| 2167 | + | #[test] | |
| 2168 | + | fn chop_method_transient_from_ui() { | |
| 2169 | + | let (mut state, _dir) = make_state(); | |
| 2170 | + | state.forge.chop_mode = ChopMode::Transient; | |
| 2171 | + | state.forge.sensitivity = 0.7; | |
| 2172 | + | match state.forge_chop_method() { | |
| 2173 | + | ChopMethod::Transient { sensitivity } => assert_eq!(sensitivity, 0.7), | |
| 2174 | + | other => panic!("expected Transient, got {other:?}"), | |
| 2175 | + | } | |
| 2176 | + | } | |
| 2177 | + | ||
| 2178 | + | #[test] | |
| 2179 | + | fn chop_method_equal_clamps_divisions_to_at_least_one() { | |
| 2180 | + | let (mut state, _dir) = make_state(); | |
| 2181 | + | state.forge.chop_mode = ChopMode::Equal; | |
| 2182 | + | state.forge.divisions = 0; // UI could hand us 0; must clamp to 1 | |
| 2183 | + | match state.forge_chop_method() { | |
| 2184 | + | ChopMethod::EqualDivisions(n) => assert_eq!(n, 1), | |
| 2185 | + | other => panic!("expected EqualDivisions, got {other:?}"), | |
| 2186 | + | } | |
| 2187 | + | } | |
| 2188 | + | ||
| 2189 | + | #[test] | |
| 2190 | + | fn chop_method_bpm_clamps_subdivisions() { | |
| 2191 | + | let (mut state, _dir) = make_state(); | |
| 2192 | + | state.forge.chop_mode = ChopMode::Bpm; | |
| 2193 | + | state.forge.bpm = 128.0; | |
| 2194 | + | state.forge.subdivisions = 0; | |
| 2195 | + | match state.forge_chop_method() { | |
| 2196 | + | ChopMethod::BpmGrid { bpm, subdivisions_per_beat } => { | |
| 2197 | + | assert_eq!(bpm, 128.0); | |
| 2198 | + | assert_eq!(subdivisions_per_beat, 1); | |
| 2199 | + | } | |
| 2200 | + | other => panic!("expected BpmGrid, got {other:?}"), | |
| 2201 | + | } | |
| 2202 | + | } | |
| 2203 | + | ||
| 2204 | + | // --- Window open/close state --- | |
| 2205 | + | ||
| 2206 | + | #[test] | |
| 2207 | + | fn open_forge_window_seeds_and_shows() { | |
| 2208 | + | let (mut state, _dir) = make_state(); | |
| 2209 | + | let hash = seed_forge_sample(&mut state); | |
| 2210 | + | assert!(state.forge.show_window); | |
| 2211 | + | assert_eq!(state.forge.hash.as_deref(), Some(hash.as_str())); | |
| 2212 | + | assert!(!state.forge.busy); | |
| 2213 | + | // Devices are cached for the conform picker on open. | |
| 2214 | + | assert!( | |
| 2215 | + | !state.forge.devices.is_empty(), | |
| 2216 | + | "bundled device profiles should populate the conform picker" | |
| 2217 | + | ); | |
| 2218 | + | } | |
| 2219 | + | ||
| 2220 | + | #[test] | |
| 2221 | + | fn open_forge_window_seeds_bpm_from_analysis() { | |
| 2222 | + | let (mut state, _dir) = make_state(); | |
| 2223 | + | // Insert a sample carrying a BPM in audio_analysis, then open forge on it. | |
| 2224 | + | insert_fake_sample(&state, "beat01"); | |
| 2225 | + | let db = audiofiles_core::db::Database::open(state.data_dir.join("audiofiles.db")).unwrap(); | |
| 2226 | + | db.conn() | |
| 2227 | + | .execute( | |
| 2228 | + | "INSERT INTO audio_analysis (hash, bpm, duration, sample_rate, channels, analyzed_at) | |
| 2229 | + | VALUES (?1, 140.0, 2.0, 44100, 1, 0)", | |
| 2230 | + | rusqlite::params!["beat01"], | |
| 2231 | + | ) | |
| 2232 | + | .unwrap(); | |
| 2233 | + | state.forge.bpm = 0.0; | |
| 2234 | + | state.open_forge_window("beat01"); | |
| 2235 | + | assert_eq!(state.forge.bpm, 140.0, "BPM should seed from analysis"); | |
| 2236 | + | } | |
| 2237 | + | ||
| 2238 | + | #[test] | |
| 2239 | + | fn close_forge_window_resets() { | |
| 2240 | + | let (mut state, _dir) = make_state(); | |
| 2241 | + | seed_forge_sample(&mut state); | |
| 2242 | + | state.forge.slice_marks = vec![0.25, 0.5, 0.75]; | |
| 2243 | + | state.close_forge_window(); | |
| 2244 | + | assert!(!state.forge.show_window); | |
| 2245 | + | assert!(state.forge.hash.is_none()); | |
| 2246 | + | assert!(state.forge.slice_marks.is_empty()); | |
| 2247 | + | assert!(state.forge.waveform.is_none()); | |
| 2248 | + | } | |
| 2249 | + | ||
| 2250 | + | // --- Busy gating --- | |
| 2251 | + | ||
| 2252 | + | #[test] | |
| 2253 | + | fn apply_chop_is_gated_while_busy() { | |
| 2254 | + | let (mut state, _dir) = make_state(); | |
| 2255 | + | seed_forge_sample(&mut state); | |
| 2256 | + | state.forge.busy = true; | |
| 2257 | + | state.status = "sentinel".to_string(); | |
| 2258 | + | state.forge_apply_chop(); | |
| 2259 | + | // Gated: no new work dispatched, status untouched. | |
| 2260 | + | assert_eq!(state.status, "sentinel"); | |
| 2261 | + | assert!(state.forge.busy); | |
| 2262 | + | } | |
| 2263 | + | ||
| 2264 | + | #[test] | |
| 2265 | + | fn conform_is_gated_while_busy() { | |
| 2266 | + | let (mut state, _dir) = make_state(); | |
| 2267 | + | seed_forge_sample(&mut state); | |
| 2268 | + | state.forge.busy = true; | |
| 2269 | + | state.status = "sentinel".to_string(); | |
| 2270 | + | state.forge_conform_device("SP-404 MKII"); | |
| 2271 | + | assert_eq!(state.status, "sentinel"); | |
| 2272 | + | } | |
| 2273 | + | ||
| 2274 | + | #[test] | |
| 2275 | + | fn conform_unknown_device_reports_not_found() { | |
| 2276 | + | let (mut state, _dir) = make_state(); | |
| 2277 | + | seed_forge_sample(&mut state); | |
| 2278 | + | state.forge_conform_device("No Such Device 9000"); | |
| 2279 | + | assert!(!state.forge.busy); | |
| 2280 | + | assert!( | |
| 2281 | + | state.status.contains("not found"), | |
| 2282 | + | "status was: {}", | |
| 2283 | + | state.status | |
| 2284 | + | ); | |
| 2285 | + | } | |
| 2286 | + | ||
| 2287 | + | // --- Overshoot toggle persistence --- | |
| 2288 | + | ||
| 2289 | + | #[test] | |
| 2290 | + | fn toggle_auto_trim_overshoot_persists_across_reload() { | |
| 2291 | + | let dir = tempfile::TempDir::new().unwrap(); | |
| 2292 | + | { | |
| 2293 | + | let shared = Arc::new(SharedState::new()); | |
| 2294 | + | let mut state = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap(); | |
| 2295 | + | assert!(!state.forge_auto_trim_overshoot, "default is off"); | |
| 2296 | + | state.toggle_forge_auto_trim_overshoot(); | |
| 2297 | + | assert!(state.forge_auto_trim_overshoot); | |
| 2298 | + | } | |
| 2299 | + | // Reopen the same vault: the toggle is loaded from user-config. | |
| 2300 | + | let shared = Arc::new(SharedState::new()); | |
| 2301 | + | let reloaded = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap(); | |
| 2302 | + | assert!( | |
| 2303 | + | reloaded.forge_auto_trim_overshoot, | |
| 2304 | + | "overshoot toggle should persist across reload" | |
| 2305 | + | ); | |
| 2306 | + | } | |
| 2307 | + | ||
| 2308 | + | // --- Worker happy-paths (real decode/slice/encode) --- | |
| 2309 | + | ||
| 2310 | + | #[test] | |
| 2311 | + | fn apply_chop_creates_slice_folder() { | |
| 2312 | + | let (mut state, _dir) = make_state(); | |
| 2313 | + | seed_forge_sample(&mut state); | |
| 2314 | + | state.forge.chop_mode = ChopMode::Equal; | |
| 2315 | + | state.forge.divisions = 4; | |
| 2316 | + | let before = state.contents.len(); | |
| 2317 | + | ||
| 2318 | + | state.forge_apply_chop(); | |
| 2319 | + | assert!(state.forge.busy, "chop should dispatch and mark busy"); | |
| 2320 | + | await_forge(&mut state); | |
| 2321 | + | ||
| 2322 | + | assert!(!state.forge.busy); | |
| 2323 | + | assert!( | |
| 2324 | + | state.status.contains("Chopped into 4 slices"), | |
| 2325 | + | "status was: {}", | |
| 2326 | + | state.status | |
| 2327 | + | ); | |
| 2328 | + | // A new slices folder appeared in the current directory. | |
| 2329 | + | assert!( | |
| 2330 | + | state.contents.len() > before, | |
| 2331 | + | "expected a new slice folder in the current dir" | |
| 2332 | + | ); | |
| 2333 | + | } | |
| 2334 | + | ||
| 2335 | + | #[test] | |
| 2336 | + | fn conform_creates_sibling_sample() { | |
| 2337 | + | let (mut state, _dir) = make_state(); | |
| 2338 | + | seed_forge_sample(&mut state); | |
| 2339 | + | let before = state.contents.len(); | |
| 2340 | + | ||
| 2341 | + | state.forge_conform_device("SP-404 MKII"); | |
| 2342 | + | assert!(state.forge.busy, "conform should dispatch and mark busy"); | |
| 2343 | + | await_forge(&mut state); | |
| 2344 | + | ||
| 2345 | + | assert!(!state.forge.busy); | |
| 2346 | + | assert!( | |
| 2347 | + | state.status.contains("Conformed for SP-404 MKII"), | |
| 2348 | + | "status was: {}", | |
| 2349 | + | state.status | |
| 2350 | + | ); | |
| 2351 | + | assert!( | |
| 2352 | + | state.contents.len() > before, | |
| 2353 | + | "expected a conformed sibling sample in the current dir" | |
| 2354 | + | ); | |
| 2355 | + | } | |
| 2356 | + | ||
| 2357 | + | // --- Chop-preview overlay (off-thread) --- | |
| 2358 | + | ||
| 2359 | + | #[test] | |
| 2360 | + | fn preview_slices_populates_marks() { | |
| 2361 | + | let (mut state, _dir) = make_state(); | |
| 2362 | + | seed_forge_sample(&mut state); | |
| 2363 | + | state.forge.chop_mode = ChopMode::Equal; | |
| 2364 | + | state.forge.divisions = 8; | |
| 2365 | + | ||
| 2366 | + | state.forge_preview_slices(); | |
| 2367 | + | assert!(state.forge.busy); | |
| 2368 | + | await_forge(&mut state); | |
| 2369 | + | ||
| 2370 | + | assert!(!state.forge.busy); | |
| 2371 | + | assert!( | |
| 2372 | + | !state.forge.slice_marks.is_empty(), | |
| 2373 | + | "preview should populate slice-boundary marks" | |
| 2374 | + | ); | |
| 2375 | + | } | |
| 2376 | + | } | |
| 2377 | + | ||
| 2378 | + | // ---- Async worker happy-paths (import → analysis → export progression) ---- | |
| 2379 | + | ||
| 2380 | + | mod async_workers { | |
| 2381 | + | use super::*; | |
| 2382 | + | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 2383 | + | ||
| 2384 | + | /// Write a real mono 16-bit 44.1 kHz PCM WAV with `frames` samples of a loud | |
| 2385 | + | /// alternating signal, so the analysis/export workers have genuine audio to | |
| 2386 | + | /// decode. A DB-only fake sample has no blob on disk and would fail decode. | |
| 2387 | + | fn write_wav(path: &std::path::Path, frames: usize) { | |
| 2388 | + | let data_len = frames * 2; // mono, 16-bit | |
| 2389 | + | let mut buf: Vec<u8> = Vec::with_capacity(44 + data_len); | |
| 2390 | + | buf.extend_from_slice(b"RIFF"); | |
| 2391 | + | buf.extend_from_slice(&((36 + data_len) as u32).to_le_bytes()); | |
| 2392 | + | buf.extend_from_slice(b"WAVE"); | |
| 2393 | + | buf.extend_from_slice(b"fmt "); | |
| 2394 | + | buf.extend_from_slice(&16u32.to_le_bytes()); | |
| 2395 | + | buf.extend_from_slice(&1u16.to_le_bytes()); // PCM | |
| 2396 | + | buf.extend_from_slice(&1u16.to_le_bytes()); // mono | |
| 2397 | + | buf.extend_from_slice(&44100u32.to_le_bytes()); | |
| 2398 | + | buf.extend_from_slice(&(44100u32 * 2).to_le_bytes()); // byte rate | |
| 2399 | + | buf.extend_from_slice(&2u16.to_le_bytes()); // block align | |
| 2400 | + | buf.extend_from_slice(&16u16.to_le_bytes()); // bits | |
| 2401 | + | buf.extend_from_slice(b"data"); | |
| 2402 | + | buf.extend_from_slice(&(data_len as u32).to_le_bytes()); | |
| 2403 | + | for i in 0..frames { | |
| 2404 | + | let s: i16 = if (i / 64) % 2 == 0 { 20000 } else { -20000 }; | |
| 2405 | + | buf.extend_from_slice(&s.to_le_bytes()); | |
| 2406 | + | } | |
| 2407 | + | std::fs::write(path, buf).unwrap(); | |
| 2408 | + | } | |
| 2409 | + | ||
| 2410 | + | /// Import a real WAV blob into the content-addressed store, link it into the | |
| 2411 | + | /// current dir under `name`, and refresh. Returns the sample hash. | |
| 2412 | + | fn import_real_wav(state: &mut BrowserState, file: &str, name: &str, frames: usize) -> String { | |
| 2413 | + | let path = state.data_dir.join(file); | |
| 2414 | + | write_wav(&path, frames); | |
| 2415 | + | let hash = state.backend.import_file(&path).unwrap(); | |
| 2416 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 2417 | + | state | |
| 2418 | + | .backend | |
| 2419 | + | .create_sample_link(vfs_id, state.current_dir, name, &hash) | |
| 2420 | + | .unwrap(); | |
| 2421 | + | state.refresh_contents(); | |
| 2422 | + | hash | |
| 2423 | + | } | |
| 2424 | + | ||
| 2425 | + | /// Drive a dispatched analysis batch to completion. The batch runs off-thread; | |
| 2426 | + | /// `poll_workers` drains progress events and finally transitions `import_mode` | |
| 2427 | + | /// out of `Analyzing` (to ReviewSuggestions / None / ReviewErrors). | |
| 2428 | + | fn await_analysis(state: &mut BrowserState) { | |
| 2429 | + | for _ in 0..4000 { | |
| 2430 | + | if !matches!(state.import_mode, ImportMode::Analyzing { .. }) { | |
| 2431 | + | return; | |
| 2432 | + | } | |
| 2433 | + | state.poll_workers(); | |
| 2434 | + | std::thread::sleep(std::time::Duration::from_millis(2)); | |
| 2435 | + | } | |
| 2436 | + | panic!("analysis batch did not complete"); | |
| 2437 | + | } | |
| 2438 | + | ||
| 2439 | + | /// Drive a dispatched export to completion (transitions out of `Exporting`). | |
| 2440 | + | fn await_export(state: &mut BrowserState) { | |
| 2441 | + | for _ in 0..4000 { | |
| 2442 | + | if !matches!(state.import_mode, ImportMode::Exporting { .. }) { | |
| 2443 | + | return; | |
| 2444 | + | } | |
| 2445 | + | state.poll_workers(); | |
| 2446 | + | std::thread::sleep(std::time::Duration::from_millis(2)); | |
| 2447 | + | } | |
| 2448 | + | panic!("export did not complete"); | |
| 2449 | + | } | |
| 2450 | + | ||
| 2451 | + | /// Recursively count regular files under `dir`. | |
| 2452 | + | fn count_files(dir: &std::path::Path) -> usize { | |
| 2453 | + | let mut n = 0; | |
| 2454 | + | if let Ok(rd) = std::fs::read_dir(dir) { | |
| 2455 | + | for entry in rd.flatten() { | |
| 2456 | + | let p = entry.path(); | |
| 2457 | + | if p.is_dir() { | |
| 2458 | + | n += count_files(&p); | |
| 2459 | + | } else { | |
| 2460 | + | n += 1; | |
| 2461 | + | } | |
| 2462 | + | } | |
| 2463 | + | } | |
| 2464 | + | n | |
| 2465 | + | } | |
| 2466 | + | ||
| 2467 | + | // --- Analysis worker happy-path --- | |
| 2468 | + | ||
| 2469 | + | #[test] | |
| 2470 | + | fn analysis_batch_runs_to_completion_and_persists() { | |
| 2471 | + | let (mut state, _dir) = make_state(); | |
| 2472 | + | let h1 = import_real_wav(&mut state, "a1.wav", "one.wav", 44_100); | |
| 2473 | + | let h2 = import_real_wav(&mut state, "a2.wav", "two.wav", 40_000); | |
| 2474 | + | assert_ne!(h1, h2, "distinct content must yield distinct hashes"); | |
| 2475 | + | ||
| 2476 | + | state.run_analysis( | |
| 2477 | + | vec![(h1.clone(), "wav".to_string()), (h2.clone(), "wav".to_string())], | |
| 2478 | + | AnalysisConfig::default(), | |
| 2479 | + | ); | |
| 2480 | + | // Dispatch marks us in the Analyzing screen. | |
| 2481 | + | assert!(matches!(state.import_mode, ImportMode::Analyzing { .. })); | |
| 2482 | + | ||
| 2483 | + | await_analysis(&mut state); | |
| 2484 | + | ||
| 2485 | + | // No longer analyzing: with real results the batch lands in the review queue. | |
| 2486 | + | assert!( | |
| 2487 | + | matches!(state.import_mode, ImportMode::ReviewSuggestions { .. }), | |
| 2488 | + | "expected ReviewSuggestions after a batch with results, got {:?}", | |
| 2489 | + | std::mem::discriminant(&state.import_mode) | |
| 2490 | + | ); | |
| 2491 | + | assert!(state.status.contains("Analyzed"), "status was: {}", state.status); | |
| 2492 | + | ||
| 2493 | + | // Analysis rows were flushed to the DB for both samples. | |
| 2494 | + | let a1 = state.backend.get_analysis(&h1).unwrap(); | |
| 2495 | + | let a2 = state.backend.get_analysis(&h2).unwrap(); | |
| 2496 | + | assert!(a1.is_some(), "analysis row should exist for h1"); | |
| 2497 | + | assert!(a2.is_some(), "analysis row should exist for h2"); | |
| 2498 | + | let a1 = a1.unwrap(); | |
| 2499 | + | assert_eq!(a1.sample_rate, 44100); | |
| 2500 | + | assert!(a1.duration > 0.0, "duration should be measured"); | |
| 2501 | + | ||
| 2502 | + | // Progress bookkeeping cleared; no stale analysis-save buffer left behind. | |
| 2503 | + | assert!(state.analysis_save_buffer.is_empty(), "save buffer should be flushed"); | |
| 2504 | + | } | |
| 2505 | + | ||
| 2506 | + | #[test] | |
| 2507 | + | fn analysis_single_sample_populates_review_item() { | |
| 2508 | + | let (mut state, _dir) = make_state(); | |
| 2509 | + | let h1 = import_real_wav(&mut state, "solo.wav", "solo.wav", 22_050); | |
| 2510 | + | ||
| 2511 | + | state.run_analysis(vec![(h1.clone(), "wav".to_string())], AnalysisConfig::default()); | |
| 2512 | + | await_analysis(&mut state); | |
| 2513 | + | ||
| 2514 | + | match &state.import_mode { | |
| 2515 | + | ImportMode::ReviewSuggestions { items, .. } => { | |
| 2516 | + | assert_eq!(items.len(), 1, "one analyzed sample -> one review item"); | |
| 2517 | + | assert_eq!(items[0].result.hash, h1); | |
| 2518 | + | } | |
| 2519 | + | other => panic!("expected ReviewSuggestions, got {:?}", std::mem::discriminant(other)), | |
| 2520 | + | } | |
| 2521 | + | } | |
| 2522 | + | ||
| 2523 | + | // --- Export worker happy-path --- | |
| 2524 | + | ||
| 2525 | + | #[test] | |
| 2526 | + | fn export_worker_writes_files_and_completes() { | |
| 2527 | + | let (mut state, _dir) = make_state(); | |
| 2528 | + | import_real_wav(&mut state, "e1.wav", "kick.wav", 4_410); | |
| 2529 | + | import_real_wav(&mut state, "e2.wav", "snare.wav", 5_200); | |
| 2530 | + | ||
| 2531 | + | // Build the export item list + config via the real flow, then redirect the | |
| 2532 | + | // destination to a temp dir we can inspect. | |
| 2533 | + | state.start_export_flow(None); | |
| 2534 | + | let (items, mut config) = match &state.import_mode { | |
| 2535 | + | ImportMode::ConfigureExport { items, config, .. } => (items.clone(), config.clone()), | |
| 2536 | + | other => panic!("expected ConfigureExport, got {:?}", std::mem::discriminant(other)), | |
| 2537 | + | }; | |
| 2538 | + | assert_eq!(items.len(), 2, "both linked samples should be export items"); | |
| 2539 | + | ||
| 2540 | + | let dest = _dir.path().join("export_out"); | |
| 2541 | + | config.destination = dest.clone(); | |
| 2542 | + | ||
| 2543 | + | state.run_export(items, config); | |
| 2544 | + | assert!(matches!(state.import_mode, ImportMode::Exporting { .. })); | |
| 2545 | + | ||
| 2546 | + | await_export(&mut state); | |
| 2547 | + | ||
| 2548 | + | // Terminal state reflects success (no errors). | |
| 2549 | + | match &state.import_mode { | |
| 2550 | + | ImportMode::ExportComplete { total, errors } => { | |
| 2551 | + | assert_eq!(*total, 2, "both files exported"); | |
| 2552 | + | assert!(errors.is_empty(), "clean export has no errors: {errors:?}"); | |
| 2553 | + | } | |
| 2554 | + | other => panic!("expected ExportComplete, got {:?}", std::mem::discriminant(other)), | |
| 2555 | + | } | |
| 2556 | + | assert!(state.status.contains("Exported"), "status was: {}", state.status); | |
| 2557 | + | ||
| 2558 | + | // The output files actually landed on disk. | |
| 2559 | + | assert!(dest.exists(), "destination dir should be created"); | |
| 2560 | + | assert_eq!(count_files(&dest), 2, "two exported files should exist under {dest:?}"); | |
| 2561 | + | } | |
| 2562 | + | ||
| 2563 | + | #[test] | |
| 2564 | + | fn export_flow_with_no_samples_reports_nothing_to_export() { | |
| 2565 | + | let (mut state, _dir) = make_state(); | |
| 2566 | + | // Empty VFS: nothing to collect. | |
| 2567 | + | state.start_export_flow(None); | |
| 2568 | + | assert!( | |
| 2569 | + | !matches!(state.import_mode, ImportMode::ConfigureExport { .. }), | |
| 2570 | + | "empty library must not open the export configurator" | |
| 2571 | + | ); | |
| 2572 | + | assert_eq!(state.status, "No samples to export"); | |
| 2573 | + | } | |
| 2574 | + | ||
| 2575 | + | // --- Import worker progression (async walk → progress → complete) --- | |
| 2576 | + | ||
| 2577 | + | #[test] | |
| 2578 | + | fn folder_import_worker_ingests_samples() { | |
| 2579 | + | let (mut state, _dir) = make_state(); | |
| 2580 | + | ||
| 2581 | + | // A source folder with two real WAVs, outside the vault. | |
| 2582 | + | let src = _dir.path().join("incoming"); | |
| 2583 | + | std::fs::create_dir_all(&src).unwrap(); | |
| 2584 | + | // Distinct lengths -> distinct content -> distinct hashes (the store | |
| 2585 | + | // dedups identical blobs, so equal-length WAVs would collapse to one). | |
| 2586 | + | write_wav(&src.join("clap.wav"), 8_000); | |
| 2587 | + | write_wav(&src.join("tom.wav"), 9_000); | |
| 2588 | + | ||
| 2589 | + | let vfs_id = state.current_vfs_id().unwrap(); | |
| 2590 | + | state.start_folder_import( | |
| 2591 | + | src.clone(), | |
| 2592 | + | crate::import::ImportStrategy::Flat { vfs_id, parent_id: None }, | |
| 2593 | + | ); | |
| 2594 | + | assert!( | |
| 2595 | + | matches!(state.import_mode, ImportMode::Importing { .. }), | |
| 2596 | + | "start_folder_import should enter the Importing screen" | |
| 2597 | + | ); | |
| 2598 | + | ||
| 2599 | + | // Drive the async import (walk → progress → ImportComplete) to completion. | |
| 2600 | + | // ImportComplete leaves import_mode in a terminal (non-Importing) state. | |
| 2601 | + | let mut landed = false; | |
| 2602 | + | for _ in 0..4000 { |
Lines truncated
| @@ -346,4 +346,73 @@ mod tests { | |||
| 346 | 346 | let id2 = create_collection(&db, "Test", None).unwrap(); | |
| 347 | 347 | assert_eq!(list_collection_members(&db, id2).unwrap().len(), 0); | |
| 348 | 348 | } | |
| 349 | + | ||
| 350 | + | // ---- Dynamic (saved-search) collections ---- | |
| 351 | + | ||
| 352 | + | fn dynamic_filter(bpm_min: f64) -> SearchFilter { | |
| 353 | + | SearchFilter { bpm_min: Some(bpm_min), ..SearchFilter::default() } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | #[test] | |
| 357 | + | fn create_dynamic_roundtrips_filter() { | |
| 358 | + | let db = setup(); | |
| 359 | + | let id = create_dynamic_collection(&db, "Fast", &dynamic_filter(120.0)).unwrap(); | |
| 360 | + | ||
| 361 | + | let col = list_collections(&db) | |
| 362 | + | .unwrap() | |
| 363 | + | .into_iter() | |
| 364 | + | .find(|c| c.id == id) | |
| 365 | + | .expect("dynamic collection should list"); | |
| 366 | + | assert!(col.is_dynamic(), "filter present => dynamic"); | |
| 367 | + | assert_eq!(col.filter.unwrap().bpm_min, Some(120.0)); | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | #[test] | |
| 371 | + | fn manual_collection_is_not_dynamic() { | |
| 372 | + | let db = setup(); | |
| 373 | + | let id = create_collection(&db, "Manual", None).unwrap(); | |
| 374 | + | let col = list_collections(&db).unwrap().into_iter().find(|c| c.id == id).unwrap(); | |
| 375 | + | assert!(!col.is_dynamic()); | |
| 376 | + | assert!(col.filter.is_none()); | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | #[test] | |
| 380 | + | fn update_filter_replaces_it() { | |
| 381 | + | let db = setup(); | |
| 382 | + | let id = create_dynamic_collection(&db, "Fast", &dynamic_filter(120.0)).unwrap(); | |
| 383 | + | update_collection_filter(&db, id, &dynamic_filter(90.0)).unwrap(); | |
| 384 | + | ||
| 385 | + | let col = list_collections(&db).unwrap().into_iter().find(|c| c.id == id).unwrap(); | |
| 386 | + | assert_eq!(col.filter.unwrap().bpm_min, Some(90.0)); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | #[test] | |
| 390 | + | fn update_filter_on_missing_collection_errors() { | |
| 391 | + | let db = setup(); | |
| 392 | + | let err = update_collection_filter(&db, CollectionId::from(9999), &dynamic_filter(90.0)) | |
| 393 | + | .unwrap_err(); | |
| 394 | + | assert!(matches!(err, CoreError::CollectionNotFound(_))); | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | #[test] | |
| 398 | + | fn malformed_filter_json_drops_to_none_but_still_lists() { | |
| 399 | + | let db = setup(); | |
| 400 | + | let id = create_collection(&db, "Corrupt", None).unwrap(); | |
| 401 | + | // Simulate a schema-drift / hand-edited row: invalid filter JSON. | |
| 402 | + | db.conn() | |
| 403 | + | .execute( | |
| 404 | + | "UPDATE collections SET filter_json = '{not valid json' WHERE id = ?1", | |
| 405 | + | rusqlite::params![id], | |
| 406 | + | ) | |
| 407 | + | .unwrap(); | |
| 408 | + | ||
| 409 | + | let col = list_collections(&db) | |
| 410 | + | .unwrap() | |
| 411 | + | .into_iter() | |
| 412 | + | .find(|c| c.id == id) | |
| 413 | + | .expect("collection must still list despite malformed filter"); | |
| 414 | + | // Warn-and-drop: the collection survives as a manual one, not an error. | |
| 415 | + | assert!(col.filter.is_none()); | |
| 416 | + | assert!(!col.is_dynamic()); | |
| 417 | + | } | |
| 349 | 418 | } |
| @@ -1819,4 +1819,63 @@ mod tests { | |||
| 1819 | 1819 | let purged = purge_missing_loose_files(&db).unwrap(); | |
| 1820 | 1820 | assert_eq!(purged, 0); | |
| 1821 | 1821 | } | |
| 1822 | + | ||
| 1823 | + | #[test] | |
| 1824 | + | fn relocate_missing_finds_moved_file_by_hash() { | |
| 1825 | + | let (dir, db, store) = setup(); | |
| 1826 | + | let src = create_test_file(&dir, "kick.wav", b"moved-away content"); | |
| 1827 | + | let hash = store.import_loose_files(&src, &db).unwrap(); | |
| 1828 | + | ||
| 1829 | + | // Move the source into a subdirectory and delete the original path. | |
| 1830 | + | let subdir = dir.path().join("relocated"); | |
| 1831 | + | fs::create_dir_all(&subdir).unwrap(); | |
| 1832 | + | let moved = subdir.join("kick.wav"); | |
| 1833 | + | fs::rename(&src, &moved).unwrap(); | |
| 1834 | + | assert_eq!(check_loose_files_integrity(&db).unwrap(), (0, 1)); | |
| 1835 | + | ||
| 1836 | + | let (relocated, still_missing) = | |
| 1837 | + | relocate_missing_loose_files(&db, dir.path()).unwrap(); | |
| 1838 | + | assert_eq!(relocated, 1); | |
| 1839 | + | assert_eq!(still_missing, 0); | |
| 1840 | + | ||
| 1841 | + | // source_path now points at the moved file, and integrity is restored. | |
| 1842 | + | let sp = sample_source_path(&db, &hash).unwrap().unwrap(); | |
| 1843 | + | assert!(sp.contains("relocated")); | |
| 1844 | + | assert_eq!(check_loose_files_integrity(&db).unwrap(), (1, 0)); | |
| 1845 | + | } | |
| 1846 | + | ||
| 1847 | + | #[test] | |
| 1848 | + | fn relocate_missing_reports_still_missing_when_absent() { | |
| 1849 | + | let (dir, db, store) = setup(); | |
| 1850 | + | let src = create_test_file(&dir, "ghost.wav", b"gone forever"); | |
| 1851 | + | store.import_loose_files(&src, &db).unwrap(); | |
| 1852 | + | fs::remove_file(&src).unwrap(); | |
| 1853 | + | ||
| 1854 | + | // Search a fresh empty directory — nothing to find. | |
| 1855 | + | let empty = dir.path().join("empty"); | |
| 1856 | + | fs::create_dir_all(&empty).unwrap(); | |
| 1857 | + | let (relocated, still_missing) = | |
| 1858 | + | relocate_missing_loose_files(&db, &empty).unwrap(); | |
| 1859 | + | assert_eq!(relocated, 0); | |
| 1860 | + | assert_eq!(still_missing, 1); | |
| 1861 | + | } | |
| 1862 | + | ||
| 1863 | + | #[test] | |
| 1864 | + | fn relocate_missing_ignores_same_name_different_content() { | |
| 1865 | + | let (dir, db, store) = setup(); | |
| 1866 | + | let src = create_test_file(&dir, "kick.wav", b"the real bytes"); | |
| 1867 | + | store.import_loose_files(&src, &db).unwrap(); | |
| 1868 | + | fs::remove_file(&src).unwrap(); | |
| 1869 | + | ||
| 1870 | + | // A decoy with the same basename but different content must NOT match | |
| 1871 | + | // (hash verify guards against same-name collisions). | |
| 1872 | + | let decoy_dir = dir.path().join("decoy"); | |
| 1873 | + | fs::create_dir_all(&decoy_dir).unwrap(); | |
| 1874 | + | fs::write(decoy_dir.join("kick.wav"), b"an impostor with other bytes").unwrap(); | |
| 1875 | + | ||
| 1876 | + | let (relocated, still_missing) = | |
| 1877 | + | relocate_missing_loose_files(&db, dir.path()).unwrap(); | |
| 1878 | + | assert_eq!(relocated, 0); | |
| 1879 | + | assert_eq!(still_missing, 1); | |
| 1880 | + | } | |
| 1822 | 1881 | } |
| @@ -335,4 +335,143 @@ mod tests { | |||
| 335 | 335 | assert_eq!(reg.vaults.len(), 1); | |
| 336 | 336 | assert_eq!(reg.vaults[0].name, "Primary"); | |
| 337 | 337 | } | |
| 338 | + | ||
| 339 | + | #[test] | |
| 340 | + | fn relocate_vault_repoints_entry_and_active() { | |
| 341 | + | let dir = tempfile::tempdir().unwrap(); | |
| 342 | + | let old_path = dir.path().join("vault_a"); | |
| 343 | + | let new_path = dir.path().join("vault_b"); | |
| 344 | + | let mut reg = temp_registry(&old_path); | |
| 345 | + | create_vault(&mut reg, "V", &old_path).unwrap(); | |
| 346 | + | reg.active = old_path.clone(); | |
| 347 | + | ||
| 348 | + | // The new location must contain an audiofiles.db to be accepted. | |
| 349 | + | fs::create_dir_all(&new_path).unwrap(); | |
| 350 | + | fs::write(new_path.join("audiofiles.db"), b"").unwrap(); | |
| 351 | + | ||
| 352 | + | relocate_vault(&mut reg, &old_path, &new_path).unwrap(); | |
| 353 | + | ||
| 354 | + | assert_eq!(reg.vaults.len(), 1); | |
| 355 | + | assert_eq!(reg.vaults[0].path, new_path); | |
| 356 | + | // Active pointer followed the moved vault. | |
| 357 | + | assert_eq!(reg.active, new_path); | |
| 358 | + | // The old path is no longer referenced anywhere. | |
| 359 | + | assert!(!reg.vaults.iter().any(|v| v.path == old_path)); | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | #[test] | |
| 363 | + | fn relocate_vault_rejects_target_without_db() { | |
| 364 | + | let dir = tempfile::tempdir().unwrap(); | |
| 365 | + | let old_path = dir.path().join("vault_a"); | |
| 366 | + | let new_path = dir.path().join("vault_b_no_db"); | |
| 367 | + | let mut reg = temp_registry(&old_path); | |
| 368 | + | create_vault(&mut reg, "V", &old_path).unwrap(); | |
| 369 | + | ||
| 370 | + | // Target dir exists but has no audiofiles.db → rejected, registry unchanged. | |
| 371 | + | fs::create_dir_all(&new_path).unwrap(); | |
| 372 | + | let err = relocate_vault(&mut reg, &old_path, &new_path).unwrap_err(); | |
| 373 | + | assert!(matches!(err, VaultError::InvalidVault(_))); | |
| 374 | + | assert_eq!(reg.vaults[0].path, old_path); | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | #[test] | |
| 378 | + | fn relocate_vault_rejects_unreachable_target() { | |
| 379 | + | let dir = tempfile::tempdir().unwrap(); | |
| 380 | + | let old_path = dir.path().join("vault_a"); | |
| 381 | + | let mut reg = temp_registry(&old_path); | |
| 382 | + | create_vault(&mut reg, "V", &old_path).unwrap(); | |
| 383 | + | ||
| 384 | + | // A path that does not exist at all is likewise rejected (no db present). | |
| 385 | + | let ghost = dir.path().join("does_not_exist"); | |
| 386 | + | let err = relocate_vault(&mut reg, &old_path, &ghost).unwrap_err(); | |
| 387 | + | assert!(matches!(err, VaultError::InvalidVault(_))); | |
| 388 | + | assert_eq!(reg.vaults[0].path, old_path); | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | #[test] | |
| 392 | + | fn relocate_vault_unknown_old_path_is_not_found() { | |
| 393 | + | let dir = tempfile::tempdir().unwrap(); | |
| 394 | + | let new_path = dir.path().join("vault_new"); | |
| 395 | + | fs::create_dir_all(&new_path).unwrap(); | |
| 396 | + | fs::write(new_path.join("audiofiles.db"), b"").unwrap(); | |
| 397 | + | ||
| 398 | + | let mut reg = temp_registry(dir.path()); | |
| 399 | + | // Registry has no such vault → NotFound (the db check passes first). | |
| 400 | + | let err = relocate_vault(&mut reg, &dir.path().join("ghost"), &new_path).unwrap_err(); | |
| 401 | + | assert!(matches!(err, VaultError::NotFound(_))); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[test] | |
| 405 | + | fn relocate_vault_leaves_active_when_moving_non_active_vault() { | |
| 406 | + | let dir = tempfile::tempdir().unwrap(); | |
| 407 | + | let moved = dir.path().join("moved"); | |
| 408 | + | let other = dir.path().join("other"); | |
| 409 | + | let new_path = dir.path().join("moved_new"); | |
| 410 | + | let mut reg = temp_registry(dir.path()); | |
| 411 | + | create_vault(&mut reg, "Moved", &moved).unwrap(); | |
| 412 | + | create_vault(&mut reg, "Other", &other).unwrap(); | |
| 413 | + | reg.active = other.clone(); | |
| 414 | + | ||
| 415 | + | fs::create_dir_all(&new_path).unwrap(); | |
| 416 | + | fs::write(new_path.join("audiofiles.db"), b"").unwrap(); | |
| 417 | + | ||
| 418 | + | relocate_vault(&mut reg, &moved, &new_path).unwrap(); | |
| 419 | + | ||
| 420 | + | // Active belonged to a different vault → untouched. | |
| 421 | + | assert_eq!(reg.active, other); | |
| 422 | + | let entry = reg.vaults.iter().find(|v| v.name == "Moved").unwrap(); | |
| 423 | + | assert_eq!(entry.path, new_path); | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // Exercises the real `save_registry`→`load_registry` roundtrip (which target | |
| 427 | + | // `registry_path()` under the platform config dir) by redirecting | |
| 428 | + | // XDG_CONFIG_HOME at a TempDir. Linux-only: on other platforms `config_dir()` | |
| 429 | + | // ignores XDG and this would clobber the user's real registry. | |
| 430 | + | #[cfg(target_os = "linux")] | |
| 431 | + | #[test] | |
| 432 | + | fn add_existing_vault_persists_through_save_and_load() { | |
| 433 | + | use std::sync::Mutex; | |
| 434 | + | // Serialize env mutation; no other test in this binary reads config_dir. | |
| 435 | + | static ENV_LOCK: Mutex<()> = Mutex::new(()); | |
| 436 | + | let _guard = ENV_LOCK.lock().unwrap(); | |
| 437 | + | ||
| 438 | + | let dir = tempfile::tempdir().unwrap(); | |
| 439 | + | let prev = std::env::var_os("XDG_CONFIG_HOME"); | |
| 440 | + | // SAFETY: guarded by ENV_LOCK; restored before returning. | |
| 441 | + | unsafe { | |
| 442 | + | std::env::set_var("XDG_CONFIG_HOME", dir.path()); | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // A real vault dir with an audiofiles.db so add_existing_vault accepts it. | |
| 446 | + | let vault_path = dir.path().join("existing_vault"); | |
| 447 | + | fs::create_dir_all(&vault_path).unwrap(); | |
| 448 | + | fs::write(vault_path.join("audiofiles.db"), b"").unwrap(); | |
| 449 | + | ||
| 450 | + | let mut reg = VaultRegistry { | |
| 451 | + | vaults: Vec::new(), | |
| 452 | + | active: vault_path.clone(), | |
| 453 | + | }; | |
| 454 | + | add_existing_vault(&mut reg, "Persisted", &vault_path).unwrap(); | |
| 455 | + | ||
| 456 | + | // The registry file must land under our redirected config dir. | |
| 457 | + | assert!(registry_path().starts_with(dir.path())); | |
| 458 | + | ||
| 459 | + | save_registry(®).unwrap(); | |
| 460 | + | let loaded = load_registry() | |
| 461 | + | .unwrap() | |
| 462 | + | .expect("registry file should exist after save"); | |
| 463 | + | ||
| 464 | + | assert_eq!(loaded.vaults.len(), 1); | |
| 465 | + | assert_eq!(loaded.vaults[0].name, "Persisted"); | |
| 466 | + | assert_eq!(loaded.vaults[0].path, vault_path); | |
| 467 | + | assert_eq!(loaded.active, vault_path); | |
| 468 | + | ||
| 469 | + | // SAFETY: guarded by ENV_LOCK. | |
| 470 | + | unsafe { | |
| 471 | + | match prev { | |
| 472 | + | Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), | |
| 473 | + | None => std::env::remove_var("XDG_CONFIG_HOME"), | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | } | |
| 338 | 477 | } |
| @@ -493,4 +493,60 @@ mod tests { | |||
| 493 | 493 | assert_eq!(sanitize_component(""), "_"); | |
| 494 | 494 | assert_eq!(sanitize_component("normal"), "normal"); | |
| 495 | 495 | } | |
| 496 | + | ||
| 497 | + | #[test] | |
| 498 | + | fn resolve_link_path_no_collision_returns_bare_name() { | |
| 499 | + | let dir = TempDir::new().unwrap(); | |
| 500 | + | let desired = dir.path().join("kick.wav"); | |
| 501 | + | let store_file = dir.path().join("store/abc.wav"); | |
| 502 | + | // Nothing occupies `desired` → returned unchanged, no suffix. | |
| 503 | + | assert_eq!(resolve_link_path(&desired, &store_file), desired); | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | #[test] | |
| 507 | + | fn resolve_link_path_disambiguates_colliding_names() { | |
| 508 | + | let dir = TempDir::new().unwrap(); | |
| 509 | + | let desired = dir.path().join("kick.wav"); | |
| 510 | + | let store_file = dir.path().join("store/abc.wav"); | |
| 511 | + | ||
| 512 | + | // A real (non-symlink) file already occupies the desired name, and it | |
| 513 | + | // does not point at our store target → first free suffix is ` (2)`. | |
| 514 | + | std::fs::write(&desired, b"other").unwrap(); | |
| 515 | + | assert_eq!( | |
| 516 | + | resolve_link_path(&desired, &store_file), | |
| 517 | + | dir.path().join("kick (2).wav") | |
| 518 | + | ); | |
| 519 | + | ||
| 520 | + | // Now ` (2)` is taken too (and unrelated) → next free is ` (3)`. | |
| 521 | + | std::fs::write(dir.path().join("kick (2).wav"), b"other2").unwrap(); | |
| 522 | + | assert_eq!( | |
| 523 | + | resolve_link_path(&desired, &store_file), | |
| 524 | + | dir.path().join("kick (3).wav") | |
| 525 | + | ); | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | #[test] | |
| 529 | + | fn resolve_link_path_disambiguates_extensionless_names() { | |
| 530 | + | let dir = TempDir::new().unwrap(); | |
| 531 | + | let desired = dir.path().join("kick"); | |
| 532 | + | let store_file = dir.path().join("store/abc"); | |
| 533 | + | std::fs::write(&desired, b"other").unwrap(); | |
| 534 | + | // No extension → suffix is appended to the bare stem. | |
| 535 | + | assert_eq!( | |
| 536 | + | resolve_link_path(&desired, &store_file), | |
| 537 | + | dir.path().join("kick (2)") | |
| 538 | + | ); | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | #[cfg(unix)] | |
| 542 | + | #[test] | |
| 543 | + | fn resolve_link_path_keeps_link_already_pointing_at_target() { | |
| 544 | + | let dir = TempDir::new().unwrap(); | |
| 545 | + | let store_file = dir.path().join("abc.wav"); | |
| 546 | + | std::fs::write(&store_file, b"bytes").unwrap(); | |
| 547 | + | let desired = dir.path().join("kick.wav"); | |
| 548 | + | // Existing symlink already resolves to our target → left as-is, no suffix. | |
| 549 | + | std::os::unix::fs::symlink(&store_file, &desired).unwrap(); | |
| 550 | + | assert_eq!(resolve_link_path(&desired, &store_file), desired); | |
| 551 | + | } | |
| 496 | 552 | } |
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | + | use rusqlite::Connection; | |
| 5 | 6 | use sha2::{Digest, Sha256}; | |
| 6 | 7 | use synckit_client::{DeviceId, SyncKitClient}; | |
| 7 | 8 | ||
| @@ -13,6 +14,30 @@ use crate::error::{Result, SyncError}; | |||
| 13 | 14 | ||
| 14 | 15 | use super::{open_conn, get_sync_state, set_sync_state}; | |
| 15 | 16 | ||
| 17 | + | /// Set a sample's `cloud_only` flag inside a transaction that raises the | |
| 18 | + | /// `applying_remote` suppression flag while the write happens and lowers it | |
| 19 | + | /// again. The flag stops the `sync_samples_update` trigger from recording this | |
| 20 | + | /// purely-local blob-presence bookkeeping as an outgoing change; keeping it all | |
| 21 | + | /// in one transaction means a crash can't leave `applying_remote` stuck at `1` | |
| 22 | + | /// (which would silently drop every subsequent local edit from the changelog). | |
| 23 | + | fn set_cloud_only_suppressed(conn: &Connection, hash: &str, cloud_only: bool) -> Result<()> { | |
| 24 | + | conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 25 | + | conn.execute( | |
| 26 | + | "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'", | |
| 27 | + | [], | |
| 28 | + | )?; | |
| 29 | + | conn.execute( | |
| 30 | + | "UPDATE samples SET cloud_only = ?2 WHERE hash = ?1", | |
| 31 | + | rusqlite::params![hash, cloud_only as i64], | |
| 32 | + | )?; | |
| 33 | + | conn.execute( | |
| 34 | + | "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'", | |
| 35 | + | [], | |
| 36 | + | )?; | |
| 37 | + | conn.execute_batch("COMMIT")?; | |
| 38 | + | Ok(()) | |
| 39 | + | } | |
| 40 | + | ||
| 16 | 41 | /// Download blobs for samples that exist in metadata but not on disk. | |
| 17 | 42 | /// | |
| 18 | 43 | /// For samples in sync-enabled VFS entries where the local file is missing, | |
| @@ -67,21 +92,7 @@ pub async fn download_missing_blobs( | |||
| 67 | 92 | let h = hash.clone(); | |
| 68 | 93 | tokio::task::spawn_blocking(move || -> Result<()> { | |
| 69 | 94 | let conn = open_conn(&p)?; | |
| 70 | - | conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 71 | - | conn.execute( | |
| 72 | - | "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'", | |
| 73 | - | [], | |
| 74 | - | )?; | |
| 75 | - | conn.execute( | |
| 76 | - | "UPDATE samples SET cloud_only = 1 WHERE hash = ?1", | |
| 77 | - | [&h], | |
| 78 | - | )?; | |
| 79 | - | conn.execute( | |
| 80 | - | "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'", | |
| 81 | - | [], | |
| 82 | - | )?; | |
| 83 | - | conn.execute_batch("COMMIT")?; | |
| 84 | - | Ok(()) | |
| 95 | + | set_cloud_only_suppressed(&conn, &h, true) | |
| 85 | 96 | }) | |
| 86 | 97 | .await | |
| 87 | 98 | .map_err(|e| SyncError::Other(e.to_string()))??; | |
| @@ -128,21 +139,7 @@ pub async fn download_missing_blobs( | |||
| 128 | 139 | let h = hash.clone(); | |
| 129 | 140 | tokio::task::spawn_blocking(move || -> Result<()> { | |
| 130 | 141 | let conn = open_conn(&p)?; | |
| 131 | - | conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 132 | - | conn.execute( | |
| 133 | - | "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'", | |
| 134 | - | [], | |
| 135 | - | )?; | |
| 136 | - | conn.execute( | |
| 137 | - | "UPDATE samples SET cloud_only = 0 WHERE hash = ?1", | |
| 138 | - | [&h], | |
| 139 | - | )?; | |
| 140 | - | conn.execute( | |
| 141 | - | "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'", | |
| 142 | - | [], | |
| 143 | - | )?; | |
| 144 | - | conn.execute_batch("COMMIT")?; | |
| 145 | - | Ok(()) | |
| 142 | + | set_cloud_only_suppressed(&conn, &h, false) | |
| 146 | 143 | }) | |
| 147 | 144 | .await | |
| 148 | 145 | .map_err(|e| SyncError::Other(e.to_string()))??; | |
| @@ -235,21 +232,7 @@ pub async fn download_one_blob( | |||
| 235 | 232 | let h = hash.to_string(); | |
| 236 | 233 | tokio::task::spawn_blocking(move || -> Result<()> { | |
| 237 | 234 | let conn = open_conn(&p)?; | |
| 238 | - | conn.execute_batch("BEGIN IMMEDIATE")?; | |
| 239 | - | conn.execute( | |
| 240 | - | "UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'", | |
| 241 | - | [], | |
| 242 | - | )?; | |
| 243 | - | conn.execute( | |
| 244 | - | "UPDATE samples SET cloud_only = 0 WHERE hash = ?1", | |
| 245 | - | [&h], | |
| 246 | - | )?; | |
| 247 | - | conn.execute( | |
| 248 | - | "UPDATE sync_state SET value = '0' WHERE key = 'applying_remote'", | |
| 249 | - | [], | |
| 250 | - | )?; | |
| 251 | - | conn.execute_batch("COMMIT")?; | |
| 252 | - | Ok(()) | |
| 235 | + | set_cloud_only_suppressed(&conn, &h, false) | |
| 253 | 236 | }) | |
| 254 | 237 | .await | |
| 255 | 238 | .map_err(|e| SyncError::Other(e.to_string()))??; | |
| @@ -310,3 +293,85 @@ pub(crate) async fn pull_changes( | |||
| 310 | 293 | ||
| 311 | 294 | Ok(total_applied) | |
| 312 | 295 | } | |
| 296 | + | ||
| 297 | + | #[cfg(test)] | |
| 298 | + | mod tests { | |
| 299 | + | use super::*; | |
| 300 | + | use audiofiles_core::db::Database; | |
| 301 | + | ||
| 302 | + | fn insert_sample(conn: &Connection, hash: &str) { | |
| 303 | + | let now = chrono::Utc::now().timestamp(); | |
| 304 | + | conn.execute( | |
| 305 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 306 | + | VALUES (?1, 'a.wav', 'wav', 1024, ?2, ?2)", | |
| 307 | + | rusqlite::params![hash, now], | |
| 308 | + | ) | |
| 309 | + | .unwrap(); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | fn cloud_only(conn: &Connection, hash: &str) -> i64 { | |
| 313 | + | conn.query_row("SELECT cloud_only FROM samples WHERE hash = ?1", [hash], |r| r.get(0)) | |
| 314 | + | .unwrap() | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | fn applying_remote(conn: &Connection) -> String { | |
| 318 | + | conn.query_row( | |
| 319 | + | "SELECT value FROM sync_state WHERE key = 'applying_remote'", | |
| 320 | + | [], | |
| 321 | + | |r| r.get(0), | |
| 322 | + | ) | |
| 323 | + | .unwrap() | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | fn changelog_count(conn: &Connection) -> i64 { | |
| 327 | + | conn.query_row("SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0)) | |
| 328 | + | .unwrap() | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[test] | |
| 332 | + | fn suppressed_toggle_sets_flag_and_restores_applying_remote() { | |
| 333 | + | let db = Database::open_in_memory().unwrap(); | |
| 334 | + | let conn = db.conn(); | |
| 335 | + | let hash = "a".repeat(64); | |
| 336 | + | insert_sample(conn, &hash); | |
| 337 | + | // The INSERT above records one changelog row; start the assertion clean. | |
| 338 | + | conn.execute("DELETE FROM sync_changelog", []).unwrap(); | |
| 339 | + | ||
| 340 | + | set_cloud_only_suppressed(conn, &hash, true).unwrap(); | |
| 341 | + | assert_eq!(cloud_only(conn, &hash), 1); | |
| 342 | + | assert_eq!(applying_remote(conn), "0", "the suppression flag must be lowered again"); | |
| 343 | + | ||
| 344 | + | set_cloud_only_suppressed(conn, &hash, false).unwrap(); | |
| 345 | + | assert_eq!(cloud_only(conn, &hash), 0); | |
| 346 | + | assert_eq!(applying_remote(conn), "0"); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | #[test] | |
| 350 | + | fn suppressed_toggle_does_not_record_a_changelog_entry() { | |
| 351 | + | let db = Database::open_in_memory().unwrap(); | |
| 352 | + | let conn = db.conn(); | |
| 353 | + | let hash = "b".repeat(64); | |
| 354 | + | insert_sample(conn, &hash); | |
| 355 | + | conn.execute("DELETE FROM sync_changelog", []).unwrap(); | |
| 356 | + | ||
| 357 | + | // Without suppression this UPDATE would fire sync_samples_update and log a | |
| 358 | + | // change; the raised applying_remote flag must stop that. | |
| 359 | + | set_cloud_only_suppressed(conn, &hash, true).unwrap(); | |
| 360 | + | assert_eq!( | |
| 361 | + | changelog_count(conn), | |
| 362 | + | 0, | |
| 363 | + | "cloud_only bookkeeping must not become an outgoing change" | |
| 364 | + | ); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[test] | |
| 368 | + | fn suppressed_toggle_on_unknown_hash_is_noop_and_clears_flag() { | |
| 369 | + | let db = Database::open_in_memory().unwrap(); | |
| 370 | + | let conn = db.conn(); | |
| 371 | + | // No sample with this hash: the UPDATE matches zero rows but must still | |
| 372 | + | // succeed and leave applying_remote back at '0'. | |
| 373 | + | set_cloud_only_suppressed(conn, &"c".repeat(64), true).unwrap(); | |
| 374 | + | assert_eq!(applying_remote(conn), "0"); | |
| 375 | + | assert_eq!(changelog_count(conn), 0); | |
| 376 | + | } | |
| 377 | + | } |
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::path::Path; | |
| 4 | 4 | ||
| 5 | + | use rusqlite::Connection; | |
| 5 | 6 | use synckit_client::{ChangeEntry, ChangeOp, DeviceId, SyncKitClient}; | |
| 6 | 7 | use uuid::Uuid; | |
| 7 | 8 | ||
| @@ -189,57 +190,28 @@ async fn push_changes( | |||
| 189 | 190 | Ok(total_pushed) | |
| 190 | 191 | } | |
| 191 | 192 | ||
| 192 | - | /// Push a single `PUSH_BATCH_LIMIT`-sized batch of unpushed changelog entries. | |
| 193 | - | /// | |
| 194 | - | /// Returns `(pushed_count, rows_read)`. `rows_read == 0` signals the backlog is | |
| 195 | - | /// drained; the caller loops until then. | |
| 196 | - | async fn push_one_batch( | |
| 197 | - | db_path: &std::path::Path, | |
| 198 | - | client: &SyncKitClient, | |
| 199 | - | device_id: DeviceId, | |
| 200 | - | ) -> Result<(i64, usize)> { | |
| 201 | - | let node = device_id.as_uuid(); | |
| 202 | - | let p = db_path.to_path_buf(); | |
| 203 | - | let rows = tokio::task::spawn_blocking(move || -> Result<_> { | |
| 204 | - | let conn = open_conn(&p)?; | |
| 205 | - | // Mint an HLC for every un-stamped pending change before reading them, so | |
| 206 | - | // each pushed ChangeEntry carries a real clock (was Default/zero, which | |
| 207 | - | // made every local edit lose conflict resolution). | |
| 208 | - | super::hlc::stamp_pending(&conn, node)?; | |
| 209 | - | let mut stmt = conn.prepare( | |
| 210 | - | "SELECT id, table_name, op, row_id, timestamp, data, hlc | |
| 211 | - | FROM sync_changelog | |
| 212 | - | WHERE pushed = 0 | |
| 213 | - | ORDER BY id ASC | |
| 214 | - | LIMIT ?1", | |
| 215 | - | )?; | |
| 216 | - | ||
| 217 | - | // (id, table_name, op, row_id, timestamp, data, hlc) | |
| 218 | - | type ChangelogRow = (i64, String, String, String, String, Option<String>, Option<String>); | |
| 219 | - | let rows: Vec<ChangelogRow> = stmt | |
| 220 | - | .query_map([PUSH_BATCH_LIMIT as i64], |row| { | |
| 221 | - | Ok(( | |
| 222 | - | row.get(0)?, | |
| 223 | - | row.get(1)?, | |
| 224 | - | row.get(2)?, | |
| 225 | - | row.get(3)?, | |
| 226 | - | row.get(4)?, | |
| 227 | - | row.get(5)?, | |
| 228 | - | row.get(6)?, | |
| 229 | - | )) | |
| 230 | - | })? | |
| 231 | - | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| 232 | - | ||
| 233 | - | Ok(rows) | |
| 234 | - | }) | |
| 235 | - | .await | |
| 236 | - | .map_err(|e| SyncError::Other(e.to_string()))??; | |
| 237 | - | ||
| 238 | - | let rows_read = rows.len(); | |
| 239 | - | if rows.is_empty() { | |
| 240 | - | return Ok((0, 0)); | |
| 241 | - | } | |
| 193 | + | /// A raw `sync_changelog` row: (id, table_name, op, row_id, timestamp, data, hlc). | |
| 194 | + | type ChangelogRow = (i64, String, String, String, String, Option<String>, Option<String>); | |
| 195 | + | ||
| 196 | + | /// The outcome of turning raw changelog rows into pushable `ChangeEntry`s. | |
| 197 | + | struct PreparedBatch { | |
| 198 | + | /// Entries to send to the server (skipped/unparseable rows are excluded). | |
| 199 | + | changes: Vec<ChangeEntry>, | |
| 200 | + | /// Every changelog id to mark `pushed = 1` — includes skipped rows so a bad | |
| 201 | + | /// row is dropped once rather than retried forever. | |
| 202 | + | pushed_ids: Vec<i64>, | |
| 203 | + | /// `(table, row_id, hlc)` committed-clock updates for our own edits. | |
| 204 | + | ledger_updates: Vec<(String, String, synckit_client::Hlc)>, | |
| 205 | + | /// Count of rows dropped for an unknown op or unparseable JSON `data`. | |
| 206 | + | skipped: i64, | |
| 207 | + | } | |
| 242 | 208 | ||
| 209 | + | /// Turn raw changelog rows into a [`PreparedBatch`]. Pure: no DB, no network. | |
| 210 | + | /// | |
| 211 | + | /// A row with an unknown op or unparseable JSON `data` is skipped (still added to | |
| 212 | + | /// `pushed_ids`, to break the retry loop). The HLC is read from the row; a row | |
| 213 | + | /// that somehow lacks one falls back to the zero clock for `node`. | |
| 214 | + | fn prepare_batch(rows: Vec<ChangelogRow>, node: Uuid) -> PreparedBatch { | |
| 243 | 215 | let mut pushed_ids: Vec<i64> = Vec::with_capacity(rows.len()); | |
| 244 | 216 | let mut skipped = 0i64; | |
| 245 | 217 | ||
| @@ -273,8 +245,8 @@ async fn push_one_batch( | |||
| 273 | 245 | None => None, | |
| 274 | 246 | }; | |
| 275 | 247 | ||
| 276 | - | // The HLC was stamped above; fall back to the zero clock only if a row | |
| 277 | - | // somehow lacks one (defensive — keeps the push going). | |
| 248 | + | // The HLC was stamped before reading; fall back to the zero clock only | |
| 249 | + | // if a row somehow lacks one (defensive — keeps the push going). | |
| 278 | 250 | let hlc = hlc_json | |
| 279 | 251 | .and_then(|s| serde_json::from_str(&s).ok()) | |
| 280 | 252 | .unwrap_or_else(|| synckit_client::Hlc::zero(node)); | |
| @@ -299,6 +271,83 @@ async fn push_one_batch( | |||
| 299 | 271 | .map(|c| (c.table.clone(), c.row_id.clone(), c.hlc)) | |
| 300 | 272 | .collect(); | |
| 301 | 273 | ||
| 274 | + | PreparedBatch { changes, pushed_ids, ledger_updates, skipped } | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | /// Mark `pushed_ids` as `pushed = 1` and record `ledger_updates` in the HLC | |
| 278 | + | /// ledger, in one transaction so a crash can't leave changelog and ledger out of | |
| 279 | + | /// step. Only the listed ids are touched; other unpushed rows are left alone. | |
| 280 | + | fn mark_pushed( | |
| 281 | + | conn: &Connection, | |
| 282 | + | pushed_ids: &[i64], | |
| 283 | + | ledger_updates: &[(String, String, synckit_client::Hlc)], | |
| 284 | + | ) -> Result<()> { | |
| 285 | + | let tx = conn.unchecked_transaction()?; | |
| 286 | + | let placeholders: String = pushed_ids.iter().map(|_| "?").collect::<Vec<_>>().join(","); | |
| 287 | + | let sql = format!("UPDATE sync_changelog SET pushed = 1 WHERE id IN ({})", placeholders); | |
| 288 | + | let params: Vec<&dyn rusqlite::types::ToSql> = pushed_ids | |
| 289 | + | .iter() | |
| 290 | + | .map(|id| id as &dyn rusqlite::types::ToSql) | |
| 291 | + | .collect(); | |
| 292 | + | tx.execute(&sql, params.as_slice())?; | |
| 293 | + | for (table, row_id, hlc) in ledger_updates { | |
| 294 | + | super::hlc::set_committed(&tx, table, row_id, hlc)?; | |
| 295 | + | } | |
| 296 | + | tx.commit()?; | |
| 297 | + | Ok(()) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// Push a single `PUSH_BATCH_LIMIT`-sized batch of unpushed changelog entries. | |
| 301 | + | /// | |
| 302 | + | /// Returns `(pushed_count, rows_read)`. `rows_read == 0` signals the backlog is | |
| 303 | + | /// drained; the caller loops until then. | |
| 304 | + | async fn push_one_batch( | |
| 305 | + | db_path: &std::path::Path, | |
| 306 | + | client: &SyncKitClient, | |
| 307 | + | device_id: DeviceId, | |
| 308 | + | ) -> Result<(i64, usize)> { | |
| 309 | + | let node = device_id.as_uuid(); | |
| 310 | + | let p = db_path.to_path_buf(); | |
| 311 | + | let rows = tokio::task::spawn_blocking(move || -> Result<_> { | |
| 312 | + | let conn = open_conn(&p)?; | |
| 313 | + | // Mint an HLC for every un-stamped pending change before reading them, so | |
| 314 | + | // each pushed ChangeEntry carries a real clock (was Default/zero, which | |
| 315 | + | // made every local edit lose conflict resolution). | |
| 316 | + | super::hlc::stamp_pending(&conn, node)?; | |
| 317 | + | let mut stmt = conn.prepare( | |
| 318 | + | "SELECT id, table_name, op, row_id, timestamp, data, hlc | |
| 319 | + | FROM sync_changelog | |
| 320 | + | WHERE pushed = 0 | |
| 321 | + | ORDER BY id ASC | |
| 322 | + | LIMIT ?1", | |
| 323 | + | )?; | |
| 324 | + | ||
| 325 | + | let rows: Vec<ChangelogRow> = stmt | |
| 326 | + | .query_map([PUSH_BATCH_LIMIT as i64], |row| { | |
| 327 | + | Ok(( | |
| 328 | + | row.get(0)?, | |
| 329 | + | row.get(1)?, | |
| 330 | + | row.get(2)?, | |
| 331 | + | row.get(3)?, | |
| 332 | + | row.get(4)?, | |
| 333 | + | row.get(5)?, | |
| 334 | + | row.get(6)?, | |
| 335 | + | )) | |
| 336 | + | })? | |
| 337 | + | .collect::<std::result::Result<Vec<_>, _>>()?; | |
| 338 | + | ||
| 339 | + | Ok(rows) | |
| 340 | + | }) | |
| 341 | + | .await | |
| 342 | + | .map_err(|e| SyncError::Other(e.to_string()))??; | |
| 343 | + | ||
| 344 | + | let rows_read = rows.len(); | |
| 345 | + | if rows.is_empty() { | |
| 346 | + | return Ok((0, 0)); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | let PreparedBatch { changes, pushed_ids, ledger_updates, skipped } = prepare_batch(rows, node); | |
| 350 | + | ||
| 302 | 351 | if skipped > 0 { | |
| 303 | 352 | tracing::warn!(skipped, "Some changelog entries could not be parsed (unknown op or bad JSON) and were marked pushed to break the retry loop; they are dropped, not retried"); | |
| 304 | 353 | } | |
| @@ -319,18 +368,7 @@ async fn push_one_batch( | |||
| 319 | 368 | let p = db_path.to_path_buf(); | |
| 320 | 369 | tokio::task::spawn_blocking(move || { | |
| 321 | 370 | let conn = open_conn(&p)?; | |
| 322 | - | let tx = conn.unchecked_transaction()?; | |
| 323 | - | let placeholders: String = pushed_ids.iter().map(|_| "?").collect::<Vec<_>>().join(","); | |
| 324 | - | let sql = format!("UPDATE sync_changelog SET pushed = 1 WHERE id IN ({})", placeholders); | |
| 325 | - | let params: Vec<&dyn rusqlite::types::ToSql> = pushed_ids | |
| 326 | - | .iter() | |
| 327 | - | .map(|id| id as &dyn rusqlite::types::ToSql) | |
| 328 | - | .collect(); | |
| 329 | - | tx.execute(&sql, params.as_slice())?; | |
| 330 | - | for (table, row_id, hlc) in &ledger_updates { | |
| 331 | - | super::hlc::set_committed(&tx, table, row_id, hlc)?; | |
| 332 | - | } | |
| 333 | - | tx.commit()?; | |
| 371 | + | mark_pushed(&conn, &pushed_ids, &ledger_updates)?; | |
| 334 | 372 | Ok::<_, SyncError>(()) | |
| 335 | 373 | }) | |
| 336 | 374 | .await | |
| @@ -339,3 +377,164 @@ async fn push_one_batch( | |||
| 339 | 377 | ||
| 340 | 378 | Ok((pushed_count, rows_read)) | |
| 341 | 379 | } | |
| 380 | + | ||
| 381 | + | #[cfg(test)] | |
| 382 | + | mod tests { | |
| 383 | + | use super::*; | |
| 384 | + | use audiofiles_core::db::Database; | |
| 385 | + | use synckit_client::Hlc; | |
| 386 | + | ||
| 387 | + | fn hlc(wall_ms: i64, node: Uuid) -> Hlc { | |
| 388 | + | Hlc { wall_ms, counter: 0, node } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | /// (id, table, op, row_id, timestamp, data, hlc) with sane defaults. | |
| 392 | + | fn row( | |
| 393 | + | id: i64, | |
| 394 | + | op: &str, | |
| 395 | + | row_id: &str, | |
| 396 | + | data: Option<&str>, | |
| 397 | + | hlc_json: Option<String>, | |
| 398 | + | ) -> ChangelogRow { | |
| 399 | + | ( | |
| 400 | + | id, | |
| 401 | + | "tags".to_string(), | |
| 402 | + | op.to_string(), | |
| 403 | + | row_id.to_string(), | |
| 404 | + | "2024-01-01T00:00:00Z".to_string(), | |
| 405 | + | data.map(str::to_string), | |
| 406 | + | hlc_json, | |
| 407 | + | ) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // ── prepare_batch (pure) ── | |
| 411 | + | ||
| 412 | + | #[test] | |
| 413 | + | fn prepare_batch_builds_entries_from_valid_rows() { | |
| 414 | + | let node = Uuid::from_u128(1); | |
| 415 | + | let h = serde_json::to_string(&hlc(500, node)).unwrap(); | |
| 416 | + | let rows = vec![ | |
| 417 | + | row(1, "INSERT", "k1", Some(r#"{"sample_hash":"h1","tag":"drums"}"#), Some(h.clone())), | |
| 418 | + | row(2, "UPDATE", "k2", Some(r#"{"sample_hash":"h2","tag":"bass"}"#), Some(h)), | |
| 419 | + | ]; | |
| 420 | + | let b = prepare_batch(rows, node); | |
| 421 | + | assert_eq!(b.skipped, 0); | |
| 422 | + | assert_eq!(b.changes.len(), 2); | |
| 423 | + | assert_eq!(b.pushed_ids, vec![1, 2]); | |
| 424 | + | // Every entry contributes a ledger update keyed by (table, row_id). | |
| 425 | + | assert_eq!(b.ledger_updates.len(), 2); | |
| 426 | + | assert_eq!(b.changes[0].op, ChangeOp::Insert); | |
| 427 | + | assert_eq!(b.changes[1].op, ChangeOp::Update); | |
| 428 | + | assert_eq!(b.changes[0].hlc, hlc(500, node)); | |
| 429 | + | assert_eq!(b.changes[0].timestamp.to_rfc3339(), "2024-01-01T00:00:00+00:00"); | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | #[test] | |
| 433 | + | fn prepare_batch_unknown_op_is_skipped_but_marked_pushed() { | |
| 434 | + | let node = Uuid::from_u128(2); | |
| 435 | + | let h = serde_json::to_string(&hlc(1, node)).unwrap(); | |
| 436 | + | let rows = vec![ | |
| 437 | + | row(1, "BOGUS", "k1", None, Some(h.clone())), | |
| 438 | + | row(2, "INSERT", "k2", Some(r#"{"sample_hash":"h2","tag":"bass"}"#), Some(h)), | |
| 439 | + | ]; | |
| 440 | + | let b = prepare_batch(rows, node); | |
| 441 | + | assert_eq!(b.skipped, 1); | |
| 442 | + | assert_eq!(b.changes.len(), 1, "only the valid row becomes a ChangeEntry"); | |
| 443 | + | // Both ids are marked pushed so the bad row is dropped, not retried. | |
| 444 | + | assert_eq!(b.pushed_ids, vec![1, 2]); | |
| 445 | + | assert_eq!(b.changes[0].row_id, "k2"); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | #[test] | |
| 449 | + | fn prepare_batch_unparseable_data_is_skipped() { | |
| 450 | + | let node = Uuid::from_u128(3); | |
| 451 | + | let rows = vec![row(7, "INSERT", "k1", Some("{not valid json"), None)]; | |
| 452 | + | let b = prepare_batch(rows, node); | |
| 453 | + | assert_eq!(b.skipped, 1); | |
| 454 | + | assert!(b.changes.is_empty()); | |
| 455 | + | assert_eq!(b.pushed_ids, vec![7], "bad-JSON row is marked pushed to break the retry loop"); | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | #[test] | |
| 459 | + | fn prepare_batch_missing_hlc_falls_back_to_zero() { | |
| 460 | + | let node = Uuid::from_u128(4); | |
| 461 | + | // hlc column NULL and an unparseable hlc string both fall back to zero. | |
| 462 | + | let rows = vec![ | |
| 463 | + | row(1, "DELETE", "k1", None, None), | |
| 464 | + | row(2, "DELETE", "k2", None, Some("garbage".to_string())), | |
| 465 | + | ]; | |
| 466 | + | let b = prepare_batch(rows, node); | |
| 467 | + | assert_eq!(b.changes.len(), 2); | |
| 468 | + | assert_eq!(b.changes[0].hlc, Hlc::zero(node)); | |
| 469 | + | assert_eq!(b.changes[1].hlc, Hlc::zero(node)); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | #[test] | |
| 473 | + | fn prepare_batch_none_data_is_preserved_not_skipped() { | |
| 474 | + | let node = Uuid::from_u128(5); | |
| 475 | + | let rows = vec![row(1, "DELETE", "k1", None, None)]; | |
| 476 | + | let b = prepare_batch(rows, node); | |
| 477 | + | assert_eq!(b.skipped, 0); | |
| 478 | + | assert_eq!(b.changes.len(), 1); | |
| 479 | + | assert!(b.changes[0].data.is_none(), "a DELETE carries no data payload"); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[test] | |
| 483 | + | fn prepare_batch_empty_input_is_empty_output() { | |
| 484 | + | let b = prepare_batch(Vec::new(), Uuid::from_u128(6)); | |
| 485 | + | assert!(b.changes.is_empty()); | |
| 486 | + | assert!(b.pushed_ids.is_empty()); | |
| 487 | + | assert!(b.ledger_updates.is_empty()); | |
| 488 | + | assert_eq!(b.skipped, 0); | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // ── mark_pushed (DB) ── | |
| 492 | + | ||
| 493 | + | fn insert_changelog(conn: &Connection, table: &str, op: &str, row_id: &str) -> i64 { | |
| 494 | + | conn.execute( | |
| 495 | + | "INSERT INTO sync_changelog (table_name, op, row_id, timestamp, pushed) | |
| 496 | + | VALUES (?1, ?2, ?3, '2024-01-01T00:00:00Z', 0)", | |
| 497 | + | rusqlite::params![table, op, row_id], | |
| 498 | + | ) | |
| 499 | + | .unwrap(); | |
| 500 | + | conn.last_insert_rowid() | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | fn pushed_flag(conn: &Connection, id: i64) -> i64 { | |
| 504 | + | conn.query_row("SELECT pushed FROM sync_changelog WHERE id = ?1", [id], |r| r.get(0)) | |
| 505 | + | .unwrap() | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | #[test] | |
| 509 | + | fn mark_pushed_marks_only_listed_ids() { | |
| 510 | + | let db = Database::open_in_memory().unwrap(); | |
| 511 | + | let conn = db.conn(); | |
| 512 | + | let a = insert_changelog(conn, "tags", "INSERT", "k1"); | |
| 513 | + | let b = insert_changelog(conn, "tags", "INSERT", "k2"); | |
| 514 | + | let c = insert_changelog(conn, "tags", "INSERT", "k3"); | |
| 515 | + | ||
| 516 | + | mark_pushed(conn, &[a, c], &[]).unwrap(); | |
| 517 | + | ||
| 518 | + | assert_eq!(pushed_flag(conn, a), 1); | |
| 519 | + | assert_eq!(pushed_flag(conn, b), 0, "an id not in the batch stays unpushed"); | |
| 520 | + | assert_eq!(pushed_flag(conn, c), 1); | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | #[test] | |
| 524 | + | fn mark_pushed_records_ledger_commits() { | |
| 525 | + | let db = Database::open_in_memory().unwrap(); | |
| 526 | + | let conn = db.conn(); | |
| 527 | + | let id = insert_changelog(conn, "tags", "INSERT", "k1"); | |
| 528 | + | let node = Uuid::from_u128(9); | |
| 529 | + | let clock = hlc(1234, node); | |
| 530 | + | ||
| 531 | + | mark_pushed(conn, &[id], &[("tags".to_string(), "k1".to_string(), clock)]).unwrap(); | |
| 532 | + | ||
| 533 | + | assert_eq!(pushed_flag(conn, id), 1); | |
| 534 | + | assert_eq!( | |
| 535 | + | super::super::hlc::committed_hlc(conn, "tags", "k1"), | |
| 536 | + | Some(clock), | |
| 537 | + | "our own edit's HLC must be committed so a later stale remote is gated" | |
| 538 | + | ); | |
| 539 | + | } | |
| 540 | + | } |