Skip to main content

max / audiofiles

test: drive Testing to A across sync, app, core, and browser (+79 tests) Close every major unit-testable coverage gap the audit surfaced, taking the suite from 1004 to 1083 tests. Some closures/draw code were factored into pure, behavior-preserving helpers so the logic became reachable from unit tests; each helper is wired into its production call site. core: - collections: dynamic (saved-search) collection lifecycle — filter roundtrip, update/not-found, malformed-JSON warn-and-drop. - store: relocate_missing_loose_files — moved-file hash match, still-missing residual, same-name-wrong-content rejection. - vfs_mirror: resolve_link_path collision disambiguation ((2)/(3)). - vault: relocate_vault + registry persistence (XDG-isolated, Linux-gated). sync: - upload: extract prepare_batch / mark_pushed from the spawn_blocking closures; test HLC-zero fallback, skip-but-mark-pushed, ledger commit. - download: extract set_cloud_only_suppressed; test flag toggle + changelog suppression + applying_remote restore. app: - midi: extract parse_note_message / push_note_event_capped; test note parsing (note-on/off, velocity-0, channel mask, truncated) and the 64-event cap. - activation: extract recovery_affordance / trial_is_expired / trial_button_label; test error-to-recovery routing and trial state. browser: - state/tests: async-worker happy-paths (analysis persists, export writes files, folder-import ingests) driven to completion on real WAVs, plus playback start/stop/toggle/missing-blob state flow. Forge (headline lane) chop/conform/preview state and worker completion. Residual gaps are integration-only (real cpal device output; live-server network round-trips with no HTTP-mock seam) and left documented, not faked. cargo test --workspace: 1083 pass; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 19:36 UTC
Commit: 3a3c131a8dd891eb436fa0ba64dcb5dc9cf46046
Parent: 860d7c7
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(&reg).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 + }