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}