|
|
|
|
@ -1,465 +0,0 @@
|
|
|
|
|
//! Модель воркаутов intervals.icu: парсинг event-JSON и разворачивание
|
|
|
|
|
//! вложенных повторов в линейный список сегментов для исполнения на станке.
|
|
|
|
|
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
// ===== Чистые (доменные) типы =====
|
|
|
|
|
|
|
|
|
|
/// Целевая мощность шага. Значения — в % от FTP (все скачанные воркауты
|
|
|
|
|
/// используют `units: "%ftp"`). Перевод %FTP -> ватты делается на этапе
|
|
|
|
|
/// исполнения, зная FTP.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
|
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
|
|
|
pub enum PowerTarget {
|
|
|
|
|
/// Фиксированная мощность, % FTP.
|
|
|
|
|
Fixed { percent: f64 },
|
|
|
|
|
/// Линейный ramp от `from` до `to` % FTP за время шага (`ramp: true`).
|
|
|
|
|
Ramp { from: f64, to: f64 },
|
|
|
|
|
/// Свободный диапазон `low..high` % FTP (не ramp) — гонщик едет внутри.
|
|
|
|
|
Range { low: f64, high: f64 },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Один шаг воркаута до разворачивания повторов.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
|
|
|
pub struct WorkoutStep {
|
|
|
|
|
pub text: Option<String>,
|
|
|
|
|
pub duration_secs: u32,
|
|
|
|
|
pub power: Option<PowerTarget>,
|
|
|
|
|
pub cadence_rpm: Option<u16>,
|
|
|
|
|
/// Сколько раз повторить (для блока с `children`); для листа — 1.
|
|
|
|
|
pub repeats: u32,
|
|
|
|
|
pub children: Vec<WorkoutStep>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Развёрнутый линейный сегмент — атом исполнения.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
|
|
|
pub struct Segment {
|
|
|
|
|
pub text: Option<String>,
|
|
|
|
|
pub duration_secs: u32,
|
|
|
|
|
pub power: Option<PowerTarget>,
|
|
|
|
|
pub cadence_rpm: Option<u16>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Воркаут целиком.
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
pub struct Workout {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub start_date_local: Option<String>,
|
|
|
|
|
pub icu_training_load: Option<f64>,
|
|
|
|
|
pub steps: Vec<WorkoutStep>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Сводка воркаута для списка.
|
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
|
|
|
pub struct WorkoutSummary {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub date: String,
|
|
|
|
|
pub duration_min: u64,
|
|
|
|
|
pub load: Option<f64>,
|
|
|
|
|
pub path: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Сырые serde-структуры (повторяют формат /events intervals.icu) =====
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct RawEvent {
|
|
|
|
|
name: Option<String>,
|
|
|
|
|
#[serde(rename = "start_date_local")]
|
|
|
|
|
start_date_local: Option<String>,
|
|
|
|
|
#[serde(rename = "icu_training_load")]
|
|
|
|
|
icu_training_load: Option<f64>,
|
|
|
|
|
workout_doc: Option<RawWorkoutDoc>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct RawWorkoutDoc {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
steps: Vec<RawStep>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct RawStep {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
text: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
duration: Option<u32>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
ramp: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
reps: Option<u32>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
power: Option<RawPower>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
cadence: Option<RawCadence>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
steps: Vec<RawStep>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct RawPower {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
units: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
value: Option<f64>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
start: Option<f64>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
end: Option<f64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct RawCadence {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
value: Option<f64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Ошибки =====
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
|
pub enum WorkoutError {
|
|
|
|
|
#[error("invalid workout JSON: {0}")]
|
|
|
|
|
Json(#[from] serde_json::Error),
|
|
|
|
|
#[error("io error: {0}")]
|
|
|
|
|
Io(#[from] std::io::Error),
|
|
|
|
|
#[error("unsafe workout path: {0}")]
|
|
|
|
|
InvalidPath(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Парсинг =====
|
|
|
|
|
|
|
|
|
|
/// Разбирает event-JSON intervals.icu в `Workout`.
|
|
|
|
|
pub fn parse_workout(json: &str) -> Result<Workout, WorkoutError> {
|
|
|
|
|
let raw: RawEvent = serde_json::from_str(json)?;
|
|
|
|
|
let steps = raw
|
|
|
|
|
.workout_doc
|
|
|
|
|
.map(|d| d.steps)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(convert_step)
|
|
|
|
|
.collect();
|
|
|
|
|
Ok(Workout {
|
|
|
|
|
name: raw.name.unwrap_or_default(),
|
|
|
|
|
start_date_local: raw.start_date_local,
|
|
|
|
|
icu_training_load: raw.icu_training_load,
|
|
|
|
|
steps,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn convert_step(raw: RawStep) -> WorkoutStep {
|
|
|
|
|
WorkoutStep {
|
|
|
|
|
text: raw.text,
|
|
|
|
|
duration_secs: raw.duration.unwrap_or(0),
|
|
|
|
|
power: raw.power.and_then(|p| convert_power(p, raw.ramp)),
|
|
|
|
|
cadence_rpm: raw.cadence.and_then(|c| c.value.map(|v| v.round() as u16)),
|
|
|
|
|
repeats: raw.reps.unwrap_or(1),
|
|
|
|
|
children: raw.steps.into_iter().map(convert_step).collect(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Переводит сырую мощность в `PowerTarget`. Флаг `ramp` (с шага) отличает
|
|
|
|
|
/// линейный ramp от свободного диапазона. Предполагаем `units == "%ftp"`
|
|
|
|
|
/// (все скачанные воркауты используют %ftp); абсолютные ватты не поддерживаются.
|
|
|
|
|
fn convert_power(p: RawPower, ramp: bool) -> Option<PowerTarget> {
|
|
|
|
|
// Отвергаем воркауты в абсолютных ваттах: перевод watts → %FTP требует FTP,
|
|
|
|
|
// которого нет на этапе парсинга. Принимаем только %ftp (units == None
|
|
|
|
|
// трактуем как %ftp — все скачанные воркауты используют %ftp).
|
|
|
|
|
if matches!(p.units.as_deref(), Some(u) if u != "%ftp") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
if let Some(v) = p.value {
|
|
|
|
|
return Some(PowerTarget::Fixed { percent: v });
|
|
|
|
|
}
|
|
|
|
|
match (p.start, p.end) {
|
|
|
|
|
(Some(s), Some(e)) if ramp => Some(PowerTarget::Ramp { from: s, to: e }),
|
|
|
|
|
(Some(s), Some(e)) => Some(PowerTarget::Range { low: s, high: e }),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== Flatten =====
|
|
|
|
|
|
|
|
|
|
/// Разворачивает шаги (включая вложенные `repeats`) в линейный список сегментов.
|
|
|
|
|
pub fn flatten(steps: &[WorkoutStep]) -> Vec<Segment> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for step in steps {
|
|
|
|
|
let reps = step.repeats.max(1) as usize;
|
|
|
|
|
for _ in 0..reps {
|
|
|
|
|
if step.children.is_empty() {
|
|
|
|
|
out.push(Segment {
|
|
|
|
|
text: step.text.clone(),
|
|
|
|
|
duration_secs: step.duration_secs,
|
|
|
|
|
power: step.power.clone(),
|
|
|
|
|
cadence_rpm: step.cadence_rpm,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
out.extend(flatten(&step.children));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Суммарная длительность линейного списка сегментов, секунды.
|
|
|
|
|
pub fn total_duration_secs(segments: &[Segment]) -> u64 {
|
|
|
|
|
segments.iter().map(|s| s.duration_secs as u64).sum()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Целевая мощность (%FTP) в момент `elapsed_secs` внутри шага.
|
|
|
|
|
/// Fixed — константа; Ramp — линейная интерполяция from→to.
|
|
|
|
|
///
|
|
|
|
|
/// Range (свободный диапазон low..high) сводится к середине: в ERG-режиме
|
|
|
|
|
/// нужна конкретная цель, середина — осознанный дефолт. Альтернативы: не слать
|
|
|
|
|
/// ERG вовсе (свободный ход) или слать low как «пол».
|
|
|
|
|
pub fn target_percent(power: &PowerTarget, elapsed_secs: f64, duration_secs: u32) -> f64 {
|
|
|
|
|
match power {
|
|
|
|
|
PowerTarget::Fixed { percent } => *percent,
|
|
|
|
|
PowerTarget::Ramp { from, to } => {
|
|
|
|
|
if duration_secs == 0 {
|
|
|
|
|
*to
|
|
|
|
|
} else {
|
|
|
|
|
let t = (elapsed_secs / duration_secs as f64).clamp(0.0, 1.0);
|
|
|
|
|
from + (to - from) * t
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
PowerTarget::Range { low, high } => (low + high) / 2.0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// %FTP → ватты (округление до целого).
|
|
|
|
|
pub fn percent_to_watts(percent: f64, ftp: u16) -> i16 {
|
|
|
|
|
(percent / 100.0 * ftp as f64).round() as i16
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Каталог воркаутов: env `RIDEIN_WORKOUTS_DIR`, иначе `workouts/` рядом с cwd.
|
|
|
|
|
pub fn workouts_dir() -> PathBuf {
|
|
|
|
|
std::env::var("RIDEIN_WORKOUTS_DIR")
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|_| PathBuf::from("workouts"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Сканирует каталог и возвращает сводки Ride-воркаутов (с непустыми шагами),
|
|
|
|
|
/// отсортированные по дате. `events.json` и неразбираемые файлы пропускаются.
|
|
|
|
|
pub fn list_workouts(dir: &Path) -> Result<Vec<WorkoutSummary>, WorkoutError> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for entry in std::fs::read_dir(dir)? {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if path.file_name().and_then(|f| f.to_str()) == Some("events.json") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let workout = match read_workout_file(&path) {
|
|
|
|
|
Ok(w) => w,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("skip unparseable workout {}: {e}", path.display());
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if workout.steps.is_empty() {
|
|
|
|
|
continue; // не Ride (нет workout_doc.steps)
|
|
|
|
|
}
|
|
|
|
|
let duration_min = total_duration_secs(&flatten(&workout.steps)) / 60;
|
|
|
|
|
out.push(WorkoutSummary {
|
|
|
|
|
name: workout.name.clone(),
|
|
|
|
|
date: workout.start_date_local.clone().unwrap_or_default(),
|
|
|
|
|
duration_min,
|
|
|
|
|
load: workout.icu_training_load,
|
|
|
|
|
path: path.file_name().unwrap().to_string_lossy().into_owned(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
out.sort_by(|a, b| a.date.cmp(&b.date));
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Читает воркаут по имени файла (без пути) из каталога воркаутов.
|
|
|
|
|
pub fn read_workout(filename: &str) -> Result<Workout, WorkoutError> {
|
|
|
|
|
let fname = Path::new(filename);
|
|
|
|
|
if filename.contains("..") || fname.components().count() != 1 {
|
|
|
|
|
return Err(WorkoutError::InvalidPath(filename.to_string()));
|
|
|
|
|
}
|
|
|
|
|
let path = workouts_dir().join(fname);
|
|
|
|
|
read_workout_file(&path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_workout_file(path: &Path) -> Result<Workout, WorkoutError> {
|
|
|
|
|
let json = std::fs::read_to_string(path)?;
|
|
|
|
|
parse_workout(&json)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
const EVENT: &str = r#"{
|
|
|
|
|
"name": "Test",
|
|
|
|
|
"start_date_local": "2026-10-28T09:00:00",
|
|
|
|
|
"icu_training_load": 91,
|
|
|
|
|
"workout_doc": {
|
|
|
|
|
"steps": [
|
|
|
|
|
{"ramp": true, "text": "Warmup", "power": {"units":"%ftp","start":45,"end":75}, "cadence": {"units":"rpm","value":85}, "duration": 600},
|
|
|
|
|
{"text": "Endurance", "power": {"units":"%ftp","start":65,"end":75}, "duration": 300},
|
|
|
|
|
{"text": "Primer", "power": {"units":"%ftp","value":95}, "cadence": {"units":"rpm","value":90}, "duration": 60},
|
|
|
|
|
{"reps": 4, "text": "Main", "steps": [
|
|
|
|
|
{"text": "Under", "power": {"units":"%ftp","value":90}, "duration": 180},
|
|
|
|
|
{"text": "Over", "power": {"units":"%ftp","value":105}, "duration": 120}
|
|
|
|
|
], "duration": 1200}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}"#;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_workout_metadata() {
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
assert_eq!(w.name, "Test");
|
|
|
|
|
assert_eq!(w.start_date_local.as_deref(), Some("2026-10-28T09:00:00"));
|
|
|
|
|
assert_eq!(w.icu_training_load, Some(91.0));
|
|
|
|
|
assert_eq!(w.steps.len(), 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_power_targets() {
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
// Warmup -> ramp
|
|
|
|
|
assert_eq!(
|
|
|
|
|
w.steps[0].power,
|
|
|
|
|
Some(PowerTarget::Ramp { from: 45.0, to: 75.0 })
|
|
|
|
|
);
|
|
|
|
|
// Endurance (start/end, no ramp flag) -> range
|
|
|
|
|
assert_eq!(
|
|
|
|
|
w.steps[1].power,
|
|
|
|
|
Some(PowerTarget::Range { low: 65.0, high: 75.0 })
|
|
|
|
|
);
|
|
|
|
|
// Primer -> fixed
|
|
|
|
|
assert_eq!(
|
|
|
|
|
w.steps[2].power,
|
|
|
|
|
Some(PowerTarget::Fixed { percent: 95.0 })
|
|
|
|
|
);
|
|
|
|
|
// cadence: Warmup 85rpm
|
|
|
|
|
assert_eq!(w.steps[0].cadence_rpm, Some(85));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_non_ftp_power_units() {
|
|
|
|
|
// units:"watts" нельзя перевести в %FTP без FTP на этапе парсинга —
|
|
|
|
|
// шаг должен терять цель мощности, а не трактовать ватты как проценты.
|
|
|
|
|
let json = r#"{"name":"W","workout_doc":{"steps":[
|
|
|
|
|
{"text":"X","duration":60,"power":{"units":"watts","value":200}}
|
|
|
|
|
]}}"#;
|
|
|
|
|
let w = parse_workout(json).unwrap();
|
|
|
|
|
assert_eq!(w.steps[0].power, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn flatten_expands_repeats() {
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
let segs = flatten(&w.steps);
|
|
|
|
|
// Warmup + Endurance + Primer + 4x(Under,Over) = 3 + 8 = 11
|
|
|
|
|
assert_eq!(segs.len(), 11);
|
|
|
|
|
// первая четвёрка после первых трёх шагов — Under/Over/Under/Over
|
|
|
|
|
let main = &segs[3..11];
|
|
|
|
|
assert_eq!(main.len(), 8);
|
|
|
|
|
assert_eq!(main[0].text.as_deref(), Some("Under"));
|
|
|
|
|
assert_eq!(main[1].text.as_deref(), Some("Over"));
|
|
|
|
|
assert_eq!(main[6].text.as_deref(), Some("Under"));
|
|
|
|
|
assert_eq!(main[7].text.as_deref(), Some("Over"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn total_duration_matches() {
|
|
|
|
|
let w = parse_workout(EVENT).unwrap();
|
|
|
|
|
let segs = flatten(&w.steps);
|
|
|
|
|
// 600 + 300 + 60 + 4*(180+120) = 2160
|
|
|
|
|
assert_eq!(total_duration_secs(&segs), 2160);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[ignore]
|
|
|
|
|
fn parses_real_downloaded_workouts() {
|
|
|
|
|
let cases = [
|
|
|
|
|
("2026-10-28_threshold-over-under-2x20.json", 4380),
|
|
|
|
|
("2026-10-30_vo2max-5x4min.json", 3600),
|
|
|
|
|
("2026-11-01_long-z2-endurance.json", 7200),
|
|
|
|
|
];
|
|
|
|
|
for (file, expected_secs) in cases {
|
|
|
|
|
let path = format!("/home/dude/code/ridein/workouts/{file}");
|
|
|
|
|
let json = std::fs::read_to_string(&path).unwrap();
|
|
|
|
|
let w = parse_workout(&json).unwrap();
|
|
|
|
|
let segs = flatten(&w.steps);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
total_duration_secs(&segs),
|
|
|
|
|
expected_secs,
|
|
|
|
|
"workout {file}: flat total mismatch"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// list_workouts на реальном каталоге: 3 Ride-воркаута, по дате.
|
|
|
|
|
let dir = std::path::Path::new("/home/dude/code/ridein/workouts");
|
|
|
|
|
let list = list_workouts(dir).unwrap();
|
|
|
|
|
assert_eq!(list.len(), 3);
|
|
|
|
|
assert_eq!(list[0].name, "Threshold Over-Under 2x20");
|
|
|
|
|
assert_eq!(list[1].name, "VO2max 5x4min");
|
|
|
|
|
assert_eq!(list[2].name, "Long Z2 (Endurance)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn lists_ride_workouts_skipping_non_ride() {
|
|
|
|
|
let dir = std::env::temp_dir().join("ridein-workout-list-test");
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
// Ride-воркаут (с шагами)
|
|
|
|
|
std::fs::write(dir.join("2026-10-28_test-a.json"), EVENT).unwrap();
|
|
|
|
|
// events.json — пропускается по имени
|
|
|
|
|
std::fs::write(dir.join("events.json"), "[{}]").unwrap();
|
|
|
|
|
// не-Ride (пустой workout_doc) — пропускается
|
|
|
|
|
std::fs::write(
|
|
|
|
|
dir.join("2026-10-30_yoga.json"),
|
|
|
|
|
r#"{"name":"Yoga","workout_doc":{"steps":[]}}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let list = list_workouts(&dir).unwrap();
|
|
|
|
|
assert_eq!(list.len(), 1);
|
|
|
|
|
assert_eq!(list[0].name, "Test");
|
|
|
|
|
assert_eq!(list[0].duration_min, 36); // 2160s / 60
|
|
|
|
|
assert_eq!(list[0].load, Some(91.0));
|
|
|
|
|
assert_eq!(list[0].path, "2026-10-28_test-a.json");
|
|
|
|
|
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn read_workout_rejects_traversal() {
|
|
|
|
|
assert!(read_workout("../etc/passwd").is_err());
|
|
|
|
|
assert!(read_workout("a/b.json").is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn percent_to_watts_rounds() {
|
|
|
|
|
assert_eq!(percent_to_watts(100.0, 250), 250);
|
|
|
|
|
assert_eq!(percent_to_watts(90.0, 250), 225);
|
|
|
|
|
assert_eq!(percent_to_watts(105.0, 250), 263); // 262.5 → 263
|
|
|
|
|
assert_eq!(percent_to_watts(55.0, 250), 138); // 137.5 → 138
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ramp_interpolates_linearly() {
|
|
|
|
|
let ramp = PowerTarget::Ramp { from: 45.0, to: 75.0 };
|
|
|
|
|
assert_eq!(target_percent(&ramp, 0.0, 600), 45.0);
|
|
|
|
|
assert_eq!(target_percent(&ramp, 300.0, 600), 60.0);
|
|
|
|
|
assert_eq!(target_percent(&ramp, 600.0, 600), 75.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn fixed_and_range_targets() {
|
|
|
|
|
let fixed = PowerTarget::Fixed { percent: 95.0 };
|
|
|
|
|
assert_eq!(target_percent(&fixed, 0.0, 60), 95.0);
|
|
|
|
|
assert_eq!(target_percent(&fixed, 30.0, 60), 95.0);
|
|
|
|
|
|
|
|
|
|
let range = PowerTarget::Range { low: 65.0, high: 75.0 };
|
|
|
|
|
assert_eq!(target_percent(&range, 0.0, 300), 70.0);
|
|
|
|
|
}
|
|
|
|
|
}
|