pure.rsannotatedpure.rssource249 lines · 11.4 KB · raw
1use whiskers_core::*;

A tiny deterministic generator, so the property needs no dependency and fails the same way twice.

4struct Lcg(u64);
5impl Lcg {
6    fn next(&mut self, below: u64) -> u64 {
7        self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
8        (self.0 >> 33) % below
9    }
10}
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

146fn at(ms: u64, event: Event) -> Entry {
147    Entry::new(ms, event)
148}
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}