1use serde::{Deserialize, Serialize};
22
23use super::settings::Settings;
24use super::types::{BreakKind, BreathPattern, RoutinePacing, RoutineStep};
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "lowercase")]
29pub enum RoutineKind {
30 Micro,
31 Long,
32}
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
36#[serde(rename_all = "snake_case")]
37pub enum RoutineCategory {
38 Eyes,
39 Mobility,
40 Breathing,
41 DeskYoga,
42}
43
44impl RoutineCategory {
45 pub(crate) fn from_disk_str(raw: &str) -> Option<Self> {
51 match raw {
52 "eyes" => Some(Self::Eyes),
53 "mobility" => Some(Self::Mobility),
54 "breathing" => Some(Self::Breathing),
55 "desk_yoga" => Some(Self::DeskYoga),
56 _ => None,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
68#[serde(rename_all = "lowercase")]
69pub enum RoutineDifficulty {
70 Gentle,
71 Moderate,
72 #[default]
73 Active,
74}
75
76impl RoutineDifficulty {
77 fn rank(self) -> u8 {
79 match self {
80 Self::Gentle => 1,
81 Self::Moderate => 2,
82 Self::Active => 3,
83 }
84 }
85
86 pub(crate) fn from_disk_str(raw: &str) -> Option<Self> {
92 match raw {
93 "gentle" => Some(Self::Gentle),
94 "moderate" => Some(Self::Moderate),
95 "active" => Some(Self::Active),
96 _ => None,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct Routine {
112 pub id: String,
113 pub label: String,
114 pub kind: RoutineKind,
115 pub category: RoutineCategory,
116 pub difficulty: RoutineDifficulty,
117 pub steps: Vec<RoutineStep>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub pacing: Option<RoutinePacing>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub max_step_secs: Option<u64>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub breath: Option<BreathPattern>,
126}
127
128fn step(text: &str, seconds: u64) -> RoutineStep {
129 RoutineStep {
130 text: text.to_string(),
131 seconds,
132 asset: None,
133 sound: None,
134 }
135}
136
137fn routine(
138 id: &str,
139 label: &str,
140 kind: RoutineKind,
141 category: RoutineCategory,
142 difficulty: RoutineDifficulty,
143 steps: Vec<RoutineStep>,
144) -> Routine {
145 Routine {
146 id: id.to_string(),
147 label: label.to_string(),
148 kind,
149 category,
150 difficulty,
151 steps,
152 pacing: None,
153 max_step_secs: None,
154 breath: None,
155 }
156}
157
158pub fn starter_routines() -> Vec<Routine> {
162 use RoutineCategory::*;
163 use RoutineDifficulty::*;
164 use RoutineKind::*;
165 vec![
166 routine(
167 "micro-eye-reset",
168 "Eye reset (20-20-20)",
169 Micro,
170 Eyes,
171 Gentle,
172 vec![
173 step("Look at something about 6 metres away.", 5),
174 step("Soften your gaze and blink slowly a few times.", 5),
175 step("Let your eyes relax — keep looking far away.", 7),
176 step("Take one slow breath, then return refreshed.", 3),
177 ],
178 ),
179 routine(
180 "micro-neck-shoulders",
181 "Neck & shoulders",
182 Micro,
183 Mobility,
184 Gentle,
185 vec![
186 step("Roll your shoulders slowly backwards.", 5),
187 step("Drop your right ear toward your right shoulder.", 5),
188 step("Switch — left ear toward your left shoulder.", 5),
189 step("Sit tall and unclench your jaw.", 5),
190 ],
191 ),
192 routine(
193 "micro-box-breathing",
194 "Box breathing",
195 Micro,
196 Breathing,
197 Gentle,
198 vec![
199 step("Breathe in slowly for four counts.", 4),
200 step("Hold gently for four counts.", 4),
201 step("Breathe out for four counts.", 4),
202 step("Hold empty for four counts, then repeat once.", 8),
203 ],
204 ),
205 routine(
206 "micro-wrist-hands",
207 "Wrist & hand release",
208 Micro,
209 Mobility,
210 Moderate,
211 vec![
212 step("Make slow fists, then spread your fingers wide.", 5),
213 step("Circle each wrist a few times in both directions.", 6),
214 step("Gently pull each hand back to stretch the forearm.", 6),
215 step("Shake your hands out loosely.", 3),
216 ],
217 ),
218 routine(
219 "long-full-body-stretch",
220 "Full-body stretch",
221 Long,
222 Mobility,
223 Moderate,
224 vec![
225 step("Stand up and reach both arms overhead.", 20),
226 step("Fold gently forward, let your head hang.", 25),
227 step("Roll up slowly, then twist to each side.", 25),
228 step("Open your chest — clasp hands behind your back.", 20),
229 step("Stretch each calf against the floor.", 30),
230 step("Shake out your arms and legs.", 20),
231 ],
232 ),
233 routine(
234 "long-walk-hydrate",
235 "Walk & hydrate",
236 Long,
237 Mobility,
238 Active,
239 vec![
240 step("Stand and step away from the screen.", 15),
241 step("Walk to fetch a glass of water.", 40),
242 step("Drink some water, unhurried.", 25),
243 step("Look out of a window at something distant.", 30),
244 step("Take three slow breaths before sitting back down.", 20),
245 ],
246 ),
247 routine(
248 "long-desk-yoga",
249 "Desk yoga flow",
250 Long,
251 DeskYoga,
252 Moderate,
253 vec![
254 step("Seated cat-cow: arch and round your back slowly.", 30),
255 step("Seated spinal twist to the right, then the left.", 40),
256 step("Reach one arm overhead into a side bend; switch.", 40),
257 step("Forward fold over your knees, let your neck release.", 30),
258 step("Sit tall and roll your shoulders to finish.", 20),
259 ],
260 ),
261 routine(
262 "long-breathing-reset",
263 "Breathing reset",
264 Long,
265 Breathing,
266 Gentle,
267 vec![
268 step("Sit comfortably and close your eyes if you like.", 20),
269 step("Breathe in for four, out for six — keep it easy.", 60),
270 step("Let your shoulders drop with every out-breath.", 60),
271 step("Widen your awareness to the room before returning.", 40),
272 ],
273 ),
274 ]
275}
276
277pub fn routines_matching<'a>(
282 routines: &'a [Routine],
283 kind: BreakKind,
284 categories: &[RoutineCategory],
285 max_difficulty: RoutineDifficulty,
286) -> Vec<&'a Routine> {
287 let want_kind = match kind {
288 BreakKind::Micro => RoutineKind::Micro,
289 BreakKind::Long => RoutineKind::Long,
290 BreakKind::Sleep => return Vec::new(),
291 };
292 routines
293 .iter()
294 .filter(|r| r.kind == want_kind)
295 .filter(|r| categories.is_empty() || categories.contains(&r.category))
296 .filter(|r| r.difficulty.rank() <= max_difficulty.rank())
297 .collect()
298}
299
300fn random_index(n: usize) -> usize {
305 if n == 0 {
306 return 0;
307 }
308 let mut buf = [0u8; 8];
309 if getrandom::getrandom(&mut buf).is_err() {
310 return 0;
311 }
312 (u64::from_le_bytes(buf) % n as u64) as usize
313}
314
315pub struct ResolvedRoutine {
319 pub steps: Vec<RoutineStep>,
320 pub pacing: Option<RoutinePacing>,
323 pub max_step_secs: Option<u64>,
325 pub breath: Option<BreathPattern>,
327}
328
329impl ResolvedRoutine {
330 fn empty() -> Self {
331 Self {
332 steps: Vec::new(),
333 pacing: None,
334 max_step_secs: None,
335 breath: None,
336 }
337 }
338}
339
340pub fn resolve_routine(kind: BreakKind, s: &Settings) -> ResolvedRoutine {
347 let (id, categories, max_difficulty) = match kind {
348 BreakKind::Micro => (
349 s.micro_routine.as_str(),
350 &s.micro_routine_categories,
351 s.micro_routine_max_difficulty,
352 ),
353 BreakKind::Long => (
354 s.long_routine.as_str(),
355 &s.long_routine_categories,
356 s.long_routine_max_difficulty,
357 ),
358 BreakKind::Sleep => return ResolvedRoutine::empty(),
359 };
360 let routines = all_routines(s);
361 let found: Option<Routine> = match id {
362 "" => None,
363 "random" => {
364 let matching = routines_matching(&routines, kind, categories, max_difficulty);
365 if matching.is_empty() {
366 None
367 } else {
368 let idx = random_index(matching.len());
369 Some(matching[idx % matching.len()].clone())
370 }
371 }
372 other => routines.iter().find(|r| r.id == other).cloned(),
373 };
374 match found {
375 None => ResolvedRoutine::empty(),
376 Some(r) => ResolvedRoutine {
377 steps: r.steps,
378 pacing: r.pacing,
379 max_step_secs: r.max_step_secs,
380 breath: r.breath,
381 },
382 }
383}
384
385pub fn all_routines(s: &Settings) -> Vec<Routine> {
390 let mut routines = starter_routines();
391 let starter_ids: std::collections::HashSet<&str> =
392 routines.iter().map(|r| r.id.as_str()).collect();
393 let extra: Vec<Routine> = s
394 .custom_routines
395 .iter()
396 .filter(|r| !starter_ids.contains(r.id.as_str()))
397 .cloned()
398 .collect();
399 routines.extend(extra);
400 routines
401}
402
403#[tauri::command]
405pub async fn get_routines(
406 scheduler: tauri::State<'_, super::Scheduler>,
407) -> Result<Vec<Routine>, String> {
408 let s = scheduler.settings.lock().await;
409 Ok(all_routines(&s))
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn routine_category_from_disk_str_round_trips_every_variant() {
418 use RoutineCategory::*;
419 for c in [Eyes, Mobility, Breathing, DeskYoga] {
420 let disk = match c {
425 Eyes => "eyes",
426 Mobility => "mobility",
427 Breathing => "breathing",
428 DeskYoga => "desk_yoga",
429 };
430 assert_eq!(serde_json::to_value(c).unwrap(), serde_json::json!(disk));
431 assert_eq!(RoutineCategory::from_disk_str(disk), Some(c));
432 }
433 }
434
435 #[test]
436 fn routine_category_from_disk_str_rejects_unknown() {
437 assert_eq!(RoutineCategory::from_disk_str("telepathy"), None);
438 assert_eq!(RoutineCategory::from_disk_str("Eyes"), None);
439 assert_eq!(RoutineCategory::from_disk_str(""), None);
440 }
441
442 #[test]
443 fn routine_difficulty_from_disk_str_round_trips_every_variant() {
444 use RoutineDifficulty::*;
445 for d in [Gentle, Moderate, Active] {
446 let disk = match d {
450 Gentle => "gentle",
451 Moderate => "moderate",
452 Active => "active",
453 };
454 assert_eq!(serde_json::to_value(d).unwrap(), serde_json::json!(disk));
455 assert_eq!(RoutineDifficulty::from_disk_str(disk), Some(d));
456 }
457 }
458
459 #[test]
460 fn routine_difficulty_from_disk_str_rejects_unknown() {
461 assert_eq!(RoutineDifficulty::from_disk_str("extreme"), None);
462 assert_eq!(RoutineDifficulty::from_disk_str("Gentle"), None);
463 assert_eq!(RoutineDifficulty::from_disk_str(""), None);
464 }
465
466 #[test]
467 fn routine_difficulty_defaults_to_active() {
468 assert_eq!(RoutineDifficulty::default(), RoutineDifficulty::Active);
471 }
472
473 #[test]
474 fn starter_routines_have_unique_nonempty_ids_and_steps() {
475 let routines = starter_routines();
476 assert!(!routines.is_empty());
477 let mut ids: Vec<&str> = routines.iter().map(|r| r.id.as_str()).collect();
478 ids.sort_unstable();
479 let unique = {
480 let mut u = ids.clone();
481 u.dedup();
482 u
483 };
484 assert_eq!(ids, unique, "routine ids must be unique");
485 for r in &routines {
486 assert!(!r.label.is_empty(), "{} has an empty label", r.id);
487 assert!(!r.steps.is_empty(), "{} has no steps", r.id);
488 for st in &r.steps {
489 assert!(!st.text.is_empty(), "{} has an empty step", r.id);
490 assert!(st.seconds > 0, "{} has a zero-length step", r.id);
491 }
492 }
493 }
494
495 #[test]
496 fn starter_routines_cover_both_kinds_and_every_category() {
497 let routines = starter_routines();
498 assert!(routines.iter().any(|r| r.kind == RoutineKind::Micro));
499 assert!(routines.iter().any(|r| r.kind == RoutineKind::Long));
500 for cat in [
501 RoutineCategory::Eyes,
502 RoutineCategory::Mobility,
503 RoutineCategory::Breathing,
504 RoutineCategory::DeskYoga,
505 ] {
506 assert!(
507 routines.iter().any(|r| r.category == cat),
508 "no routine in category {cat:?}",
509 );
510 }
511 }
512
513 #[test]
514 fn routines_matching_filters_by_kind() {
515 let r = starter_routines();
516 let micro = routines_matching(&r, BreakKind::Micro, &[], RoutineDifficulty::Active);
517 assert!(micro.iter().all(|x| x.kind == RoutineKind::Micro));
518 let long = routines_matching(&r, BreakKind::Long, &[], RoutineDifficulty::Active);
519 assert!(long.iter().all(|x| x.kind == RoutineKind::Long));
520 assert!(routines_matching(&r, BreakKind::Sleep, &[], RoutineDifficulty::Active).is_empty());
521 }
522
523 #[test]
524 fn routines_matching_empty_categories_means_all() {
525 let r = starter_routines();
526 let all = routines_matching(&r, BreakKind::Micro, &[], RoutineDifficulty::Active);
527 let micro_total = r.iter().filter(|x| x.kind == RoutineKind::Micro).count();
528 assert_eq!(all.len(), micro_total);
529 }
530
531 #[test]
532 fn routines_matching_respects_category_filter() {
533 let r = starter_routines();
534 let eyes = routines_matching(
535 &r,
536 BreakKind::Micro,
537 &[RoutineCategory::Eyes],
538 RoutineDifficulty::Active,
539 );
540 assert!(!eyes.is_empty());
541 assert!(eyes.iter().all(|x| x.category == RoutineCategory::Eyes));
542 }
543
544 #[test]
545 fn routines_matching_caps_at_max_difficulty() {
546 let r = starter_routines();
547 let gentle = routines_matching(&r, BreakKind::Micro, &[], RoutineDifficulty::Gentle);
548 assert!(!gentle.is_empty());
549 assert!(gentle
550 .iter()
551 .all(|x| x.difficulty == RoutineDifficulty::Gentle));
552 let moderate = routines_matching(&r, BreakKind::Micro, &[], RoutineDifficulty::Moderate);
554 assert!(moderate.len() >= gentle.len());
555 }
556
557 #[test]
558 fn routines_matching_intersects_category_and_difficulty() {
559 let r = starter_routines();
560 let got = routines_matching(
561 &r,
562 BreakKind::Long,
563 &[RoutineCategory::Breathing],
564 RoutineDifficulty::Gentle,
565 );
566 assert!(got.iter().all(|x| x.category == RoutineCategory::Breathing
567 && x.difficulty.rank() <= RoutineDifficulty::Gentle.rank()));
568 }
569
570 #[test]
571 fn resolve_returns_empty_when_no_routine_selected() {
572 let s = Settings::default();
573 assert!(resolve_routine(BreakKind::Micro, &s).steps.is_empty());
574 assert!(resolve_routine(BreakKind::Long, &s).steps.is_empty());
575 }
576
577 #[test]
578 #[allow(clippy::field_reassign_with_default)]
579 fn resolve_returns_pinned_routine_steps() {
580 let mut s = Settings::default();
581 s.micro_routine = "micro-eye-reset".to_string();
582 let micro = resolve_routine(BreakKind::Micro, &s).steps;
583 let expected = starter_routines()
584 .into_iter()
585 .find(|r| r.id == "micro-eye-reset")
586 .unwrap()
587 .steps;
588 assert_eq!(micro, expected);
589 }
590
591 #[test]
592 #[allow(clippy::field_reassign_with_default)]
593 fn resolve_random_returns_a_matching_routine() {
594 let mut s = Settings::default();
595 s.micro_routine = "random".to_string();
596 s.micro_routine_categories = vec![RoutineCategory::Eyes];
597 let steps = resolve_routine(BreakKind::Micro, &s).steps;
598 let expected = starter_routines()
600 .into_iter()
601 .find(|r| r.id == "micro-eye-reset")
602 .unwrap()
603 .steps;
604 assert_eq!(steps, expected);
605 }
606
607 #[test]
608 #[allow(clippy::field_reassign_with_default)]
609 fn resolve_random_empty_when_filters_match_nothing() {
610 let mut s = Settings::default();
611 s.long_routine = "random".to_string();
612 s.long_routine_categories = vec![RoutineCategory::Eyes];
614 assert!(resolve_routine(BreakKind::Long, &s).steps.is_empty());
615 }
616
617 #[test]
618 #[allow(clippy::field_reassign_with_default)]
619 fn resolve_falls_back_to_empty_for_unknown_id() {
620 let mut s = Settings::default();
621 s.micro_routine = "does-not-exist".to_string();
622 assert!(resolve_routine(BreakKind::Micro, &s).steps.is_empty());
623 }
624
625 #[test]
626 #[allow(clippy::field_reassign_with_default)]
627 fn resolve_ignores_routines_for_sleep() {
628 let mut s = Settings::default();
629 s.micro_routine = "random".to_string();
630 s.long_routine = "long-desk-yoga".to_string();
631 assert!(resolve_routine(BreakKind::Sleep, &s).steps.is_empty());
632 }
633
634 #[test]
635 fn all_routines_is_just_starters_by_default() {
636 assert_eq!(all_routines(&Settings::default()), starter_routines());
637 }
638
639 #[test]
640 #[allow(clippy::field_reassign_with_default)]
641 fn all_routines_appends_custom_and_drops_starter_id_collisions() {
642 let mut s = Settings::default();
643 let custom = Routine {
644 id: "custom-stretch".to_string(),
645 label: "My stretch".to_string(),
646 kind: RoutineKind::Long,
647 category: RoutineCategory::Mobility,
648 difficulty: RoutineDifficulty::Gentle,
649 steps: vec![step("Reach up", 10)],
650 pacing: None,
651 max_step_secs: None,
652 breath: None,
653 };
654 let collide = Routine {
656 id: "micro-eye-reset".to_string(),
657 ..custom.clone()
658 };
659 s.custom_routines = vec![custom.clone(), collide];
660 let all = all_routines(&s);
661 assert_eq!(all.len(), starter_routines().len() + 1);
662 assert!(all.iter().any(|r| r.id == "custom-stretch"));
663 let eye = all
665 .iter()
666 .filter(|r| r.id == "micro-eye-reset")
667 .collect::<Vec<_>>();
668 assert_eq!(eye.len(), 1);
669 assert_eq!(eye[0].label, "Eye reset (20-20-20)");
670 }
671
672 #[test]
673 #[allow(clippy::field_reassign_with_default)]
674 fn resolve_picks_an_imported_routine_by_id() {
675 let mut s = Settings::default();
676 s.custom_routines = vec![Routine {
677 id: "custom-breathe".to_string(),
678 label: "Imported breathing".to_string(),
679 kind: RoutineKind::Micro,
680 category: RoutineCategory::Breathing,
681 difficulty: RoutineDifficulty::Gentle,
682 steps: vec![step("In", 4), step("Out", 4)],
683 pacing: None,
684 max_step_secs: None,
685 breath: None,
686 }];
687 s.micro_routine = "custom-breathe".to_string();
688 let steps = resolve_routine(BreakKind::Micro, &s).steps;
689 assert_eq!(steps, vec![step("In", 4), step("Out", 4)]);
690 }
691
692 #[test]
693 fn random_index_zero_is_safe() {
694 assert_eq!(random_index(0), 0);
696 }
697
698 #[test]
699 fn random_index_stays_in_range() {
700 for n in 1..=8usize {
701 for _ in 0..64 {
702 assert!(random_index(n) < n, "random_index({n}) out of range");
703 }
704 }
705 }
706
707 #[test]
710 fn starter_routines_have_no_pacing_by_default() {
711 for r in starter_routines() {
714 assert!(
715 r.pacing.is_none(),
716 "{} should have no pacing override",
717 r.id
718 );
719 assert!(
720 r.max_step_secs.is_none(),
721 "{} should have no max_step_secs",
722 r.id
723 );
724 }
725 }
726
727 #[test]
728 fn routine_with_fill_pacing_round_trips_through_serde() {
729 let r = Routine {
730 id: "test".to_string(),
731 label: "Test".to_string(),
732 kind: RoutineKind::Micro,
733 category: RoutineCategory::Breathing,
734 difficulty: RoutineDifficulty::Gentle,
735 steps: vec![step("Breathe in", 4), step("Breathe out", 4)],
736 pacing: Some(RoutinePacing::Fill),
737 max_step_secs: Some(30),
738 breath: None,
739 };
740 let json = serde_json::to_string(&r).unwrap();
741 let back: Routine = serde_json::from_str(&json).unwrap();
742 assert_eq!(back.pacing, Some(RoutinePacing::Fill));
743 assert_eq!(back.max_step_secs, Some(30));
744 }
745
746 #[test]
747 fn routine_without_pacing_round_trips_and_omits_null_fields() {
748 let r = Routine {
749 id: "test".to_string(),
750 label: "Test".to_string(),
751 kind: RoutineKind::Micro,
752 category: RoutineCategory::Eyes,
753 difficulty: RoutineDifficulty::Gentle,
754 steps: vec![step("Look away", 5)],
755 pacing: None,
756 max_step_secs: None,
757 breath: None,
758 };
759 let json = serde_json::to_string(&r).unwrap();
760 assert!(
762 !json.contains("pacing"),
763 "pacing key must be omitted: {json}"
764 );
765 assert!(
766 !json.contains("max_step_secs"),
767 "max_step_secs key must be omitted: {json}"
768 );
769 let back: Routine = serde_json::from_str(&json).unwrap();
770 assert_eq!(back.pacing, None);
771 assert_eq!(back.max_step_secs, None);
772 }
773
774 #[test]
775 fn all_pacing_variants_round_trip() {
776 for (variant, expected) in [
777 (RoutinePacing::Hold, "\"hold\""),
778 (RoutinePacing::Fill, "\"fill\""),
779 (RoutinePacing::Loop, "\"loop\""),
780 ] {
781 let json = serde_json::to_string(&variant).unwrap();
782 assert_eq!(json, expected, "{variant:?} serialises to wrong string");
783 let back: RoutinePacing = serde_json::from_str(&json).unwrap();
784 assert_eq!(back, variant, "{variant:?} did not round-trip");
785 }
786 }
787
788 #[test]
789 #[allow(clippy::field_reassign_with_default)]
790 fn resolve_routine_returns_pacing_from_pinned_routine() {
791 let mut s = Settings::default();
792 s.custom_routines = vec![Routine {
793 id: "fill-breathe".to_string(),
794 label: "Fill breathing".to_string(),
795 kind: RoutineKind::Micro,
796 category: RoutineCategory::Breathing,
797 difficulty: RoutineDifficulty::Gentle,
798 steps: vec![step("In", 4), step("Out", 4)],
799 pacing: Some(RoutinePacing::Fill),
800 max_step_secs: Some(20),
801 breath: None,
802 }];
803 s.micro_routine = "fill-breathe".to_string();
804 let resolved = resolve_routine(BreakKind::Micro, &s);
805 assert_eq!(resolved.steps, vec![step("In", 4), step("Out", 4)]);
806 assert_eq!(resolved.pacing, Some(RoutinePacing::Fill));
807 assert_eq!(resolved.max_step_secs, Some(20));
808 }
809
810 #[test]
811 #[allow(clippy::field_reassign_with_default)]
812 fn resolve_routine_returns_breath_from_pinned_routine() {
813 let pattern = BreathPattern {
814 inhale: 4,
815 hold: 7,
816 exhale: 8,
817 hold_out: 0,
818 cycles: None,
819 then: None,
820 sounds: None,
821 };
822 let mut s = Settings::default();
823 s.custom_routines = vec![Routine {
824 id: "478".to_string(),
825 label: "4-7-8".to_string(),
826 kind: RoutineKind::Micro,
827 category: RoutineCategory::Breathing,
828 difficulty: RoutineDifficulty::Gentle,
829 steps: vec![],
830 pacing: None,
831 max_step_secs: None,
832 breath: Some(pattern.clone()),
833 }];
834 s.micro_routine = "478".to_string();
835 let resolved = resolve_routine(BreakKind::Micro, &s);
836 assert_eq!(resolved.breath, Some(pattern));
837 assert!(resolved.steps.is_empty());
838 }
839
840 #[test]
841 #[allow(clippy::field_reassign_with_default)]
842 fn resolve_routine_returns_none_pacing_for_starter_routine() {
843 let mut s = Settings::default();
844 s.micro_routine = "micro-eye-reset".to_string();
845 let resolved = resolve_routine(BreakKind::Micro, &s);
846 assert!(!resolved.steps.is_empty());
847 assert_eq!(resolved.pacing, None);
848 assert_eq!(resolved.max_step_secs, None);
849 }
850
851 #[test]
852 fn resolve_routine_empty_when_no_routine_selected() {
853 let s = Settings::default();
854 let resolved = resolve_routine(BreakKind::Micro, &s);
855 assert!(resolved.steps.is_empty());
856 assert_eq!(resolved.pacing, None);
857 }
858
859 #[test]
860 #[allow(clippy::field_reassign_with_default)]
861 fn resolve_routine_empty_for_sleep() {
862 let mut s = Settings::default();
863 s.micro_routine = "random".to_string();
864 let resolved = resolve_routine(BreakKind::Sleep, &s);
865 assert!(resolved.steps.is_empty());
866 assert_eq!(resolved.pacing, None);
867 }
868}