From bbf6e97d87e28849f1905c33a42003ed65c5ea04 Mon Sep 17 00:00:00 2001 From: lambda Date: Tue, 27 Oct 2026 16:25:24 +0300 Subject: [PATCH] test: mock-verify workout engine command sequence (phase 6) --- src-tauri/src/ble/mock.rs | 5 ++ src-tauri/src/lib.rs | 143 ++++++++++++++++++++++++++++---------- 2 files changed, 112 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/ble/mock.rs b/src-tauri/src/ble/mock.rs index 685b087..8fe1d9f 100644 --- a/src-tauri/src/ble/mock.rs +++ b/src-tauri/src/ble/mock.rs @@ -57,6 +57,11 @@ impl MockSource { commands: Arc::new(Mutex::new(Vec::new())), } } + + /// Записанные команды (для тестов движка воркаута). + pub(crate) fn commands(&self) -> Vec { + self.commands.lock().unwrap().clone() + } } #[async_trait] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 208ce38..d169bc3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -279,44 +279,40 @@ async fn apply_power( watts } -/// Шлёт событие прогресса воркаута на фронт. -fn emit_progress( - app: &AppHandle, +/// Собирает событие прогресса воркаута. +fn progress_event( idx: usize, seg: &workout::Segment, watts: Option, elapsed_total: u64, total: u64, -) { - let _ = app.emit( - "workout-progress", - WorkoutProgressEvent { - step_index: idx, - step_name: seg.text.clone().unwrap_or_default(), - target_watts: watts, - target_cadence: seg.cadence_rpm, - elapsed_secs: elapsed_total, - total_secs: total, - }, - ); +) -> WorkoutProgressEvent { + WorkoutProgressEvent { + step_index: idx, + step_name: seg.text.clone().unwrap_or_default(), + target_watts: watts, + target_cadence: seg.cadence_rpm, + elapsed_secs: elapsed_total, + total_secs: total, + } } -/// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце. -async fn run_workout( - app: AppHandle, - source: Arc, - device: Device, - segments: Vec, +/// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource. +async fn run_workout_engine( + source: &Arc, + device: &Device, + segments: &[workout::Segment], ftp: u16, - paused: Arc, -) { - if source.request_control(&device).await.is_err() { - let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: false }); - return; + paused: &AtomicBool, + tick_duration: Duration, + mut on_progress: impl FnMut(WorkoutProgressEvent) + Send, +) -> bool { + if source.request_control(device).await.is_err() { + return false; } - let _ = source.command(&device, Command::FtmsStart).await; + let _ = source.command(device, Command::FtmsStart).await; - let total = workout::total_duration_secs(&segments); + let total = workout::total_duration_secs(segments); let mut elapsed_total: u64 = 0; for (idx, seg) in segments.iter().enumerate() { @@ -324,17 +320,17 @@ async fn run_workout( // каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS). if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) { - let _ = source.command(&device, Command::FtmsSetTargetCadence(half)).await; + let _ = source.command(device, Command::FtmsSetTargetCadence(half)).await; } let mut last_watts: Option = None; - let mut tick = time::interval(Duration::from_secs(1)); + let mut tick = time::interval(tick_duration); tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay); // t = 0 - let w = apply_power(&source, &device, seg, 0.0, ftp, last_watts).await; + let w = apply_power(source, device, seg, 0.0, ftp, last_watts).await; last_watts = w; - emit_progress(&app, idx, seg, w, elapsed_total, total); + on_progress(progress_event(idx, seg, w, elapsed_total, total)); let mut elapsed_in_seg: u64 = 0; while elapsed_in_seg < dur { @@ -343,15 +339,40 @@ async fn run_workout( continue; } elapsed_in_seg += 1; - let w = apply_power(&source, &device, seg, elapsed_in_seg as f64, ftp, last_watts).await; + let w = apply_power(source, device, seg, elapsed_in_seg as f64, ftp, last_watts).await; last_watts = w; elapsed_total += 1; - emit_progress(&app, idx, seg, w, elapsed_total, total); + on_progress(progress_event(idx, seg, w, elapsed_total, total)); } } - let _ = source.command(&device, Command::FtmsStop).await; - let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true }); + let _ = source.command(device, Command::FtmsStop).await; + true +} + +/// Исполняет воркаут: обёртка run_workout_engine с эмитом событий в приложение. +async fn run_workout( + app: AppHandle, + source: Arc, + device: Device, + segments: Vec, + ftp: u16, + paused: Arc, +) { + let app_progress = app.clone(); + let ok = run_workout_engine( + &source, + &device, + &segments, + ftp, + paused.as_ref(), + Duration::from_secs(1), + move |p| { + let _ = app_progress.emit("workout-progress", p); + }, + ) + .await; + let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok }); } /// Запустить воркаут (по имени файла из каталога воркаутов). @@ -638,4 +659,54 @@ mod tests { assert!(saw); assert_eq!(seen.len(), 2); } + + #[tokio::test] + async fn workout_engine_sends_expected_commands() { + let mock = Arc::new(MockSource::new()); + let source: Arc = mock.clone(); + let devices = source.scan(0).await.unwrap(); + let trainer = devices.iter().find(|d| d.id == "mock-trainer").unwrap().clone(); + + // короткий воркаут: фикс-сегмент + ramp-сегмент + let segments = vec![ + workout::Segment { + text: Some("Warmup".into()), + duration_secs: 1, + power: Some(workout::PowerTarget::Fixed { percent: 50.0 }), + cadence_rpm: Some(85), + }, + workout::Segment { + text: Some("Ramp".into()), + duration_secs: 2, + power: Some(workout::PowerTarget::Ramp { from: 100.0, to: 200.0 }), + cadence_rpm: None, + }, + ]; + + let paused = Arc::new(AtomicBool::new(false)); + let ok = run_workout_engine( + &source, + &trainer, + &segments, + 250, + paused.as_ref(), + Duration::from_millis(20), + |_| {}, + ) + .await; + assert!(ok); + + assert_eq!( + mock.commands(), + vec![ + Command::FtmsStart, + Command::FtmsSetTargetCadence(170), // 85 rpm * 2 + Command::FtmsSetTargetPower(125), // 50% of 250 + Command::FtmsSetTargetPower(250), // 100% (ramp start) + Command::FtmsSetTargetPower(375), // 150% + Command::FtmsSetTargetPower(500), // 200% (ramp end) + Command::FtmsStop, + ] + ); + } }