limits.rsannotatedlimits.rssource679 lines · 27.9 KB · raw
1use std::collections::BTreeMap;
2#[cfg(not(target_family = "wasm"))]
3use std::path::{Path, PathBuf};
4
5use ::log::{debug, info, warn};
6#[cfg(not(target_family = "wasm"))]
7use ::log::trace;
8use serde::{Deserialize, Serialize};
9
10#[cfg(not(target_family = "wasm"))]
11use crate::atomic::write_atomic;
12use crate::profile::Child;
13
14const MINUTES_PER_DAY: u16 = 24 * 60;
15
16/// A span of the day when Whiskers sleeps, in minutes since midnight. It may run past midnight
17/// (19:30 until 07:00). `from` and `until` are never equal and never past the end of the day, so
18/// "asleep all day" and "asleep for no time" cannot be written down.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Quiet {
21    from: u16,
22    until: u16,
23}
24
25impl Quiet {
26    pub fn new(from: u16, until: u16) -> Result<Self, String> {
27        if from >= MINUTES_PER_DAY || until >= MINUTES_PER_DAY {
28            warn!("quiet hours refused: {from}..{until} is past the end of the day");
29            return Err("a time of day is under 24 hours".into());
30        }
31        if from == until {
32            warn!("quiet hours refused: start and end are both {from}");
33            return Err("quiet hours need different start and end times".into());
34        }
35        Ok(Self { from, until })
36    }
37
38    pub fn from(&self) -> u16 {
39        self.from
40    }
41
42    pub fn until(&self) -> u16 {
43        self.until
44    }
45
46    pub fn contains(&self, minute: u16) -> bool {
47        if self.from < self.until { (self.from..self.until).contains(&minute) } else { minute >= self.from || minute < self.until }
48    }
49}
50
51/// What the grown-ups allow: time with Whiskers each day, and hours when it sleeps.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Limits {
54    daily_minutes: Option<u32>,
55    quiet: Option<Quiet>,
56}
57
58impl Limits {
59    pub fn new(daily_minutes: Option<u32>, quiet: Option<Quiet>) -> Result<Self, String> {
60        if daily_minutes == Some(0) {
61            warn!("limits refused: a daily limit of zero minutes");
62            return Err("a daily limit of no minutes would mean never; leave it unset for no limit".into());
63        }
64        Ok(Self { daily_minutes, quiet })
65    }
66
67    pub fn daily_minutes(&self) -> Option<u32> {
68        self.daily_minutes
69    }
70
71    pub fn quiet(&self) -> Option<Quiet> {
72        self.quiet
73    }
74}
75
76/// Whether Whiskers will talk right now, and if not, why.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum TimeStatus {
79    /// `minutes_left` is `None` when there is no daily limit.
80    Open { minutes_left: Option<u32> },
81    TodaysTimeIsUp,
82    /// Asleep until this many minutes past midnight.
83    QuietHours { until: u16 },
84}
85
86/// The thinking allowance: so many tokens in any rolling window, like a session limit.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct TokenLimit {
89    per_window: Option<u64>,
90    window_hours: u64,
91}
92
93impl TokenLimit {
94    /// `per_window` of `None` means no limit. A window is at least an hour.
95    pub fn new(per_window: Option<u64>, window_hours: u64) -> Result<Self, String> {
96        if window_hours == 0 {
97            warn!("token limit refused: a window of zero hours");
98            return Err("a window is at least one hour".into());
99        }
100        if per_window == Some(0) {
101            warn!("token limit refused: an allowance of zero tokens");
102            return Err("an allowance of no tokens would mean never; leave it unset for no limit".into());
103        }
104        Ok(Self { per_window, window_hours })
105    }
106
107    pub fn per_window(&self) -> Option<u64> {
108        self.per_window
109    }
110
111    pub fn window_hours(&self) -> u64 {
112        self.window_hours
113    }
114}
115
116impl Default for TokenLimit {
117    /// About forty exchanges in five hours.
118    fn default() -> Self {
119        Self { per_window: Some(400_000), window_hours: 5 }
120    }
121}
122
123/// How the cat looks. A household chooses one, and every device draws it. A document that names
124/// neither reads as [`CatTheme::Grey`], so no document ever selects the other by omission.
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum CatTheme {
128    /// The default cat: grey, with a pale belly, tall ears and a raised tail.
129    #[default]
130    Grey,
131    /// The ginger cat.
132    Ginger,
133}
134
135impl CatTheme {
136    pub const ALL: [CatTheme; 2] = [CatTheme::Grey, CatTheme::Ginger];
137}
138
139/// Every choice the grown-ups make, as one thing, so that it can be the same on every device.
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141pub struct HouseholdConfig {
142    pub limits: Limits,
143    pub tokens: TokenLimit,
144    /// Whether a microphone left open by a tap stays open after she has spoken.
145    pub keep_mic_open: bool,
146    /// How many characters of the natural voice she may hear in a day, across every device.
147    pub voice_daily_chars: u32,
148    /// The grown-up PIN, kept as an opaque salted hash, or none for the multiplication question.
149    /// It is the same on every device because it is part of this document.
150    pub pin: Option<String>,
151    /// The child Whiskers talks to. `None` until the parents fill it in; documents written before
152    /// this field existed read as `None`. With none, the guard assumes the youngest supported age
153    /// (see `profile`), so a missing profile is never the loose case.
154    #[serde(default, deserialize_with = "crate::profile::lenient_child")]
155    pub child: Option<Child>,
156    /// How the cat looks, the same on every device. Documents written before this field existed
157    /// read as [`CatTheme::Grey`].
158    #[serde(default)]
159    pub cat_theme: CatTheme,
160}
161
162impl Default for HouseholdConfig {
163    fn default() -> Self {
164        Self {
165            limits: Limits::default(),
166            tokens: TokenLimit::default(),
167            keep_mic_open: false,
168            voice_daily_chars: 1000,
169            pin: None,
170            child: None,
171            cat_theme: CatTheme::default(),
172        }
173    }
174}
175
176/// Today's time with Whiskers. Each device counts its own, and the day's total is their sum, so
177/// merging two devices' counts can never lose or double any.
178#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
179struct Today {
180    day: u32,
181    used_ms: BTreeMap<String, u64>,
182    /// Minutes the grown-ups gave, by the device they gave them on.
183    extra_minutes: BTreeMap<String, u32>,
184}
185
186/// The grown-ups' choices and today's time, kept in one mergeable document. The service holds
187/// the master copy; each device merges with it, and merging in any order gives the same result.
188#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
189pub struct Household {
190    config: HouseholdConfig,
191    /// When `config` was last changed, by whoever changed it; the later change wins.
192    config_updated_ms: u64,
193    today: Today,
194}
195
196impl Household {
197    pub fn config(&self) -> HouseholdConfig {
198        self.config.clone()
199    }
200
201    /// Takes in another copy. Returns whether anything here changed.
202    pub fn merge(&mut self, other: &Household) -> bool {
203        let before = self.clone();
204        debug!(
205            "household merge: ours config@{} day {}, theirs config@{} day {}",
206            self.config_updated_ms, self.today.day, other.config_updated_ms, other.today.day
207        );
208        if other.config_updated_ms > self.config_updated_ms {
209            info!("household merge: taking the newer choices (changed at {} ms)", other.config_updated_ms);
210            self.config = other.config.clone();
211            self.config_updated_ms = other.config_updated_ms;
212        }
213        if other.today.day > self.today.day {
214            info!("household merge: taking the later day {} over {}", other.today.day, self.today.day);
215            self.today = other.today.clone();
216        } else if other.today.day == self.today.day {
217            for (d, ms) in &other.today.used_ms {
218                let e = self.today.used_ms.entry(d.clone()).or_insert(0);
219                *e = (*e).max(*ms);
220            }
221            for (d, m) in &other.today.extra_minutes {
222                let e = self.today.extra_minutes.entry(d.clone()).or_insert(0);
223                *e = (*e).max(*m);
224            }
225        }
226        let changed = *self != before;
227        debug!("household merge done: changed = {changed}");
228        changed
229    }
230
231    #[cfg(not(target_family = "wasm"))]
232    fn used_ms(&self) -> u64 {
233        self.today.used_ms.values().sum()
234    }
235
236    #[cfg(not(target_family = "wasm"))]
237    fn extra_ms(&self) -> u64 {
238        self.today.extra_minutes.values().map(|m| u64::from(*m) * 60_000).sum()
239    }
240}
241
242#[cfg(not(target_family = "wasm"))]
243/// A household document on disk, and this device's view of it.
244pub struct TimeKeeper {
245    path: PathBuf,
246    device: String,
247    saved: Household,
248}
249
250#[cfg(not(target_family = "wasm"))]
251/// The longest stretch one tick may add. The app ticks about once a second while the screen is in
252/// use; a longer gap means the device slept or the app was frozen, and that is not time spent.
253const MAX_TICK_MS: u64 = 5_000;
254
255#[cfg(not(target_family = "wasm"))]
256impl TimeKeeper {
257    /// A missing or unreadable file starts with the default choices: the worst case is a day
258    /// without limits, not a child locked out by a damaged file. `device` names this device in
259    /// the day's count; it must differ between devices.
260    pub fn open(path: &Path, device: &str) -> Self {
261        let saved = match std::fs::read_to_string(path) {
262            Ok(t) => match serde_json::from_str(&t) {
263                Ok(h) => {
264                    debug!("household document loaded from {}", path.display());
265                    h
266                }
267                Err(e) => {
268                    warn!("household document {} is unreadable ({e}); starting with the default choices", path.display());
269                    Household::default()
270                }
271            },
272            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
273                debug!("no household document at {}; starting with the default choices", path.display());
274                Household::default()
275            }
276            Err(e) => {
277                warn!("household document {} cannot be read ({e}); starting with the default choices", path.display());
278                Household::default()
279            }
280        };
281        Self { path: path.to_owned(), device: device.to_owned(), saved }
282    }
283
284    fn save(&self) {
285        // Best effort: failing to save costs a little remembered time, never a refused conversation.
286        if let Err(e) = self.try_save() {
287            warn!("household document not saved ({e}); some counted time may be lost");
288        }
289    }
290
291    fn try_save(&self) -> std::io::Result<()> {
292        let json = serde_json::to_string(&self.saved).map_err(|e| {
293            warn!("household document does not serialize: {e}");
294            std::io::Error::other(e)
295        })?;
296        write_atomic(&self.path, json.as_bytes())
297    }
298
299    pub fn config(&self) -> HouseholdConfig {
300        self.saved.config.clone()
301    }
302
303    pub fn limits(&self) -> Limits {
304        self.saved.config.limits
305    }
306
307    /// The grown-ups changed a choice, at `now_ms`.
308    pub fn set_config(&mut self, config: HouseholdConfig, now_ms: u64) {
309        info!(
310            "household choices set at {now_ms} ms: daily minutes {:?}, quiet {}, token window {}h, voice {} chars/day, pin set = {}, child profile set = {}",
311            config.limits.daily_minutes,
312            config.limits.quiet.is_some(),
313            config.tokens.window_hours,
314            config.voice_daily_chars,
315            config.pin.is_some(),
316            config.child.is_some()
317        );
318        self.saved.config = config;
319        // Never earlier than what was here, or a device with a slow clock could not change anything.
320        self.saved.config_updated_ms = now_ms.max(self.saved.config_updated_ms + 1);
321        self.save();
322    }
323
324    pub fn set_limits(&mut self, limits: Limits, now_ms: u64) {
325        debug!("set_limits: daily minutes {:?}, quiet {}", limits.daily_minutes, limits.quiet.is_some());
326        let c = HouseholdConfig { limits, ..self.saved.config.clone() };
327        self.set_config(c, now_ms);
328    }
329
330    fn roll(&mut self, day: u32) {
331        if self.saved.today.day < day {
332            info!("a new day: {} -> {day}; today's counts start from zero", self.saved.today.day);
333            self.saved.today = Today { day, ..Today::default() };
334        }
335    }
336
337    /// Time was spent with Whiskers: `delta_ms` of it, on `day` (any number that changes at midnight).
338    pub fn tick(&mut self, day: u32, delta_ms: u64) {
339        self.roll(day);
340        if day != self.saved.today.day {
341            warn!("tick for day {day} ignored: the document is already on day {} (clock went backwards?)", self.saved.today.day);
342            return;
343        }
344        if delta_ms > MAX_TICK_MS {
345            debug!("tick of {delta_ms} ms capped to {MAX_TICK_MS} ms (device slept or app froze?)");
346        }
347        trace!("tick: {} ms on day {day}", delta_ms.min(MAX_TICK_MS));
348        *self.saved.today.used_ms.entry(self.device.clone()).or_insert(0) += delta_ms.min(MAX_TICK_MS);
349        self.save();
350    }
351
352    /// The grown-ups give more time today than the limit says.
353    pub fn grant(&mut self, day: u32, minutes: u32) {
354        self.roll(day);
355        if day != self.saved.today.day {
356            warn!("grant for day {day} ignored: the document is on day {}", self.saved.today.day);
357            return;
358        }
359        info!("grown-ups granted {minutes} extra minute(s) for day {day}");
360        *self.saved.today.extra_minutes.entry(self.device.clone()).or_insert(0) += minutes;
361        self.save();
362    }
363
364    pub fn status(&mut self, day: u32, minute_of_day: u16) -> TimeStatus {
365        self.roll(day);
366        if let Some(q) = self.saved.config.limits.quiet {
367            if q.contains(minute_of_day) {
368                debug!("status: quiet hours at minute {minute_of_day}, until {}", q.until());
369                return TimeStatus::QuietHours { until: q.until() };
370            }
371        }
372        match self.saved.config.limits.daily_minutes {
373            None => {
374                trace!("status: open, no daily limit");
375                TimeStatus::Open { minutes_left: None }
376            }
377            Some(m) => {
378                let allowed = u64::from(m) * 60_000 + self.saved.extra_ms();
379                let used = self.saved.used_ms();
380                if used >= allowed {
381                    info!("status: today's time is up ({} of {} ms used)", used, allowed);
382                    TimeStatus::TodaysTimeIsUp
383                } else {
384                    // Rounded up: with thirty seconds left it says one minute, not none.
385                    TimeStatus::Open { minutes_left: Some(((allowed - used).div_ceil(60_000)) as u32) }
386                }
387            }
388        }
389    }
390
391    /// Minutes spent today, on every device, for the parents' screen.
392    pub fn used_minutes(&mut self, day: u32) -> u32 {
393        self.roll(day);
394        let m = (self.saved.used_ms() / 60_000) as u32;
395        trace!("used minutes today: {m}");
396        m
397    }
398
399    /// What this device knows, to be merged elsewhere.
400    pub fn household(&self) -> Household {
401        self.saved.clone()
402    }
403
404    /// Takes in another copy and keeps the result, or does neither. A device may lose a little counted
405    /// time to a failed save (see [`merge`](Self::merge)); the service that holds the master copy of
406    /// the grown-ups' choices may not, since a PIN or a limit that quietly vanished on restart is worse
407    /// than a refused sync. If the document cannot be saved the merge is undone and the error returned.
408    pub fn merge_durably(&mut self, other: &Household) -> std::io::Result<bool> {
409        let before = self.saved.clone();
410        let changed = self.saved.merge(other);
411        if changed && let Err(e) = self.try_save() {
412            warn!("household merge not kept, the save failed: {e}");
413            self.saved = before;
414            return Err(e);
415        }
416        Ok(changed)
417    }
418
419    /// Takes in another device's copy (the service's). Returns whether anything changed here.
420    pub fn merge(&mut self, other: &Household) -> bool {
421        let changed = self.saved.merge(other);
422        if changed {
423            debug!("time keeper merged a changed household document");
424            self.save();
425        }
426        changed
427    }
428}
429
430#[cfg(not(target_family = "wasm"))]
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn keeper(name: &str) -> TimeKeeper {
436        keeper_on(name, "phone")
437    }
438
439    fn keeper_on(name: &str, device: &str) -> TimeKeeper {
440        let p = std::env::temp_dir().join(format!("whiskers-limits-{name}-{device}-{}.json", std::process::id()));
441        let _ = std::fs::remove_file(&p);
442        TimeKeeper::open(&p, device)
443    }
444
445    fn at(h: u16, m: u16) -> u16 {
446        h * 60 + m
447    }
448
449    #[test]
450    fn quiet_hours_that_cross_midnight_and_ones_that_do_not() {
451        let night = Quiet::new(at(19, 30), at(7, 0)).unwrap();
452        assert!(night.contains(at(23, 0)) && night.contains(at(3, 0)) && night.contains(at(19, 30)));
453        assert!(!night.contains(at(7, 0)) && !night.contains(at(12, 0)) && !night.contains(at(19, 29)));
454        let nap = Quiet::new(at(13, 0), at(14, 30)).unwrap();
455        assert!(nap.contains(at(13, 0)) && nap.contains(at(14, 29)) && !nap.contains(at(14, 30)) && !nap.contains(at(9, 0)));
456    }
457
458    #[test]
459    fn nonsense_limits_cannot_be_written_down() {
460        assert!(Quiet::new(100, 100).is_err());
461        assert!(Quiet::new(0, 1440).is_err());
462        assert!(Limits::new(Some(0), None).is_err());
463        assert!(Limits::new(None, None).is_ok());
464    }
465
466    #[test]
467    fn with_no_limits_it_is_always_open() {
468        let mut k = keeper("none");
469        k.tick(1, 5_000);
470        assert_eq!(k.status(1, at(3, 0)), TimeStatus::Open { minutes_left: None });
471    }
472
473    #[test]
474    fn the_daily_limit_counts_down_and_stops_at_zero() {
475        let mut k = keeper("daily");
476        k.set_limits(Limits::new(Some(1), None).unwrap(), 1);
477        assert_eq!(k.status(1, 600), TimeStatus::Open { minutes_left: Some(1) });
478        for _ in 0..11 {
479            k.tick(1, 5_000);
480        }
481        assert_eq!(k.status(1, 600), TimeStatus::Open { minutes_left: Some(1) }, "five seconds left rounds up to a minute");
482        k.tick(1, 5_000);
483        assert_eq!(k.status(1, 600), TimeStatus::TodaysTimeIsUp);
484        assert_eq!(k.used_minutes(1), 1);
485    }
486
487    #[test]
488    fn a_new_day_starts_fresh_and_a_grant_lasts_only_its_day() {
489        let mut k = keeper("grant");
490        k.set_limits(Limits::new(Some(1), None).unwrap(), 1);
491        for _ in 0..12 {
492            k.tick(1, 5_000);
493        }
494        assert_eq!(k.status(1, 600), TimeStatus::TodaysTimeIsUp);
495        k.grant(1, 15);
496        assert_eq!(k.status(1, 600), TimeStatus::Open { minutes_left: Some(15) });
497        assert_eq!(k.status(2, 600), TimeStatus::Open { minutes_left: Some(1) });
498    }
499
500    fn ada() -> Child {
501        Child { name: crate::profile::ChildName::new("Ada").unwrap(), age: crate::profile::Age::new(6).unwrap() }
502    }
503
504    #[test]
505    fn a_document_written_before_the_child_profile_existed_still_loads_with_none() {
506        let old = r#"{"config":{"limits":{"daily_minutes":null,"quiet":null},"tokens":{"per_window":400000,"window_hours":5},"keep_mic_open":false,"voice_daily_chars":1000,"pin":null},"config_updated_ms":5,"today":{"day":0,"used_ms":{},"extra_minutes":{}}}"#;
507        let h: Household = serde_json::from_str(old).expect("an old document must load");
508        assert_eq!(h.config().child, None);
509        assert_eq!(h.config().voice_daily_chars, 1000);
510    }
511
512    #[test]
513    fn the_default_cat_is_grey_and_an_old_document_reads_as_grey() {
514        assert_eq!(CatTheme::default(), CatTheme::Grey);
515        assert_eq!(HouseholdConfig::default().cat_theme, CatTheme::Grey);
516        let old = r#"{"config":{"limits":{"daily_minutes":null,"quiet":null},"tokens":{"per_window":400000,"window_hours":5},"keep_mic_open":false,"voice_daily_chars":1000,"pin":null},"config_updated_ms":5,"today":{"day":0,"used_ms":{},"extra_minutes":{}}}"#;
517        let h: Household = serde_json::from_str(old).expect("an old document must load");
518        assert_eq!(h.config().cat_theme, CatTheme::Grey);
519    }
520
521    #[test]
522    fn the_cats_look_round_trips_and_the_later_change_wins_on_merge() {
523        let mut phone = keeper_on("theme-merge", "phone");
524        let mut tablet = keeper_on("theme-merge", "tablet");
525        phone.set_config(HouseholdConfig { cat_theme: CatTheme::Ginger, ..phone.config() }, 100);
526        let json = serde_json::to_string(&phone.household()).unwrap();
527        assert!(json.contains("\"cat_theme\":\"ginger\""), "{json}");
528        let back: Household = serde_json::from_str(&json).unwrap();
529        assert_eq!(back.config().cat_theme, CatTheme::Ginger);
530        assert!(tablet.merge(&phone.household()));
531        assert_eq!(tablet.config().cat_theme, CatTheme::Ginger);
532        tablet.set_config(HouseholdConfig { cat_theme: CatTheme::Grey, ..tablet.config() }, 200);
533        assert!(!tablet.merge(&phone.household()), "a stale copy cannot bring the old look back");
534        assert!(phone.merge(&tablet.household()));
535        assert_eq!(phone.config().cat_theme, CatTheme::Grey);
536    }
537
538    #[test]
539    fn a_child_profile_is_kept_and_the_later_change_wins_on_merge() {
540        let mut phone = keeper_on("child-merge", "phone");
541        let mut tablet = keeper_on("child-merge", "tablet");
542        phone.set_config(HouseholdConfig { child: Some(ada()), ..phone.config() }, 100);
543        assert!(tablet.merge(&phone.household()));
544        assert_eq!(tablet.config().child, Some(ada()));
545        // a later change on the other device (clearing it) wins, and a stale copy cannot bring it back
546        tablet.set_config(HouseholdConfig { child: None, ..tablet.config() }, 200);
547        assert!(!tablet.merge(&phone.household()));
548        assert_eq!(tablet.config().child, None);
549        assert!(phone.merge(&tablet.household()));
550        assert_eq!(phone.config().child, None);
551    }
552
553    #[test]
554    fn a_child_profile_that_does_not_validate_in_a_document_reads_as_none_and_leaves_the_rest() {
555        let mut h = Household::default();
556        h.config.child = Some(ada());
557        h.config.pin = Some("salted".into());
558        let json = serde_json::to_string(&h).unwrap().replace("\"age\":6", "\"age\":99");
559        let back: Household = serde_json::from_str(&json).expect("the rest of the document survives");
560        assert_eq!(back.config().child, None);
561        assert_eq!(back.config().pin.as_deref(), Some("salted"));
562    }
563
564    #[test]
565    fn a_long_gap_is_a_sleeping_device_not_time_spent() {
566        let mut k = keeper("gap");
567        k.set_limits(Limits::new(Some(10), None).unwrap(), 1);
568        k.tick(1, 3 * 60 * 60 * 1000);
569        assert_eq!(k.status(1, 600), TimeStatus::Open { minutes_left: Some(10) }, "only one tick's worth counted");
570    }
571
572    #[test]
573    fn quiet_hours_win_and_say_when_it_wakes() {
574        let mut k = keeper("quiet");
575        k.set_limits(Limits::new(Some(60), Some(Quiet::new(at(19, 30), at(7, 0)).unwrap())).unwrap(), 1);
576        assert_eq!(k.status(1, at(21, 0)), TimeStatus::QuietHours { until: at(7, 0) });
577        assert_eq!(k.status(1, at(8, 0)), TimeStatus::Open { minutes_left: Some(60) });
578    }
579
580    #[test]
581    fn limits_and_time_spent_survive_a_restart_and_a_damaged_file_means_no_limits() {
582        let p = std::env::temp_dir().join(format!("whiskers-limits-restart-{}.json", std::process::id()));
583        let _ = std::fs::remove_file(&p);
584        let mut k = TimeKeeper::open(&p, "phone");
585        k.set_limits(Limits::new(Some(30), None).unwrap(), 1);
586        k.tick(7, 4_000);
587        let mut again = TimeKeeper::open(&p, "phone");
588        assert_eq!(again.limits().daily_minutes(), Some(30));
589        assert_eq!(again.status(7, 600), TimeStatus::Open { minutes_left: Some(30) });
590        std::fs::write(&p, "{ nope").unwrap();
591        assert_eq!(TimeKeeper::open(&p, "phone").limits(), Limits::default());
592    }
593
594    #[test]
595    fn two_devices_share_one_days_time_and_the_total_is_the_sum() {
596        let mut phone = keeper_on("share", "phone");
597        let mut tablet = keeper_on("share", "tablet");
598        phone.set_limits(Limits::new(Some(1), None).unwrap(), 1);
599        tablet.merge(&phone.household());
600        for _ in 0..6 {
601            phone.tick(1, 5_000); // half a minute on the phone
602            tablet.tick(1, 5_000); // and half on the tablet
603        }
604        phone.merge(&tablet.household());
605        tablet.merge(&phone.household());
606        assert_eq!(phone.status(1, 600), TimeStatus::TodaysTimeIsUp, "a minute in all");
607        assert_eq!(tablet.status(1, 600), TimeStatus::TodaysTimeIsUp);
608        // Merging again, in the other order, changes nothing.
609        assert!(!phone.merge(&tablet.household()));
610        assert_eq!(phone.household(), tablet.household());
611    }
612
613    #[test]
614    fn a_choice_made_later_wins_on_every_device() {
615        let mut phone = keeper_on("lww", "phone");
616        let mut tablet = keeper_on("lww", "tablet");
617        phone.set_limits(Limits::new(Some(30), None).unwrap(), 100);
618        tablet.set_limits(Limits::new(Some(60), None).unwrap(), 200);
619        phone.merge(&tablet.household());
620        tablet.merge(&phone.household());
621        assert_eq!(phone.limits().daily_minutes(), Some(60));
622        assert_eq!(tablet.limits().daily_minutes(), Some(60));
623    }
624
625    #[test]
626    fn grants_from_two_devices_both_count_and_a_new_day_leaves_old_time_behind() {
627        let mut phone = keeper_on("grants", "phone");
628        let mut tablet = keeper_on("grants", "tablet");
629        phone.set_limits(Limits::new(Some(10), None).unwrap(), 1);
630        tablet.merge(&phone.household());
631        phone.grant(1, 15);
632        tablet.grant(1, 5);
633        phone.merge(&tablet.household());
634        assert_eq!(phone.status(1, 600), TimeStatus::Open { minutes_left: Some(30) });
635        assert_eq!(phone.status(2, 600), TimeStatus::Open { minutes_left: Some(10) });
636        // The next day's document wins over the old day's everywhere.
637        tablet.merge(&phone.household());
638        assert_eq!(tablet.status(2, 600), TimeStatus::Open { minutes_left: Some(10) });
639    }
640
641    #[test]
642    fn the_token_allowance_is_part_of_the_shared_choices() {
643        let mut phone = keeper_on("tok", "phone");
644        let mut c = phone.config();
645        c.tokens = TokenLimit::new(Some(100_000), 2).unwrap();
646        c.keep_mic_open = true;
647        c.pin = Some("salt:hash".into());
648        c.voice_daily_chars = 2500;
649        phone.set_config(c, 5);
650        let mut hub = keeper_on("tok", "hub");
651        hub.merge(&phone.household());
652        assert_eq!(hub.config().tokens.per_window(), Some(100_000));
653        assert_eq!(hub.config().tokens.window_hours(), 2);
654        assert!(hub.config().keep_mic_open);
655        assert_eq!(hub.config().pin.as_deref(), Some("salt:hash"), "the PIN is the same everywhere");
656        assert_eq!(hub.config().voice_daily_chars, 2500, "and so is the voice allowance");
657        assert!(TokenLimit::new(Some(0), 5).is_err() && TokenLimit::new(None, 0).is_err());
658    }
659
660    #[test]
661    fn a_hub_that_cannot_save_a_merge_undoes_it_and_says_so() {
662        let mut phone = keeper_on("durable-phone", "phone");
663        let mut c = phone.config();
664        c.voice_daily_chars = 77;
665        phone.set_config(c, 50);
666        let p = std::env::temp_dir().join(format!("whiskers-no-such-dir-{}", std::process::id())).join("hub.json");
667        let mut hub = TimeKeeper::open(&p, "service");
668        assert!(hub.merge_durably(&phone.household()).is_err(), "the directory does not exist, so nothing can be kept");
669        assert_eq!(hub.config().voice_daily_chars, HouseholdConfig::default().voice_daily_chars, "and the choice was not taken");
670        // With somewhere to save, the same merge is kept and reported once.
671        let ok = std::env::temp_dir().join(format!("whiskers-durable-hub-{}.json", std::process::id()));
672        let _ = std::fs::remove_file(&ok);
673        let mut hub = TimeKeeper::open(&ok, "service");
674        assert!(hub.merge_durably(&phone.household()).unwrap());
675        assert!(!hub.merge_durably(&phone.household()).unwrap(), "the second time changes nothing");
676        assert_eq!(TimeKeeper::open(&ok, "service").config().voice_daily_chars, 77, "kept on disk");
677        let _ = std::fs::remove_file(ok);
678    }
679}