use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use ts_rs::TS;
use ulid::Ulid;
use crate::execution_mode::ExecutionMode;
use crate::intensity::Intensity;
use crate::measure::Measure;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct NodeId(pub String);
impl NodeId {
pub fn new() -> Self {
Self(Ulid::new().to_string())
}
pub fn from_string(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl Default for NodeId {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
#[serde(tag = "type", content = "value")]
pub enum NodeKind {
Set,
Exercise,
Block,
Session,
Day,
Week,
Phase,
Program,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
#[serde(tag = "type")]
pub enum NodePayload {
Leaf {
measures: Vec<Measure>,
#[serde(skip_serializing_if = "Option::is_none")]
intensity: Option<Intensity>,
},
Exercise {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
measures: Vec<Measure>,
#[serde(skip_serializing_if = "Option::is_none")]
intensity: Option<Intensity>,
#[serde(skip_serializing_if = "Option::is_none")]
rest_seconds: Option<f64>,
},
Block {
execution_mode: ExecutionMode,
#[serde(skip_serializing_if = "Option::is_none")]
rest_seconds: Option<f64>,
},
Temporal {
#[serde(skip_serializing_if = "Option::is_none")]
rest_seconds: Option<f64>,
},
Custom {
#[serde(flatten)]
data: BTreeMap<String, serde_json::Value>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "../../../bindings/napi/generated/")]
pub struct Node {
pub id: NodeId,
pub kind: NodeKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<Node>,
pub payload: NodePayload,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::measure::{Measure, WeightUnit};
#[test]
fn leaf_node_round_trip() {
let node = Node {
id: NodeId::from_string("test-set-1"),
kind: NodeKind::Set,
name: None,
children: vec![],
payload: NodePayload::Leaf {
measures: vec![
Measure::Weight { amount: 80.0, unit: WeightUnit::Kg },
Measure::Reps(8),
],
intensity: Some(Intensity::RPE(8.0)),
},
metadata: BTreeMap::new(),
};
let json = serde_json::to_string_pretty(&node).unwrap();
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back, node);
}
#[test]
fn exercise_with_sets_round_trip() {
let set1 = Node {
id: NodeId::from_string("set-1"),
kind: NodeKind::Set,
name: None,
children: vec![],
payload: NodePayload::Leaf {
measures: vec![
Measure::Weight { amount: 100.0, unit: WeightUnit::Kg },
Measure::Reps(5),
],
intensity: None,
},
metadata: BTreeMap::new(),
};
let set2 = Node {
id: NodeId::from_string("set-2"),
kind: NodeKind::Set,
name: None,
children: vec![],
payload: NodePayload::Leaf {
measures: vec![
Measure::Weight { amount: 100.0, unit: WeightUnit::Kg },
Measure::Reps(5),
],
intensity: None,
},
metadata: BTreeMap::new(),
};
let exercise = Node {
id: NodeId::from_string("bench-press"),
kind: NodeKind::Exercise,
name: Some("Bench Press".into()),
children: vec![set1, set2],
payload: NodePayload::Exercise {
measures: vec![],
intensity: None,
rest_seconds: Some(180.0),
},
metadata: BTreeMap::new(),
};
let json = serde_json::to_string_pretty(&exercise).unwrap();
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back.children.len(), 2);
assert_eq!(back.name, Some("Bench Press".into()));
}
#[test]
fn block_with_superset_round_trip() {
let ex1 = Node {
id: NodeId::from_string("ex-1"),
kind: NodeKind::Exercise,
name: Some("DB Row".into()),
children: vec![],
payload: NodePayload::Exercise {
measures: vec![],
intensity: Some(Intensity::RPE(8.0)),
rest_seconds: None,
},
metadata: BTreeMap::new(),
};
let ex2 = Node {
id: NodeId::from_string("ex-2"),
kind: NodeKind::Exercise,
name: Some("Incline Press".into()),
children: vec![],
payload: NodePayload::Exercise {
measures: vec![],
intensity: Some(Intensity::RPE(7.0)),
rest_seconds: None,
},
metadata: BTreeMap::new(),
};
let block = Node {
id: NodeId::from_string("block-b"),
kind: NodeKind::Block,
name: Some("Superset B".into()),
children: vec![ex1, ex2],
payload: NodePayload::Block {
execution_mode: ExecutionMode::Parallel,
rest_seconds: Some(90.0),
},
metadata: BTreeMap::new(),
};
let json = serde_json::to_string_pretty(&block).unwrap();
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back.children.len(), 2);
if let NodePayload::Block { execution_mode, .. } = &back.payload {
assert_eq!(*execution_mode, ExecutionMode::Parallel);
} else {
panic!("expected Block payload");
}
}
#[test]
fn metadata_round_trip() {
let mut metadata = BTreeMap::new();
metadata.insert("warmup".into(), serde_json::json!(true));
let node = Node {
id: NodeId::from_string("test"),
kind: NodeKind::Set,
name: None,
children: vec![],
payload: NodePayload::Leaf {
measures: vec![Measure::Reps(10)],
intensity: None,
},
metadata,
};
let json = serde_json::to_string(&node).unwrap();
let back: Node = serde_json::from_str(&json).unwrap();
assert_eq!(back.metadata.get("warmup"), Some(&serde_json::json!(true)));
}
}