Skip to main content

talk_core/
selection.rs

1use crate::clock::{time_of_day, TimeOfDay};
2use crate::questions::{Pack, Question};
3
4/// Caller-supplied selection state (persisted on disk by the binary).
5#[derive(Default)]
6pub struct SelectionState {
7    /// id -> times served
8    pub served_count: std::collections::HashMap<String, u32>,
9    /// id -> last-served ordinal (monotonic counter; higher = more recent)
10    pub last_served: std::collections::HashMap<String, u64>,
11    /// An in-progress held run: (question id, turns completed so far).
12    pub held_run: Option<(String, u32)>,
13}
14
15pub fn select<'a>(pack: &'a Pack, state: &SelectionState, hour: u32) -> Option<&'a Question> {
16    // 1. A held run in progress wins until complete.
17    if let Some((id, done)) = &state.held_run {
18        if let Some(q) = pack.questions.iter().find(|q| &q.id == id) {
19            if let Some(len) = Pack::held_len(&q.cadence) {
20                if *done < len {
21                    return Some(q);
22                }
23            }
24        }
25    }
26
27    let slot = match time_of_day(hour) {
28        TimeOfDay::Morning => Some("morning"),
29        TimeOfDay::Evening => Some("evening"),
30        _ => None,
31    };
32
33    let candidates: Vec<&Question> = match slot {
34        Some(s) if pack.questions.iter().any(|q| q.slot.as_deref() == Some(s)) => {
35            pack.questions.iter().filter(|q| q.slot.as_deref() == Some(s)).collect()
36        }
37        _ => pack.questions.iter().collect(),
38    };
39
40    // 2/3. Least-recently-served, then lowest count, then declaration order.
41    candidates.into_iter().enumerate().min_by(|(ia, a), (ib, b)| {
42        let la = state.last_served.get(&a.id).copied().unwrap_or(0);
43        let lb = state.last_served.get(&b.id).copied().unwrap_or(0);
44        let ca = state.served_count.get(&a.id).copied().unwrap_or(0);
45        let cb = state.served_count.get(&b.id).copied().unwrap_or(0);
46        la.cmp(&lb).then(ca.cmp(&cb)).then(ia.cmp(ib))
47    }).map(|(_, q)| q)
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    fn pack() -> Pack {
55        Pack::from_toml(r#"
56            name = "t"
57            [[questions]]
58            id = "a"
59            text = "A?"
60            slot = "morning"
61            [[questions]]
62            id = "b"
63            text = "B?"
64            slot = "evening"
65            [[questions]]
66            id = "h"
67            text = "Held?"
68            cadence = "held:7"
69        "#).unwrap()
70    }
71
72    #[test]
73    fn held_run_keeps_serving_until_complete() {
74        let p = pack();
75        let st = SelectionState { held_run: Some(("h".into(), 3)), ..Default::default() };
76        assert_eq!(select(&p, &st, 10).unwrap().id, "h");
77    }
78
79    #[test]
80    fn held_run_releases_when_complete() {
81        let p = pack();
82        let st = SelectionState { held_run: Some(("h".into(), 7)), ..Default::default() };
83        assert_ne!(select(&p, &st, 7).unwrap().id, "h");
84    }
85
86    #[test]
87    fn morning_prefers_morning_slot() {
88        let p = pack();
89        let st = SelectionState::default();
90        assert_eq!(select(&p, &st, 7).unwrap().id, "a");
91    }
92
93    #[test]
94    fn rotation_avoids_the_most_recent() {
95        let p = pack();
96        let mut st = SelectionState::default();
97        st.last_served.insert("a".into(), 5);
98        // At midday no slot filter; "a" is most recent so it should be skipped.
99        assert_ne!(select(&p, &st, 12).unwrap().id, "a");
100    }
101}