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())),
}
}
/// Записанные команды (для тестов движка воркаута).
pub(crate) fn commands(&self) -> Vec<Command> {
self.commands.lock().unwrap().clone()
}
}
#[async_trait]

@ -279,17 +279,14 @@ async fn apply_power(
watts
}
/// Шлёт событие прогресса воркаута на фронт.
fn emit_progress(
app: &AppHandle,
/// Собирает событие прогресса воркаута.
fn progress_event(
idx: usize,
seg: &workout::Segment,
watts: Option<i16>,
elapsed_total: u64,
total: u64,
) {
let _ = app.emit(
"workout-progress",
) -> WorkoutProgressEvent {
WorkoutProgressEvent {
step_index: idx,
step_name: seg.text.clone().unwrap_or_default(),
@ -297,26 +294,25 @@ fn emit_progress(
target_cadence: seg.cadence_rpm,
elapsed_secs: elapsed_total,
total_secs: total,
},
);
}
}
/// Исполняет воркаут: request_control + start, тик-луп по сегментам, stop в конце.
async fn run_workout(
app: AppHandle,
source: Arc<dyn SensorSource>,
device: Device,
segments: Vec<workout::Segment>,
/// Ядро исполнения воркаута (без AppHandle) — тестируемо против MockSource.
async fn run_workout_engine(
source: &Arc<dyn SensorSource>,
device: &Device,
segments: &[workout::Segment],
ftp: u16,
paused: Arc<AtomicBool>,
) {
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<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);
// 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<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_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