1use whiskers_core::*;
A tiny deterministic generator, so the property needs no dependency and fails the same way twice.
12fn gist(d: &MemoryDoc) -> Vec<(String, Visibility)> { 13 let mut v: Vec<_> = d.snapshot().facts.into_iter().map(|f| (f.gid, f.visibility)).collect(); 14 v.sort_by(|a, b| a.0.cmp(&b.0)); 15 v 16} 17 18fn replica(gids: &[&str], acts: &[(usize, bool, u64)]) -> MemorySnapshot { 19 // One replica's view: it knows the facts and applied some hide/restore acts to them. 20 let facts = gids 21 .iter() 22 .map(|g| { 23 let mut v = Visibility::default(); 24 for &(i, hide, t) in acts { 25 if gids[i] == *g { 26 v = if hide { v.hidden_after(t) } else { v.restored_after(t) }; 27 } 28 } 29 Fact { 30 id: 0, text: format!("fact {g}"), learned_at_ms: 1, kind: Kind::Other, who: vec![], place: None, when: None, 31 pictures: vec![], embedding: vec![], gid: (*g).to_owned(), visibility: v, icon: None, cover: Default::default(), 32 mentions: vec![], text_at_ms: 0, revisions: vec![], 33 } 34 }) 35 .collect(); 36 MemorySnapshot { facts, ..MemorySnapshot::default() } 37} 38 39 40#[test] 41fn a_digest_orders_the_day_and_puts_needs_a_grown_up_first() { 42 let h = |at, text: &str| Entry::new(at, Event::Heard { text: text.into(), pictures: vec![] }); 43 let said = |at, text: &str, outcome| Entry::new(at, Event::Said { text: text.into(), outcome }); 44 let entries = vec![ 45 h(10, "my bunny is Biscuit"), 46 said(11, "Lovely!", Outcome::Answered), 47 h(20, "my tummy hurts"), 48 Entry::new(21, Event::Guarded { direction: Direction::FromChild, verdict: Verdict::Refuse { reason: "hurt".into(), kind: RefusalKind::NeedsAGrownUp } }), 49 said(22, "Let's find Mommy.", Outcome::Fallback(Fallback::NeedsAGrownUp)), 50 Entry::new(23, Event::Remembered { fact: "bunny is Biscuit".into(), gid: "g1".into() }), 51 h(500, "tomorrow"), 52 said(501, "ok", Outcome::Answered), 53 ]; 54 let d = Digest::between(&entries, 0, 100); 55 assert_eq!(d.exchanges.len(), 2); 56 assert_eq!(d.counts(), (1, 1)); 57 let urgent = d.needs_a_grown_up(); 58 assert_eq!(urgent.len(), 1); 59 assert_eq!(urgent[0].heard, "my tummy hurts"); 60 assert_eq!(urgent[0].notes.len(), 1); 61 assert_eq!(d.facts_learned, vec!["bunny is Biscuit".to_owned()]); 62 let ada = Audience::new(Some(&Child { name: ChildName::new("Ada").unwrap(), age: Age::new(6).unwrap() })); 63 assert!(d.transcript(&ada).contains("Ada: my bunny is Biscuit")); 64 assert!(d.transcript(&Audience::new(None)).contains("Child: my bunny is Biscuit")); 65} 66 67#[test] 68fn a_turn_the_log_never_finished_is_shown_not_hidden() { 69 let entries = vec![Entry::new(5, Event::Heard { text: "cut off".into(), pictures: vec![] })]; 70 let d = Digest::between(&entries, 0, 100); 71 assert_eq!(d.exchanges.len(), 1); 72 assert_ne!(d.exchanges[0].outcome, Outcome::Answered); 73} 74 75#[test] 76fn cosine_is_zero_for_anything_it_cannot_compare() { 77 assert_eq!(cosine(&[], &[]), 0.0); 78 assert_eq!(cosine(&[1.0, 0.0], &[1.0]), 0.0); 79 assert_eq!(cosine(&[0.0, 0.0], &[1.0, 0.0]), 0.0); 80 assert!((cosine(&[1.0, 2.0], &[2.0, 4.0]) - 1.0).abs() < 1e-6); 81} 82 83#[test] 84fn the_greeting_never_closes_a_turn_a_crash_left_open() { 85 let at = |ms, event| Entry::new(ms, event); 86 let entries = vec![ 87 at(1, Event::Heard { text: "my tummy hurts".into(), pictures: vec![] }), 88 at(5, Event::Greeted { text: "Hi!".into() }), 89 ]; 90 let d = Digest::between(&entries, 0, 10); 91 assert_eq!(d.exchanges.len(), 1); 92 assert_ne!(d.exchanges[0].outcome, Outcome::Answered, "she never got an answer"); 93 assert!(d.exchanges[0].said.is_empty()); 94} 95 96 97#[test] 98fn only_plain_picture_names_are_trusted() { 99 for ok in ["0000001790000000-0000.jpg", "a-1_b.png"] { 100 assert!(PictureId(ok.into()).is_safe(), "{ok}"); 101 } 102 for bad in ["", "../secret.jpg", "a/b.jpg", ".hidden.jpg", "a..b.jpg", "x.sh", "x", &"a".repeat(70)] { 103 assert!(!PictureId(bad.into()).is_safe(), "{bad}"); 104 } 105} 106 107#[test] 108fn merging_replicas_in_any_order_any_number_of_times_gives_the_same_visibility() { 109 let gids = ["g1", "g2", "g3"]; 110 let mut rng = Lcg(42); 111 for _round in 0..200 { 112 let snaps: Vec<MemorySnapshot> = (0..4) 113 .map(|_| { 114 let acts: Vec<(usize, bool, u64)> = 115 (0..rng.next(5)).map(|_| (rng.next(3) as usize, rng.next(2) == 0, rng.next(6))).collect(); 116 replica(&gids, &acts) 117 }) 118 .collect(); 119 let mut expected = None; 120 for _try in 0..6 { 121 // a random order, with repeats 122 let mut doc = MemoryDoc::default(); 123 let mut order: Vec<usize> = (0..snaps.len()).collect(); 124 for i in (1..order.len()).rev() { 125 order.swap(i, rng.next(i as u64 + 1) as usize); 126 } 127 for &i in &order { 128 doc.merge(snaps[i].clone()); 129 if rng.next(2) == 0 { 130 doc.merge(snaps[i].clone()); 131 } 132 } 133 let g = gist(&doc); 134 match &expected { 135 None => expected = Some(g), 136 Some(e) => assert_eq!(&g, e, "order must not matter"), 137 } 138 let again = doc.merge(snaps[0].clone()); 139 assert_eq!(again, 0, "a second merge changes nothing"); 140 } 141 } 142}
a turn being answered is not a turn that failed
150#[test] 151fn a_turn_still_being_answered_is_in_progress_and_counts_as_neither_answered_nor_stopped() { 152 let entries = vec![ 153 at(10, Event::Heard { text: "hello".into(), pictures: vec![] }), 154 at(11, Event::Guarded { direction: Direction::FromChild, verdict: Verdict::Allow }), 155 at(20, Event::Heard { text: "tell me a story".into(), pictures: vec![] }), 156 ]; 157 let d = Digest::between_at(&entries, 0, 100, Some(30_000)); 158 assert_eq!(d.exchanges.len(), 2); 159 assert_eq!(d.exchanges[0].progress, Progress::Interrupted, "a later turn began, so the first one was cut off"); 160 assert_eq!(d.exchanges[1].progress, Progress::InProgress, "the last has a recent line"); 161 assert_eq!(d.counts(), (0, 1), "one stopped, none answered; the one being answered is neither"); 162 assert_eq!(d.in_progress(), 1); 163} 164 165#[test] 166fn a_turn_with_no_end_and_no_recent_line_was_cut_off_and_without_the_time_every_one_is() { 167 let entries = vec![at(10, Event::Heard { text: "hello".into(), pictures: vec![] })]; 168 let stale = Digest::between_at(&entries, 0, 100, Some(10 + IN_PROGRESS_MS)); 169 assert_eq!(stale.exchanges[0].progress, Progress::Interrupted); 170 assert_eq!(stale.counts(), (0, 1)); 171 let fresh = Digest::between_at(&entries, 0, 100, Some(10 + IN_PROGRESS_MS - 1)); 172 assert_eq!(fresh.exchanges[0].progress, Progress::InProgress); 173 assert_eq!(Digest::between(&entries, 0, 100).exchanges[0].progress, Progress::Interrupted, "no clock, no claim that it is being answered"); 174} 175 176#[test] 177fn a_finished_turn_is_finished_whenever_it_is_read() { 178 let entries = vec![at(10, Event::Heard { text: "hi".into(), pictures: vec![] }), at(11, Event::Said { text: "hello".into(), outcome: Outcome::Answered })]; 179 let d = Digest::between_at(&entries, 0, 100, Some(12)); 180 assert_eq!(d.exchanges[0].progress, Progress::Finished); 181 assert_eq!(d.counts(), (1, 0)); 182} 183 184#[test] 185fn a_turn_that_needs_a_grown_up_is_urgent_even_when_its_closing_line_was_never_written() { 186 let refused = at(21, Event::Guarded { direction: Direction::FromChild, verdict: Verdict::Refuse { reason: "hurt".into(), kind: RefusalKind::NeedsAGrownUp } }); 187 // The closing `Said` could not be written (the log failed): the guard's line before it is what is left. 188 let entries = vec![at(20, Event::Heard { text: "my tummy hurts".into(), pictures: vec![] }), refused.clone()]; 189 let d = Digest::between_at(&entries, 0, 100, Some(10_000_000)); 190 assert_eq!(d.needs_a_grown_up().len(), 1, "still urgent"); 191 assert_eq!(d.exchanges[0].progress, Progress::Interrupted); 192 // An off-limits refusal is not urgent, and neither is a refusal of what the model wrote. 193 let off = at(21, Event::Guarded { direction: Direction::FromChild, verdict: Verdict::Refuse { reason: "no".into(), kind: RefusalKind::OffLimits } }); 194 let model = at(22, Event::Guarded { direction: Direction::ToChild, verdict: Verdict::Refuse { reason: "hurt".into(), kind: RefusalKind::NeedsAGrownUp } }); 195 let d = Digest::between(&[at(20, Event::Heard { text: "x".into(), pictures: vec![] }), off, model], 0, 100); 196 assert!(d.needs_a_grown_up().is_empty()); 197 // And when the closing line is there, it is urgent as before. 198 let d = Digest::between( 199 &[at(20, Event::Heard { text: "help".into(), pictures: vec![] }), at(22, Event::Said { text: "find a grown-up".into(), outcome: Outcome::Fallback(Fallback::NeedsAGrownUp) })], 200 0, 201 100, 202 ); 203 assert_eq!(d.needs_a_grown_up().len(), 1); 204}
the household's day
208#[test] 209fn the_offset_from_utc_is_read_off_the_local_time_and_nothing_odd_is_believed() { 210 let now = 20_000 * 86_400_000 + 3 * 3_600_000 + 7 * 60_000 + 30_000; // 03:07:30 UTC 211 let local = |minutes_ahead: i64| { 212 let m = (now / 60_000) as i64 + minutes_ahead; 213 LocalMoment { day: m.div_euclid(1440) as u32, minute_of_day: m.rem_euclid(1440) as u16 } 214 }; 215 assert_eq!(local(0).utc_offset_minutes(now), Some(0)); 216 assert_eq!(local(-240).utc_offset_minutes(now), Some(-240), "four hours behind, across midnight"); 217 assert_eq!(local(330).utc_offset_minutes(now), Some(330)); 218 assert_eq!(local(-239).utc_offset_minutes(now), Some(-240), "a minute of skew between the two clocks moves nothing"); 219 assert_eq!(LocalMoment { day: 20_000 + 3, minute_of_day: 0 }.utc_offset_minutes(now), None, "three days apart is not a time zone"); 220 assert!(valid_offset(-720) && valid_offset(345) && !valid_offset(7) && !valid_offset(15 * 60)); 221}
223#[test] 224fn the_household_keeps_its_zone_and_its_retention_as_settings_and_refuses_nonsense() { 225 let mut h = Household::default(); 226 h.merge(&{ 227 let mut other = TimeKeeper::new(Box::new(MemStore::default()), "x"); 228 assert!(other.set_utc_offset(-240, 50)); 229 assert!(!other.set_utc_offset(-240, 60), "the same zone again writes nothing"); 230 assert!(!other.set_utc_offset(7, 70), "and a zone no place has is refused"); 231 other.household() 232 }); 233 assert_eq!(h.config().utc_offset_minutes, Some(-240)); 234 assert_eq!(h.config().log_retention_days, None, "the default keeps everything"); 235 let bad: HouseholdConfig = serde_json::from_str(r#"{"limits":{"daily_minutes":null,"quiet":null},"tokens":{"per_window":null,"window_hours":5},"keep_mic_open":false,"voice_daily_chars":1,"pin":null,"utc_offset_minutes":7}"#).unwrap(); 236 assert_eq!(bad.utc_offset_minutes, None, "a wrong zone reads as none: the UTC day is better than a wrong one"); 237} 238 239#[derive(Default)] 240struct MemStore(Option<Household>); 241impl HouseholdStore for MemStore { 242 fn load(&self) -> Result<Household, HouseholdStoreError> { 243 Ok(self.0.clone().unwrap_or_default()) 244 } 245 fn save(&mut self, household: &Household) -> Result<(), HouseholdStoreError> { 246 self.0 = Some(household.clone()); 247 Ok(()) 248 } 249}