1use std::path::PathBuf; 2use std::sync::Arc; 3 4use whiskers_core::*; 5use whiskers_engine::*; 6 7struct Say(&'static str); 8impl Model for Say { 9 fn complete(&self, _s: &str, _h: &[Turn]) -> Result<String, ModelError> { 10 Ok(self.0.to_owned()) 11 } 12} 13struct AllowAll; 14impl Guard for AllowAll { 15 fn check(&self, _d: Direction, _a: Age, _t: &str) -> Result<Verdict, GuardError> { 16 Ok(Verdict::Allow) 17 } 18} 19struct NoEmbedding; 20impl Embedder for NoEmbedding { 21 fn embed(&self, _t: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> { 22 Err(EmbedError("none in tests".into())) 23 } 24} 25struct NoRanking; 26impl Ranker for NoRanking { 27 fn rank(&self, _q: &str, _c: &[String]) -> Result<Vec<f32>, RankError> { 28 Err(RankError("none in tests".into())) 29 } 30} 31 32fn dir(name: &str) -> PathBuf { 33 let d = std::env::temp_dir().join(format!("whiskers-engine-{name}-{}", std::process::id())); 34 let _ = std::fs::remove_dir_all(&d); 35 std::fs::create_dir_all(&d).unwrap(); 36 d 37} 38 39fn engine(d: &std::path::Path, answer: &'static str) -> Engine { 40 let cfg = EngineConfig { 41 data_dir: d.to_owned(), 42 gateway_url: String::new(), 43 guard_url: String::new(), 44 model: String::new(), 45 system_prompt: None, 46 }; 47 let model: Arc<dyn Model> = Arc::new(Say(answer)); 48 let guard: Arc<dyn Guard> = Arc::new(AllowAll); 49 let parts = Parts { 50 model, 51 guard, 52 log: SharedLog::new(Box::new(JsonlLog::open(&d.join("log.jsonl")).unwrap())), 53 pictures: Box::new(DirPictures::open(&d.join("pictures")).unwrap()), 54 memory: SharedMemory::new(Box::new(JsonMemory::open(&d.join("memory.json")).unwrap())), 55 chat: SharedChat::open(Box::new(JsonChat::open(&d.join("chat.json")))).unwrap(), 56 recall: Arc::new(Recall::new(Arc::new(NoEmbedding), Arc::new(NoRanking))), 57 clock: Arc::new(SystemClock), 58 profile: SharedProfile::default(), 59 }; 60 Engine::with_parts(cfg, parts, Arc::new(NoEmbedding), Box::new(Say("A quiet day with a bunny."))).unwrap() 61} 62 63#[test] 64fn the_engine_is_shareable_between_threads() { 65 fn needs<T: Send + Sync>() {} 66 needs::<Engine>(); 67} 68 69#[test] 70fn a_poke_through_the_engine_tickles_the_cat() { 71 let e = engine(&dir("poke"), "hi"); 72 e.tick(0); 73 e.touch(Touch { kind: TouchKind::Down, x: 0.0, y: 0.0, at_ms: 100 }); 74 e.touch(Touch { kind: TouchKind::Up, x: 0.0, y: 0.0, at_ms: 180 }); 75 let v = e.tick(200); 76 assert_eq!((v.mood, v.pokes), (Mood::Tickled, 1)); 77} 78 79#[test] 80fn a_chat_is_logged_and_shows_up_in_the_parents_digest() { 81 let d = dir("digest"); 82 let e = engine(&d, "Biscuit is a lovely name!"); 83 let said = e.hear("my bunny is Biscuit", vec![]); 84 assert_eq!(said.outcome, Outcome::Answered); 85 let digest = e.digest(0, u64::MAX).unwrap(); 86 assert_eq!((digest.answered, digest.stopped), (1, 0)); 87 assert_eq!(digest.exchanges[0].heard, "my bunny is Biscuit"); 88 assert_eq!(e.summarize(0, u64::MAX).unwrap(), "A quiet day with a bunny."); 89} 90 91#[test] 92fn needs_a_grown_up_sorts_first_in_the_digest() { 93 let d = dir("urgent"); 94 let path = d.join("log.jsonl"); 95 { 96 let mut log = JsonlLog::open(&path).unwrap(); 97 let h = |at, t: &str| Entry { at_ms: at, event: Event::Heard { text: t.into(), pictures: vec![] } }; 98 let s = |at, t: &str, o| Entry { at_ms: at, event: Event::Said { text: t.into(), outcome: o } }; 99 for e in [ 100 h(1, "first"), 101 s(2, "ok", Outcome::Answered), 102 h(3, "i am hurt"), 103 s(4, "find mommy", Outcome::Fallback(Fallback::NeedsAGrownUp)), 104 ] { 105 log.append(&e).unwrap(); 106 } 107 } 108 let e = engine(&d, "x"); 109 let digest = e.digest(0, 100).unwrap(); 110 assert!(digest.exchanges[0].needs_a_grown_up); 111 assert_eq!(digest.exchanges[0].heard, "i am hurt"); 112} 113 114#[test] 115fn a_relaunch_does_not_say_hello_again_and_resumes_the_chat() { 116 let d = dir("relaunch"); 117 { 118 let e = engine(&d, "Nice to meet you!"); 119 assert!(e.greeting_due(), "the first time ever"); 120 e.greet(); 121 e.hear("my name is Ada", vec![]); 122 } 123 let again = engine(&d, "Welcome back"); 124 assert!(!again.greeting_due(), "folding the phone is not a new chat"); 125} 126 127#[test] 128fn reflecting_does_not_wait_for_a_turn_and_a_second_call_while_busy_does_nothing() { 129 let d = dir("reflect"); 130 let e = engine(&d, r#"["a fact"]"#); 131 e.hear("hello", vec![]); 132 // The memory work runs without the conversation lock: facts() and a new turn both proceed. 133 let learned = e.reflect(); 134 assert_eq!(learned.len(), 1); 135 assert_eq!(e.facts().len(), 1); 136 assert!(e.reflect().is_empty(), "nothing pending"); 137 assert!(e.forget(learned[0].id)); 138 assert!(e.facts().is_empty()); 139} 140 141#[test] 142fn putting_a_fact_away_hides_it_from_her_keeps_it_for_the_parents_and_is_logged() { 143 let d = dir("put-away"); 144 let e = engine(&d, r#"["a fact"]"#); 145 e.hear("hello", vec![]); 146 let id = e.reflect()[0].id; 147 assert_eq!(e.facts_she_sees().len(), 1); 148 assert!(e.put_away(id)); 149 assert!(e.facts_she_sees().is_empty(), "gone from her view"); 150 assert_eq!(e.facts().len(), 1, "still the parents'"); 151 assert!(e.facts()[0].visibility.is_hidden()); 152 assert!(e.reflect().is_empty()); 153 assert!(e.restore(id)); 154 assert_eq!(e.facts_she_sees().len(), 1); 155 assert!(!e.put_away(9999) && !e.restore(9999)); 156 let (entries, _) = read_log(&d.join("log.jsonl")).unwrap(); 157 assert!(entries.iter().any(|x| matches!(&x.event, Event::PutAway { fact } if fact == "a fact"))); 158 assert!(entries.iter().any(|x| matches!(&x.event, Event::Restored { fact } if fact == "a fact"))); 159 // It survives a restart, and forgetting is still the parents' permanent act. 160 e.put_away(id); 161 drop(e); 162 let again = engine(&d, "x"); 163 assert!(again.facts_she_sees().is_empty()); 164 assert!(again.forget(id)); 165 assert!(again.facts().is_empty()); 166} 167 168#[test] 169fn damaged_state_files_do_not_stop_the_engine_starting() { 170 let d = dir("damaged-start"); 171 std::fs::write(d.join("chat.json"), "").unwrap(); 172 std::fs::write(d.join("memory.json"), "{ nope").unwrap(); 173 // Engine::new, the path the app takes (no network is touched until a turn is spoken). 174 let e = Engine::new(EngineConfig { 175 data_dir: d.clone(), 176 gateway_url: "http://127.0.0.1:1".into(), 177 guard_url: "http://127.0.0.1:1".into(), 178 model: String::new(), 179 system_prompt: None, 180 }) 181 .expect("a damaged file must not stop the app starting"); 182 assert!(e.greeting_due(), "the chat started afresh"); 183 assert!(e.facts().is_empty()); 184 let (entries, _) = read_log(&d.join("log.jsonl")).unwrap(); 185 assert!(entries.iter().any(|x| matches!(&x.event, Event::MemoryFailed { error } if error.contains("damaged")))); 186} 187 188#[test] 189fn the_child_profile_set_in_the_settings_is_used_at_once_and_survives_a_relaunch() { 190 let d = dir("profile"); 191 let child = Child { name: ChildName::new("Ada").unwrap(), age: Age::new(6).unwrap() }; 192 { 193 let e = engine(&d, "x"); 194 assert!(e.greet().text.starts_with("Hi there!"), "no profile yet: neutral"); 195 e.set_household_config(HouseholdConfig { child: Some(child.clone()), ..e.household_config() }); 196 assert!(e.greet().text.starts_with("Hi Ada!")); 197 } 198 let again = engine(&d, "x"); 199 assert_eq!(again.household_config().child, Some(child)); 200 assert!(again.greet().text.starts_with("Hi Ada!"), "the profile is read from the saved document at start"); 201}