Skip to main content

entracte_lib/scheduler/
routines.rs

1//! Guided break routines + the routine engine (#152, #153).
2//!
3//! A *routine* is an ordered list of [`RoutineStep`]s — each a short
4//! instruction shown for a number of seconds — that the break overlay walks
5//! through instead of rotating flat hint text. Each routine is tagged with a
6//! [`RoutineCategory`] and a [`RoutineDifficulty`].
7//!
8//! Per break kind the user picks, in the Breaks tab, one of three modes
9//! (persisted in `Settings` as `micro_routine` / `long_routine`):
10//! - `""` — off; the overlay falls back to plain hint rotation.
11//! - a routine **id** — always run that specific routine.
12//! - `"random"` — the *engine*: pick a routine at break time from the bundled
13//!   set, filtered by the profile's chosen categories
14//!   (`*_routine_categories`) and a maximum difficulty
15//!   (`*_routine_max_difficulty`).
16//!
17//! The selection core ([`routines_matching`] + [`resolve_routine`]) is
18//! pure and deterministic — the only impurity is [`random_index`], which
19//! chooses *which* of the matching routines to run.
20
21use serde::{Deserialize, Serialize};
22
23use super::settings::Settings;
24use super::types::{BreakKind, BreathPattern, RoutinePacing, RoutineStep};
25
26/// Which break kind a routine is offered for. Sleep has no routines.
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "lowercase")]
29pub enum RoutineKind {
30    Micro,
31    Long,
32}
33
34/// The theme a routine belongs to, used to filter the randomized pool.
35#[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    /// Parse the on-disk (snake_case) string; `None` for an unknown value so
46    /// a stale, hand-edited, or future category can be dropped from a
47    /// settings filter list rather than failing the whole profile load
48    /// (#212). Content packs parse routines through the strict derived
49    /// `Deserialize`, so a bad category there is still rejected.
50    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/// How demanding a routine is. Ordered `Gentle < Moderate < Active`; the
62/// per-kind `*_routine_max_difficulty` filter includes everything up to and
63/// including the chosen level. `Default` is `Active` (the most permissive
64/// filter) so a stale/unknown value can fall back through the shared
65/// `deserialize_with_fallback` helper, matching the other tolerant settings
66/// enums.
67#[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    /// Monotonic rank for the `<=` comparison the difficulty filter uses.
78    fn rank(self) -> u8 {
79        match self {
80            Self::Gentle => 1,
81            Self::Moderate => 2,
82            Self::Active => 3,
83        }
84    }
85
86    /// Parse the on-disk (lowercase) string; `None` for an unknown value so a
87    /// stale `*_routine_max_difficulty` falls back to the default instead of
88    /// failing the whole profile load (#212). Content packs parse routines
89    /// through the strict derived `Deserialize`, so a bad difficulty there is
90    /// still rejected.
91    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/// A curated, ordered sequence of guided break steps with a stable `id`
102/// (persisted in settings), a human `label`, and engine metadata
103/// (`category` / `difficulty`). `Deserialize` so user routines can arrive
104/// from imported content packs (#155) and persist in `Settings`.
105///
106/// The optional `pacing` field declares how step durations relate to the
107/// break length (see [`RoutinePacing`]); absent means the global
108/// `routine_fill` setting decides. `max_step_secs` caps the duration of
109/// any single fill-scaled step before the overlay falls back to loop mode.
110#[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    /// A guided breathing pattern animated on the ring. When present, the
123    /// overlay shows breath phase labels instead of (often empty) step text.
124    #[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
158/// The bundled starter routines, ordered as they appear in the picker (micro
159/// first, then long). Pure and allocation-only so it can be returned straight
160/// from the `get_routines` command and unit-tested without state.
161pub 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
277/// The routines that match a break `kind` and the engine filters: the kind's
278/// pool, intersected with `categories` (empty means "all categories") and
279/// capped at `max_difficulty`. Pure so every filter combination is
280/// unit-testable. Sleep matches nothing.
281pub 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
300/// A random index in `[0, n)`, or `0` when `n` is `0` or entropy is
301/// unavailable. The lone impurity in the routine engine; kept tiny so the
302/// pure selection core stays fully testable. Uniform enough for picking a
303/// routine (the modulo bias across a handful of routines is negligible).
304fn 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
315/// Steps + pacing metadata resolved for a single break, produced by a
316/// single [`random_index`] call so all three fields always come from the
317/// same routine draw.
318pub struct ResolvedRoutine {
319    pub steps: Vec<RoutineStep>,
320    /// The routine's own declared [`RoutinePacing`], if any. `None` means
321    /// the frontend should fall back to the global `routine_fill` setting.
322    pub pacing: Option<RoutinePacing>,
323    /// Per-step duration cap for fill-mode routines. See [`RoutinePacing::Fill`].
324    pub max_step_secs: Option<u64>,
325    /// The routine's breathing pattern, if any.
326    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
340/// Resolve the guided routine for a break of `kind` from the user's
341/// per-kind settings: `""` → none, a routine id → that routine,
342/// `"random"` → the engine picks one from the filtered pool. Unknown ids
343/// and the `Sleep` kind resolve to an empty routine. A single
344/// [`random_index`] call is made so steps and pacing always come from the
345/// same pick.
346pub 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
385/// Every routine available to a profile: the bundled starters plus any the
386/// user has imported from a content pack (`custom_routines`). A custom
387/// routine whose id collides with a starter is dropped so the built-in always
388/// wins (import already rejects such ids, but resolve stays defensive).
389pub 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/// List every routine (starter + imported) for the Breaks-tab picker.
404#[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            // Exhaustive match: a new variant fails to compile here until it
421            // gains a `from_disk_str` arm, guarding against one silently
422            // parsing to `None` through that fn's `_` arm. Also pins the disk
423            // string to the serde rename in both directions.
424            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            // Exhaustive match: see the category test above — a new variant
447            // can't compile without a `from_disk_str` arm and a serde-rename
448            // cross-check.
449            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        // The tolerant settings deserializer falls back through this default,
469        // so it must stay the most permissive level.
470        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        // Raising the cap can only add routines, never remove them.
553        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        // Only the eye-reset routine matches, so random must return its steps.
599        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        // No long Eyes routine exists.
613        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        // A custom routine reusing a starter id must not shadow the built-in.
655        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        // Exactly one routine carries the starter id, and it's the starter.
664        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        // The empty-pool guard: index 0 is the only sensible value.
695        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    // -- Pacing fields ---------------------------------------------------
708
709    #[test]
710    fn starter_routines_have_no_pacing_by_default() {
711        // Bundled starters are authored without pacing so the global
712        // `routine_fill` setting decides their behaviour.
713        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        // skip_serializing_if = "Option::is_none" keeps the JSON compact.
761        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}