Sync your own workout data from your "Strong" app
1use strong_api_lib::data_transformer::DataTransformer;
2use strong_api_lib::models::measurement::MeasurementsResponse;
3use strong_api_lib::models::workout::UserResponse;
4
5fn load_fixture(name: &str) -> String {
6 std::fs::read_to_string(format!(
7 "{}/tests/fixtures/{}",
8 env!("CARGO_MANIFEST_DIR"),
9 name
10 ))
11 .unwrap_or_else(|_| panic!("fixture '{name}' not found"))
12}
13
14fn measurements_from_fixture() -> MeasurementsResponse {
15 let json = load_fixture("measurements_response.json");
16 serde_json::from_str(&json).unwrap()
17}
18
19fn logs_from_fixture() -> Option<Vec<strong_api_lib::models::workout::Log>> {
20 let json = load_fixture("user_response.json");
21 let user: UserResponse = serde_json::from_str(&json).unwrap();
22 user.embedded.log
23}
24
25// ---------------------------------------------------------------------------
26// Basic transformation — no measurements loaded, names fall back to empty
27// ---------------------------------------------------------------------------
28
29#[test]
30fn test_transform_without_measurements_produces_workouts() {
31 let transformer = DataTransformer::new();
32 let logs = logs_from_fixture();
33 let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
34
35 assert!(!workouts.is_empty(), "should produce at least one workout");
36 // Without a measurement lookup every exercise name is empty string
37 for exercise in &workouts[0].exercises {
38 assert_eq!(exercise.name, "");
39 }
40}
41
42// ---------------------------------------------------------------------------
43// With measurements: names are resolved and exercises are non-empty
44// ---------------------------------------------------------------------------
45
46#[test]
47fn test_transform_with_measurements_resolves_names() {
48 let transformer =
49 DataTransformer::new().with_measurements_response(measurements_from_fixture());
50 let logs = logs_from_fixture();
51 let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
52
53 let exercises = &workouts[0].exercises;
54 assert!(!exercises.is_empty(), "should have at least one exercise");
55
56 // Every exercise whose measurement was found in the lookup has a non-empty name.
57 let named = exercises.iter().filter(|e| !e.name.is_empty()).count();
58 assert!(
59 named > 0,
60 "at least one exercise should have a resolved name"
61 );
62}
63
64// ---------------------------------------------------------------------------
65// Exercises come only from groups that have at least one valid (non-rest) set
66// ---------------------------------------------------------------------------
67
68#[test]
69fn test_only_groups_with_real_sets_become_exercises() {
70 let transformer =
71 DataTransformer::new().with_measurements_response(measurements_from_fixture());
72 let logs = logs_from_fixture();
73 let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
74
75 // Count CSGs in the raw fixture log
76 let json = load_fixture("user_response.json");
77 let user: UserResponse = serde_json::from_str(&json).unwrap();
78 let raw_csg_count = user.embedded.log.unwrap()[0].embedded.cell_set_group.len();
79
80 // Transformed exercise count can be at most the number of raw CSGs
81 assert!(workouts[0].exercises.len() <= raw_csg_count);
82}
83
84// ---------------------------------------------------------------------------
85// Sets: weight and reps fields are always present on every set
86// ---------------------------------------------------------------------------
87
88#[test]
89fn test_sets_have_weight_and_reps() {
90 let transformer =
91 DataTransformer::new().with_measurements_response(measurements_from_fixture());
92 let logs = logs_from_fixture();
93 let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
94
95 let exercises = &workouts[0].exercises;
96 for exercise in exercises {
97 assert!(
98 !exercise.sets.is_empty(),
99 "exercise {} should have sets",
100 exercise.id
101 );
102 for set in &exercise.sets {
103 let _ = set.reps; // always present
104 let _ = set.weight; // optional (None for bodyweight)
105 }
106 }
107}
108
109// ---------------------------------------------------------------------------
110// Workout metadata is preserved
111// ---------------------------------------------------------------------------
112
113#[test]
114fn test_workout_metadata_is_present() {
115 let transformer = DataTransformer::new();
116 let logs = logs_from_fixture();
117 let workouts = transformer.get_measurements_from_logs(&logs).unwrap();
118
119 let workout = &workouts[0];
120 assert!(!workout.id.is_empty());
121 assert!(workout.timezone.is_some(), "timezone should be present");
122 assert!(workout.start_date.is_some(), "start_date should be present");
123 assert!(workout.end_date.is_some(), "end_date should be present");
124}
125
126// ---------------------------------------------------------------------------
127// Measurement ID extraction from link (replaces former inline unit tests)
128// Tested indirectly through the public API: if the ID is extracted correctly
129// the name lookup succeeds; if the link is absent the name is empty.
130// ---------------------------------------------------------------------------
131
132fn make_log_with_measurement_link(
133 measurement_href: Option<&str>,
134) -> Vec<strong_api_lib::models::workout::Log> {
135 use serde_json::json;
136 use strong_api_lib::models::common::Link;
137 use strong_api_lib::models::workout::{
138 Cell, CellSet, CellSetGroup, CellSetGroupEmbedded, CellSetGroupLinks, Log, LogEmbedded,
139 };
140
141 vec![Log {
142 id: "log-link-test".to_string(),
143 embedded: LogEmbedded {
144 cell_set_group: vec![CellSetGroup {
145 id: "csg-link-test".to_string(),
146 links: CellSetGroupLinks {
147 measurement: measurement_href.map(|href| Link {
148 href: href.to_string(),
149 }),
150 },
151 embedded: CellSetGroupEmbedded {},
152 cell_sets: vec![CellSet {
153 id: "cs-link-test".to_string(),
154 is_completed: Some(true),
155 cells: vec![
156 Cell {
157 id: "c1".to_string(),
158 cell_type: "BARBELL_WEIGHT".to_string(),
159 value: Some("80".to_string()),
160 },
161 Cell {
162 id: "c2".to_string(),
163 cell_type: "REPS".to_string(),
164 value: Some("5".to_string()),
165 },
166 ],
167 }],
168 }],
169 },
170 links: json!({}),
171 timezone_id: None,
172 created: "2024-01-01T00:00:00Z".to_string(),
173 last_changed: "2024-01-01T00:00:00Z".to_string(),
174 name: None,
175 access: "private".to_string(),
176 start_date: None,
177 end_date: None,
178 log_type: "WORKOUT".to_string(),
179 }]
180}
181
182#[test]
183fn test_measurement_id_extracted_from_link_resolves_name() {
184 // Use a known measurement ID from the fixture
185 let measurements = measurements_from_fixture();
186 let known = &measurements.embedded.measurements[0];
187 let known_id = known.id.clone();
188 let expected_name = known.name.to_string();
189
190 let href = format!("/api/users/00000000-0000-0000-0000-000000000001/measurements/{known_id}");
191 let logs = make_log_with_measurement_link(Some(&href));
192
193 let transformer = DataTransformer::new().with_measurements_response(measurements);
194 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
195
196 assert_eq!(workouts[0].exercises[0].name, expected_name);
197}
198
199#[test]
200fn test_missing_measurement_link_gives_empty_name() {
201 let logs = make_log_with_measurement_link(None);
202
203 let transformer =
204 DataTransformer::new().with_measurements_response(measurements_from_fixture());
205 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
206
207 assert_eq!(workouts[0].exercises[0].name, "");
208}
209
210// ---------------------------------------------------------------------------
211// Cell type coverage — NOTE filter, all weight variants, RPE, missing REPS
212// ---------------------------------------------------------------------------
213
214fn make_log_with_cells(
215 cells: Vec<(String, Option<String>)>,
216) -> Vec<strong_api_lib::models::workout::Log> {
217 use serde_json::json;
218 use strong_api_lib::models::workout::{
219 Cell, CellSet, CellSetGroup, CellSetGroupEmbedded, CellSetGroupLinks, Log, LogEmbedded,
220 };
221
222 let cells: Vec<Cell> = cells
223 .into_iter()
224 .enumerate()
225 .map(|(i, (cell_type, value))| Cell {
226 id: format!("c{i}"),
227 cell_type,
228 value,
229 })
230 .collect();
231
232 vec![Log {
233 id: "log-cell-test".to_string(),
234 embedded: LogEmbedded {
235 cell_set_group: vec![CellSetGroup {
236 id: "csg-cell-test".to_string(),
237 links: CellSetGroupLinks { measurement: None },
238 embedded: CellSetGroupEmbedded {},
239 cell_sets: vec![CellSet {
240 id: "cs-cell-test".to_string(),
241 is_completed: Some(true),
242 cells,
243 }],
244 }],
245 },
246 links: json!({}),
247 timezone_id: None,
248 created: "2024-01-01T00:00:00Z".to_string(),
249 last_changed: "2024-01-01T00:00:00Z".to_string(),
250 name: None,
251 access: "private".to_string(),
252 start_date: None,
253 end_date: None,
254 log_type: "WORKOUT".to_string(),
255 }]
256}
257
258#[test]
259fn test_note_cell_type_is_excluded() {
260 let logs = make_log_with_cells(vec![
261 ("NOTE".to_string(), Some("Good session".to_string())),
262 ("REPS".to_string(), Some("10".to_string())),
263 ]);
264 let transformer = DataTransformer::new();
265 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
266 assert!(
267 workouts[0].exercises.is_empty(),
268 "NOTE cell should cause the group to be filtered out"
269 );
270}
271
272#[test]
273fn test_dumbbell_weight_cell_type() {
274 let logs = make_log_with_cells(vec![
275 ("DUMBBELL_WEIGHT".to_string(), Some("20".to_string())),
276 ("REPS".to_string(), Some("12".to_string())),
277 ]);
278 let transformer = DataTransformer::new();
279 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
280 assert_eq!(workouts[0].exercises[0].sets[0].weight, Some(20.0));
281}
282
283#[test]
284fn test_other_weight_cell_type() {
285 let logs = make_log_with_cells(vec![
286 ("OTHER_WEIGHT".to_string(), Some("15.5".to_string())),
287 ("REPS".to_string(), Some("8".to_string())),
288 ]);
289 let transformer = DataTransformer::new();
290 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
291 assert_eq!(workouts[0].exercises[0].sets[0].weight, Some(15.5));
292}
293
294#[test]
295fn test_weighted_bodyweight_cell_type() {
296 let logs = make_log_with_cells(vec![
297 ("WEIGHTED_BODYWEIGHT".to_string(), Some("10".to_string())),
298 ("REPS".to_string(), Some("15".to_string())),
299 ]);
300 let transformer = DataTransformer::new();
301 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
302 assert_eq!(workouts[0].exercises[0].sets[0].weight, Some(10.0));
303}
304
305#[test]
306fn test_no_weight_cell_gives_none() {
307 // Only REPS, no weight cell at all
308 let logs = make_log_with_cells(vec![("REPS".to_string(), Some("10".to_string()))]);
309 let transformer = DataTransformer::new();
310 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
311 assert_eq!(workouts[0].exercises[0].sets[0].weight, None);
312}
313
314#[test]
315fn test_rpe_cell_type() {
316 let logs = make_log_with_cells(vec![
317 ("BARBELL_WEIGHT".to_string(), Some("100".to_string())),
318 ("REPS".to_string(), Some("5".to_string())),
319 ("RPE".to_string(), Some("9".to_string())),
320 ]);
321 let transformer = DataTransformer::new();
322 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
323 assert_eq!(workouts[0].exercises[0].sets[0].rpe, Some(9.0));
324}
325
326#[test]
327fn test_missing_reps_value_defaults_to_zero() {
328 let logs = make_log_with_cells(vec![
329 ("BARBELL_WEIGHT".to_string(), Some("60".to_string())),
330 ("REPS".to_string(), None), // value is None
331 ]);
332 let transformer = DataTransformer::new();
333 let workouts = transformer.get_measurements_from_logs(&Some(logs)).unwrap();
334 assert_eq!(workouts[0].exercises[0].sets[0].reps, 0);
335}
336
337#[test]
338fn test_empty_logs_vec_returns_empty_workouts() {
339 let transformer = DataTransformer::new();
340 let workouts = transformer
341 .get_measurements_from_logs(&Some(vec![]))
342 .unwrap();
343 assert!(workouts.is_empty());
344}
345
346#[test]
347fn test_logs_option_none_returns_empty_workouts() {
348 let transformer = DataTransformer::new();
349 let workouts = transformer.get_measurements_from_logs(&None).unwrap();
350 assert!(workouts.is_empty());
351}
352
353#[test]
354fn test_data_transformer_default_equals_new() {
355 // Exercises the Default impl (derived via `impl Default for DataTransformer`)
356 let by_default: DataTransformer = Default::default();
357 let workouts = by_default.get_measurements_from_logs(&None).unwrap();
358 assert!(workouts.is_empty());
359}