syntax-workout-core 0.2.0

Workout tree algebra — represent any physical workout as a recursive tree
Documentation
use std::fs;
use std::path::PathBuf;
use syntax_workout_core::{Visit, Workout};
use syntax_workout_core::stats::{
    self, compute_tree_stats, SetCounter, VolumeCalculator, RepCounter, ExerciseVolumeCalculator,
};

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .to_path_buf()
}

fn load_workout(path: &str) -> Workout {
    let full_path = workspace_root().join(path);
    let content = fs::read_to_string(&full_path)
        .unwrap_or_else(|e| panic!("failed to read {}: {e}", full_path.display()));
    serde_json::from_str(&content)
        .unwrap_or_else(|e| panic!("failed to parse {}: {e}", full_path.display()))
}

#[test]
fn estimate_1rm_known_values() {
    // 80kg x 5 reps -> 80 * (1 + 5/30) = 80 * 7/6 = 93.333...
    let result = stats::estimate_1rm(80.0, 5);
    assert!((result - 93.333).abs() < 0.01);

    // single rep = weight itself
    assert_eq!(stats::estimate_1rm(100.0, 1), 100.0);
}

#[test]
fn straight_sets_stats() {
    let w = load_workout("examples/strength/straight-sets.json");
    let stats = compute_tree_stats(&w.root);

    // 3 sets of Bench Press: 80kg x 5, 80kg x 5, 80kg x 4
    assert_eq!(stats.total_sets, 3);
    assert_eq!(stats.total_reps, 14);
    assert!((stats.total_volume_kg - 1120.0).abs() < 0.01); // 400 + 400 + 320

    assert_eq!(stats.exercises.len(), 1);
    let bench = &stats.exercises[0];
    assert_eq!(bench.name, "Bench Press");
    assert_eq!(bench.sets, 3);
    assert_eq!(bench.reps, 14);
    assert!((bench.volume_kg - 1120.0).abs() < 0.01);
    assert!(bench.estimated_1rm.is_some());
    // best 1RM from 80x5 = 93.33
    assert!((bench.estimated_1rm.unwrap() - 93.333).abs() < 0.01);
}

#[test]
fn supersets_counts_parallel_blocks() {
    let w = load_workout("examples/strength/supersets.json");
    let stats = compute_tree_stats(&w.root);

    // 2 exercises x 2 sets each = 4 total sets
    assert_eq!(stats.total_sets, 4);
    assert_eq!(stats.total_reps, 44); // 10+10+12+12
    assert!((stats.total_volume_kg - 1200.0).abs() < 0.01);

    assert_eq!(stats.exercises.len(), 2);
    assert_eq!(stats.exercises[0].name, "DB Row");
    assert_eq!(stats.exercises[0].sets, 2);
    assert_eq!(stats.exercises[1].name, "Incline Press");
    assert_eq!(stats.exercises[1].sets, 2);
}

#[test]
fn endurance_has_zero_volume() {
    let w = load_workout("examples/endurance/easy-run.json");
    let stats = compute_tree_stats(&w.root);

    assert_eq!(stats.total_sets, 1);
    assert_eq!(stats.total_reps, 0);
    assert!((stats.total_volume_kg).abs() < 0.01);
}

#[test]
fn combined_matches_individual_visitors() {
    let w = load_workout("examples/strength/straight-sets.json");

    let mut set_counter = SetCounter(0);
    set_counter.visit_tree(&w.root);

    let mut vol_calc = VolumeCalculator(0.0);
    vol_calc.visit_tree(&w.root);

    let mut rep_counter = RepCounter(0);
    rep_counter.visit_tree(&w.root);

    let mut ex_vol = ExerciseVolumeCalculator {
        exercise_name: "Bench Press".to_string(),
        volume: 0.0,
    };
    ex_vol.visit_tree(&w.root);

    let stats = compute_tree_stats(&w.root);

    assert_eq!(stats.total_sets, set_counter.0);
    assert_eq!(stats.total_reps, rep_counter.0);
    assert!((stats.total_volume_kg - vol_calc.0).abs() < 0.001);
    assert!((stats.exercises[0].volume_kg - ex_vol.volume).abs() < 0.001);
}

#[test]
fn tree_stats_serializes_to_json() {
    let w = load_workout("examples/strength/straight-sets.json");
    let stats = compute_tree_stats(&w.root);

    let json = serde_json::to_string(&stats).unwrap();
    assert!(json.contains("total_sets"));
    assert!(json.contains("total_volume_kg"));
    assert!(json.contains("Bench Press"));

    // Can deserialize back
    let back: syntax_workout_core::stats::TreeStats = serde_json::from_str(&json).unwrap();
    assert_eq!(back.total_sets, stats.total_sets);
}