test: mock-verify workout engine command sequence (phase 6)

pull/4/head
lambda 6 days ago
parent 680d80fded
commit bbf6e97d87
No known key found for this signature in database
GPG Key ID: CF45A8EE158BBCB6

@ -57,6 +57,11 @@ impl MockSource {
commands: Arc::new(Mutex::new(Vec::new())), commands: Arc::new(Mutex::new(Vec::new())),
} }
} }
/// Записанные команды (для тестов движка воркаута).
pub(crate) fn commands(&self) -> Vec<Command> {
self.commands.lock().unwrap().clone()
}
} }
#[async_trait] #[async_trait]

@ -279,17 +279,14 @@ async fn apply_power(
watts watts
} }
/// Шлёт событие прогресса воркаута на фронт. /// Собирает событие прогресса воркаута.
fn emit_progress( fn progress_event(
app: &AppHandle,
idx: usize, idx: usize,
seg: &workout::Segment, seg: &workout::Segment,
watts: Option<i16>, watts: Option<i16>,
elapsed_total: u64, elapsed_total: u64,
total: u64, total: u64,
) { ) -> WorkoutProgressEvent {
let _ = app.emit(
"workout-progress",
WorkoutProgressEvent { WorkoutProgressEvent {
step_index: idx, step_index: idx,
step_name: seg.text.clone().unwrap_or_default(), step_name: seg.text.clone().unwrap_or_default(),
@ -297,26 +294,25 @@ fn emit_progress(
target_cadence: seg.cadence_rpm, target_cadence: seg.cadence_rpm,
elapsed_secs: elapsed_total, elapsed_secs: elapsed_total,
total_secs: total, total_secs: total,
}, }
);
} }
/// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце. /// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource.
async fn run_workout( async fn run_workout_engine(
app: AppHandle, source: &Arc<dyn SensorSource>,
source: Arc<dyn SensorSource>, device: &Device,
device: Device, segments: &[workout::Segment],
segments: Vec<workout::Segment>,
ftp: u16, ftp: u16,
paused: Arc<AtomicBool>, paused: &AtomicBool,
) { tick_duration: Duration,
if source.request_control(&device).await.is_err() { mut on_progress: impl FnMut(WorkoutProgressEvent) + Send,
let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: false }); ) -> bool {
return; 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; let mut elapsed_total: u64 = 0;
for (idx, seg) in segments.iter().enumerate() { for (idx, seg) in segments.iter().enumerate() {
@ -324,17 +320,17 @@ async fn run_workout(
// каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS). // каденс — константа сегмента, шлём один раз (в 0.5-rpm единицах FTMS).
if let Some(half) = seg.cadence_rpm.and_then(|rpm| rpm.checked_mul(2)) { 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<i16> = None; let mut last_watts: Option<i16> = 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); tick.set_missed_tick_behavior(time::MissedTickBehavior::Delay);
// t = 0 // 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; 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; let mut elapsed_in_seg: u64 = 0;
while elapsed_in_seg < dur { while elapsed_in_seg < dur {
@ -343,15 +339,40 @@ async fn run_workout(
continue; continue;
} }
elapsed_in_seg += 1; 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; last_watts = w;
elapsed_total += 1; 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 _ = source.command(device, Command::FtmsStop).await;
let _ = app.emit("workout-finished", WorkoutFinishedEvent { ok: true }); true
}
/// Исполняет воркаут: обёртка run_workout_engine с эмитом событий в приложение.
async fn run_workout(
app: AppHandle,
source: Arc<dyn SensorSource>,
device: Device,
segments: Vec<workout::Segment>,
ftp: u16,
paused: Arc<AtomicBool>,
) {
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!(saw);
assert_eq!(seen.len(), 2); assert_eq!(seen.len(), 2);
} }
#[tokio::test]
async fn workout_engine_sends_expected_commands() {
let mock = Arc::new(MockSource::new());
let source: Arc<dyn SensorSource> = 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,
]
);
}
} }

Loading…
Cancel
Save