1//! The recorded request script: every request is fixed text, so every host is asked the same thing and
2//! its answers can be compared byte for byte.
3//!
4//! The script is restricted to the hub routes (`/memory/sync`, `/household/sync`, `/chat/sync`) plus the
5//! refusals every route shares (wrong method, unknown route, a body that is not a document). It
6//! starts from an empty household and each step's answer depends on the steps before it.
7
8use whiskers_conformance::suite::build::household;
9
10/// One request in the script.
11pub struct Step {
12    pub name: &'static str,
13    pub method: &'static str,
14    pub path: &'static str,
15    pub body: String,
16    /// The status every host must answer (checked in-process by this crate's tests against the reference service).
17    pub expect: u16,
18}
19
20fn post(name: &'static str, path: &'static str, body: impl Into<String>) -> Step {
21    let name_says_refused = name.contains("not JSON") || name.contains("empty body") || name.contains("wrong shape") || name.contains("limit");
22    let expect = if name.starts_with("refusal: an unknown") || name.starts_with("refusal: the root") { 404 } else if name_says_refused { 400 } else { 200 };
23    Step { name, method: "POST", path, body: body.into(), expect }
24}
25
26/// A fact as a device writes it, by hand, so the keys are in the order a device sends them.
27fn fact(gid: &str, text: &str, embedding: &str) -> String {
28    format!(
29        r#"{{"id":0,"text":{},"learned_at_ms":5,"kind":"Other","who":[],"place":null,"when":null,"pictures":[],"embedding":{embedding},"gid":"{gid}"}}"#,
30        serde_json::to_string(text).expect("text")
31    )
32}
33
34fn memory(facts: &[String], forgotten: &[&str]) -> String {
35    let forgotten: Vec<String> = forgotten.iter().map(|g| format!("\"{g}\"")).collect();
36    format!(r#"{{"facts":[{}],"forgotten":[{}]}}"#, facts.join(","), forgotten.join(","))
37}
38
39fn chat(version: u64, active: u64, summary: &str) -> String {
40    format!(r#"{{"summary":"{summary}","turns":[{{"speaker":"Child","text":"hi"}},{{"speaker":"Whiskers","text":"hello"}}],"last_active_ms":{active},"version":{version}}}"#)
41}
42
43pub fn script() -> Vec<Step> {
44    let a = fact("g1", "Biscuit is a bunny", "[]");
45    let b = fact("g2", "she loves purple", "[]");
46    let dup = fact("g3", "BISCUIT is a  bunny", "[]");
47    let embedded = fact("g1", "Biscuit is a bunny", "[0.1,1e-7,16777216.0,-3.4028235e38,0.30000001192092896]");
48    let accents = fact("g4", "Nana lives by the sea \u{1F30A} caf\u{e9} \u{65e5}\u{672c}", "[]");
49    let long = fact("g5", &"a long thing she said ".repeat(5000), "[]");
50    let mut steps = vec![
51        post("memory: an empty household is empty", "/memory/sync", memory(&[], &[])),
52        post("memory: device A teaches two facts", "/memory/sync", memory(&[a.clone(), b.clone()], &[])),
53        post("memory: device B learns the same words twice and forgets g2", "/memory/sync", memory(&[dup], &["g2"])),
54        post("memory: device A repeats itself (idempotent)", "/memory/sync", memory(&[a.clone(), b.clone()], &[])),
55        post("memory: a forgotten fact does not come back from a device that has it", "/memory/sync", memory(&[b], &[])),
56        post("memory: an embedding computed on one device serves all", "/memory/sync", memory(&[embedded], &[])),
57        post("memory: a second embedding is ignored", "/memory/sync", memory(&[fact("g1", "Biscuit is a bunny", "[9.5]")], &[])),
58        post("memory: a fact with no identity is ignored", "/memory/sync", memory(&[fact("", "no identity yet", "[]")], &[])),
59        post("memory: accents, emoji and kana are kept byte for byte", "/memory/sync", memory(&[accents], &[])),
60        post("memory: a long fact (110 KB)", "/memory/sync", memory(&[long], &[])),
61        post("memory: a forgotten identity never held", "/memory/sync", memory(&[], &["g99"])),
62        post("memory: read it all back", "/memory/sync", memory(&[], &[])),
63        post("memory: unknown fields are ignored", "/memory/sync", r#"{"facts":[],"forgotten":[],"extra":1}"#),
64        post("memory: not JSON", "/memory/sync", "this is not json"),
65        post("memory: empty body", "/memory/sync", ""),
66        post("memory: JSON of the wrong shape", "/memory/sync", r#"{"facts":"nope"}"#),
67        post("household: an empty household's choices", "/household/sync", serde_json::to_string(&household(0, 0, 0, 0, &[])).expect("json")),
68        post("household: the grown-ups choose (config at 10 ms)", "/household/sync", serde_json::to_string(&household(10, 150, 3, 1, &[("tablet", 60_000)])).expect("json")),
69        post("household: an older choice loses", "/household/sync", serde_json::to_string(&household(5, 999, 9, 1, &[("phone", 30_000)])).expect("json")),
70        post("household: a later day wins", "/household/sync", serde_json::to_string(&household(10, 150, 3, 2, &[("tablet", 1_000)])).expect("json")),
71        post("household: repeat", "/household/sync", serde_json::to_string(&household(10, 150, 3, 2, &[("tablet", 1_000)])).expect("json")),
72        post("chat: the first copy", "/chat/sync", chat(2, 5, "kept")),
73        post("chat: fewer changes loses even if later", "/chat/sync", chat(1, 99, "old")),
74        post("chat: a tie goes to the more recently active", "/chat/sync", chat(2, 9, "later")),
75        post("chat: repeat", "/chat/sync", chat(2, 9, "later")),
76        post("chat: not JSON", "/chat/sync", "{"),
77    ];
78    steps.push(Step { name: "refusal: GET is not served", method: "GET", path: "/memory/sync", body: String::new(), expect: 405 });
79    steps.push(Step { name: "refusal: PUT is not served", method: "PUT", path: "/memory/sync", body: String::new(), expect: 405 });
80    steps.push(post("refusal: an unknown route", "/memory/nope", "{}"));
81    steps.push(post("refusal: the root", "/", "{}"));
82    steps.push(post("refusal: a body over the limit (8 MiB + 1)", "/memory/sync", "x".repeat(8 * 1024 * 1024 + 1)));
83    steps
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn the_script_is_fixed_text_and_every_step_is_named_once() {
92        let steps = script();
93        assert!(steps.len() >= 30);
94        let mut names: Vec<_> = steps.iter().map(|s| s.name).collect();
95        names.sort_unstable();
96        names.dedup();
97        assert_eq!(names.len(), steps.len());
98        // Fixed: asking twice builds the same bytes.
99        let again = script();
100        assert!(steps.iter().zip(&again).all(|(a, b)| a.body == b.body && a.path == b.path));
101    }
102
103    #[test]
104    fn the_reference_service_answers_every_step_as_the_script_says() {
105        use whiskers_conformance::MemBackend;
106        use whiskers_ports::run_ready;
107        use whiskers_service::routes::{ChatSync, HouseholdSync, MemorySync};
108        use whiskers_service::{Method, Request, Service};
109        // The routes the Worker serves, and no others: the script is the same on every host.
110        let service = Service::new(MemBackend::default()).serve(MemorySync).serve(HouseholdSync).serve(ChatSync);
111        for step in script() {
112            let method = if step.method == "POST" { Method::Post } else { Method::Other(step.method.into()) };
113            let r = run_ready(service.handle(Request { method, ..Request::post(step.path, step.body.clone()) }));
114            assert_eq!(r.status, step.expect, "{}", step.name);
115        }
116    }
117
118    #[test]
119    fn the_documents_in_the_script_are_documents() {
120        for s in script().iter().filter(|s| s.name.starts_with("memory: ") && !s.name.contains("not JSON") && !s.name.contains("empty body") && !s.name.contains("wrong shape") && !s.name.contains("limit")) {
121            let v: serde_json::Value = serde_json::from_str(&s.body).unwrap_or_else(|e| panic!("{}: {e}", s.name));
122            assert!(v.get("facts").is_some(), "{}", s.name);
123        }
124    }
125}