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}